--- issue: 438 issue_title: "pi-permission-system: Session approval for path-bearing tools on files in the current working directory never matches (always re-prompts)" --- # Bound session approval for current-directory files ## Release Recommendation **Release:** ship independently Issue #438 is a standalone bug fix. It is not a member of any architecture-roadmap phase or release batch, so it ships on its own once verified. ## Problem Statement When a path-bearing tool (`edit`, `write`, `read`, …) acts on a file directly in the current working directory (e.g. `index.html`), choosing "Allow for this session" never sticks — every subsequent call on that file (or any other root-level file) prompts again. The root cause is a pattern/value mismatch in `deriveApprovalPattern()`. For a root-level relative path the function takes `dirname("index.html") === "."`, builds the prefix `"./"`, and returns the glob `"./*"`. But the values the rule is later matched against come from `getPathPolicyValues()` and carry **no** `"./"` prefix: `["/index.html", "index.html"]`. The compiled regex for `"./*"` is `^\.\/.*$`, which requires the value to start with `./`, so neither value matches. The session rule is recorded but is dead — the next call falls back to the configured default (`ask`) and re-prompts. The issue's reproduction configures the `edit` **tool** surface (`edit: { "*": "ask" }`), so the primary affected gate is the per-tool gate (`describeToolGate` → `suggestSessionPattern`), with the cross-cutting `path` gate and the bash `path` gate affected by the same root-relative case. ## Goals - A "Allow for this session" choice on a current-working-directory file stops further prompts for that file (and other files reachable under CWD) for the rest of the session. - The fix is **bounded to the working-directory subtree**: approving a CWD-root file must not silently approve paths outside CWD. - The fix covers every gate that derives a session-approval pattern from a possibly-relative path: the per-tool gate, the cross-cutting `path` gate, and the bash `path` gate. - No regression to the existing sub-directory behavior (`src/foo.ts` → `src/*`) or to its dialog label readability. This change is **not breaking**: it only makes a currently-dead session rule live, and only for the CWD-root case that previously re-prompted. No existing pattern, default, or output shape changes for paths that already matched. ## Non-Goals - Changing the directory-glob semantics for sub-directory files (`src/foo.ts` keeps deriving the relative `src/*`). - Re-deriving approval patterns from absolute paths for the common sub-directory case (would make dialog labels show long absolute paths — see Design). - Fixing the `cwd`-absent edge case (no working directory threaded to the gate). With no CWD there is no absolute policy value to bind to; the function keeps its current safe-but-re-prompting `"./*"` output. This path is unreachable for real tool calls (`tcc.cwd` is always set), so it is left as documented behavior, not a fix target. - The Windows backslash variant (`.\*`) is the same code path — `dirname` collapses both to `.`, and the bounded fix produces a single normalized pattern — so no separate Windows-only branch is added. ## Background Relevant modules: - `src/session-rules.ts` — `deriveApprovalPattern(normalizedPath)` turns a path into a directory-scoped glob (`/*`) recorded as a session `allow` rule. This is the single function with the bug. - `src/pattern-suggest.ts` — `suggestSessionPattern(surface, value)` wraps `deriveApprovalPattern` for the per-tool gate and builds the dialog label (`buildLabel`). - `src/handlers/gates/tool.ts` — `describeToolGate` / `deriveSuggestionValue`: the per-tool gate, the **primary** repro path. For a path-bearing tool it passes the raw `input.path` (e.g. `index.html`) to `suggestSessionPattern`. - `src/handlers/gates/path.ts` — `describePathGate`: the cross-cutting `path` gate, passes the raw `filePath` to `deriveApprovalPattern`. - `src/handlers/gates/bash-path.ts` — `describeBashPathGate`: the bash `path` gate, passes the raw `worstToken`. - `src/handlers/gates/external-directory.ts` — already passes the **absolute** path (`normalizePathForComparison(...)`) to `deriveApprovalPattern`, so external-directory approvals are not affected (an external path's `dirname` is never `.`). - `src/path-utils.ts` — `getPathPolicyValues` / `normalizePathForComparison` produce the policy values and the canonical absolute form. Relevant invariant (from the `package-pi-permission-system` skill): "Wildcard matching must be explicit and tested — silent over-matching is a permission bypass," and "Default to least privilege." This is why the bounded form (`/*`) is chosen over the issue's suggested universal `"*"`, which would match every path — including files outside CWD — for the rest of the session (operator-confirmed direction). `tcc.cwd` is available to all three gates via `ToolCallContext` (`src/handlers/gates/types.ts`). ## Design Overview ### Decision `deriveApprovalPattern` gains an optional second argument carrying the working directory. When the path is a CWD-root relative file (`dirname === "."`) **and** a CWD is supplied, it returns the CWD-absolute directory glob; otherwise it behaves exactly as today. ```typescript export function deriveApprovalPattern( normalizedPath: string, options?: { cwd?: string }, ): string { if (normalizedPath.endsWith(sep)) return `${normalizedPath}*`; const dir = dirname(normalizedPath); if (dir === normalizedPath) return `${dir}*`; // filesystem root "/" if (dir === "." && options?.cwd) { // Relative file in the current directory. A relative "*" glob would // over-match every path; resolve against cwd so the approval is bounded // to the working-directory subtree and matches the absolute policy value. const base = normalizePathForComparison(options.cwd, options.cwd); return `${base}${sep}*`; } const prefix = dir.endsWith(sep) ? dir : `${dir}${sep}`; return `${prefix}*`; } ``` `normalizePathForComparison(cwd, cwd)` returns the normalized (and, on Windows, lowercased) absolute CWD — the same transform applied to the policy values' absolute form, so `/*` matches `/index.html` consistently across platforms. The trailing `*` compiles to `.*`, which crosses `/`, so `/*` covers the whole CWD subtree — identical recursive semantics to today's `src/*`. ### Why bounded, not relative `*` The policy values for a CWD-root file always include the absolute form (`/index.html`) when CWD is known. Binding the pattern to `/*` matches that absolute value while **excluding** any path outside CWD (e.g. `/etc/passwd` produces values `["/etc/passwd"]`, which `/*` does not match). The issue's suggested `"*"` would match `/etc/passwd` too — disabling a configured `ask` after a single approval. ### Why not absolute for the sub-directory case Sub-directory files already work: `deriveApprovalPattern("src/foo.ts")` → `"src/*"` matches the relative policy value `"src/foo.ts"`. Keeping that branch untouched preserves the readable dialog label (`Yes, allow edit "src/*" for this session`); switching it to absolute would render `Yes, allow edit "/Users/.../project/src/*" for this session`. Only the `dirname === "."` branch — currently broken, so nothing to regress — changes, and only it must show the absolute CWD glob (unavoidable for boundedness). ### Threading CWD to the call sites - `suggestSessionPattern(surface, value, cwd?)` — new optional `cwd`, forwarded to `deriveApprovalPattern` in the `path`, `external_directory`, and path-bearing-tool branches. `tool.ts` passes `tcc.cwd`. - `path.ts` — `deriveApprovalPattern(filePath, { cwd: tcc.cwd })`. - `bash-path.ts` — `deriveApprovalPattern(worstToken, { cwd: tcc.cwd })`. For a bash token after a literal `cd ` (within CWD), the token's absolute value (`/sub/index.html`) is still a descendant of ``, so `/*` matches it. This is marginally broader than `/sub/*` but remains bounded to CWD; tokens that `cd` outside CWD are handled by the external-directory gate, not this one. ### Call-site sketch (per-tool gate, the repro) ```typescript // tool.ts — describeToolGate const suggestion = suggestSessionPattern( tcc.toolName, // "edit" deriveSuggestionValue(tcc, check), // "index.html" tcc.cwd, // "/Users/.../project" ); // → suggestion.pattern === "/Users/.../project/*" // Recorded as session rule { surface: "edit", pattern, action: "allow" }. // Next edit of index.html → values ["/Users/.../project/index.html", "index.html"] // → "/Users/.../project/*" matches the absolute value → allow → no re-prompt. ``` ### Import direction `session-rules.ts` will import `normalizePathForComparison` from `path-utils.ts`. `path-utils.ts` imports neither `session-rules` nor `pattern-suggest`, so no import cycle is introduced. ## Module-Level Changes - `src/session-rules.ts` — add the optional `options?: { cwd?: string }` parameter to `deriveApprovalPattern`; add the bounded `dirname === "."` branch; import `normalizePathForComparison` from `./path-utils`. - `src/pattern-suggest.ts` — add an optional `cwd?: string` parameter to `suggestSessionPattern`; forward it to `deriveApprovalPattern` in the `path`, `external_directory`, and path-bearing-tool branches. - `src/handlers/gates/tool.ts` — pass `tcc.cwd` as the third argument to `suggestSessionPattern`. - `src/handlers/gates/path.ts` — pass `{ cwd: tcc.cwd }` to `deriveApprovalPattern`. - `src/handlers/gates/bash-path.ts` — pass `{ cwd: tcc.cwd }` to `deriveApprovalPattern`. No exported symbol is removed or renamed; both signature changes are additive (optional trailing parameters), so existing call sites compile unchanged. Docs/skill check: `grep` of `.pi/skills/package-pi-permission-system/SKILL.md` and `packages/pi-permission-system/docs/` for `deriveApprovalPattern` / `suggestSessionPattern` finds no prose describing the derived pattern shape that this change contradicts; the architecture `rule.ts` type listing is untouched (no `Rule`/`Ruleset` field change). No `docs/architecture/` layout, complexity, or health table references these files by the changed behavior. ## Test Impact Analysis This is a behavior fix, not an extraction, so the analysis is narrow: 1. **New tests enabled** — `deriveApprovalPattern` gains direct unit coverage for the CWD-root case (with and without `cwd`), which was previously untested and silently wrong. A round-trip test (record the derived pattern on a tool surface, then evaluate the CWD-root file's policy values) pins the end-to-end "no re-prompt" behavior and the boundedness (an outside-CWD file still evaluates to `ask`). 2. **Redundant tests** — none. No existing test asserted the broken `"./*"` output, so nothing is removed. 3. **Tests that must stay** — the existing `deriveApprovalPattern` cases (absolute file, trailing-separator directory, filesystem root, sub-directory glob, "matches under directory" / "not sibling directories") continue to pin the unchanged branches. ## Invariants at risk - The "produces a pattern that matches paths under the approved directory" and "does not match sibling directories" tests in `test/session-rules.test.ts` pin the recursive-but-bounded directory-glob invariant. The new branch must preserve it: `/*` matches descendants of CWD and excludes siblings of CWD. A new round-trip test adds the CWD-root counterpart (root file matches; outside-CWD file does not). This change touches no surface refactored by a prior architecture-roadmap phase step, so there is no earlier `Outcome:` invariant to re-pin beyond the above. ## TDD Order 1. **`deriveApprovalPattern` bounded CWD-root pattern** — `test/session-rules.test.ts`. Red: assert `deriveApprovalPattern("index.html", { cwd: "/test/project" })` → `"/test/project/*"`; `deriveApprovalPattern("index.html")` (no cwd) → `"./*"` (documents the safe, unchanged fallback); sub-directory and absolute cases unchanged; round-trip — record the derived pattern on the `edit` surface, then `evaluate("edit", "/test/project/index.html", ruleset)` → `allow` and `evaluate("edit", "/etc/passwd", ruleset)` → `ask` (boundedness). Green: add the optional `options` parameter and the `dirname === "." && options?.cwd` branch; import `normalizePathForComparison`. Commit: `fix(session-rules): bound current-directory approval pattern to cwd (#438)`. 2. **Per-tool gate threads CWD (primary repro)** — `test/handlers/gates/tool.test.ts` and `test/pattern-suggest.test.ts`. Red: `describeToolGate` for `edit` on `{ path: "index.html" }` with `cwd: "/test/project"` → `sessionApproval.representativePattern === "/test/project/*"` (not `"./*"`); `suggestSessionPattern("edit", "index.html", "/test/project")` → bounded pattern and a label showing it. Green: add the optional `cwd` parameter to `suggestSessionPattern` and forward it; pass `tcc.cwd` from `deriveSuggestionValue`'s call site in `tool.ts`. Commit: `fix(pattern-suggest): scope per-tool session approval for cwd-root files (#438)`. 3. **Cross-cutting `path` gate threads CWD** — `test/handlers/gates/path.test.ts`. Red: `describePathGate` for a `read`/`edit` on `{ path: "index.html" }` (ask) with `cwd: "/test/project"` → `sessionApproval.representativePattern === "/test/project/*"`. Green: pass `{ cwd: tcc.cwd }` to `deriveApprovalPattern` in `path.ts`. Commit: `fix(path-gate): scope path session approval for cwd-root files (#438)`. 4. **Bash `path` gate threads CWD** — `test/handlers/gates/bash-path.test.ts`. Red: a bash command referencing a CWD-root token (e.g. `cat index.html`) that resolves to `ask` with `cwd: "/test/project"` → `sessionApproval.representativePattern === "/test/project/*"`. Green: pass `{ cwd: tcc.cwd }` to `deriveApprovalPattern` in `bash-path.ts`. Commit: `fix(bash-path-gate): scope bash path session approval for cwd-root files (#438)`. Step 1 must land first — steps 2–4 depend on the new optional parameters, but because the parameters are optional the suite stays green after every step. Run `pnpm run check` after step 1 (signature change) and `pnpm -r run test` before the pre-completion review. ## Risks and Mitigations - **Risk: over-broad approval (the operator-rejected universal `"*"`).** Mitigation: bind to `/*`; the round-trip test asserts an outside-CWD path still evaluates to `ask`. - **Risk: platform divergence (Windows lowercasing / separators).** Mitigation: derive the CWD base via `normalizePathForComparison`, the same transform applied to policy values; `path` surfaces already fold case and separators in `wildcardMatch`. - **Risk: a stray `dirname === "."` call without CWD silently returns `"./*"` again.** Mitigation: all three live gates thread `tcc.cwd`; the no-cwd fallback is documented and unit-tested as the safe (re-prompting, not over-approving) behavior. - **Risk: import cycle from `session-rules` → `path-utils`.** Mitigation: verified `path-utils` imports neither `session-rules` nor `pattern-suggest`. ## Open Questions - Bash tokens after a literal `cd ` within CWD approve the whole `/*` subtree rather than `/sub/*`. This is bounded and acceptable; tightening to the per-token absolute (`policyValues[0]`) is a possible follow-up if a narrower bash approval is ever requested, but it is out of scope here.