--- issue: 418 issue_title: '[Bug] Even though "Allow" is configured, the permission system still prompts for confirmation on access requests' --- # Match `external_directory` patterns against both the typed and the symlink-resolved path ## Problem Statement A user configured `external_directory: { "*": "ask", "/tmp/*": "allow" }` (and `path: { "/tmp/*": "allow" }`), yet an agent running `ls -la /tmp/` still triggered an external-directory confirmation prompt. The denial log shows the gate evaluated `/private/tmp`, not `/tmp`: on macOS `/tmp` is a symlink to `/private/tmp`. The root cause is that both external-directory gates resolve symlinks (`/tmp` → `/private/tmp`) **before** pattern matching, so the user's `/tmp/*` pattern is matched against the resolved `/private/tmp` and never hits. Symlink resolution is correct for the outside-CWD **boundary** decision (is this path outside the working directory?), but wrong for **pattern matching** against the patterns a user actually typed. `canonicalNormalizePathForComparison`'s own docstring already says it is for "containment decisions ... not for pattern matching", yet `describeExternalDirectoryGate` feeds its output straight into the resolver, and `BashProgram.externalPaths` returns the canonical (symlink-resolved) path that `describeBashExternalDirectoryGate` then pattern-matches. This issue was filed by a third party (`lipaysamart`, not the maintainer). The maintainer confirmed the direction: fix the bug, and match patterns against **both** the typed and the symlink-resolved forms as aliases (last-match-wins), so a `/tmp/*` rule works and any existing `/private/tmp/*` workaround keeps working. ## Goals - Make `external_directory` allow/deny/ask patterns match the path **as written** (`/tmp/*`) on systems where the path is a symlink, fixing the reported false prompt. - Preserve matching against the **symlink-resolved** form too (`/private/tmp/*`), so existing canonical-form workaround configs keep working. Both forms are evaluated as equivalent aliases under the existing last-match-wins alias mechanism (`evaluateAnyValue`). - Keep the outside-CWD **boundary** decision on the symlink-resolved path, so the gate still fires for every external access and least-privilege is preserved. - Apply the fix consistently to **both** external-directory surfaces: the tool gate (`describeExternalDirectoryGate`) and the bash gate (`describeBashExternalDirectoryGate` over `BashProgram.externalPaths`). - Reuse the existing resolver surface (generalize `resolvePathPolicy`/`checkPathPolicy` with a `surface` parameter) rather than adding a new resolver method, honoring the architecture's "resolver surface widening" risk note (architecture.md lines 594–595). This change alters observable behavior for existing configs on upgrade without a user edit: a symlinked `external_directory` allow that previously prompted will now allow, and — importantly — a symlinked `external_directory` **deny** that previously fell through to the `*` fallback (silently allowed) will now correctly deny. The behavior change is the correction itself and moves toward least privilege, so this is a bug fix (`fix:`), not a breaking change (no documented default or public surface is removed or redefined). ## Non-Goals - Do **not** change the cross-cutting `path` surface or the path-bearing tool surfaces (`read`, `write`, `edit`, `grep`, `find`, `ls`) to add canonical aliases. They already match against the lexical path; the bug and the fix are scoped to `external_directory`. - Do **not** change the outside-CWD boundary decision (`isPathOutsideWorkingDirectory`) — it stays on the canonical, symlink-resolved path. - Do **not** change Pi-infrastructure-read containment semantics (`isPiInfrastructureRead`) — that check stays on the canonical path. - Do **not** add a new resolver method (would widen the resolver surface the architecture flags as a risk). Generalize the existing `resolvePathPolicy`/`checkPathPolicy` with an optional `surface` parameter instead. - Do **not** add `**` (globstar) syntax — a single `*` already crosses subdirectory boundaries. - Do **not** change Windows case-folding behavior. ## Background Relevant modules and the current (buggy) data flow: - `src/handlers/gates/external-directory.ts` — `describeExternalDirectoryGate(tcc, infraDirs, extractors)`. Computes `normalizedExtPath = canonicalNormalizePathForComparison(externalDirectoryPath, cwd)` (symlink-resolved) and sets `input: { path: normalizedExtPath }`, which the runner passes to `resolver.resolve("external_directory", input)`. That is the tool-gate bug: pattern matching runs against the resolved path. The gate does **not** currently receive a resolver; the sibling `describePathGate` does. - `src/handlers/gates/bash-external-directory.ts` — `describeBashExternalDirectoryGate(tcc, bashProgram, resolver)`. Iterates `bashProgram.externalPaths(cwd)` and calls `resolver.resolve("external_directory", { path: p })` per path. This is the surface that actually fired in the report (`toolName: "bash"`). - `src/handlers/gates/bash-program.ts` — `BashProgram.externalPaths(cwd): string[]`. For each candidate token it computes `canonicalizePath(normalizePathForComparison(candidate, resolveBase))`, uses the canonical form for the within-CWD boundary check, and pushes the **canonical** form into the returned list (deduped by canonical). That is the bash-gate bug source: the returned, pattern-matched value is symlink-resolved. - `src/path-utils.ts` — `normalizePathForComparison` (lexical, no symlink), `canonicalNormalizePathForComparison` (lexical + `realpathSync`; docstring: containment only, "not for pattern matching"), `getPathPolicyValues` (lexical alias list: absolute + cwd-relative + literal, home-expanded via #350). - `src/permission-resolver.ts` — `ScopedPermissionResolver` with `resolve`, `resolvePathPolicy(values)` (hardcoded to the `path` surface), `checkPermission`. - `src/permission-manager.ts` — `checkPathPolicy(values, agentName?, sessionRules?)` (hardcoded surface/toolName `"path"`) → `buildCheckResult`. `buildCheckResult` uses `evaluateAnyValue` for any surface in `PATH_SURFACES` (which includes `external_directory`): **last-rule-wins across the alias set** (`rules.findLast(r => values.some(v => ruleMatches(r, surface, v)))`). `deriveSource` returns `"special"` for `external_directory` (a `SPECIAL_PERMISSION_KEYS` member), matching today's `checkPermission` source. - `src/session-rules.ts` — `deriveApprovalPattern(normalizedPath)` → `/*` for session approvals. Constraints from AGENTS.md / the package skill: - "The four path layers compose with most-restrictive-wins"; the boundary gate must keep firing — preserved here (boundary stays canonical). - "Wildcard matching must be explicit and tested — silent over-matching is a permission bypass." New alias matching needs deterministic symlink tests. - "When a gate resolves through a new manager/resolver method beyond `checkPermission`/`resolve` (e.g. `checkPathPolicy`/`resolvePathPolicy`), wire it through the same surface dispatcher in `makeHandler`" — the #393 false-green class. Because the external-directory gates will now resolve through `resolvePathPolicy`/`checkPathPolicy`, `makeHandler` must route the `external_directory` surface onto `checkPathPolicy` (mirroring `checkPermission`), or `makeSurfaceCheck`-driven tests will silently pass `allow`. - `docs/architecture/architecture.md` inline-documents the gate listing, `path-utils.ts`, `bash-program.ts`, and the resolver surface; these need updating. ## Design Overview ### Decision model For the `external_directory` surface, evaluate a tool/bash path against the **union** of: 1. the lexical (as-typed, normalized, non-symlink-resolved) policy values from `getPathPolicyValues`, and 2. the canonical (symlink-resolved) absolute path, as equivalent aliases, using the existing `evaluateAnyValue` (last-rule-wins across aliases) path already wired for `PATH_SURFACES`. The outside-CWD boundary check and the infrastructure-read check keep using the canonical path. Why last-match-wins is correct here: `evaluateAnyValue` returns the last config rule (in config order) that matches *any* alias. So `{ "*": "ask", "/tmp/*": "allow" }` resolves `allow` (the `/tmp/*` rule matches the lexical alias and is later than `*`); `{ "*": "allow", "/tmp/*": "deny" }` resolves `deny` (closing today's silent-allow hole on symlinked denies). ### New shared helper (`path-utils.ts`) ```ts /** * Equivalent external_directory policy-match values for a path: the lexical * (as-typed) alias list plus the canonical (symlink-resolved) absolute path. * The boundary/containment decision uses the canonical form separately; this * helper is only for pattern matching, so user patterns on the typed path and * on the resolved path both match (last-match-wins across aliases). */ export function getExternalDirectoryPolicyValues( pathValue: string, cwd: string, ): string[] { const lexical = getPathPolicyValues(pathValue, { cwd }); const canonical = canonicalNormalizePathForComparison(pathValue, cwd); return canonical ? [...new Set([...lexical, canonical])] : lexical; } ``` Lexical aliases come first so the representative value (used by `evaluateAnyValue`'s fallback and display) is the typed form; the `Set` collapses the no-symlink case (Linux `/tmp`) to a single value. ### Resolver surface generalization (no new method) Add an optional `surface` parameter (default `"path"`) to the existing methods: ```ts // ScopedPermissionManager checkPathPolicy( values: readonly string[], agentName?: string, sessionRules?: Ruleset, surface?: string, // default "path" ): PermissionCheckResult; // ScopedPermissionResolver resolvePathPolicy( values: readonly string[], agentName?: string, surface?: string, // default "path" ): PermissionCheckResult; ``` `PermissionManager.checkPathPolicy` threads `surface` into `buildCheckResult(surface, lookupValues, {}, surface, surface, fullRules)`. Existing callers (the bash-path gate, `resolvePathPolicy`) are unaffected by the default. This keeps the resolver surface at four methods (`resolve` + `resolvePathPolicy` + `checkPermission` + `checkPathPolicy`), consistent with architecture.md's risk note rather than widening it. ### Tool gate call site (Tell-Don't-Ask check) ```ts // describeExternalDirectoryGate, after the boundary + infra checks const matchValues = getExternalDirectoryPolicyValues(externalDirectoryPath, tcc.cwd); const preCheck = resolver.resolvePathPolicy( matchValues, tcc.agentName ?? undefined, "external_directory", ); const approvalPath = normalizePathForComparison(externalDirectoryPath, tcc.cwd); // descriptor: input: {}, preCheck, sessionApproval: single(deriveApprovalPattern(approvalPath)) ``` The gate gains a `resolver` parameter (mirroring `describePathGate`), threaded from `ToolCallGatePipeline.this.resolver`. The runner consumes `descriptor.preCheck` and skips its own `resolve`, so `input` becomes `{}` (as the bash gate already does). The session fast-path still works because `resolvePathPolicy` applies session rules via `getRuleset()`. ### Bash gate + `externalPaths` (extraction interaction check) `BashProgram.externalPaths(cwd)` keeps computing the canonical form for the **boundary** check and the dedup identity, but **returns the lexical** (normalized, non-symlink-resolved) form: ```ts // inside externalPaths, per accepted candidate: const lexical = normalizePathForComparison(candidate, resolveBase); const canonical = canonicalizePath(lexical); if (canonical && normalizedCwd && !isSafeSystemPath(canonical) && !isPathWithinDirectory(canonical, normalizedCwd) && !seen.has(canonical)) { seen.add(canonical); // dedup identity stays canonical externalPaths.push(lexical); // returned value is the typed form } ``` `describeBashExternalDirectoryGate` then resolves each returned path through both aliases: ```ts const check = resolver.resolvePathPolicy( getExternalDirectoryPolicyValues(p, tcc.cwd), tcc.agentName ?? undefined, "external_directory", ); ``` The `uncovered`/`pickMostRestrictive` logic is unchanged (config-level `deny` is still not downgraded to `ask`). Approval patterns derive from the lexical path; display/message strings now show the typed path (`/tmp`) instead of `/private/tmp` — a UX improvement. `externalPaths(): string[]` keeps its shape (only the value semantics change canonical → lexical), so the test-only facade `extractExternalPathsFromBashCommand` and its 29 test references are unaffected except where they assert a real symlinked path (none today, since `/tmp`-symlink behavior is platform-dependent and untested). ### Edge cases - No symlink (Linux `/tmp`, or a non-existent path): `canonicalizePath` no-ops (ENOENT/ENOTDIR fall back to lexical), so the alias list dedups to one value — behavior identical to today. - `EACCES`/`ELOOP` during `realpathSync`: `canonicalizePath` returns the lexical form; aliasing degrades to lexical-only, still matching the typed pattern. - Contradictory config (`{ "/private/tmp/*": "deny", "/tmp/*": "allow" }`): last-match-wins picks `/tmp/*` allow (documented behavior); noted in Risks. - Session dedup: approval pattern from the lexical path matches the lexical alias on subsequent requests (`external-directory-session-dedup` stays green). ## Module-Level Changes - `src/path-utils.ts` — add `getExternalDirectoryPolicyValues(pathValue, cwd)`. No symbol removed. - `src/permission-manager.ts` — add optional `surface` param (default `"path"`) to `ScopedPermissionManager.checkPathPolicy` (interface) and `PermissionManager.checkPathPolicy` (impl); thread it into `buildCheckResult`. - `src/permission-resolver.ts` — add optional `surface` param (default `"path"`) to `ScopedPermissionResolver.resolvePathPolicy` (interface) and `PermissionResolver.resolvePathPolicy` (impl); pass through to `checkPathPolicy`. Update the doc comments (path → path-shaped surface). - `src/handlers/gates/external-directory.ts` — add a `resolver: ScopedPermissionResolver` parameter; replace the `input: { path: normalizedExtPath }` matching with a precomputed `preCheck` via `resolver.resolvePathPolicy(getExternalDirectoryPolicyValues(...), …, "external_directory")`; set `input: {}`; keep the canonical path for the infra-read bypass; derive the approval pattern from the lexical normalized path. - `src/handlers/gates/tool-call-gate-pipeline.ts` — update the `describeExternalDirectoryGate(...)` call to pass `this.resolver`. - `src/handlers/gates/bash-program.ts` — `externalPaths` returns the lexical normalized path (dedup identity stays canonical; boundary check stays canonical). - `src/handlers/gates/bash-external-directory.ts` — resolve each external path through `resolver.resolvePathPolicy(getExternalDirectoryPolicyValues(p, cwd), …, "external_directory")`; approval patterns from the lexical path. - `test/helpers/handler-fixtures.ts` — route the `external_directory` surface in `makeHandler`'s dispatcher onto `checkPathPolicy` (mirroring `checkPermission`) so `makeSurfaceCheck`/`makeBashCommandCheck`-driven tests do not false-green (#393 class). - `test/helpers/gate-fixtures.ts` — `makePathDispatchResolver`/`makeResolver` already stub `resolvePathPolicy`; confirm the `surface` argument is accepted (the stubs dispatch on `values`, ignoring `surface`, so they remain compatible). Add fixtures only if a gate unit test needs surface-aware dispatch. - Docs and metadata: - `docs/architecture/architecture.md` — update the `external-directory.ts` and `bash-external-directory.ts` gate lines, the `bash-program.ts` `externalPaths` description (now returns the typed form, dedup by canonical), the `path-utils.ts` line (add `getExternalDirectoryPolicyValues`; reaffirm `canonicalNormalizePathForComparison` is containment-only), and the resolver-surface note (methods now take a `surface` param; count unchanged). - `.pi/skills/package-pi-permission-system/SKILL.md` — update the fixture notes (`checkPathPolicy` now covers path-shaped surfaces including `external_directory`; `makeHandler` routes `external_directory` through `checkPathPolicy`). - `docs/configuration.md` — add a short note in the `external_directory` section that patterns match both the path as written and its symlink-resolved form, with `/tmp/*` on macOS as the example. - `README.md` / `config/config.example.json` / `schemas/permissions.schema.json` — only if a worked example references symlinked paths; otherwise no change (the surface shape is unchanged). Symbol-grep performed: no exported symbol is removed or renamed (`externalPaths` keeps its name and `string[]` shape; the resolver/manager methods only gain an optional trailing parameter). The reworded mechanism (canonical → lexical return value of `externalPaths`; "matches resolved path" → "matches typed and resolved path") is searched in `docs/architecture/architecture.md` and `SKILL.md` and updated above. ## Test Impact Analysis This is a bug fix with a small extraction (the policy-values helper), not a large refactor. 1. New tests enabled: - `path-utils.test.ts` — unit-test `getExternalDirectoryPolicyValues`: returns `[lexical, canonical]` for a real symlinked tmpdir, dedups when canonical equals lexical, and handles relative inputs. - `permission-manager` / `permission-resolver` tests — `checkPathPolicy`/`resolvePathPolicy` with `surface: "external_directory"` evaluate against the `external_directory` ruleset. - `bash-program.test.ts` — `externalPaths` returns the typed form for a symlinked candidate (deterministic via a created tmpdir symlink). - An end-to-end acceptance test (real tmpdir symlink) pinning the reported repro for both a path-bearing tool and a bash command. 2. Existing tests to update (not redundant, but assert the old behavior): - `handlers/gates/external-directory.test.ts` — the "input contains normalized path for checkPermission" test (the gate now uses `preCheck`, not `input.path`); add the resolver argument; assert allow for a symlinked `/tmp/*` config. - `handlers/gates/bash-external-directory.test.ts` — switch the resolver stub from `resolve` to `resolvePathPolicy`; assert both typed and resolved patterns match. 3. Tests that must stay as-is (genuinely exercise the boundary layer): the within-CWD / outside-CWD boundary tests in `path-utils.test.ts` and `bash-program.test.ts`, and `external-directory-session-dedup.test.ts`. ## Invariants at risk - **#393 false-green** (stubbed-but-unrouted resolver method silently passing `allow`). Pinned by routing `external_directory` through `checkPathPolicy` in `makeHandler` and by the end-to-end acceptance test using real instances. - **#352 extension/MCP path gating** (`Outcome:` extension and MCP tools are external-directory gated). Preserved — `getToolInputPath` extraction is unchanged; only the matching values change. Pinned by the existing `describeExternalDirectoryGate — extension and MCP tools (#352)` tests. - **Boundary still fires / most-restrictive-wins** — the canonical boundary check is unchanged. Pinned by the existing outside-CWD tests. - **Bash config-deny not downgraded to ask** (`pickMostRestrictive`). Preserved; pinned by the existing bash-external-directory deny tests. ## TDD Order 1. `refactor:` — generalize path-policy resolution with a `surface` parameter. Surface: `permission-manager` + `permission-resolver` unit tests. Red: `resolvePathPolicy(values, agent, "external_directory")` (and `checkPathPolicy(..., "external_directory")`) evaluate against an `external_directory` pattern map; default still resolves the `path` surface. Green: add the optional `surface` param to both interfaces and impls; thread into `buildCheckResult`. Commit: `refactor(pi-permission-system): generalize path-policy resolution to any path-shaped surface (#418)`. 2. `feat:` — add `getExternalDirectoryPolicyValues` helper. Surface: `path-utils.test.ts` (create a real symlink in a tmpdir for determinism). Red: returns the union of lexical aliases and the canonical absolute path; dedups when equal. Green: implement the helper. Commit: `feat(pi-permission-system): add external-directory typed+resolved policy aliases (#418)`. 3. `fix:` — bash external-directory gate matches typed and resolved paths. Surface: `bash-program.test.ts` + `handlers/gates/bash-external-directory.test.ts`. Red: with a symlinked external path, `externalPaths` returns the typed form; the gate allows for both a `/tmp/*` and a `/private/tmp/*` allow config and prompts for neither; a `/tmp/*` deny now denies. Green: `externalPaths` returns lexical (dedup by canonical); the gate resolves via `resolvePathPolicy(getExternalDirectoryPolicyValues(...), …, "external_directory")`; approval patterns from the lexical path. Commit: `fix(pi-permission-system): match bash external_directory patterns against typed and resolved paths (#418)`. 4. `fix:` — tool external-directory gate matches typed and resolved paths. Surface: `handlers/gates/external-directory.test.ts` + `tool-call-gate-pipeline` wiring. Red: with a symlinked path, a `/tmp/*` allow config resolves `allow` (no prompt); update the `input.path` assertion to the `preCheck` shape. Green: thread `resolver` into `describeExternalDirectoryGate`; use `preCheck` via `resolvePathPolicy(..., "external_directory")`; keep canonical for the infra-read bypass; derive approval from the lexical path; update the pipeline call site (same commit — the signature change breaks the call site). Also update `makeHandler` to route `external_directory` through `checkPathPolicy` (same commit — required to avoid the #393 false-green for the new tests). Commit: `fix(pi-permission-system): match external_directory tool patterns against typed and resolved paths (#418)`. 5. `test:` — end-to-end acceptance for the reported repro. Surface: `handlers/external-directory-integration.test.ts` (real instances via `makeHandler`/`createManager`, real tmpdir symlink outside CWD). Red→Green: with `external_directory: { "*": "ask", "/*": "allow" }` where `` is a symlink to a real external dir, both a path-bearing tool read and a bash `ls ` are allowed without forwarding/prompt. Commit: `test(pi-permission-system): pin symlinked external_directory allow acceptance (#418)`. 6. `docs:` — documentation and metadata alignment. Update `docs/architecture/architecture.md` (gate lines, `externalPaths`, `path-utils`, resolver-surface note), `.pi/skills/package-pi-permission-system/SKILL.md` (fixture/`makeHandler` notes), and `docs/configuration.md` (the typed+resolved matching note with the macOS `/tmp/*` example). Touch `README.md`/`config.example.json`/`schemas/permissions.schema.json` only if a symlink example is added. Commit: `docs(pi-permission-system): document external_directory symlink alias matching (#418)`. ## Risks and Mitigations - Risk: a symlink could let a typed-form pattern bypass a resolved-form deny (or vice versa) because `evaluateAnyValue` is last-match-wins, not most-restrictive. Mitigation: the boundary still fires on the canonical path (the gate always runs), and the universal `*` default is `ask`, so an unmatched external path always prompts — never silently allows. The fix also closes today's hole where a symlinked **deny** silently fell through to `*`. Document the contradictory-config edge case. - Risk: false-green from a stubbed-but-unrouted `checkPathPolicy` in `makeHandler` (#393 class). Mitigation: route `external_directory` through `checkPathPolicy` in `makeHandler` and add the real-instance acceptance test in step 5. - Risk: changing `externalPaths` return value (canonical → lexical) churns the 29 test references. Mitigation: most references use non-existent synthetic paths where `canonicalizePath` no-ops (lexical == canonical), so they are unaffected; only symlink-specific assertions (none today) change. - Risk: double `realpathSync` (once in `externalPaths` for the boundary, once in the gate via the helper). Mitigation: negligible (external paths per command are few); keeping `externalPaths(): string[]` avoids a 29-reference shape change. - Risk: scope creep into the `path` surface or boundary semantics. Mitigation: Non-Goals fence the change to `external_directory` pattern matching only. ## Open Questions - Should the bash external-directory **prompt/log message** display the typed path (`/tmp`) or the resolved path (`/private/tmp`)? This plan shows the typed form (clearer for the user, matches what they configured); defer to the build step if a reviewer prefers showing both. - Should `docs/configuration.md` cross-reference the macOS `/tmp` → `/private/tmp` case explicitly, or keep the note surface-agnostic? Defer to the docs step; lean toward one concrete macOS example plus a general statement.