18 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 507 | fix(pi-permission-system): external-directory prompt shows the typed path, not the resolved path that triggered the gate |
Disclose the resolved symlink target in external-directory messages
Release Recommendation
Release: ship independently
This is a standalone message-clarity bug fix, not part of any roadmap phase (Phase 7 is complete). It touches no shared release batch, so it ships on its own as a patch.
Problem Statement
When a tool or bash call names a path that is lexically inside the working directory but resolves, via a symlink, to a location outside it, the external-directory prompt names the typed path while asserting it is "outside working directory".
That reads as a contradiction: the typed path (demo-symlink-passwd) is plainly inside the working directory, and the resolved path (/etc/passwd) that actually tripped the gate is never shown.
The gating decision is correct — this is the #418 / #486 dual-match protection working — but the message hides why it fired.
The fix is purely about message clarity.
When the resolved (canonical, symlink-followed) path differs from the typed path, every external-directory message should disclose it as (resolves to '<canonical>'); when they are equal (the common non-symlink case), the message is unchanged.
Goals
- Disclose the resolved canonical path in the tool external-directory ask prompt when it differs from the typed path.
- Disclose the resolved canonical path(s) in the bash external-directory ask prompt when any differ.
- Disclose the resolved path in the tool external-directory denial messages (deny / no-UI / user-denied bodies).
- Disclose the resolved path(s) in the bash external-directory denial message (deny body — the only bash denial body that lists paths).
- Keep the message unchanged when the typed and resolved forms are equal (non-symlink case).
- Non-breaking: the gating decision, the review-log values, and the session-approval patterns are unchanged; only human-facing message text changes.
Non-Goals
- No change to the gating decision, boundary check, infra-read bypass, or dual-match logic — the gate already resolves symlinks correctly (#418, #486).
- No change to the review-log
path/externalPathsvalues or the derived session-approval patterns — those stay the lexical policy-matching values. - No change to the
path/ per-tool /bash_pathsurfaces — the contradiction is specific toexternal_directorymessages, which are the only ones that assert "outside working directory". - No public-API change — the message builders and
DenialContextare internal (not exported fromindex.ts).
Background
The canonical (symlink-resolved) path is already computed at both gates via AccessPath (src/access-intent/access-path.ts), which holds three type-distinct forms:
value()— the lexical absolute form (as-typed, normalized, not symlink-resolved).boundaryValue()— the canonical form (symlink-resolved viarealpathSync, win32-lowercased per #382).matchValues()— the lexical ∪ canonical alias union for pattern matching.
Both value() and boundaryValue() are win32-lowercased (path-normalization.ts), so a case-only difference on Windows does not spuriously diverge — the two forms differ only when a symlink actually resolves elsewhere.
canonicalizePath (src/canonicalize-path.ts) returns its input unchanged for non-symlink or unresolvable paths, so boundaryValue() equals value() exactly when there is no distinct symlink target.
The four message-producing sites (issue scope), plus the two additional denial bodies the same DenialContext feeds (confirmed in Decide):
src/handlers/gates/external-directory-messages.ts—formatExternalDirectoryAskPrompt(tool prompt) andformatBashExternalDirectoryAskPrompt(bash prompt).src/denial-messages.ts— theexternal_directoryandbash_external_directorybodies inbuildDenyBody,buildUnavailableBody, andbuildUserDeniedBody.
The tool gate (describeExternalDirectoryGate) already builds const accessPath = normalizer.forPath(externalDirectoryPath); the bash gate (describeBashExternalDirectoryGate) already holds an AccessPath per uncovered entry (uncoveredEntries.map(({ path }) => …)).
Constraint from the package skill: PathNormalizer owns platform handling; the gates already hold their AccessPath values, so this fix reads from those value objects and adds no new process.platform / cwd threading.
Design Overview
Decision: disclose only when the canonical form is distinct
The comparison must be value() (lexical absolute) vs boundaryValue() (canonical absolute) — not the raw typed string vs canonical, because the typed string is relative (demo-symlink-passwd) and would always differ from an absolute canonical.
This comparison is domain logic about a path's own representations, so it belongs on the value object (give behavior to data), not scattered at two call sites.
Add one accessor to AccessPath:
/**
* The canonical (symlink-resolved) form when it names a location distinct
* from the lexical form — for disclosing the resolved target in a prompt or
* denial message. `undefined` when the path is not a symlink (canonical
* equals lexical) or has no canonical (literal-only / empty input).
*/
resolvedAlias(): string | undefined {
if (!this.canonical || this.canonical === this.lexical) {
return undefined;
}
return this.canonical;
}
resolvedAlias() fields which fields it reads: canonical and lexical — both already held.
It carries no unused inputs and follows the existing accessor style (value() / boundaryValue()).
Shared formatting primitive and disclosure type
The displayed suffix is identical everywhere the resolved path is disclosed, so it is single-sourced as one primitive. The bash case lists several paths, each independently symlinked-or-not, so it needs a per-path pairing of display value and resolved alias.
In src/denial-messages.ts (the module that already owns DenialContext), add:
/** A displayed external path paired with its resolved target, when distinct. */
export interface ExternalPathDisclosure {
/** The path as displayed (typed for tools, lexical-absolute for bash). */
path: string;
/** The canonical symlink-resolved target; present only when it differs. */
resolvedPath?: string;
}
/** ` (resolves to '<canonical>')` when a distinct target exists, else "". */
export function resolvesToSuffix(resolvedPath?: string): string {
return resolvedPath ? ` (resolves to '${resolvedPath}')` : "";
}
external-directory-messages.ts (handler layer) imports resolvesToSuffix and ExternalPathDisclosure from denial-messages.ts (core-ish leaf) — a handler→leaf dependency in the correct direction, with no cycle (denial-messages.ts imports nothing from external-directory-messages.ts).
Tool external-directory path (scalar disclosure)
formatExternalDirectoryAskPrompt gains a resolvedPath parameter positioned right after pathValue (so the message reads path '<typed>' (resolves to '<canonical>') outside working directory '<cwd>'):
export function formatExternalDirectoryAskPrompt(
toolName: string,
pathValue: string,
resolvedPath: string | undefined,
cwd: string,
agentName?: string,
): string {
const subject = agentName ? `Agent '${agentName}'` : "Current agent";
return `${subject} requested tool '${toolName}' for path '${pathValue}'${resolvesToSuffix(resolvedPath)} outside working directory '${cwd}'. Allow this external directory access?`;
}
The external_directory DenialContext variant gains an optional resolvedPath?: string (additive), and all three body builders append resolvesToSuffix(ctx.resolvedPath) where they render ctx.pathValue.
Tool gate call site (describeExternalDirectoryGate):
const resolvedAlias = accessPath.resolvedAlias();
const extDirMessage = formatExternalDirectoryAskPrompt(
tcc.toolName, externalDirectoryPath, resolvedAlias, tcc.cwd, tcc.agentName ?? undefined,
);
// denialContext: { kind: "external_directory", …, resolvedPath: resolvedAlias }
The displayed primary path stays the raw typed externalDirectoryPath (the path the agent requested); only the suffix is derived from the value object.
Bash external-directory paths (list disclosure)
formatBashExternalDirectoryAskPrompt's externalPaths parameter changes from string[] to ExternalPathDisclosure[], and each entry renders as <path>${resolvesToSuffix(resolvedPath)} before joining (preserving the current unquoted-path list style).
The bash_external_directory DenialContext variant's externalPaths changes from string[] to ExternalPathDisclosure[]; buildDenyBody maps the disclosures through the same rendering (buildUnavailableBody / buildUserDeniedBody for bash render only ctx.command, so they are unchanged).
Bash gate call site (describeBashExternalDirectoryGate):
const disclosures = uncoveredEntries.map(({ path }) => ({
path: path.value(),
resolvedPath: path.resolvedAlias(),
}));
// prompt + denialContext.externalPaths take `disclosures`
// uncoveredPaths (string[]) is retained unchanged for deriveApprovalPattern + logContext.externalPaths
uncoveredPaths (the lexical value() strings) stays the source for session-approval patterns and the review log — those match on the policy value, not the disclosure.
Edge cases
- Non-symlink path:
resolvedAlias()isundefined,resolvesToSuffixis"", message unchanged. - macOS
/etc→/private/etc: the disclosed canonical is the fully-resolved/private/etc/passwd(what the gate actually matched), which is more accurate than the issue's idealized/etc/passwd. - win32: both forms are lowercased, so a case-only difference yields
undefined(no spurious disclosure); a real symlink target is disclosed lowercased. forLiteralbash token (unknown base):canonicalis"", soresolvedAlias()isundefined— no disclosure, correct.
Module-Level Changes
src/access-intent/access-path.ts— addresolvedAlias(): string | undefined; extend the class doc comment's accessor list.src/denial-messages.ts— addExternalPathDisclosureinterface andresolvesToSuffixhelper; addresolvedPath?: stringto theexternal_directoryDenialContextvariant; changebash_external_directory.externalPathsfromstring[]toExternalPathDisclosure[]; apply the suffix inbuildDenyBody/buildUnavailableBody/buildUserDeniedBody(external_directory) and inbuildDenyBody(bash_external_directory).src/handlers/gates/external-directory-messages.ts— addresolvedPathparam toformatExternalDirectoryAskPrompt; changeformatBashExternalDirectoryAskPrompt'sexternalPathstoExternalPathDisclosure[]; import the type + helper fromdenial-messages.ts.src/handlers/gates/external-directory.ts— computeaccessPath.resolvedAlias(), pass it to the prompt, and setdenialContext.resolvedPath.src/handlers/gates/bash-external-directory.ts— build thedisclosuresarray; pass it to the prompt anddenialContext.externalPaths; retainuncoveredPathsfor patterns/logs.docs/architecture/architecture.md— line 679: addresolvedAlias(): string | undefinedto theAccessPathaccessor enumeration; line 703: notedescribeExternalDirectoryGatediscloses the resolved alias in prompts/denials; add a[#507]reference-link definition.
Doc-grep results (no other stale references):
- Grepped
src/+test/forformatExternalDirectoryAskPrompt/formatBashExternalDirectoryAskPrompt/DenialContext/boundaryValue/resolvedAlias— call sites and tests enumerated below; no other producers. bash_external_directory/external_directoryDenialContexteach have a single producer (their gate).README.mddocuments commands/config, not these message internals — no change..pi/skills/package-*/SKILL.mdmentionsDenialContextonly via the caller-suppliedmakeDenialDescriptorfixture (no shape enumeration) — no change;makeDenialDescriptortakes a caller-supplied context, so no fixture edit.- No sample-log / ADR prose in
docs/renders these message strings — no stale literals.
Test Impact Analysis
- New unit tests enabled:
AccessPath.resolvedAlias()gets direct value-object tests (symlink → canonical; non-symlink →undefined; literal-only →undefined; empty →undefined; win32 real symlink → lowercased canonical; win32 case-only →undefined) — previously the lexical/canonical comparison did not exist as a testable unit. - Redundant tests: none removed — existing
.toContain("outside working directory")assertions stay valid (the suffix is inserted before that phrase). - Tests that must change (type/signature-coupled, so they land in the same commit as their production change):
test/handlers/gates/external-directory-messages.test.ts— both prompt signatures; add resolves-to and non-symlink cases.test/denial-messages.test.ts— external_directory cases gain aresolvedPathcase; bashexternalPaths: ["…"]literals become[{ path: "…" }], plus a resolves-to case.test/bash-external-directory.test.ts—formatBashExternalDirectoryAskPromptcalls (lines ~923–946) takeExternalPathDisclosure[]; add a symlink-disclosure assertion.test/handlers/external-directory-integration.test.ts—formatExternalDirectoryAskPromptcall (line ~50) takes the newresolvedPatharg.
Invariants at risk
The dual-match symlink protection (#418, #486) and the outside-CWD boundary decision must remain unchanged — this fix reads AccessPath for display only and touches no matching or boundary code.
- Invariant: external-directory gating still fires on the canonical form for an in-CWD symlink to an outside target.
Pinned by the existing symlink external-directory tests in
test/handlers/gates/external-directory-policy.test.tsandtest/bash-external-directory.test.ts(assert the gate resolves/denies) — unchanged by this plan. - Invariant: session-approval patterns and review-log values stay the lexical policy values.
Pinned by keeping
uncoveredPaths/deriveApprovalPattern(accessPath.value())untouched; the bash gate's existing approval/log assertions cover this.
TDD Order
-
AccessPath.resolvedAlias()accessor. Red: addresolvedAlias()tests intest/access-intent/access-path.test.ts(symlink, non-symlink, literal-only, empty, win32 real symlink, win32 case-only). Green: implement the accessor. Commit:fix(pi-permission-system): add AccessPath.resolvedAlias() for symlink-target disclosure. (An internal enabler with no standalone user-facing effect — keptfix:so the issue ships as one patch release, not a minor bump.) -
Tool external-directory message disclosure. Red: assert
formatExternalDirectoryAskPromptand the three external_directory denial bodies emit(resolves to '<canonical>')whenresolvedPathis set and omit it whenundefined. Green: addresolvesToSuffix+resolvedPath?toDenialContext.external_directoryindenial-messages.ts; add theresolvedPathparam to the tool prompt; wireaccessPath.resolvedAlias()throughdescribeExternalDirectoryGate; update the coupled tests (external-directory-messages.test.ts,denial-messages.test.tsexternal_directory cases,external-directory-integration.test.ts). Commit:fix(pi-permission-system): disclose resolved symlink target in tool external-directory messages. -
Bash external-directory message disclosure. Red: assert the bash prompt and bash deny body render
(resolves to '<canonical>')per uncovered entry that differs, and plain otherwise. Green: addExternalPathDisclosure; changeformatBashExternalDirectoryAskPromptandDenialContext.bash_external_directory.externalPathsto the disclosure type; render disclosures in the bash deny body; builddisclosuresindescribeBashExternalDirectoryGate(retaininguncoveredPathsfor patterns/logs); update the coupled tests (external-directory-messages.test.ts,denial-messages.test.tsbash cases,bash-external-directory.test.ts). Commit:fix(pi-permission-system): disclose resolved symlink targets in bash external-directory messages. -
Docs. Update
docs/architecture/architecture.md(accessor enumeration + gate note +[#507]link def). Commit:docs(pi-permission-system): record resolved-path disclosure on external-directory messages (#507).
Steps 1–4 land on one branch and are pushed together; CI/fallow run on the final SHA, where resolvedAlias() has production callers (steps 2–3), so no transient dead-code gate fires.
Risks and Mitigations
- Risk: message-string test assertions elsewhere break.
Mitigation: the suffix is inserted before "outside working directory", so
.toContain(...)assertions hold; only the four enumerated test files (signature/type-coupled) change, and they land with their production commits. - Risk:
resolvedAlias()transiently has no production caller after step 1. Mitigation: steps 2–3 add the callers on the same branch; fallow gates on the pushed final state, and step 1's tests reference the method. - Risk: over-disclosure noise on non-symlink paths.
Mitigation:
resolvedAlias()returnsundefinedwhenever canonical equals lexical (including win32 case-only and unresolvable paths), so the common case is unchanged.
Open Questions
None — scope confirmed in Decide (disclose across all external-directory message variants: ask prompts + deny + no-UI + user-denied).