Files
my-pi/pi-permission-system/docs/retro/0393-normalize-path-policy-inputs.md
T

16 KiB

issue, issue_title
issue issue_title
393 fix(pi-permission-system): normalize path policy inputs

Retro: #393 — fix(pi-permission-system): normalize path policy inputs

Stage: PR Review (2026-06-12T14:20:51Z)

Session summary

PR #393 (third-party, @moekyo) makes the path gates match relative tool/bash inputs against absolute allowlist rules by feeding the evaluator a set of equivalent "policy values" (absolute, project-relative, raw) derived from the known working directory. The underlying gap is real: PermissionManager.configureForCwd already records the cwd, but the evaluator never used it, so a relative input like src/App.jsx could never match an absolute rule such as /workspace/project/*. The operator chose to adopt the capability and plan a simplified design (direction 1), treating the PR as reference rather than the merge target, and to classify the behavior change as breaking (feat!:/fix!:).

Evaluation

Valuable core (keep):

  • getPathPolicyValues / normalizePathPolicyLiteral (src/path-utils.ts) — a clean way to derive the equivalent lookup forms for a path, reusing the existing normalizePathForComparison lexical cleanup.
  • evaluateAnyValue (src/rule.ts) — genuinely distinct from evaluateFirst: it preserves global last-match-wins across aliases of the same path, so a catch-all match on the first alias can't mask a later, more specific rule on another alias. This is the right semantic for "same path, multiple spellings" and is correctly gated behind PATH_SURFACES in permission-manager.ts while MCP (genuinely different targets) keeps evaluateFirst.
  • The cwd plumbing in permission-manager.ts (currentCwd captured in configureForCwd, threaded into normalizeInput).

What I would change (simplify):

  • Symbol side-channel. INTERNAL_PATH_POLICY_VALUES (src/input-normalizer.ts) smuggles bash's pre-computed policy values through a symbol-keyed field on the tool input object — threaded bash-path.tsresolver.resolve → manager → normalizeInput, then re-stamped onto the gate descriptor's input so it survives the post-approval gate run. This is over-wide threading and a divergent shape: input is meant to be raw tool input, and the gate now special-cases a symbol on it. The user-string guard (getInternalPathPolicyValues reads only the symbol, never a pathPolicyValues string key) is the correct least-privilege instinct, but the mechanism it protects is the part to rework. The real driver is that bash needs a per-token resolveBase (the effective dir after a literal cd) that normalizeInput can't compute — a simplified design should pass that resolution context explicitly rather than as a symbol on input.
  • Orphaned pathTokens(). After the refactor, bash-path.ts consumes the new pathRuleCandidates(cwd) and BashProgram.pathTokens() is reachable only via extractTokensForPathRules (bash-path-extractor.ts), which is itself referenced only by test/bash-external-directory.test.ts. The PR keeps the method alive with a fallow-ignore-next-line unused-class-member comment instead of removing the now test-only chain. Per the package skill ("treat any declared field not read at runtime as a maintenance trap"), the simplified design should delete the orphaned pathTokens / extractTokensForPathRules surface, not suppress the flag.
  • Minor: evaluateAnyValue's returned value (the matching alias) is consumed only for MCP extras in checkPermission; for path surfaces it is discarded. Symmetry with evaluateFirst is fine, but worth noting the alias selection does no work on the path path.

Behavior / breaking: The change flips decisions on upgrade with no config edit. In the loosening direction it weakens a gate — e.g. path: { "*": "ask", "/workspace/project/*": "allow" } turns a relative src/App.jsx from askallow. For a least-privilege package that is a breaking change; the operator confirmed feat!:/fix!: with a migration note.

Decision and attribution

Direction: Adopt the capability, plan a simplified design (use #393 as reference, not the merge target). Scope (in): the getPathPolicyValues + evaluateAnyValue core, cwd plumbing, and docs/schema updates. Non-goals / rework: drop the INTERNAL_PATH_POLICY_VALUES symbol side-channel in favor of explicit per-token resolution context for bash; remove the orphaned pathTokens / extractTokensForPathRules chain instead of fallow-ignore-ing it. Classification: breaking (feat!:/fix!:) with a migration note covering the loosening case.

/plan-issue should plan around this recorded decision (the Decide gate is satisfied here) rather than re-litigate the direction.

Attribution (required on every implementation/docs commit):

Co-authored-by: moekyo <shigotods@outlook.com>

The ship-stage PR close comment thanks @moekyo by name and links the implementing SHA(s). Reference the PR as Refs #393 / (#393) in commits — never Closes #393 (it pre-empts the curated close comment).

Stage: Planning (2026-06-12T00:00:00Z)

Session summary

Produced docs/plans/0393-normalize-path-policy-inputs.md planning the simplified design the PR-review stage chose: keep getPathPolicyValues/normalizePathPolicyLiteral, evaluateAnyValue, and the cwd plumbing, but replace the INTERNAL_PATH_POLICY_VALUES symbol side-channel with an explicit checkPathPolicy/resolvePathPolicy method pair and remove the orphaned pathTokens/extractTokensForPathRules chain. The Decide gate was already satisfied by the recorded PR-review decision, so the third-party ask_user direction gate was not re-run. Plan is 9 TDD cycles (two feat!:), committed; next step is /tdd-plan.

Observations

  • Confirmed runDescriptor (runner.ts) uses descriptor.preCheck whenever set, and the bash path gate always sets it — so the PR's symbol stamp on descriptor.input was vestigial. The simplified design carries the per-token policy values through a dedicated resolver method instead of any field on input, eliminating both the symbol and the user-string-spoofing concern.
  • Chose a new narrow method (checkPathPolicy(values) on ScopedPermissionManager, resolvePathPolicy(values) on ScopedPermissionResolver) over threading a resolveBase through resolve/normalizeInput: the unknown-base and no-cwd "literal only" decisions are bash-specific, so bash must own value computation and pass the finished array.
  • Flagged the interface breaks (steps 4 and 5) as fold-fixtures-in-same-commit: makeFakePermissionManager (session-fixtures.ts) gains checkPathPolicy; makeResolver/makeGateRunner/makePathDispatchResolver (gate-fixtures.ts) gain resolvePathPolicy; grep both interface names for inline mocks.
  • Used lift-and-shift for pathTokens removal (add pathRuleCandidates → migrate gate → delete) to keep every commit compiling.
  • Did not port the PR's symbol-spoofing tests; replaced with one normalizeInput no-side-channel assertion.
  • Breaking classification kept (feat!: on the manager and bash-gate steps) — relative inputs now match absolute allowlists, a loosening change for a least-privilege package; no config opt-out is named because none exists.

Stage: Implementation — TDD (2026-06-12T00:00:00Z)

Session summary

Implemented all 9 planned TDD cycles plus two follow-up docs commits (architecture listing + fixture skill), 11 commits total, every commit Co-authored-by: moekyo. The simplified design landed as planned: getPathPolicyValues/normalizePathPolicyLiteral (path-utils.ts), evaluateAnyValue (rule.ts), cwd plumbing + checkPathPolicy + shared buildCheckResult (permission-manager.ts), resolvePathPolicy (permission-resolver.ts), pathRuleCandidates (bash-program.ts), and the gate migration — with the INTERNAL_PATH_POLICY_VALUES symbol replaced by the explicit checkPathPolicy/resolvePathPolicy pair and the orphaned pathTokens/extractTokensForPathRules chain removed. Full suite green at 1972 tests (net change from a 1951 baseline: added ~40 new cases across 6 files, removed ~19 from the deleted pathTokens/extractTokensForPathRules blocks); check, lint, and fallow dead-code all clean.

Observations

  • One unplanned fixture fix: the bash path gate now resolves through checkPathPolicy, so makeHandler (handler-fixtures.ts) had to route permissionManager.checkPathPolicy through the same surface dispatcher as checkPermission — otherwise makeSurfaceCheck({ path: deny }) only stubbed checkPermission and the real tool-call.test.ts bash-path block silently passed allow. Caught by the full-suite run after step 7, not by the directly-edited test file.
  • The home-relative bash tests (~/.ssh/config, $HOME/.ssh/config) needed their makePathDispatchResolver byPath keys changed from the raw token to the home-expanded /mock/home/.ssh/config, because policy values now expand ~/$HOME before dispatch while the raw token is kept only for prompts. Relative-token tests were unaffected since the literal alias stays in policyValues.
  • Confirmed the PR's symbol stamp on descriptor.input was vestigial: runDescriptor uses preCheck whenever set and the bash path gate always sets it, so descriptor.input is never re-resolved — the simplified design drops the symbol with no behavior loss.
  • Two interface-break steps (manager checkPathPolicy, resolver resolvePathPolicy) each folded their fixture/inline-mock updates into the same commit; pnpm run check immediately after each caught the permission-resolver.test.ts inline fake manager that needed checkPathPolicy.
  • Deviation from the plan's Module-Level Changes: also updated docs/architecture/architecture.md (lines for bash-path.ts, bash-program.ts, bash-path-extractor.ts) — the plan didn't list it but its own guidance to check docs/architecture/ for stale module listings applied. Committed as a separate docs:.
  • Pre-completion reviewer: WARN (no FAILs). Sole finding — stale fixture descriptions in package-pi-permission-system/SKILL.md (makeResolver, makePathDispatchResolver, makeFakePermissionManager omitted the new stubs). Fixed in the final docs: commit before shipping, so the WARN is resolved.

Stage: Final Retrospective (2026-06-12T21:04:22Z)

Session summary

Shipped @gotgenes/pi-permission-system v13.0.0 — cwd-aware path-policy matching for tool inputs and bash tokens — across a single end-to-end session covering PR-review triage, planning, 9 TDD cycles, and shipping (CI green, issue closed, release-please PR #394 merged). The third-party PR #393 (@moekyo) was adopted as reference with a simplified design (explicit checkPathPolicy/resolvePathPolicy pair replacing the PR's INTERNAL_PATH_POLICY_VALUES symbol side-channel; orphaned pathTokens/extractTokensForPathRules chain removed). Two feat!: commits carried BREAKING CHANGE: footers; every implementation/docs commit credited Co-authored-by: moekyo.

Observations

What went well

  • Retro-as-bridge worked end-to-end. The PR-review stage recorded a precise Decide-gate decision (adopt + simplify, drop the symbol, remove the orphan chain, classify breaking). Planning read it, explicitly skipped re-running the third-party ask_user direction gate, and executed against it; TDD never re-litigated the direction. This is the multi-session lifecycle's context-bridge mechanism working as designed, collapsed into one session.
  • Pre-implementation code reading prevented carrying over dead mechanism. Reading runDescriptor (runner.ts) during planning revealed the PR's symbol stamp on descriptor.input was vestigial (the bash path gate always sets preCheck, so descriptor.input is never re-resolved). This directly shaped the simpler design — no rework, the insight was confirmed again during TDD.
  • Incremental verification caught breakage at the seam, not at the end. pnpm run check run immediately after each interface-break step (manager checkPathPolicy, resolver resolvePathPolicy) caught the inline fake manager in permission-resolver.test.ts the moment it broke.
  • The pre-completion reviewer earned its keep. It caught the stale SKILL.md fixture catalog that the implementation missed; fixed before ship.

What caused friction (agent side)

  • missing-context — adding checkPathPolicy to the manager required wiring it through the same surface dispatcher in makeHandler (handler-fixtures.ts); makeSurfaceCheck({ path: deny }) stubs only checkPermission, so the new method returned its fixture default and the real tool-call.test.ts bash-path block silently passed allow. Self-identified via the full-suite run after step 7 (not the directly-edited test file). Impact: ~3 tool calls to diagnose and fix; folded into the same step-7 commit, no extra commit. A false-green on a deny path is security-relevant, so this is the most consequential friction of the session despite being small.
  • missing-context — the package SKILL.md fixture catalog (makeResolver, makePathDispatchResolver, makeFakePermissionManager) was not updated when the new stubs were added; the pre-completion reviewer flagged it as the sole WARN. Impact: one extra docs: commit before ship; no rework, the safety net held.
  • other (plan/file-list divergence) — the plan's Module-Level Changes omitted docs/architecture/architecture.md, but the plan's own prose guidance to check docs/architecture/ for stale module listings applied; the listing referenced the removed pathTokens. Self-identified during the post-TDD cross-check. Impact: one extra docs: commit; no rework.

What caused friction (user side)

  • None. The operator's strategic input was front-loaded into the PR-review Decide gate (direction + breaking classification), which let every downstream stage proceed without mechanical oversight — the intended division of labor.

Diagnostic details

  • Model-performance correlation — one subagent dispatch (pre-completion-reviewer) ran on anthropic/claude-sonnet-4-6, a valid registry alias; judgment-heavy review work on sonnet-4-6 is appropriate, no mismatch. Parent session interleaved claude-opus-4-8 (design/triage/TDD) and claude-sonnet-4-6; no reasoning-weak model landed on judgment work.
  • Escalation-delay tracking — no rabbit-hole points; the longest single-error sequence was the makeHandler regression, resolved in ~3 tool calls (locate test → read fixture → grep → edit), well under the 5-call escalation threshold.
  • Unused-tool detection — no missing-context point would have been better served by an unused subagent or tool; the makeHandler gap was a runtime-behavior issue surfaced correctly by the full suite, not a search gap.
  • Feedback-loop gap analysis — verification was incremental throughout: pnpm run check after each interface-break step, full suite after the gate migration (step 7) where it caught the fixture regression, and check/lint/test/fallow before ship. No end-only verification pattern.

Changes made

  1. .pi/skills/package-pi-permission-system/SKILL.md — added a Testing-section note: when a gate resolves through a new manager/resolver method beyond checkPermission/resolve (e.g. checkPathPolicy/resolvePathPolicy), wire it through the same surface dispatcher in makeHandler (handler-fixtures.ts), or makeSurfaceCheck leaves the new method returning its default and the gate silently passes allow.
  2. packages/pi-permission-system/docs/retro/0393-normalize-path-policy-inputs.md — this Final Retrospective entry.

Considered but not implemented (recorded as no-ops): a redundant rule to update the SKILL.md fixture catalog when adding stubs (the pre-completion reviewer already catches this), and a generic tdd-plan.md/AGENTS.md interface-fixture-fanout rule (the lesson is package-specific to makeHandler's surface-dispatch indirection).