--- issue: 393 issue_title: "fix(pi-permission-system): normalize path policy inputs" --- # Normalize path policy inputs ## Problem Statement The path gates already know the current Pi working directory — `PermissionManager.configureForCwd` records it — but the evaluator never used it. So a relative tool input or bash token such as `src/App.jsx` could never match an absolute allowlist rule such as `/workspace/project/*`, even though the two name the same file. After extension and MCP tools started flowing through the same path gates ([#352]), this gap became more visible: relative inputs silently miss absolute rules. The fix is to feed the evaluator a set of equivalent "policy values" for a path (absolute, project-relative, and raw relative), derived from the known working directory, and to match them with last-match-wins preserved across the aliases so a catch-all on one spelling cannot mask a later specific rule on another. This plan adopts that capability from third-party PR [#393] (`@moekyo`) but plans a simplified design: the PR is reference, not the merge target. The direction, scope, and breaking classification were confirmed by the operator during the PR Review stage (recorded in the retro) — the `Decide` gate is satisfied and is not re-litigated here. ## Goals - Derive equivalent path-policy lookup values (absolute, project-relative, raw) for path surfaces when the working directory is known. - Match those values with last-match-wins preserved **across** aliases of the same path, so an early catch-all match cannot mask a later, more specific rule. - Make the bash `path` gate use cd-aware policy values for literal current-shell `cd` commands, while keeping prompts, logs, and session approvals on the raw token. - Pass bash's per-token resolution context **explicitly** through a dedicated resolver/manager method — not as a symbol-keyed side-channel on the tool `input` object. - Remove the now-orphaned `pathTokens()` / `extractTokensForPathRules` chain rather than suppressing the dead-code flag. - Document the matching semantics in the README, configuration guide, and JSON schema description. - **This change is breaking.** It flips permission decisions on upgrade with no config edit: a relative input under a config like `path: { "*": "ask", "/workspace/project/*": "allow" }` moves from `ask` to `allow`. For a least-privilege package that loosening is breaking — use `feat!:` with a `BREAKING CHANGE:` footer. ## Non-Goals - Adding a config flag to opt out of cwd-aware matching — the flat permission model stays as-is; no new config surface. - Changing MCP target resolution: MCP keeps `evaluateFirst` (its candidates are genuinely different targets, not aliases of one path). - Per-tool path maps for extension tools (threading the access extractor through `normalizeInput`) — a deferred follow-up noted in [#352]. - Reworking `extractExternalPathsFromBashCommand` or the `external_directory` token classifier — out of scope; only the orphaned `pathTokens` surface is removed. ## Background Relevant existing modules: - `src/path-utils.ts` — `normalizePathForComparison` (lexical cleanup + resolve against cwd), `PATH_SURFACES` (the set of path-matching surfaces), `PATH_BEARING_TOOLS`. - `src/rule.ts` — `evaluate` (last-match-wins over one value), `evaluateFirst` (first-non-default across MCP candidates). - `src/input-normalizer.ts` — `normalizeInput(toolName, input, mcpServerNames)` maps a tool call to `{ surface, values, resultExtras }`; `normalizePathSurfaceValue` extracts and home-expands `input.path`. - `src/permission-manager.ts` — `configureForCwd` already records the cwd via the loader; `checkPermission` calls `normalizeInput` then `evaluateFirst`. `ScopedPermissionManager` is the narrow interface gates depend on. - `src/permission-resolver.ts` — `ScopedPermissionResolver.resolve(surface, input, agentName)`; the bash path gate's sole evaluation entry point. - `src/handlers/gates/bash-program.ts` — parses a command once; `rawCandidates` pairs each path token with its `EffectiveBase` (the effective dir after folding literal `cd` commands). `pathTokens()` returns deduplicated rule-candidate tokens (no cwd resolution). - `src/handlers/gates/bash-path.ts` — `describeBashPathGate` evaluates each `pathTokens()` token against the `path` surface and returns the most restrictive result; always sets `preCheck`. - `src/handlers/gates/bash-path-extractor.ts` — `extractTokensForPathRules` (thin facade over `pathTokens`, test-only) and `extractExternalPathsFromBashCommand` (used by the external-directory gate path; **kept**). - `src/handlers/gates/runner.ts` — `runDescriptor` uses `descriptor.preCheck` when set and **only** calls `resolver.resolve(descriptor.surface, descriptor.input, …)` when `preCheck` is absent. Constraints from AGENTS.md / package skill that apply: - Default to least privilege; a loosening behavior change is breaking. - Keep schema, example config, `docs/configuration.md`, `README.md`, and TS types/loaders aligned. - "Treat any declared field not read at runtime as a maintenance trap" — drives removing the orphaned `pathTokens` chain. - Do not smuggle policy through a symbol on raw tool `input`; pass resolution context explicitly. ## Design Overview ### Equivalent path-policy values (path-utils.ts) Add a pure value-deriver shared by every path surface: ```typescript export interface PathPolicyValueOptions { /** Current Pi working directory; enables a project-relative alias. */ cwd?: string; /** Directory used to resolve into an absolute value. Defaults to cwd; bash * passes the effective dir after a literal cd. */ resolveBase?: string; } /** Lexical cleanup only — trim, strip wrapping quotes, strip leading `@`, * expand `~`/`$HOME`. No cwd resolution. Preserves `src/*`, `*.env` rules. */ export function normalizePathPolicyLiteral(pathValue: string): string; /** Equivalent lookup values, most-specific first: * [ absolute (resolved against resolveBase ?? cwd), * project-relative (when inside cwd), * raw literal ] — deduped. `"*"` and empty collapse to themselves. */ export function getPathPolicyValues( pathValue: string, options?: PathPolicyValueOptions, ): string[]; ``` When no base is available, `getPathPolicyValues` returns just the literal — so behavior with cwd unknown is unchanged. ### Alias-aware evaluation (rule.ts) Add `evaluateAnyValue`, distinct from `evaluateFirst`: ```typescript /** Last rule that matches ANY alias wins (last-match-wins across aliases). * Lets an absolute allowlist and a legacy relative rule coexist without an * early catch-all masking a later specific rule. */ export function evaluateAnyValue( surface: string, values: string[], rules: Ruleset, platform?: NodeJS.Platform, ): { rule: Rule; value: string }; ``` Refactor the surface/pattern match in `evaluate` into a private `ruleMatches(rule, surface, value, platform)` (and `pathMatchOptions`) so `evaluate` and `evaluateAnyValue` share the Windows case/separator folding. This is a pure internal refactor — no behavior change to `evaluate`. ### Manager: cwd plumbing + explicit path-policy entry (permission-manager.ts) - Capture `currentCwd` in `configureForCwd` (trimmed, empty → `undefined`). - Thread `currentCwd` into `normalizeInput`. - For `PATH_SURFACES`, evaluate with `evaluateAnyValue`; MCP and all other surfaces keep `evaluateFirst`. - Add an explicit method so the bash gate can evaluate its own precomputed values without a side-channel: ```typescript export interface ScopedPermissionManager { // …existing… checkPathPolicy( values: readonly string[], agentName?: string, sessionRules?: Ruleset, ): PermissionCheckResult; } ``` `checkPathPolicy` composes rules + session rules exactly as `checkPermission`, then evaluates `evaluateAnyValue("path", values, fullRules)` and builds a `PermissionCheckResult` with `toolName: "path"`, `source: "special"`. Extract the shared post-evaluation result-building (rule dispatch + extras + source/pattern derivation) into a private helper used by both `checkPermission` and `checkPathPolicy` — same synchronous lifecycle, genuine duplication, safe to extract. This method is the explicit replacement for the PR's `INTERNAL_PATH_POLICY_VALUES` symbol. It is safe by construction: the values come only from the bash gate's own computation, never from a string-keyed field on untrusted tool `input`, so there is no spoofing surface. ### Resolver: narrow delegating method (permission-resolver.ts) ```typescript export interface ScopedPermissionResolver { resolve(surface: string, input: unknown, agentName?: string): PermissionCheckResult; resolvePathPolicy( values: readonly string[], agentName?: string, ): PermissionCheckResult; } ``` `PermissionResolver.resolvePathPolicy` delegates to `manager.checkPathPolicy(values, agentName, this.sessionRules.getRuleset())`, mirroring how `resolve` composes the session ruleset. ### Bash program: cd-aware candidates (bash-program.ts) Add `pathRuleCandidates(cwd?)` returning the raw token (for prompts) paired with policy values: ```typescript export interface BashPathRuleCandidate { readonly token: string; // raw — prompts, logs, approvals readonly policyValues: readonly string[]; // cd-aware — policy matching } pathRuleCandidates(cwd?: string): BashPathRuleCandidate[]; ``` A private `getPolicyValuesForRuleCandidate(candidate, base, cwd)` owns the cd semantics: - No `cwd` → literal only (unchanged behavior). - `base.kind === "unknown"` (non-literal `cd "$DIR"`, `cd -`, bare `cd`) on a relative token → literal only (conservative — do not invent an absolute alias). - Otherwise → `getPathPolicyValues(candidate, { cwd, resolveBase: base.kind === "known" ? resolve(cwd, base.offset) : cwd })`. ### Bash path gate: consume candidates, resolve explicitly (bash-path.ts) Call site (verifies Tell-Don't-Ask / LoD — bash tells the resolver "resolve these values", no reach-through): ```typescript const candidates = bashProgram.pathRuleCandidates(tcc.cwd); if (candidates.length === 0) return null; for (const { token, policyValues } of candidates) { const check = resolver.resolvePathPolicy(policyValues, tcc.agentName ?? undefined); // …existing backward-compat (#58), session-cover, deny/ask aggregation… // descriptor uses `token` for pattern, prompt, log, decision, and input } ``` The descriptor keeps `input: { path: worstToken }` and `preCheck: worstCheck`. Because `runDescriptor` uses `preCheck` whenever it is set (and the bash path gate always sets it), `descriptor.input` is never re-resolved — so no symbol on `input` is needed (the PR's stamp was vestigial). ### Edge cases - cwd unknown → single literal value; identical to current behavior. - Token outside cwd (e.g. `/etc/hosts`) → absolute + literal, no project-relative alias. - Non-literal `cd` before a relative token → literal-only policy values (no spurious absolute allow). - `"*"` / empty path → `["*"]` (surface catch-all), unchanged. ## Module-Level Changes - `src/path-utils.ts` — add `PathPolicyValueOptions`, `normalizePathPolicyLiteral`, `getPathPolicyValues` (+ private `getAbsolutePathPolicyValues`, `getCwdRelativePathPolicyValues`); import `relative` from `node:path`. - `src/rule.ts` — add `evaluateAnyValue`; extract private `ruleMatches` + `pathMatchOptions` from `evaluate`. - `src/input-normalizer.ts` — add optional `cwd` param to `normalizeInput`; `normalizePathSurfaceValue` → `normalizePathSurfaceValues` returning `string[]` via `getPathPolicyValues`. **No** `INTERNAL_PATH_POLICY_VALUES` symbol; no `normalizeOptionalStringArray` import. - `src/permission-manager.ts` — add `currentCwd` field; capture in `configureForCwd`; thread into `normalizeInput`; dispatch `PATH_SURFACES` to `evaluateAnyValue`; add `checkPathPolicy` to `ScopedPermissionManager` + class; extract shared result-builder helper. - `src/permission-resolver.ts` — add `resolvePathPolicy` to `ScopedPermissionResolver` + `PermissionResolver`. - `src/handlers/gates/bash-program.ts` — add `BashPathRuleCandidate`, `pathRuleCandidates`, private `getPolicyValuesForRuleCandidate`; **remove** `pathTokens()`. - `src/handlers/gates/bash-path.ts` — consume `pathRuleCandidates(tcc.cwd)` and `resolver.resolvePathPolicy`; keep raw `token` for presentation. - `src/handlers/gates/bash-path-extractor.ts` — **remove** `extractTokensForPathRules` (orphaned after the gate migrates); keep `extractExternalPathsFromBashCommand`. - `test/helpers/session-fixtures.ts` — add `checkPathPolicy` to `makeFakePermissionManager`. - `test/helpers/gate-fixtures.ts` — add `resolvePathPolicy` to `makeResolver`, `makeGateRunner`'s resolver, and `makePathDispatchResolver`. - `test/handlers/gates/tool-call-gate-pipeline.test.ts` — mock `pathRuleCandidates` instead of `pathTokens`. - Tests: `test/path-utils.test.ts`, `test/rule.test.ts`, `test/input-normalizer.test.ts`, `test/permission-manager-unified.test.ts`, `test/permission-resolver.test.ts`, `test/handlers/gates/bash-program.test.ts`, `test/handlers/gates/bash-path.test.ts`, `test/bash-external-directory.test.ts` (remove `extractTokensForPathRules` block). - Docs/schema: `README.md`, `docs/configuration.md`, `schemas/permissions.schema.json` (markdownDescription). Before finalizing, grep `src/`, `test/`, and `.pi/skills/package-pi-permission-system/SKILL.md` for `pathTokens` and `extractTokensForPathRules` to confirm every reference is removed or migrated. Any test file that constructs a `ScopedPermissionManager` or `ScopedPermissionResolver` mock inline (not via the shared fixtures) must gain the new method in the same step the interface changes — grep both interface names across `test/`. ## Test Impact Analysis 1. **New tests enabled by the change:** - `getPathPolicyValues` / `normalizePathPolicyLiteral` unit tests (cwd present/absent, inside/outside cwd, `resolveBase`, `"*"`, quotes/`@`/`~`). - `evaluateAnyValue` unit tests (last-match-wins across aliases; absolute-alias fallback). - `pathRuleCandidates` unit tests (relative→absolute+relative; literal `cd`; unknown `cd`→literal only). - `normalizeInput` cwd-alias tests for path surfaces. - Manager cwd-aware path-policy tests (relative input vs. absolute allowlist; legacy relative still works; last-match-wins; cross-cutting `path` surface). - `resolvePathPolicy` delegation test. - `bash-path` cd-aware policy-value test (resolves against literal `cd`, preserves raw prompt token) and unknown-`cd` conservative test. 2. **Redundant tests removed:** the `pathTokens` describe block (`bash-program.test.ts`) and the `extractTokensForPathRules` describe block (`bash-external-directory.test.ts`) — both exercise the orphaned chain being deleted. 3. **Tests that must stay as-is:** the `extractExternalPathsFromBashCommand` suite (`bash-external-directory.test.ts`) — it exercises the kept external-directory extraction, untouched by this change. The PR's symbol-spoofing tests are **not** ported (the symbol is not implemented); instead a single `normalizeInput` test locks the no-side-channel property by asserting an extra `pathPolicyValues`-like key on `input` is ignored. ## TDD Order 1. **path-utils policy values.** Surface: `test/path-utils.test.ts`. Add `normalizePathPolicyLiteral` + `getPathPolicyValues` (pure functions); red tests for cwd/resolveBase/outside-cwd/`"*"`/quotes. Commit: `feat: add path-policy value derivation (#393)`. 2. **Alias-aware evaluation.** Surface: `test/rule.test.ts`. Add `evaluateAnyValue`; refactor `ruleMatches`/`pathMatchOptions` (no behavior change to `evaluate`). Commit: `feat: add alias-aware evaluateAnyValue (#393)`. 3. **normalizeInput cwd aliases.** Surface: `test/input-normalizer.test.ts`. Add optional `cwd`; `normalizePathSurfaceValues` returns aliases via `getPathPolicyValues`; add the no-side-channel test. The single call site (`permission-manager.ts`) compiles unchanged (optional param). Commit: `feat: normalize path inputs to cwd-aware policy values (#393)`. 4. **Manager: cwd plumbing, evaluateAnyValue, checkPathPolicy.** Surface: `test/permission-manager-unified.test.ts` (+ `session-fixtures.ts`). Capture `currentCwd`; thread into `normalizeInput`; dispatch `PATH_SURFACES` → `evaluateAnyValue`; add `checkPathPolicy` to interface + class + shared result-builder; add `checkPathPolicy` to `makeFakePermissionManager` and any inline `ScopedPermissionManager` mock (same commit — interface break). This flips tool/path-surface decisions for relative inputs. Commit: `feat!: match relative path inputs against absolute allowlists (#393)` with a `BREAKING CHANGE:` footer. Run `pnpm run check` immediately (shared-interface change). 5. **Resolver: resolvePathPolicy.** Surface: `test/permission-resolver.test.ts` (+ `gate-fixtures.ts`). Add `resolvePathPolicy` to `ScopedPermissionResolver` + `PermissionResolver`; add it to `makeResolver`, `makeGateRunner`, `makePathDispatchResolver` and any inline resolver mock (same commit — interface break). Commit: `feat: add resolvePathPolicy resolver method (#393)`. Run `pnpm run check` immediately. 6. **Bash program: pathRuleCandidates (additive).** Surface: `test/handlers/gates/bash-program.test.ts`. Add `BashPathRuleCandidate` + `pathRuleCandidates` + `getPolicyValuesForRuleCandidate`; keep `pathTokens` for now (lift-and-shift). Commit: `feat: add cd-aware pathRuleCandidates to BashProgram (#393)`. 7. **Bash path gate: migrate to candidates + explicit resolve.** Surface: `test/handlers/gates/bash-path.test.ts` (+ `tool-call-gate-pipeline.test.ts` mock → `pathRuleCandidates`). Switch `describeBashPathGate` to `pathRuleCandidates(tcc.cwd)` + `resolver.resolvePathPolicy`; keep raw `token` for prompt/log/approval/`input`. This makes bash tokens cd-aware against absolute rules. Commit: `feat!: resolve bash path tokens with cd-aware policy values (#393)` with a `BREAKING CHANGE:` footer. 8. **Remove the orphaned pathTokens chain.** Surface: `bash-program.ts`, `bash-path-extractor.ts`, `bash-program.test.ts`, `bash-external-directory.test.ts`. Remove `BashProgram.pathTokens`, `extractTokensForPathRules`, and their tests; re-check orphaned imports. Commit: `refactor: remove orphaned bash pathTokens extraction (#393)`. 9. **Docs + schema.** Surface: `README.md`, `docs/configuration.md`, `schemas/permissions.schema.json`. Document cwd-aware matching and the bash literal-`cd` behavior. Commit: `docs: document cwd-aware path policy matching (#393)`. Every implementation/docs commit carries `Co-authored-by: moekyo ` and references the PR as `(#393)` / `Refs #393` — never `Closes #393`. ## Risks and Mitigations - **Silent gate loosening on upgrade (security).** Mitigation: classify as breaking (`feat!:`), with a `BREAKING CHANGE:` footer and migration note in the README/configuration guide; the close comment thanks `@moekyo` and links the implementing SHAs. Do not name a config opt-out — none exists; the note explains that absolute allowlists now also cover their relative spellings and that tighter control needs narrower patterns or a `path` deny. - **Interface break cascades to mocks.** Mitigation: steps 4 and 5 fold every fixture/inline-mock update into the same commit; `pnpm run check` runs immediately after each. - **Removing `pathTokens` while a caller remains.** Mitigation: lift-and-shift — add `pathRuleCandidates` (step 6), migrate the gate (step 7), delete `pathTokens`/`extractTokensForPathRules` only after no src caller remains (step 8). - **Bash cd resolution over-reaching on non-literal `cd`.** Mitigation: `unknown` base yields literal-only policy values; covered by an explicit conservative test in steps 6–7. ## Open Questions - Whether to later expose cwd-aware matching as an opt-out config flag if users report unexpected loosening — deferred until there is a concrete request. - Whether the `external_directory` token classifier should adopt the same alias derivation — out of scope; revisit if a parallel gap surfaces. [#352]: https://github.com/gotgenes/pi-packages/issues/352 [#393]: https://github.com/gotgenes/pi-packages/issues/393