15 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 395 | feat(pi-permission-system): deny patterns with custom reason |
Retro: #395 — feat(pi-permission-system): deny patterns with custom reason
Stage: PR Review (2026-06-13T01:30:06Z)
Session summary
Third-party PR #395 (author @k0valik, not the repo owner) extends the flat permission config with an object syntax for deny rules carrying an optional custom reason, surfaced to the agent in the block message (e.g. npm * → deny, Reason: Use pnpm instead).
The underlying problem is real: a denied command currently yields only a generic block message, so the agent is told no but never why or what to do instead — a denial that teaches is more actionable.
Operator's chosen direction: adopt the capability with our own simplified design, planned via /plan-issue; use the PR as reference, not the merge target.
Evaluation
What is valuable (keep):
- The capability and API shape —
reasonthreaded ontoRule(rule.ts) andPermissionCheckResult(types.ts), surfaced inbuildToolDenyBody(denial-messages.ts) asReason: <reason>.appended after the sentence-ending period. - Backward-compatible config syntax
{ "action": "deny", "reason": "..." }; existing string values are untouched. - Schema (
schemas/permissions.schema.json), example (config/config.example.json),docs/configuration.md, and TypeScript types all kept aligned — matches the package's "keep schema/example/docs/loader/types aligned" rule. - Non-breaking and least-privilege-preserving: the object form only annotates
deny, so it can never loosen policy (deny stays deny;reasonis purely explanatory).feat:(notfeat!:) is correct. - Solid test coverage (17 new tests across
normalize,rule,denial-messages,permission-manager-unified), including malformed-reasonrejection and last-match-wins propagation.
What I would change (over-built / divergent — simplify in our design):
- Duplicated type guard.
isDenyWithReasonis defined twice — innormalize.ts(typedvalue is DenyWithReason) and inconfig-loader.ts(typed against an inline anonymous{ action: "deny"; reason?: string }, not the namedDenyWithReason). Two copies of the same predicate with divergent annotations. Collapse to one shared guard besideisPermissionStateincommon.ts, returningvalue is DenyWithReason. - Single-inhabitant discriminator.
DenyWithReason.actioncan only ever be"deny"— schema pins it"const": "deny", both guards check=== "deny", andnormalize.tshardcodesaction: "deny"when building the rule. It carries no runtime information beyond disambiguating "this object is a deny-with-reason" from "this object is a nested pattern map" (see thetop-level DenyWithReason object is treated as pattern maptest). This is the envelope-whose-only-consumed-field-is-one-value smell the design heuristics flag. Operator decision: keep the explicit{ action, reason }shape — the disambiguation it provides is real and the explicitness is forward-compatible — but treat it as the part to scrutinize, not extend. PatternValuetype (minor) — introduced and threaded intoFlatPermissionConfig; confirm it earns its keep versus inliningPermissionState | DenyWithReason.
Surface/security: this is a permission package, so the review weight is on what the change exposes.
The change only adds an annotation to deny; it cannot widen access.
No new permission surface, no default change on upgrade.
Least-privilege intact.
Mechanic confirmed during review (drives the scope non-goal): only deny reasons reach the agent.
applyPermissionGate (permission-gate.ts) returns { action: "block", reason: messages.denyReason } for deny, and that block reason becomes the tool result the agent reads.
For ask, the gate triggers an interactive GatePrompter.prompt() to the human user; the agent only sees the outcome, so an ask reason would be human-prompt context only and never cause agent backtracking.
For allow, nothing is surfaced.
Hence deny-only captures 100% of the agent-facing value.
Decision and attribution
Direction: adopt the capability, plan a simplified design (/plan-issue #395).
The retro records the decision so /plan-issue's Decide gate is satisfied — it should plan around this, not re-litigate.
Agreed scope:
- Capability: a custom
reasonon deny rules, surfaced in the agent-facing block message. - Object shape: keep the explicit
{ "action": "deny", "reason": "..." }form (operator's call). - Simplify: collapse the two
isDenyWithReasonguards into a single shared guard (incommon.ts), using the namedDenyWithReasontype at both call sites; reassess whetherPatternValueearns its keep.
Non-goals:
- No reason on
ask(would be human-prompt context only — different, weaker, human-facing consumer). - No reason on
allow(invisible — dead weight). - No change to defaults or to any existing string-form config.
Attribution (required durable credit):
-
Every implementation/docs commit in
/plan-issue→/tdd-plancarries, at the end of the body after a blank line:Co-authored-by: k0valik <85703878+k0valik@users.noreply.github.com>The PR commit recorded a placeholder email (
kovalik@example.com); the GitHub no-reply form (user id85703878+ login) is used so the trailer links to@k0valik's profile. -
The ship-stage PR/issue close comment thanks
@k0valikby name and links the implementing SHA(s). -
Never use
Closes #395in a commit (pre-empts the curated close comment); reference asRefs #395/(#395).
Stage: Planning (2026-06-13T01:45:00Z)
Session summary
Wrote the numbered implementation plan docs/plans/0395-deny-patterns-custom-reason.md for the operator-confirmed direction (adopt-with-simplified-design, deny-only, explicit { action, reason } shape).
The PR-review retro already satisfied the Decide gate, so planning proceeded without re-asking.
The plan lands the capability across types.ts, common.ts, rule.ts, normalize.ts, config-loader.ts, permission-manager.ts, denial-messages.ts, schema, example, and docs, in six TDD steps.
Observations
- Three concrete simplifications over PR #395, all baked into the plan: (1) a single shared
isDenyWithReasonguard incommon.tsreplaces the PR's two divergent copies; (2)FlatPermissionConfigkeepsDenyWithReasononly inside the pattern map (PermissionState | Record<string, PatternValue>), not at the surface level, matching runtime; (3) the schema gets a new$defs/denyWithReasonreferenced only frompermissionMap, so it never accepts a top-level deny-with-reason the runtime rejects. - Confirmed during exploration that
evaluate()returns the matchedRuleverbatim viafindLast, soreasononRuleauto-propagates — no change toevaluate()needed; the PR'srule.test.tscases just document the existing last-match-wins behavior. - Two parse layers must both preserve the object (
config-loader.normalizeFlatPermissionValueandnormalize.normalizeFlatConfig) — the loader currently strips it silently. Plan step 3 tests the loader directly; step 4's end-to-end manager test is the backstop that fails if either layer drops the reason. - Design-review checklist: one optional field on two already-wide value types (
Rule,PermissionCheckResult); the shared guard is the missing abstraction collapsing the duplication;reasonrides existing value-object carriers (no parameter-relay smell). No structural concerns. - Classified non-breaking (additive optional field, no default change) →
feat:, notfeat!:. - Attribution trailer and
@k0valikclose-comment credit carried into the plan's Risks section so the TDD stage applies them per commit.
Stage: Implementation — TDD (2026-06-12T21:50:00Z)
Session summary
Implemented all six planned TDD steps plus two pre-completion fixups; test count went 1972 → 1996 (+24).
The capability ships end-to-end: { "action": "deny", "reason": "..." } at the pattern-value level now flows config-loader → normalizeFlatConfig → Rule.reason → evaluate() → PermissionCheckResult.reason → the agent-facing denial message (Reason: ...).
All three planned simplifications over PR #395 landed as designed (single shared guard, pattern-map-only type, schema scoped to permissionMap).
Observations
- Each step went red→green cleanly with no plan deviations; the plan's prediction that
evaluate()needs no change held — therule.test.tscases pass purely fromfindLastreturning the matched rule verbatim. - TDD step 4's manager end-to-end tests were the only ones requiring the
buildCheckResultchange to go green; therule.test.tshalf was already green when added, exactly as the plan noted. - Hit one tool-path slip: an
Editused the wrong absolute path (pi-permission-system/...instead ofpi-packages/packages/pi-permission-system/...) and was denied by the permission system's own external-directory gate — corrected on retry. - No schema-validation test exists for
config.example.json, so the example/schema changes were verified only bynode-parsing both files and by rumdl; worth a future AJV round-trip test (out of scope here). - Pre-completion reviewer: WARN (no failing checks).
Two non-blocking findings, both fixed before stopping: (1)
docs/architecture/architecture.md's inlineRulelisting was missingreason?— added (docs:commit); (2)buildToolDenyBodyinlined theReason:suffix instead of reusing the existingreasonSuffixhelper — refactored to reuse it (refactor:commit, output unchanged). - Final state:
check,lint,test(1996), andfallow dead-codeall green; no lockfile changes; all 10 source/data files from the plan's Module-Level Changes touched, no deviation.
Stage: Final Retrospective (2026-06-12T22:10:00Z)
Session summary
One continuous session carried PR #395 end-to-end through PR-review → planning → TDD → ship, landing pi-permission-system-v13.1.0.
The third-party capability shipped as a simplified design (single shared guard, pattern-map-only type, schema scoped to permissionMap), 1972 → 1996 tests, non-breaking, with @k0valik credited via Co-authored-by trailers and a curated close comment.
Execution was clean: no rabbit holes, no plan deviations, and every plan prediction held during TDD.
Observations
What went well
- The cross-stage retro bridge worked exactly as designed.
The PR-review retro satisfied planning's Decide gate (no re-litigation of direction), and the plan's two load-bearing predictions —
evaluate()needs no change (it returns the matchedRuleverbatim) and both parse layers silently strip the object — both held in TDD with zero rework. - The pre-completion reviewer earned its keep.
All deterministic gates (
check,lint,test,fallow dead-code) were green, yet the judgment-based review surfaced two real issues a pure-determinism pipeline would have shipped: a stale inlineRulelisting and a missed helper reuse. - Clean
ask-userre-ask handshake. When the operator asked a clarifying question ("can the agent ever see theaskrationale?"), the response gathered evidence first (permission-gate.ts,gate-prompter.ts) and then re-asked scope, rather than guessing — resolving the deny-only boundary cleanly.
What caused friction (agent side)
missing-context—docs/architecture/architecture.mdinline-copies the corerule.tstypes (Rule,RuleOrigin,Ruleset), so addingreason?toRuleleft that listing stale. The plan explicitly concluded "no architecture doc update needed" because no module was added/moved/removed — but a field change to an inline-copied type stales the doc without any module move. Caught by the pre-completion reviewer → onedocs:fixup (fd880d49). Impact: one extra commit; backstop worked, no shipped staleness.missing-context—buildToolDenyBodyinlined theReason: <reason>.suffix instead of reusing the existingreasonSuffixhelper in the same file. No grep ofdenial-messages.tsfor an existing helper before adding the formatting. Caught by the reviewer → onerefactor:fixup (4c2dc7c9, output unchanged). Impact: one extra commit.other(tool-path slip) — anEditused the wrong absolute path (dropped thepi-packages/packages/segment) and was denied by the permission system's own external-directory gate; corrected on the immediate retry. Self-identified. Impact: one denied tool call, no rework.
What caused friction (user side)
- The operator surfaced the attribution-email concern mid-TDD ("we don't have a proper email for
k0valik?"). The no-reply form had already been chosen in the PR-review retro, so there was no rework — but the question shows the placeholder-email rationale, though recorded, was not prominent enough to pre-empt the doubt. Opportunity, not criticism: a one-line "why the no-reply form" note travels well in the close comment itself.
Follow-ups (not implemented here)
- No schema-validation test exists for
config.example.json; the schema/example changes were verified only bynode-parsing and rumdl. An AJV round-trip test (example config validates againstpermissions.schema.json) would catch schema/example drift automatically. Out of scope for this retro (new test infra + dependency) — candidate for a dedicated issue.
Diagnostic details
- Model-performance correlation — one subagent dispatch: the
pre-completion-reviewerran onanthropic/claude-sonnet-4-6(per its agent frontmatter), a reasoning-capable model appropriate for judgment-heavy review. No mismatch; the verdict (WARN with two actionable findings) confirms the model engaged the judgment checklist rather than rubber-stamping. - Escalation-delay tracking — no rabbit holes; the path slip resolved in a single retry, well under the 5-call flag.
- Unused-tool detection — the
reasonSuffixmiss was the one avoidable gap: agrep reasonSuffix denial-messages.tsbefore adding the inline suffix would have surfaced the helper. - Feedback-loop gap analysis — exemplary; verification ran incrementally (per-step red→green on the affected file,
pnpm run checkafter each shared-interface step 1/3/4) and comprehensively at the end (full suite +check+lint+fallow). No end-loaded verification.
Changes made
.pi/skills/package-pi-permission-system/SKILL.md— added a two-line note to the Configuration alignment list recording thatdocs/architecture/architecture.mdinline-copies the corerule.tstypes (Rule,RuleOrigin,Ruleset), so a field add/remove on those must update the listing (a module-move check misses it).