--- issue: 504 issue_title: "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 the `PATH_BEARING_TOOLS` branch from `normalizeInput` in `src/input-normalizer.ts`. - Drop the now-unused `platform` and `cwd` parameters from `normalizeInput`; `normalizeInput` then handles only bash / skill / mcp / extension surfaces. - In `src/permission-manager.ts`, stop passing `this.platform` / `this.currentCwd` to `normalizeInput` (the `tool` branch no longer normalizes paths), and remove the now-dead `currentCwd` field and its `configureForCwd` assignment. - Migrate the manager-level path-surface integration tests off the dead `tool`-intent path branches and onto the production `path-values` intent. - 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.ts` grab-bag (relocating `getPathPolicyValues` and friends behind `AccessPath`) — that is Phase 7 Step 4 ([#505]). - Deciding/formalizing the `path-values` boundary — Phase 7 Step 5 ([#506]). - Any change to `buildAccessIntentForSurface` (the service/RPC path-query builder added in Step 2) — it stays, still consuming `PATH_SURFACES` and `normalizer.forPath`. - Any change to the manager's own `SPECIAL_PERMISSION_KEYS` constant (`permission-manager.ts:41`) — it backs `getToolPermission` and `deriveSource` and is unrelated to the `input-normalizer.ts` copy 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. The `tool`-intent branch of `PermissionManager.check` calls it. Its special-surface and `PATH_BEARING_TOOLS` branches both call the private `normalizePathSurfaceValues` → `getPathPolicyValues` (lexical only); `platform`/`cwd` exist 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 on `intent.kind`: `path-values` evaluates precomputed values directly; `tool` calls `normalizeInput(..., this.platform, this.currentCwd)`. `currentCwd` is set only by `configureForCwd` and read only by that `normalizeInput` call (verified by grep) — removing the call makes the field dead. - `src/handlers/gates/tool-call-gate-pipeline.ts` — `resolvePerToolCheck` emits `access-path` for a path-bearing tool with a present `input.path` (via `getPathBearingToolPath`), and a `tool` intent only when the path is absent (missing-path case). - `src/handlers/gates/path.ts` / `external-directory-policy.ts` — emit `access-path` and carry a `preCheck`, so the `path` / `external_directory` surfaces never re-emit a `tool` intent through the runner. - `src/permission-resolver.ts` — `toResolvedIntent` unwraps `access-path` → `path-values` via `AccessPath.matchValues()` (the sole unwrap site). Constraints from AGENTS.md / package skill that apply: - No `src/` module may read `process.platform`; leaf functions take an injected `platform`. Dropping `platform` from `normalizeInput` removes 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-code` is a CI gate; the `currentCwd` field, `normalizePathSurfaceValues`, the `SPECIAL_PERMISSION_KEYS` const, 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 ```typescript 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: ```typescript // 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, , {})` 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: ```typescript 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` — remove `normalizePathSurfaceValues`, the `SPECIAL_PERMISSION_KEYS` const, the special-surface branch, and the `PATH_BEARING_TOOLS` branch from `normalizeInput`; drop the `platform`/`cwd` parameters; remove the `getPathPolicyValues` and `PATH_BEARING_TOOLS` imports. Update the `normalizeInput` JSDoc (it documents the path/special handling). - `src/permission-manager.ts` — drop `this.platform, this.currentCwd` from the `normalizeInput` call in `check`; remove the `currentCwd` field declaration and its assignment in `configureForCwd`. Update the `check` JSDoc bullet `"tool" → normalizes raw input through normalizeInput` if it implies path handling. - `test/permission-manager-unified.test.ts` — add the `checkPath` helper; migrate the ~30 real-path `checkTool(manager, , { path })` calls to it (preserving every assertion); import `getPathPolicyValues` from `#src/path-utils`. The ~39 empty-input `checkTool` calls stay as-is. - `test/input-normalizer.test.ts` — remove the `special / path`, `special / external_directory`, and `path-bearing tools` describe 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 remaining `normalizeInput(...)` call (bash / skill / mcp / extension). The `buildAccessIntentForSurface` describe block is unaffected. - `docs/architecture/architecture.md` — rewrite the `### Path-bearing tool normalization` section (lines ~292–303): per-tool path patterns now match via the `access-path` intent the per-tool gate emits ([#502]); the missing-path case falls through to the generic `["*"]` branch; home-expansion/alias derivation is attributed to `getPathPolicyValues`/`AccessPath`, not `normalizeInput`. Mark Phase 7 Step 3 complete: `✅` on the Step 3 heading (line ~810) and the `S3` Mermaid node (line ~837). Update the migration tracker (line ~886) noting the `normalizePathSurfaceValues` retirement is now done. Adjust the high-level `normalizeInput()` pseudocode reference (line ~397) only if it now misleads; leave the conceptual `evaluate()` flow otherwise. - `.pi/skills/package-pi-permission-system/SKILL.md` — reword the deferred-follow-up note (line ~130) that describes "threading the extractor through `normalizeInput`": `normalizeInput` no 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 1. **New coverage enabled.** None required — this removes code rather than adding a collaborator. The post-removal `normalizeInput` contract (path-bearing/special tool names collapse to `["*"]`) gains a focused unit assertion. 2. **Tests that become redundant / simplified.** The `input-normalizer.test.ts` `special / path`, `special / external_directory`, and `path-bearing tools` describe blocks test branches that no longer exist — removed. Their pattern-matching intent is preserved at the integration layer by the migrated `permission-manager-unified.test.ts` `checkPath` tests (which now exercise the production `path-values` intent) and by the existing gate/resolver tests (`path.test.ts`, `tool-call-gate-pipeline.test.ts`) that drive the `access-path` flow end to end. 3. **Tests that must stay as-is.** The `buildAccessIntentForSurface` block in `input-normalizer.test.ts` (the surviving path-query builder), the bash/skill/mcp/extension `normalizeInput` blocks (modulo the dropped `"linux"` arg), the `check — path-values intent` and `check — tool intent (bash/read)` blocks in `permission-manager-unified.test.ts`, and `permission-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 `path` pattern matching** (e.g. `*.env` denies `.env` but not `.env.example`) — pinned by the migrated `checkPath` tests in `permission-manager-unified.test.ts` (kept, just rerouted through `path-values`). - **[#502] per-tool gate emits `access-path`; missing-path falls back to `["*"]`** — pinned by `tool-call-gate-pipeline.test.ts` and the new `normalizeInput("read", {}) → ["*"]` assertion. - **[#503] service/RPC path queries match lexical ∪ canonical** — pinned by `service.test.ts` / `permission-event-rpc.test.ts`; untouched here (`buildAccessIntentForSurface` is 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 1. **`test:` migrate manager path-surface tests to the `path-values` intent.** Add the `checkPath` helper and the `getPathPolicyValues` import to `test/permission-manager-unified.test.ts`; migrate the ~30 real-path `checkTool(manager, , { path })` calls to `checkPath`, 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`. 2. **`test:` red — assert the post-removal `normalizeInput` contract.** In `test/input-normalizer.test.ts`, replace the three path-related describe blocks with assertions that `normalizeInput("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`. 3. **`refactor:` green — remove the dead path normalization.** In `src/input-normalizer.ts`: delete `normalizePathSurfaceValues`, the `SPECIAL_PERMISSION_KEYS` const, the special-surface branch, the `PATH_BEARING_TOOLS` branch, and the `getPathPolicyValues`/`PATH_BEARING_TOOLS` imports; drop the `platform`/`cwd` parameters; update JSDoc. In `src/permission-manager.ts`: drop the two trailing args from the `normalizeInput` call; remove the `currentCwd` field and its `configureForCwd` assignment. In `test/input-normalizer.test.ts`: drop the trailing `"linux"` arg from every remaining `normalizeInput(...)` call (this rides with the signature change — `tsc` rejects the extra arg otherwise). Run `pnpm run check` (signature change), the full suite, and `pnpm fallow dead-code`. Commit: `refactor(pi-permission-system): retire input-normalizer path normalization (#504)`. 4. **`docs:` update architecture + skill and mark Step 3 complete.** Rewrite the `### Path-bearing tool normalization` section; mark Phase 7 Step 3 `✅` (heading + `S3` Mermaid 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 `checkTool` path call breaks at runtime, not `tsc`** (esbuild skips types; the `tool` intent shape is unchanged). Mitigation: Step 1 lands green first and the full suite runs after each step; any unmigrated real-path `checkTool` surfaces as a red in Step 3's full-suite run. - **Risk: `currentCwd` removal silently changes loader behavior.** Mitigation: grep confirms `configureForCwd` rebuilds the loader from its `cwd` parameter, not the field; the field is read only by the removed `normalizeInput` call. The `configureForCwd` tests (`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-code` after 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-path` gate ([#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. [#393]: https://github.com/gotgenes/pi-packages/issues/393 [#438]: https://github.com/gotgenes/pi-packages/issues/438 [#486]: https://github.com/gotgenes/pi-packages/issues/486 [#487]: https://github.com/gotgenes/pi-packages/issues/487 [#502]: https://github.com/gotgenes/pi-packages/issues/502 [#503]: https://github.com/gotgenes/pi-packages/issues/503 [#505]: https://github.com/gotgenes/pi-packages/issues/505 [#506]: https://github.com/gotgenes/pi-packages/issues/506