19 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 504 | pi-permission-system: retire input-normalizer path normalization (Phase 7 Step 3) |
Retire input-normalizer path normalization (Phase 7 Step 3)
Release Recommendation
Release: ship now — batch "symlink-resistant-path-matching" tail (this issue completes the batch)
This is the tail of the three-step batch "symlink-resistant-path-matching" (Phase 7 Steps 1–3 of #487).
Steps 1 (#502) and 2 (#503) already landed their breaking feat!: parity changes on main, where they have been auto-batching, un-released, waiting for the tail.
This tail commit is itself a refactor: (dead-code removal, no observable behavior change) and a hidden changelog type that would not cut a release on its own — but its landing is the cue to ship the major-bump release that the two pending feat!: commits have been batching.
So at ship time, merge the release-please PR (the breaking parity changes drive the major bump); do not defer.
Problem Statement
After Steps 1 and 2, the per-tool path-bearing gate and the service/RPC policy queries emit access-path intents that the resolver unwraps to path-values, so input-normalizer.ts's lexical-only path derivation is dead.
Specifically, normalizePathSurfaceValues, the special-surface (path / external_directory) branch, and the PATH_BEARING_TOOLS branch of normalizeInput no longer receive any production caller for a real path value, and the platform/cwd parameters they consumed are dead.
Removing this dead path eliminates the duplicate, symlink-blind normalization the #487 vision targets and leaves a single AccessPath-based path-derivation entry.
Goals
- Remove
normalizePathSurfaceValues, the special-surface branch, and thePATH_BEARING_TOOLSbranch fromnormalizeInputinsrc/input-normalizer.ts. - Drop the now-unused
platformandcwdparameters fromnormalizeInput;normalizeInputthen handles only bash / skill / mcp / extension surfaces. - In
src/permission-manager.ts, stop passingthis.platform/this.currentCwdtonormalizeInput(thetoolbranch no longer normalizes paths), and remove the now-deadcurrentCwdfield and itsconfigureForCwdassignment. - Migrate the manager-level path-surface integration tests off the dead
tool-intent path branches and onto the productionpath-valuesintent. - Update the architecture doc and package skill to reflect the removed mechanism, and mark Phase 7 Step 3 complete.
This change is non-breaking: production path surfaces already route through access-path → path-values, so no observable decision, output shape, or default changes on upgrade.
Non-Goals
- Dissolving the
path-utils.tsgrab-bag (relocatinggetPathPolicyValuesand friends behindAccessPath) — that is Phase 7 Step 4 (#505). - Deciding/formalizing the
path-valuesboundary — Phase 7 Step 5 (#506). - Any change to
buildAccessIntentForSurface(the service/RPC path-query builder added in Step 2) — it stays, still consumingPATH_SURFACESandnormalizer.forPath. - Any change to the manager's own
SPECIAL_PERMISSION_KEYSconstant (permission-manager.ts:41) — it backsgetToolPermissionandderiveSourceand is unrelated to theinput-normalizer.tscopy being removed.
Background
Relevant modules and their current state after Steps 1 and 2:
src/input-normalizer.ts—normalizeInput(toolName, input, configuredMcpServerNames, platform, cwd)maps a raw tool invocation to a{ surface, values, resultExtras }triple. Thetool-intent branch ofPermissionManager.checkcalls it. Its special-surface andPATH_BEARING_TOOLSbranches both call the privatenormalizePathSurfaceValues→getPathPolicyValues(lexical only);platform/cwdexist only to feed that derivation.buildAccessIntentForSurface(the inverse, for service/RPC queries) also lives here and is unaffected.src/permission-manager.ts—check(intent)dispatches onintent.kind:path-valuesevaluates precomputed values directly;toolcallsnormalizeInput(..., this.platform, this.currentCwd).currentCwdis set only byconfigureForCwdand read only by thatnormalizeInputcall (verified by grep) — removing the call makes the field dead.src/handlers/gates/tool-call-gate-pipeline.ts—resolvePerToolCheckemitsaccess-pathfor a path-bearing tool with a presentinput.path(viagetPathBearingToolPath), and atoolintent only when the path is absent (missing-path case).src/handlers/gates/path.ts/external-directory-policy.ts— emitaccess-pathand carry apreCheck, so thepath/external_directorysurfaces never re-emit atoolintent through the runner.src/permission-resolver.ts—toResolvedIntentunwrapsaccess-path→path-valuesviaAccessPath.matchValues()(the sole unwrap site).
Constraints from AGENTS.md / package skill that apply:
- No
src/module may readprocess.platform; leaf functions take an injectedplatform. DroppingplatformfromnormalizeInputremoves a relay, not a guard exemption. - Keep schema/example/loader/docs aligned — this change touches no config field, so only the architecture doc and skill need updating.
pnpm fallow dead-codeis a CI gate; thecurrentCwdfield,normalizePathSurfaceValues, theSPECIAL_PERMISSION_KEYSconst, and the removed imports must all go, or fallow flags them.
Design Overview
Decision model: why the branches are dead
After Steps 1 and 2, every production path that once reached normalizeInput's path branches with a real value now routes elsewhere:
| Surface | Production path after Steps 1–2 | Reaches normalizeInput path branch? |
|---|---|---|
path |
gate emits access-path (preCheck) → resolver → path-values |
no |
external_directory |
gate emits access-path (preCheck) → resolver → path-values |
no |
read/write/edit/grep/find/ls with input.path |
pipeline emits access-path → resolver → path-values |
no |
path-bearing tool with no input.path |
pipeline emits tool → normalizeInput → ["*"] |
yes (collapses to ["*"]) |
| service/RPC path query | buildAccessIntentForSurface → access-path → resolver → path-values |
no |
The only surviving caller is the missing-path case, which produces ["*"].
After removal, that input falls through to the generic extension-tool branch (surface: toolName, values: ["*"]) — an identical result.
So the special-surface branch and the PATH_BEARING_TOOLS branch can be deleted with no behavior change.
Result equivalence for the missing-path / empty-input case
normalizeInput("read", {}) today (path-bearing branch): normalizePathSurfaceValues({}) → path is null → ["*"], surface: "read", resultExtras: {}.
After removal (generic branch): surface: "read", values: ["*"], resultExtras: {}.
buildCheckResult is unchanged — read ∈ PATH_SURFACES, so both feed evaluateAnyValue(surface, ["*"], …), and deriveSource("read") → "tool" either way.
The same holds for external_directory with empty input (["*"], source "special").
normalizeInput after the change
export function normalizeInput(
toolName: string,
input: unknown,
configuredMcpServerNames: readonly string[],
): NormalizedInput {
if (toolName === "skill") {
/* unchanged */
}
if (toolName === "bash") {
/* unchanged */
}
if (toolName === "mcp") {
/* unchanged */
}
// Extension tools (and path-bearing tools, now uniformly) → surface catch-all.
return { surface: toolName, values: ["*"], resultExtras: {} };
}
Removed: the SPECIAL_PERMISSION_KEYS const, the special-surface branch, the PATH_BEARING_TOOLS branch, the private normalizePathSurfaceValues, the platform/cwd parameters, and the getPathPolicyValues / PATH_BEARING_TOOLS imports.
Retained: PATH_SURFACES, getNonEmptyString, toRecord, stripBashCommentLines, createMcpPermissionTargets (all still used — PATH_SURFACES and getNonEmptyString by buildAccessIntentForSurface).
permission-manager.ts call site
The tool branch drops the two trailing arguments:
// kind === "tool"
const toolName = intent.surface.trim();
const { surface, values, resultExtras } = normalizeInput(
toolName,
intent.input,
this.loader.getConfiguredMcpServerNames(),
);
currentCwd (field declaration + the this.currentCwd = … line in configureForCwd) is removed; configureForCwd keeps its loader-rebuild logic, which already reads its cwd parameter directly, not the field.
Test migration (the bulk of the work)
test/permission-manager-unified.test.ts exercises path-surface pattern matching via the checkTool adapter, which builds a tool intent. ~30 calls pass a real { path: … } and rely on normalizeInput's path derivation; the ~39 empty-input checkTool(manager, <surface>, {}) calls produce ["*"] and are unaffected.
The ~30 real-path calls migrate to the production intent kind via a new local helper that computes the same lexical alias set normalizeInput produced today:
function checkPath(
manager: PermissionManager,
path: string,
opts: { cwd?: string } = {},
surface = "path",
agentName?: string,
sessionRules?: Ruleset,
): PermissionCheckResult {
return checkPathValues(
manager,
getPathPolicyValues(path, opts.cwd ? { cwd: opts.cwd } : {}, "linux"),
agentName,
sessionRules,
surface,
);
}
This is faithful: the old tool branch computed getPathPolicyValues(path, cwd ? { cwd } : {}, this.platform) (platform defaults to "linux" in these tests), and the path-values branch evaluates the same values through the same evaluateAnyValue with identical toolName/source/origin results.
So the migration is green against current production — a tidy-first preparatory step that decouples the manager integration tests from the doomed branches before they are removed.
getPathPolicyValues is imported from #src/path-utils (it stays — AccessPath consumes it).
Module-Level Changes
src/input-normalizer.ts— removenormalizePathSurfaceValues, theSPECIAL_PERMISSION_KEYSconst, the special-surface branch, and thePATH_BEARING_TOOLSbranch fromnormalizeInput; drop theplatform/cwdparameters; remove thegetPathPolicyValuesandPATH_BEARING_TOOLSimports. Update thenormalizeInputJSDoc (it documents the path/special handling).src/permission-manager.ts— dropthis.platform, this.currentCwdfrom thenormalizeInputcall incheck; remove thecurrentCwdfield declaration and its assignment inconfigureForCwd. Update thecheckJSDoc bullet"tool" → normalizes raw input through normalizeInputif it implies path handling.test/permission-manager-unified.test.ts— add thecheckPathhelper; migrate the ~30 real-pathcheckTool(manager, <path-surface>, { path })calls to it (preserving every assertion); importgetPathPolicyValuesfrom#src/path-utils. The ~39 empty-inputcheckToolcalls stay as-is.test/input-normalizer.test.ts— remove thespecial / path,special / external_directory, andpath-bearing toolsdescribe blocks; add a small block asserting the post-removal contract (normalizeInput("read"/"path"/"external_directory", { path: P })→{ surface, values: ["*"] }); drop the trailing"linux"argument from every remainingnormalizeInput(...)call (bash / skill / mcp / extension). ThebuildAccessIntentForSurfacedescribe block is unaffected.docs/architecture/architecture.md— rewrite the### Path-bearing tool normalizationsection (lines ~292–303): per-tool path patterns now match via theaccess-pathintent the per-tool gate emits (#502); the missing-path case falls through to the generic["*"]branch; home-expansion/alias derivation is attributed togetPathPolicyValues/AccessPath, notnormalizeInput. Mark Phase 7 Step 3 complete:✅on the Step 3 heading (line ~810) and theS3Mermaid node (line ~837). Update the migration tracker (line ~886) noting thenormalizePathSurfaceValuesretirement is now done. Adjust the high-levelnormalizeInput()pseudocode reference (line ~397) only if it now misleads; leave the conceptualevaluate()flow otherwise..pi/skills/package-pi-permission-system/SKILL.md— reword the deferred-follow-up note (line ~130) that describes "threading the extractor throughnormalizeInput":normalizeInputno longer derives paths, so describe the follow-up without naming it as the mechanism.
Note: docs/plans/** and docs/retro/** are historical snapshots and are not retroactively edited; the archive plans referencing normalizeInput are out of scope.
Test Impact Analysis
- New coverage enabled.
None required — this removes code rather than adding a collaborator.
The post-removal
normalizeInputcontract (path-bearing/special tool names collapse to["*"]) gains a focused unit assertion. - Tests that become redundant / simplified.
The
input-normalizer.test.tsspecial / path,special / external_directory, andpath-bearing toolsdescribe blocks test branches that no longer exist — removed. Their pattern-matching intent is preserved at the integration layer by the migratedpermission-manager-unified.test.tscheckPathtests (which now exercise the productionpath-valuesintent) and by the existing gate/resolver tests (path.test.ts,tool-call-gate-pipeline.test.ts) that drive theaccess-pathflow end to end. - Tests that must stay as-is.
The
buildAccessIntentForSurfaceblock ininput-normalizer.test.ts(the surviving path-query builder), the bash/skill/mcp/extensionnormalizeInputblocks (modulo the dropped"linux"arg), thecheck — path-values intentandcheck — tool intent (bash/read)blocks inpermission-manager-unified.test.ts, andpermission-resolver.test.ts(the unwrap site).
Invariants at risk
This change touches surfaces refactored by #393, #486, #502, and #503; their documented outcomes must stay green:
- #393 / #486 per-tool and
pathpattern matching (e.g.*.envdenies.envbut not.env.example) — pinned by the migratedcheckPathtests inpermission-manager-unified.test.ts(kept, just rerouted throughpath-values). - #502 per-tool gate emits
access-path; missing-path falls back to["*"]— pinned bytool-call-gate-pipeline.test.tsand the newnormalizeInput("read", {}) → ["*"]assertion. - #503 service/RPC path queries match lexical ∪ canonical — pinned by
service.test.ts/permission-event-rpc.test.ts; untouched here (buildAccessIntentForSurfaceis unchanged). - #438 cwd-bounding and external-directory semantics — pinned by the existing gate tests; the
tool-branch removal does not touch them.
No invariant lives only in prose; each has a pinning test.
TDD Order
test:migrate manager path-surface tests to thepath-valuesintent. Add thecheckPathhelper and thegetPathPolicyValuesimport totest/permission-manager-unified.test.ts; migrate the ~30 real-pathcheckTool(manager, <path-surface>, { path })calls tocheckPath, preserving every assertion. Green against current production (preparatory tidy-first — no production change). Run the full package suite to confirm green. Commit:test(pi-permission-system): route manager path-surface checks through path-values intent.test:red — assert the post-removalnormalizeInputcontract. Intest/input-normalizer.test.ts, replace the three path-related describe blocks with assertions thatnormalizeInput("read"/"path"/"external_directory", { path: ".env" }, [], "linux")yields{ surface, values: ["*"] }(keep the current 5-arg signature so the file still compiles); these fail against current code. Commit:test(pi-permission-system): expect normalizeInput to drop path special-casing.refactor:green — remove the dead path normalization. Insrc/input-normalizer.ts: deletenormalizePathSurfaceValues, theSPECIAL_PERMISSION_KEYSconst, the special-surface branch, thePATH_BEARING_TOOLSbranch, and thegetPathPolicyValues/PATH_BEARING_TOOLSimports; drop theplatform/cwdparameters; update JSDoc. Insrc/permission-manager.ts: drop the two trailing args from thenormalizeInputcall; remove thecurrentCwdfield and itsconfigureForCwdassignment. Intest/input-normalizer.test.ts: drop the trailing"linux"arg from every remainingnormalizeInput(...)call (this rides with the signature change —tscrejects the extra arg otherwise). Runpnpm run check(signature change), the full suite, andpnpm fallow dead-code. Commit:refactor(pi-permission-system): retire input-normalizer path normalization (#504).docs:update architecture + skill and mark Step 3 complete. Rewrite the### Path-bearing tool normalizationsection; mark Phase 7 Step 3✅(heading +S3Mermaid node); update the migration tracker; reword the SKILL deferred-follow-up note. Commit:docs(pi-permission-system): retire path-bearing normalization; mark Phase 7 Step 3 done (#504).
Risks and Mitigations
- Risk: a missed
checkToolpath call breaks at runtime, nottsc(esbuild skips types; thetoolintent shape is unchanged). Mitigation: Step 1 lands green first and the full suite runs after each step; any unmigrated real-pathcheckToolsurfaces as a red in Step 3's full-suite run. - Risk:
currentCwdremoval silently changes loader behavior. Mitigation: grep confirmsconfigureForCwdrebuilds the loader from itscwdparameter, not the field; the field is read only by the removednormalizeInputcall. TheconfigureForCwdtests (permission-manager-unified.test.ts) pin loader behavior. - Risk: a stale fallow suppression or a now-unused import survives (the #502 lesson).
Mitigation: run
pnpm fallow dead-codeafter Step 3, before the docs commit. - Risk: the architecture-doc rewrite drifts from the code (per-tool patterns still work, only the mechanism moved).
Mitigation: the rewrite attributes per-tool matching to the
access-pathgate (#502) and keeps the feature description (read: { *.env: deny }) intact.
Open Questions
None. The proposal is unambiguous, operator-authored, and roadmap-blessed; no follow-up issues are filed.