mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 16:45:22 +00:00
293 lines
18 KiB
Markdown
293 lines
18 KiB
Markdown
---
|
||
issue: 395
|
||
issue_title: "feat(pi-permission-system): deny patterns with custom reason"
|
||
---
|
||
|
||
# Deny patterns with a custom reason
|
||
|
||
## Problem Statement
|
||
|
||
A denied command currently produces only a generic block message — the agent is told *no* but never *why* or *what to do instead*.
|
||
A user who blocks `npm *` cannot tell the agent to use `pnpm`; the agent just sees a bare denial and may flail.
|
||
PR #395 (third-party, from `@k0valik`) proposes an object syntax for deny rules carrying an optional `reason` that is surfaced to the agent in the block message:
|
||
|
||
```jsonc
|
||
"bash": { "npm *": { "action": "deny", "reason": "Use pnpm instead" } }
|
||
```
|
||
|
||
The PR-review stage (see `docs/retro/0395-deny-patterns-with-custom-reason.md`) confirmed the operator's direction: **adopt the capability with our own simplified design** — keep the capability and the explicit `{ action, reason }` shape, but collapse the PR's two duplicated type guards into one and tighten the types/schema so they match runtime behavior.
|
||
The Decide gate is therefore already satisfied by the retro; this plan implements the recorded decision.
|
||
|
||
## Goals
|
||
|
||
- Add an optional `reason` to **deny** rules via the object syntax `{ "action": "deny", "reason": "..." }` at the pattern-value level.
|
||
- Surface the reason to the agent in the denial message, appended after the sentence-ending period: `... (matched 'npm *'). Reason: Use pnpm instead.`.
|
||
- Thread `reason` from config through the rule pipeline to `PermissionCheckResult`.
|
||
- Keep the change **non-breaking**: existing string-form config is untouched, the new field is optional everywhere, no default changes on upgrade.
|
||
Suggested commits are `feat:`, not `feat!:`.
|
||
- Keep schema, example config, `docs/configuration.md`, and the TypeScript types/loaders aligned (per the package's alignment rule).
|
||
- Preserve least privilege: the object form only annotates `deny`, so it can never widen access.
|
||
|
||
## Non-Goals
|
||
|
||
- No `reason` on `ask` (it would render only in the human confirmation dialog, never reach the agent — a separate, weaker, human-facing feature).
|
||
Deferred as an Open Question.
|
||
- No `reason` on `allow` (invisible — dead weight).
|
||
- No support for a **top-level** (surface-level) deny-with-reason object — a surface value stays either a `PermissionState` string or a pattern→action map.
|
||
A top-level `{ "action": "deny", "reason": "..." }` is, and remains, parsed as a pattern map (`action` → `"deny"`), consistent with current behavior.
|
||
- No change to wildcard matching, merge precedence, or the `path` cross-cutting gate.
|
||
|
||
## Background
|
||
|
||
Relevant modules (all under `packages/pi-permission-system/src/`):
|
||
|
||
- `types.ts` — `PermissionState`, `FlatPermissionConfig` (the on-disk shape), and `PermissionCheckResult` (the evaluation result returned to gates).
|
||
- `common.ts` — shared narrow type guards, including `isPermissionState`.
|
||
This is the natural home for a shared `isDenyWithReason` guard, beside its sibling.
|
||
- `rule.ts` — the `Rule` value object and `evaluate()`. `evaluate()` returns the matched `Rule` **directly** (last-match-wins via `findLast`), so a `reason` field on `Rule` propagates to the result automatically — no change to `evaluate()` is needed.
|
||
- `normalize.ts` — `normalizeFlatConfig(FlatPermissionConfig): Ruleset`.
|
||
Converts the on-disk flat config into `Rule[]`.
|
||
The object-value branch currently only accepts `isPermissionState(action)` and silently drops everything else (including deny-with-reason objects).
|
||
- `config-loader.ts` — `normalizeFlatPermissionValue(unknown): FlatPermissionConfig`.
|
||
Validates raw parsed JSON into a `FlatPermissionConfig`.
|
||
Its inner pattern-map loop currently only keeps `isPermissionState(action)` values, so a deny-with-reason object read from a JSON config file is **silently stripped before it ever reaches `normalizeFlatConfig`**.
|
||
Both layers must preserve the object for the feature to work end-to-end.
|
||
- `permission-manager.ts` — `buildCheckResult()` assembles a `PermissionCheckResult` from the matched `Rule`.
|
||
It must copy `rule.reason` onto the result.
|
||
- `denial-messages.ts` — `buildToolDenyBody()` formats the agent-facing block message.
|
||
It must append the reason when present.
|
||
- `schemas/permissions.schema.json` — `$defs/permissionState` and `$defs/permissionMap`.
|
||
The surface-level value is `oneOf[permissionState, permissionMap]`; `permissionMap` maps patterns to `permissionState`.
|
||
|
||
Constraints from AGENTS.md / package skill that apply:
|
||
|
||
- "Keep schema, example config, `docs/configuration.md`, `README.md`, and TypeScript types/loaders aligned — changing one without the others is a bug."
|
||
- "Treat any declared config field not read at runtime as a maintenance trap."
|
||
The `action` discriminator **is** read at runtime (the guard checks `=== "deny"` and it disambiguates a deny-object from a pattern map), so it earns its keep despite being single-valued — this is the explicit shape the operator chose to keep.
|
||
- "When a config example sets a policy for `write`, include the same policy for `edit`." (Applies only if a new example touches `write`/`edit`; the `npm *` example does not.)
|
||
|
||
### Where this plan diverges from PR #395 (the simplifications)
|
||
|
||
1. **One shared guard, not two.**
|
||
PR #395 defines `isDenyWithReason` twice — in `normalize.ts` (typed `value is DenyWithReason`) and in `config-loader.ts` (typed against an inline anonymous `{ action: "deny"; reason?: string }`).
|
||
This plan defines a single `isDenyWithReason` in `common.ts` returning `value is DenyWithReason`, imported by both call sites.
|
||
2. **Tighter `FlatPermissionConfig`.**
|
||
PR #395 sets the top-level value to `PatternValue | Record<string, PatternValue>`, which falsely implies a surface-level deny-with-reason is valid.
|
||
This plan uses `PermissionState | Record<string, PatternValue>` — `PatternValue` (which includes `DenyWithReason`) appears only inside the pattern map, matching runtime behavior.
|
||
3. **Schema object form scoped to the pattern map.**
|
||
PR #395 adds the object variant to `$defs/permissionState`, which is also referenced at the surface level — so the schema would accept a top-level deny-with-reason the runtime rejects.
|
||
This plan adds a new `$defs/denyWithReason` and references it **only** from `permissionMap.additionalProperties`, leaving the surface-level `oneOf` unchanged.
|
||
|
||
## Design Overview
|
||
|
||
### Data shapes (`types.ts`)
|
||
|
||
```typescript
|
||
/**
|
||
* A deny action with an optional reason annotation, used when a pattern maps
|
||
* to an object instead of a plain PermissionState string.
|
||
*/
|
||
export interface DenyWithReason {
|
||
action: "deny";
|
||
reason?: string;
|
||
}
|
||
|
||
/** A pattern value: a PermissionState string OR a DenyWithReason object. */
|
||
export type PatternValue = PermissionState | DenyWithReason;
|
||
|
||
/**
|
||
* The on-disk permission shape inside the `"permission"` key.
|
||
* A surface value is a PermissionState string (catch-all shorthand) or a
|
||
* pattern→value map. Pattern values may be a string or a DenyWithReason.
|
||
*/
|
||
export type FlatPermissionConfig = Record<
|
||
string,
|
||
PermissionState | Record<string, PatternValue>
|
||
>;
|
||
|
||
export interface PermissionCheckResult {
|
||
toolName: string;
|
||
state: PermissionState;
|
||
/** Custom denial reason from a deny-with-reason pattern, when present. */
|
||
reason?: string;
|
||
// …existing fields unchanged…
|
||
}
|
||
```
|
||
|
||
`rule.ts` gains one optional field:
|
||
|
||
```typescript
|
||
export interface Rule {
|
||
surface: string;
|
||
pattern: string;
|
||
action: PermissionState;
|
||
/** Custom denial reason for deny rules (optional). */
|
||
reason?: string;
|
||
// …existing fields unchanged…
|
||
}
|
||
```
|
||
|
||
### Shared guard (`common.ts`)
|
||
|
||
```typescript
|
||
/**
|
||
* Narrow type guard: a raw value representing a DenyWithReason object.
|
||
* Accepts `{ action: "deny" }` and `{ action: "deny", reason: "…" }`.
|
||
* Rejects a non-string `reason` to keep malformed config out of the rule set.
|
||
*/
|
||
export function isDenyWithReason(value: unknown): value is DenyWithReason {
|
||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||
return false;
|
||
}
|
||
const record = value as Record<string, unknown>;
|
||
return (
|
||
record.action === "deny" &&
|
||
(record.reason === undefined || typeof record.reason === "string")
|
||
);
|
||
}
|
||
```
|
||
|
||
### Data flow
|
||
|
||
`config-loader.normalizeFlatPermissionValue` (preserve object) → `FlatPermissionConfig` → `normalize.normalizeFlatConfig` (build `Rule` with `reason`) → `evaluate()` (returns the matched rule verbatim) → `permission-manager.buildCheckResult` (`reason: rule.reason`) → `PermissionCheckResult` → `denial-messages.buildToolDenyBody` (append `Reason: …`).
|
||
`reason` rides existing value-object carriers (`Rule`, `PermissionCheckResult`); it is **not** a parameter threaded through callbacks, so there is no parameter-relay smell.
|
||
|
||
Consumer call sites (verifying Tell-Don't-Ask / LoD — both just read one field off a value they already hold):
|
||
|
||
```typescript
|
||
// normalize.ts — object branch
|
||
for (const [pattern, raw] of Object.entries(value)) {
|
||
if (isDenyWithReason(raw)) {
|
||
rules.push({ surface, pattern, action: "deny", reason: raw.reason, origin: "builtin" });
|
||
} else if (isPermissionState(raw)) {
|
||
rules.push({ surface, pattern, action: raw, origin: "builtin" });
|
||
}
|
||
}
|
||
|
||
// permission-manager.ts — buildCheckResult
|
||
return { toolName, state: rule.action, reason: rule.reason, /* …existing… */ };
|
||
|
||
// denial-messages.ts — buildToolDenyBody, after the period
|
||
let message = `${parts.join(" ")}.`;
|
||
if (check.reason) message += ` Reason: ${check.reason}.`;
|
||
return message;
|
||
```
|
||
|
||
### Schema (`permissions.schema.json`)
|
||
|
||
Add a `$def` and reference it only from the pattern map:
|
||
|
||
```jsonc
|
||
"permissionMap": {
|
||
"additionalProperties": {
|
||
"oneOf": [
|
||
{ "$ref": "#/$defs/permissionState" },
|
||
{ "$ref": "#/$defs/denyWithReason" }
|
||
]
|
||
}
|
||
},
|
||
"denyWithReason": {
|
||
"type": "object",
|
||
"description": "Deny with an optional custom reason shown to the agent.",
|
||
"properties": {
|
||
"action": { "const": "deny", "description": "The decision — must be \"deny\"." },
|
||
"reason": { "type": "string", "maxLength": 500, "description": "Reason shown to the agent when denied." }
|
||
},
|
||
"required": ["action"],
|
||
"additionalProperties": false
|
||
}
|
||
```
|
||
|
||
The surface-level `properties.permission.additionalProperties.oneOf` stays `[permissionState, permissionMap]` — unchanged.
|
||
|
||
### Edge cases
|
||
|
||
- `{ "action": "deny", "reason": 42 }` — non-string reason: the guard returns `false`, so neither `isDenyWithReason` nor `isPermissionState` matches and the pattern is dropped (falls through to the surface/default).
|
||
Documented by tests in both `normalize` and the manager end-to-end suite.
|
||
- `{ "action": "deny" }` — no reason: a deny rule with `reason` absent (`undefined`).
|
||
- Top-level `{ "action": "deny", "reason": "…" }` at the surface level: parsed as a pattern map (`action` → `deny`); `reason`'s value `"…"` is not a valid `PermissionState`, so that pattern is dropped, leaving a single `action`→`deny` rule.
|
||
Unchanged from today; asserted by an existing-behavior test.
|
||
- `allow`/`ask` object forms: not matched by `isDenyWithReason` (action ≠ "deny") and not a string, so dropped — only `deny` gets the object form.
|
||
|
||
## Module-Level Changes
|
||
|
||
- `src/types.ts` — add `DenyWithReason` interface and `PatternValue` type; change `FlatPermissionConfig` pattern-map value from `PermissionState` to `PatternValue`; add `reason?: string` to `PermissionCheckResult`.
|
||
- `src/rule.ts` — add `reason?: string` to `Rule`.
|
||
No change to `evaluate()`.
|
||
- `src/common.ts` — add `isDenyWithReason` guard (imports `DenyWithReason` from `./types`).
|
||
- `src/normalize.ts` — import `isDenyWithReason`; in the object-value branch, build a deny rule with `reason` when the guard matches, else fall back to `isPermissionState`.
|
||
Remove the eslint-disable only if the type change makes it unnecessary (re-verify; the defensive null check likely stays).
|
||
- `src/config-loader.ts` — import `isDenyWithReason`; in `normalizeFlatPermissionValue`'s inner pattern-map loop, keep deny-with-reason objects alongside `isPermissionState` strings; widen the inner `map` type to `Record<string, PatternValue>`.
|
||
- `src/permission-manager.ts` — in `buildCheckResult`, add `reason: rule.reason` to the returned `PermissionCheckResult`.
|
||
- `src/denial-messages.ts` — in `buildToolDenyBody`, append `Reason: ${check.reason}.` when `check.reason` is set.
|
||
- `schemas/permissions.schema.json` — add `$defs/denyWithReason`; reference it from `permissionMap.additionalProperties` via `oneOf`.
|
||
Leave the surface-level value `oneOf` unchanged.
|
||
- `config/config.example.json` — add an illustrative `"npm *": { "action": "deny", "reason": "Use pnpm instead" }` entry under `bash`.
|
||
- `docs/configuration.md` — document the object form at the pattern-value level (one prose paragraph + reflect the example), noting the reason is shown to the agent on denial and is deny-only.
|
||
|
||
No exported symbol is removed or renamed, so no skill/architecture-doc grep is required for removals.
|
||
No file is added or deleted, so `docs/architecture/` layout listings are unaffected.
|
||
|
||
## Test Impact Analysis
|
||
|
||
This is an additive feature, not an extraction/refactor, so there are no redundant tests to remove.
|
||
|
||
1. **New unit coverage enabled:** a focused `isDenyWithReason` test in `common.test.ts` (previously the predicate did not exist); deny-with-reason branches in `normalize.test.ts`, `config-loader.test.ts`, `rule.test.ts`, `denial-messages.test.ts`; and an end-to-end thread-through in `permission-manager-unified.test.ts`.
|
||
2. **Tests that become redundant:** none — existing tests cover only the string form, which is unchanged.
|
||
3. **Tests that must stay as-is:** all existing `normalize`/`rule`/`denial-messages`/`permission-manager` tests — they pin the string-form behavior the feature must preserve (regression guard).
|
||
|
||
The override-driven helpers already support the new field: `toolCheck`/`mcpCheck` (`denial-messages.test.ts`) spread `Partial<PermissionCheckResult>`, and `makeManagerWithConfig` (`permission-manager-unified.test.ts`) writes arbitrary JSON config — both accept `reason` with no helper change once the type carries it.
|
||
|
||
## TDD Order
|
||
|
||
1. **Types + shared guard.**
|
||
Red: add `isDenyWithReason` cases to `common.test.ts` (accepts `{action:"deny"}` and `{action:"deny",reason:"x"}`; rejects `{action:"allow"}`, a non-string `reason`, `null`, arrays, and non-objects).
|
||
Green: add `DenyWithReason`/`PatternValue` and the `PermissionCheckResult.reason` field to `types.ts`, `reason?` to `Rule` in `rule.ts`, widen `FlatPermissionConfig`, and add `isDenyWithReason` to `common.ts`.
|
||
Run `pnpm run check` (shared-interface change).
|
||
Commit: `feat(pi-permission-system): add DenyWithReason type and shared guard`.
|
||
2. **Normalizer.**
|
||
Red: add the `deny with reason` describe block to `normalize.test.ts` (rule with reason; without reason; coexists with strings; top-level object treated as a pattern map; non-string reason dropped).
|
||
Green: update `normalize.ts`'s object branch to use `isDenyWithReason`.
|
||
Commit: `feat(pi-permission-system): build deny rules with reason in normalizeFlatConfig`.
|
||
3. **Config loader.**
|
||
Red: add deny-with-reason cases to `config-loader.test.ts` (object preserved into `FlatPermissionConfig`; non-string reason stripped; coexists with string values).
|
||
Green: update `normalizeFlatPermissionValue` to keep deny-with-reason objects and widen the inner map type.
|
||
Commit: `feat(pi-permission-system): preserve deny-with-reason from JSON config`.
|
||
4. **Rule propagation + manager thread-through.**
|
||
Red: add `evaluate()` reason cases to `rule.test.ts` (reason propagates from the matched rule; carried through last-match-wins; absent on the synthetic fallback) and the end-to-end deny-with-reason block to `permission-manager-unified.test.ts` (`result.reason` set for bash and non-bash surfaces; `undefined` for plain deny; non-string reason falls through to default).
|
||
Green: add `reason: rule.reason` to `buildCheckResult`.
|
||
Commit: `feat(pi-permission-system): thread deny reason into PermissionCheckResult`.
|
||
5. **Denial message.**
|
||
Red: add reason cases to `denial-messages.test.ts` (bash with reason; generic tool with reason and no matched pattern; agent-name + reason; MCP target + reason).
|
||
Green: append `Reason: ….` in `buildToolDenyBody`.
|
||
Commit: `feat(pi-permission-system): append custom reason to denial messages`.
|
||
6. **Schema, example, docs.**
|
||
No test cycle (data + prose).
|
||
Update `schemas/permissions.schema.json` (`$defs/denyWithReason` + `permissionMap` `oneOf`), `config/config.example.json`, and `docs/configuration.md`.
|
||
Run `pnpm run lint` (rumdl) and `pnpm run check`.
|
||
Commit: `docs(pi-permission-system): document deny-with-reason config form`.
|
||
|
||
Every commit body ends with a blank line then the attribution trailer (see Risks → Attribution).
|
||
Each step is independently green: steps 1–3 are exercised by their own direct-call tests; the manager end-to-end assertions live in step 4 once both normalize and config-loader handle the object form.
|
||
|
||
## Risks and Mitigations
|
||
|
||
- **Risk: forgetting one of the two parse layers** (config-loader strips the object before normalize sees it).
|
||
Mitigation: step 3 tests the loader directly, and step 4's end-to-end manager test fails if either layer drops the reason.
|
||
- **Risk: schema/runtime drift** (accepting a top-level deny-with-reason in the schema that the runtime treats as a pattern map).
|
||
Mitigation: the schema object form is referenced only from `permissionMap`, never the surface-level value; an existing-behavior test pins the top-level case.
|
||
- **Risk: re-introducing the PR's duplicated guard.**
|
||
Mitigation: the single guard lives in `common.ts`; both `normalize.ts` and `config-loader.ts` import it (step 1 lands it before steps 2–3 consume it).
|
||
- **Risk: breaking-change misclassification.**
|
||
Mitigation: every change is additive and optional; no existing config or default changes, so `feat:` (not `feat!:`) is correct.
|
||
- **Attribution (required):** every implementation/docs commit body carries, after a blank line, `Co-authored-by: k0valik <85703878+k0valik@users.noreply.github.com>` (the PR commit recorded a placeholder email; the GitHub no-reply form links to `@k0valik`).
|
||
The ship-stage close comment thanks `@k0valik` and links the implementing SHA(s).
|
||
Never use `Closes #395` in a commit; reference as `Refs #395` / `(#395)`.
|
||
|
||
## Open Questions
|
||
|
||
- Should a reason be available on `ask` rules too, surfaced in the interactive confirmation dialog (human-facing, never reaching the agent)?
|
||
Deferred as a possible follow-up; out of scope here.
|
||
- Should the denial message distinguish a config-authored reason from a synthesized one?
|
||
Not currently needed — only config deny rules carry a reason.
|