24 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 418 | [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_directoryallow/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 (describeBashExternalDirectoryGateoverBashProgram.externalPaths). - Reuse the existing resolver surface (generalize
resolvePathPolicy/checkPathPolicywith asurfaceparameter) 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
pathsurface 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 toexternal_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/checkPathPolicywith an optionalsurfaceparameter 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). ComputesnormalizedExtPath = canonicalNormalizePathForComparison(externalDirectoryPath, cwd)(symlink-resolved) and setsinput: { path: normalizedExtPath }, which the runner passes toresolver.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 siblingdescribePathGatedoes.src/handlers/gates/bash-external-directory.ts—describeBashExternalDirectoryGate(tcc, bashProgram, resolver). IteratesbashProgram.externalPaths(cwd)and callsresolver.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 computescanonicalizePath(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—ScopedPermissionResolverwithresolve,resolvePathPolicy(values)(hardcoded to thepathsurface),checkPermission.src/permission-manager.ts—checkPathPolicy(values, agentName?, sessionRules?)(hardcoded surface/toolName"path") →buildCheckResult.buildCheckResultusesevaluateAnyValuefor any surface inPATH_SURFACES(which includesexternal_directory): last-rule-wins across the alias set (rules.findLast(r => values.some(v => ruleMatches(r, surface, v)))).deriveSourcereturns"special"forexternal_directory(aSPECIAL_PERMISSION_KEYSmember), matching today'scheckPermissionsource.src/session-rules.ts—deriveApprovalPattern(normalizedPath)→<dir>/*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 inmakeHandler" — the #393 false-green class. Because the external-directory gates will now resolve throughresolvePathPolicy/checkPathPolicy,makeHandlermust route theexternal_directorysurface ontocheckPathPolicy(mirroringcheckPermission), ormakeSurfaceCheck-driven tests will silently passallow. docs/architecture/architecture.mdinline-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:
- the lexical (as-typed, normalized, non-symlink-resolved) policy values from
getPathPolicyValues, and - 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)
/**
* 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:
// 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)
// 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:
// 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:
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):canonicalizePathno-ops (ENOENT/ENOTDIR fall back to lexical), so the alias list dedups to one value — behavior identical to today. EACCES/ELOOPduringrealpathSync:canonicalizePathreturns 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-dedupstays green).
Module-Level Changes
src/path-utils.ts— addgetExternalDirectoryPolicyValues(pathValue, cwd). No symbol removed.src/permission-manager.ts— add optionalsurfaceparam (default"path") toScopedPermissionManager.checkPathPolicy(interface) andPermissionManager.checkPathPolicy(impl); thread it intobuildCheckResult.src/permission-resolver.ts— add optionalsurfaceparam (default"path") toScopedPermissionResolver.resolvePathPolicy(interface) andPermissionResolver.resolvePathPolicy(impl); pass through tocheckPathPolicy. Update the doc comments (path → path-shaped surface).src/handlers/gates/external-directory.ts— add aresolver: ScopedPermissionResolverparameter; replace theinput: { path: normalizedExtPath }matching with a precomputedpreCheckviaresolver.resolvePathPolicy(getExternalDirectoryPolicyValues(...), …, "external_directory"); setinput: {}; 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 thedescribeExternalDirectoryGate(...)call to passthis.resolver.src/handlers/gates/bash-program.ts—externalPathsreturns the lexical normalized path (dedup identity stays canonical; boundary check stays canonical).src/handlers/gates/bash-external-directory.ts— resolve each external path throughresolver.resolvePathPolicy(getExternalDirectoryPolicyValues(p, cwd), …, "external_directory"); approval patterns from the lexical path.test/helpers/handler-fixtures.ts— route theexternal_directorysurface inmakeHandler's dispatcher ontocheckPathPolicy(mirroringcheckPermission) somakeSurfaceCheck/makeBashCommandCheck-driven tests do not false-green (#393 class).test/helpers/gate-fixtures.ts—makePathDispatchResolver/makeResolveralready stubresolvePathPolicy; confirm thesurfaceargument is accepted (the stubs dispatch onvalues, ignoringsurface, so they remain compatible). Add fixtures only if a gate unit test needs surface-aware dispatch.- Docs and metadata:
docs/architecture/architecture.md— update theexternal-directory.tsandbash-external-directory.tsgate lines, thebash-program.tsexternalPathsdescription (now returns the typed form, dedup by canonical), thepath-utils.tsline (addgetExternalDirectoryPolicyValues; reaffirmcanonicalNormalizePathForComparisonis containment-only), and the resolver-surface note (methods now take asurfaceparam; count unchanged)..pi/skills/package-pi-permission-system/SKILL.md— update the fixture notes (checkPathPolicynow covers path-shaped surfaces includingexternal_directory;makeHandlerroutesexternal_directorythroughcheckPathPolicy).docs/configuration.md— add a short note in theexternal_directorysection 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.
- New tests enabled:
path-utils.test.ts— unit-testgetExternalDirectoryPolicyValues: returns[lexical, canonical]for a real symlinked tmpdir, dedups when canonical equals lexical, and handles relative inputs.permission-manager/permission-resolvertests —checkPathPolicy/resolvePathPolicywithsurface: "external_directory"evaluate against theexternal_directoryruleset.bash-program.test.ts—externalPathsreturns 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.
- 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 usespreCheck, notinput.path); add the resolver argument; assert allow for a symlinked/tmp/*config.handlers/gates/bash-external-directory.test.ts— switch the resolver stub fromresolvetoresolvePathPolicy; assert both typed and resolved patterns match.
- Tests that must stay as-is (genuinely exercise the boundary layer): the within-CWD / outside-CWD boundary tests in
path-utils.test.tsandbash-program.test.ts, andexternal-directory-session-dedup.test.ts.
Invariants at risk
- #393 false-green (stubbed-but-unrouted resolver method silently passing
allow). Pinned by routingexternal_directorythroughcheckPathPolicyinmakeHandlerand 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 —getToolInputPathextraction is unchanged; only the matching values change. Pinned by the existingdescribeExternalDirectoryGate — 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
-
refactor:— generalize path-policy resolution with asurfaceparameter. Surface:permission-manager+permission-resolverunit tests. Red:resolvePathPolicy(values, agent, "external_directory")(andcheckPathPolicy(..., "external_directory")) evaluate against anexternal_directorypattern map; default still resolves thepathsurface. Green: add the optionalsurfaceparam to both interfaces and impls; thread intobuildCheckResult. Commit:refactor(pi-permission-system): generalize path-policy resolution to any path-shaped surface (#418). -
feat:— addgetExternalDirectoryPolicyValueshelper. 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). -
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,externalPathsreturns 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:externalPathsreturns lexical (dedup by canonical); the gate resolves viaresolvePathPolicy(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). -
fix:— tool external-directory gate matches typed and resolved paths. Surface:handlers/gates/external-directory.test.ts+tool-call-gate-pipelinewiring. Red: with a symlinked path, a/tmp/*allow config resolvesallow(no prompt); update theinput.pathassertion to thepreCheckshape. Green: threadresolverintodescribeExternalDirectoryGate; usepreCheckviaresolvePathPolicy(..., "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 updatemakeHandlerto routeexternal_directorythroughcheckPathPolicy(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). -
test:— end-to-end acceptance for the reported repro. Surface:handlers/external-directory-integration.test.ts(real instances viamakeHandler/createManager, real tmpdir symlink outside CWD). Red→Green: withexternal_directory: { "*": "ask", "<link>/*": "allow" }where<link>is a symlink to a real external dir, both a path-bearing tool read and a bashls <link>are allowed without forwarding/prompt. Commit:test(pi-permission-system): pin symlinked external_directory allow acceptance (#418). -
docs:— documentation and metadata alignment. Updatedocs/architecture/architecture.md(gate lines,externalPaths,path-utils, resolver-surface note),.pi/skills/package-pi-permission-system/SKILL.md(fixture/makeHandlernotes), anddocs/configuration.md(the typed+resolved matching note with the macOS/tmp/*example). TouchREADME.md/config.example.json/schemas/permissions.schema.jsononly 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
evaluateAnyValueis last-match-wins, not most-restrictive. Mitigation: the boundary still fires on the canonical path (the gate always runs), and the universal*default isask, 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
checkPathPolicyinmakeHandler(#393 class). Mitigation: routeexternal_directorythroughcheckPathPolicyinmakeHandlerand add the real-instance acceptance test in step 5. - Risk: changing
externalPathsreturn value (canonical → lexical) churns the 29 test references. Mitigation: most references use non-existent synthetic paths wherecanonicalizePathno-ops (lexical == canonical), so they are unaffected; only symlink-specific assertions (none today) change. - Risk: double
realpathSync(once inexternalPathsfor the boundary, once in the gate via the helper). Mitigation: negligible (external paths per command are few); keepingexternalPaths(): string[]avoids a 29-reference shape change. - Risk: scope creep into the
pathsurface or boundary semantics. Mitigation: Non-Goals fence the change toexternal_directorypattern 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.mdcross-reference the macOS/tmp→/private/tmpcase explicitly, or keep the note surface-agnostic? Defer to the docs step; lean toward one concrete macOS example plus a general statement.