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

290 lines
20 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: 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 <shigotods@outlook.com>` 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 67.
## 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