Files
my-pi/pi-permission-system/docs/plans/0504-retire-input-normalizer-path-normalization.md
T

241 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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 13 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 12 | 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, <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:
```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-surface>, { 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 ~292303): 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-surface>, { 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