18 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 395 | 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:
"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
reasonto 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
reasonfrom config through the rule pipeline toPermissionCheckResult. - 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:, notfeat!:. - 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
reasononask(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
reasononallow(invisible — dead weight). - No support for a top-level (surface-level) deny-with-reason object — a surface value stays either a
PermissionStatestring 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
pathcross-cutting gate.
Background
Relevant modules (all under packages/pi-permission-system/src/):
types.ts—PermissionState,FlatPermissionConfig(the on-disk shape), andPermissionCheckResult(the evaluation result returned to gates).common.ts— shared narrow type guards, includingisPermissionState. This is the natural home for a sharedisDenyWithReasonguard, beside its sibling.rule.ts— theRulevalue object andevaluate().evaluate()returns the matchedRuledirectly (last-match-wins viafindLast), so areasonfield onRulepropagates to the result automatically — no change toevaluate()is needed.normalize.ts—normalizeFlatConfig(FlatPermissionConfig): Ruleset. Converts the on-disk flat config intoRule[]. The object-value branch currently only acceptsisPermissionState(action)and silently drops everything else (including deny-with-reason objects).config-loader.ts—normalizeFlatPermissionValue(unknown): FlatPermissionConfig. Validates raw parsed JSON into aFlatPermissionConfig. Its inner pattern-map loop currently only keepsisPermissionState(action)values, so a deny-with-reason object read from a JSON config file is silently stripped before it ever reachesnormalizeFlatConfig. Both layers must preserve the object for the feature to work end-to-end.permission-manager.ts—buildCheckResult()assembles aPermissionCheckResultfrom the matchedRule. It must copyrule.reasononto the result.denial-messages.ts—buildToolDenyBody()formats the agent-facing block message. It must append the reason when present.schemas/permissions.schema.json—$defs/permissionStateand$defs/permissionMap. The surface-level value isoneOf[permissionState, permissionMap];permissionMapmaps patterns topermissionState.
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
actiondiscriminator 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 foredit." (Applies only if a new example toucheswrite/edit; thenpm *example does not.)
Where this plan diverges from PR #395 (the simplifications)
- One shared guard, not two.
PR #395 defines
isDenyWithReasontwice — innormalize.ts(typedvalue is DenyWithReason) and inconfig-loader.ts(typed against an inline anonymous{ action: "deny"; reason?: string }). This plan defines a singleisDenyWithReasonincommon.tsreturningvalue is DenyWithReason, imported by both call sites. - Tighter
FlatPermissionConfig. PR #395 sets the top-level value toPatternValue | Record<string, PatternValue>, which falsely implies a surface-level deny-with-reason is valid. This plan usesPermissionState | Record<string, PatternValue>—PatternValue(which includesDenyWithReason) appears only inside the pattern map, matching runtime behavior. - 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/denyWithReasonand references it only frompermissionMap.additionalProperties, leaving the surface-leveloneOfunchanged.
Design Overview
Data shapes (types.ts)
/**
* 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:
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)
/**
* 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):
// 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:
"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 returnsfalse, so neitherisDenyWithReasonnorisPermissionStatematches and the pattern is dropped (falls through to the surface/default). Documented by tests in bothnormalizeand the manager end-to-end suite.{ "action": "deny" }— no reason: a deny rule withreasonabsent (undefined).- Top-level
{ "action": "deny", "reason": "…" }at the surface level: parsed as a pattern map (action→deny);reason's value"…"is not a validPermissionState, so that pattern is dropped, leaving a singleaction→denyrule. Unchanged from today; asserted by an existing-behavior test. allow/askobject forms: not matched byisDenyWithReason(action ≠ "deny") and not a string, so dropped — onlydenygets the object form.
Module-Level Changes
src/types.ts— addDenyWithReasoninterface andPatternValuetype; changeFlatPermissionConfigpattern-map value fromPermissionStatetoPatternValue; addreason?: stringtoPermissionCheckResult.src/rule.ts— addreason?: stringtoRule. No change toevaluate().src/common.ts— addisDenyWithReasonguard (importsDenyWithReasonfrom./types).src/normalize.ts— importisDenyWithReason; in the object-value branch, build a deny rule withreasonwhen the guard matches, else fall back toisPermissionState. Remove the eslint-disable only if the type change makes it unnecessary (re-verify; the defensive null check likely stays).src/config-loader.ts— importisDenyWithReason; innormalizeFlatPermissionValue's inner pattern-map loop, keep deny-with-reason objects alongsideisPermissionStatestrings; widen the innermaptype toRecord<string, PatternValue>.src/permission-manager.ts— inbuildCheckResult, addreason: rule.reasonto the returnedPermissionCheckResult.src/denial-messages.ts— inbuildToolDenyBody, appendReason: ${check.reason}.whencheck.reasonis set.schemas/permissions.schema.json— add$defs/denyWithReason; reference it frompermissionMap.additionalPropertiesviaoneOf. Leave the surface-level valueoneOfunchanged.config/config.example.json— add an illustrative"npm *": { "action": "deny", "reason": "Use pnpm instead" }entry underbash.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.
- New unit coverage enabled: a focused
isDenyWithReasontest incommon.test.ts(previously the predicate did not exist); deny-with-reason branches innormalize.test.ts,config-loader.test.ts,rule.test.ts,denial-messages.test.ts; and an end-to-end thread-through inpermission-manager-unified.test.ts. - Tests that become redundant: none — existing tests cover only the string form, which is unchanged.
- Tests that must stay as-is: all existing
normalize/rule/denial-messages/permission-managertests — 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
- Types + shared guard.
Red: add
isDenyWithReasoncases tocommon.test.ts(accepts{action:"deny"}and{action:"deny",reason:"x"}; rejects{action:"allow"}, a non-stringreason,null, arrays, and non-objects). Green: addDenyWithReason/PatternValueand thePermissionCheckResult.reasonfield totypes.ts,reason?toRuleinrule.ts, widenFlatPermissionConfig, and addisDenyWithReasontocommon.ts. Runpnpm run check(shared-interface change). Commit:feat(pi-permission-system): add DenyWithReason type and shared guard. - Normalizer.
Red: add the
deny with reasondescribe block tonormalize.test.ts(rule with reason; without reason; coexists with strings; top-level object treated as a pattern map; non-string reason dropped). Green: updatenormalize.ts's object branch to useisDenyWithReason. Commit:feat(pi-permission-system): build deny rules with reason in normalizeFlatConfig. - Config loader.
Red: add deny-with-reason cases to
config-loader.test.ts(object preserved intoFlatPermissionConfig; non-string reason stripped; coexists with string values). Green: updatenormalizeFlatPermissionValueto keep deny-with-reason objects and widen the inner map type. Commit:feat(pi-permission-system): preserve deny-with-reason from JSON config. - Rule propagation + manager thread-through.
Red: add
evaluate()reason cases torule.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 topermission-manager-unified.test.ts(result.reasonset for bash and non-bash surfaces;undefinedfor plain deny; non-string reason falls through to default). Green: addreason: rule.reasontobuildCheckResult. Commit:feat(pi-permission-system): thread deny reason into PermissionCheckResult. - 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: appendReason: ….inbuildToolDenyBody. Commit:feat(pi-permission-system): append custom reason to denial messages. - Schema, example, docs.
No test cycle (data + prose).
Update
schemas/permissions.schema.json($defs/denyWithReason+permissionMaponeOf),config/config.example.json, anddocs/configuration.md. Runpnpm run lint(rumdl) andpnpm 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; bothnormalize.tsandconfig-loader.tsimport 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:(notfeat!:) 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@k0valikand links the implementing SHA(s). Never useCloses #395in a commit; reference asRefs #395/(#395).
Open Questions
- Should a reason be available on
askrules 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.