25 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 645 | pi-permission-system: Bash path gates miss bare symlink operands and paths embedded in flags |
Close the bash bare-token and flag-embedded path-gate gaps
Release Recommendation
Release: ship independently
This issue is not part of any architecture-roadmap phase, and it is a breaking security fix (fix!) that should reach users as its own major release rather than batching behind unrelated work.
Problem Statement
The bash path projection can miss real filesystem operands in two compositions, letting a broad bash allow rule (cat *, grep *) bypass path and external_directory enforcement:
- A bare operand (
cat outside-link, whereoutside-linkis an in-project symlink to/tmp/…) has none of the shapes the token classifiers accept, and rule-driven promotion (#509) fires only when the raw token matches a specific non-*pathdeny/ask rule — so a symlink whose target is denied, or any bare token under only-wildcard/external_directorypolicies, never reaches canonicalization or either path gate. - A path embedded in an option (
grep --file=/tmp/patterns target) is rejected by the sharedrejectNonPathTokenprelude (leading-) before the embedded/tmp/patternscan be classified.
Both are silent fail-open collapses of the same structural gap: token classification is binary (path-candidate / not), while the domain is three-valued (definitely-path / definitely-not / unknown), and "unknown" is folded into "not a path".
This is a third-party issue (author marcoscale98); the operator confirmed direction across three ask_user rounds (see Background).
Goals
- Close bypass 1: a bare token that names an existing filesystem entry is promoted into the path projection, canonicalized (symlink-resolved), and gated — by explicit
pathrules for in-tree targets, and by theexternal_directorysurface when the canonical form resolves outside the working tree. - Close bypass 2: a
--opt=valuetoken has its value split out at collection time and classified by the existing shape classifiers, so--file=/tmp/patternsreaches both path surfaces while--format=jsonstays untouched. - Delete the #509 rule-driven promotion machinery (
PathRuleTokenMatcher,getPromotablePathTokenMatcher, and its five-layer thread from manager to resolver) — the existence probe subsumes it and decouples the classifier from the ruleset. - Land ADR 0009 documenting the bash path projection's completeness contract: what it guarantees, what it deliberately omits, and the surfacing-vs-judge layering principle.
- Run a performance spike against real command data from the permission review log before implementation, with a go/no-go criterion.
- This change is breaking: on upgrade, bash commands referencing existing bare-named files/symlinks whose resolved form matches a
pathdeny/ask rule (or resolves outside the tree), and commands carrying path-shaped--opt=valuevalues, are now gated where they were previously allowed by a permissive bash rule. Commits usefix(pi-permission-system)!:withBREAKING CHANGE:footers.
Non-Goals
- Glued short-option values (
-f/tmp/x) — genuinely per-command knowledge; out of contract (recorded in ADR 0009). - Computed paths (
$VAR,$(…)) — already conservatively handled by the unknown-base machinery where visible; resolving them is out of contract. - Per-command argument-semantics tables (which args of
grep/gitare files) — the principled home is the model-judge authorizer link (#620, already filed and on the roadmap), which reviews surfaced asks with full command context. - Gating nonexistent bare write targets (
touch newfile) — the probe cannot see a file that does not exist yet; redirect targets are already collected separately and unaffected. - No follow-up issues are filed: the operator folded both bypass cases, the ADR, and the spike into this single issue.
Background
src/access-intent/bash/token-classification.ts— the three pure classifiers plus the sharedrejectNonPathTokenprelude.classifyPromotedRuleCandidateimplements #509 raw-token promotion and is replaced by this change.src/access-intent/bash/bash-path-resolver.ts— walks the AST once, projectingexternalPaths(strict classifier) andruleCandidates(broad classifier + promotion fallback); holds the injectedPathNormalizerand the #509isPromotablePathTokenpredicate.src/access-intent/bash/token-collection.ts— collects argument/redirect tokens; ownsPATTERN_FIRST_COMMANDS(embryonic per-command knowledge:grep/sedpattern args are skipped). The--opt=valuesplit lands here.src/access-intent/bash/program.ts—BashProgram.parse(command, normalizer, isPromotablePathToken?, options?); the promotion parameter is deleted.src/permission-manager.ts—getPromotablePathTokenMatcherbuilds the #509 matcher; deleted. Constraint (AGENTS/ADR-0002): the manager stays string-based and must not importAccessPath; this plan only removes a manager method, so the lint-guarded boundary is untouched.src/permission-session.ts,src/handlers/gates/tool-call-gate-pipeline.ts— delegate andToolCallGateInputsthread of the matcher; both shrink.src/handlers/gates/bash-path.ts— the bashpathgate already implements the decision discipline this design needs: a candidate whose check hasmatchedPattern === undefined(only the synthesized universal default matched —permission-manager.tssetsmatchedPatternonly forconfig/session-layer rules) is treated as unrestricted (#58). Promoted tokens inherit this guard with no new flag.src/path/canonicalize-path.ts— best-effortrealpathSynccanonicalization (#493); already makes filesystem state part of the decision input, so anlstatprobe adds nothing new to the trust model.src/handlers/gates/bash-path-extractor.ts— secondaryBashProgram.parsecaller (no matcher today); gains probe behavior automatically once the resolver owns it.- Operator decisions (three
ask_userrounds): (1) bare-symlink case in scope; (2) rule-scoped rather than literal read-tool parity (no universal-fallback blow-up); (3) final direction — existence probe for candidacy + explicit-rules/external-boundary for decision, ADR included, flag-value split folded in, performance spike required.
Design Overview
The decision model in one sentence
A bare token is a path candidate iff it names an existing filesystem entry; a promoted candidate is gated only by explicit path/external_directory rules or by resolving outside the working tree — never by the universal fallback.
Three-valued classification and the probe
The classifiers already partition tokens into definitely-path (shape), definitely-not (prelude), and unknown (bare words). Today unknown collapses to not-path (fail-open). The probe resolves unknown deterministically at decision time via the filesystem:
// PathNormalizer gains one method (lives beside canonicalization, the
// package's existing fs edge):
/** True when `absolutePath` names an existing filesystem entry (lstat —
* a symlink counts even when its target is dangling). */
entryExists(absolutePath: string): boolean;
Consumer sketch (BashPathResolver.projectRuleCandidates, promoted branch):
const bare = classifyBareTokenCandidate(token); // prelude-only; null for flags/URLs/…
if (bare && base.kind === "known") {
const path = this.normalizer.forBashToken(bare, { resolveBase });
if (this.normalizer.entryExists(path.value())) result.push({ token: bare, path });
}
cat outside-link→./outside-linkexists (lstat) → promoted →AccessPathcanonicalization resolves the symlink →/tmp/pi-permission-test-secret→ the bashexternal_directorygate and thepathgate both see it. Bypass 1 closed.git status→./statusENOENT → dropped. No prompt noise; the #509 no-blow-up property is preserved and improved — even under an explicitpath: {"*": "deny"}, only bare words naming real files are gated.a_sym → .some.secretunderpath: {".some.secret": "deny"}→ promoted (exists), canonical match value is the target → denied. The raw-token matcher could never catch this; the probe + existing canonicalization does.- A dangling symlink lstats as existing but canonicalizes lexically (its target is gone); it stays internal and unrestricted — harmless, since the read itself fails.
Decision discipline (no new mechanism)
pathsurface: promoted candidates enterpathRuleCandidates()like./-prefixed tokens; the existing #58 guard indescribeBashPathGate(matchedPattern === undefined→ unrestricted) already scopes the decision to explicitconfig/sessionrules. Nopromotedflag, no manager consult, no new result field.external_directorysurface: a promoted candidate whose canonical boundary form resolves outside the tree joinsexternalPathsand is gated exactly likecat /tmp/xis today — including the universal fallback (askby default). This makes bare symlinks consistent with absolute paths: one rule to explain.- Unknown effective base (#393): a bare token after a non-literal
cdcannot be resolved, so it cannot be probed — it stays dropped (conservative, unchanged).
Flag-value extraction (bypass 2)
Token preprocessing in token-collection.ts, not a classifier change: when a collected argument token matches ^-{1,2}[^=\s]+=(.+)$, additionally emit the value part as its own token (the original flag token is still emitted and still rejected by the prelude — harmless).
The value then flows through the existing shape classifiers and the new probe:
--file=/tmp/patterns→/tmp/patterns— definitely-path → both surfaces.--format=json→json— bare,./jsonalmost never exists → dropped.--file=~/x,--file=../x,--file=C:\x→ shape-classified as today.
This is command-agnostic — no option tables — and benefits both projections in one place.
Deletion of the #509 matcher thread
PathRuleTokenMatcher (types.ts), getPromotablePathTokenMatcher (manager interface + implementation + session delegate + ToolCallGateInputs), the isPromotablePathToken parameter of BashProgram.parse and the BashPathResolver constructor, both NO_PROMOTION constants, and classifyPromotedRuleCandidate's matcher parameter all go away.
classifyPromotedRuleCandidate is renamed classifyBareTokenCandidate(token): string | null — prelude-only, returning the token when it could be a path (not a flag/URL/env-assignment/@scope/regex).
BashProgram.parse shrinks to (command, normalizer, options?).
Performance spike (pre-implementation gate)
The probe adds one lstatSync per bare token that survives the prelude, per parsed command — only bare words (log, status, build), since shaped tokens skip it.
- Corpus: extract
commandfields from the permission review log (<globalLogsDir>/pi-permission-system-permission-review.jsonl, seeREVIEW_LOG_FILENAMEinsrc/config-paths.ts), deduplicated; fall back to a synthetic corpus of representative commands if the log is sparse. - Measure: per-command added wall time of lstat-probing every prelude-surviving bare token (existing and ENOENT mixes), compared against the already-paid tree-sitter parse cost.
- Criterion: added p95 < 1 ms per command (expectation: single-digit µs per lstat, 1–3 bare tokens per command).
- Contingency if the criterion fails (not expected): gate the probe behind "any explicit
path/external_directoryrestriction exists in config" — a pipeline-level config consult, still no classifier↔ruleset coupling. - The spike is a scratch script; results are recorded in the retro file, not committed as product code.
ADR 0009 — bash path projection completeness contract
- Guarantees: shape-classified tokens (absolute,
~/,.., separator-bearing, drive-letter, win32 backslash-relative), redirect targets,--opt=valueembedded values, existing bare entries (the probe), literal-cdbase folding, wrapper flooring for opacity. - Deliberate omissions: nonexistent bare write targets, glued short options, computed paths, per-command argument semantics.
- Layering principle: the deterministic layer biases toward surfacing (
ask) and the model-judge chain (#620) absorbs false positives — over-suppression is unrecoverable, over-surfacing is recoverable. - Determinism note: filesystem state (existence, symlink targets) is part of the decision input, accepted since canonicalization (#493); same policy + same fs state + same input → same decision.
- Future triage rule: a new report is either inside the contract (fix) or outside it (accepted residual / judge's job).
Module-Level Changes
src/path-normalizer.ts— addentryExists(absolutePath): boolean(lstat-based; delegates fs to the same edge as canonicalization).src/access-intent/bash/token-classification.ts— renameclassifyPromotedRuleCandidate→classifyBareTokenCandidate; drop theisPromotableparameter and thePathRuleTokenMatcherimport; update module JSDoc (three-valued framing, probe pointer).src/access-intent/bash/token-collection.ts—--opt=valuesplit emitting the value token; unit-visible viacollectCommandTokens/collectPathCandidateTokens.src/access-intent/bash/bash-path-resolver.ts— constructor losesisPromotablePathTokenandNO_PROMOTION;projectRuleCandidatespromoted branch becomes probe-based;projectExternalPathsgains the probe branch for bare tokens (known base only); class JSDoc updated.src/access-intent/bash/program.ts—parsesignature shrinks to(command, normalizer, options?); JSDoc updated.src/permission-manager.ts— deletegetPromotablePathTokenMatcher(interface + implementation),NO_PROMOTION, and now-unused imports (PathRuleTokenMatcher;wildcardMatch/pathMatchOptionsif unused after removal).src/permission-session.ts— delete the delegate method andPathRuleTokenMatcherimport.src/handlers/gates/tool-call-gate-pipeline.ts— removegetPromotablePathTokenMatcherfromToolCallGateInputs; update theparsecall.src/types.ts— deletePathRuleTokenMatcher.src/handlers/gates/bash-path-extractor.ts—parsecall updated (signature only; gains probe behavior automatically).docs/decisions/0009-bash-path-projection-completeness-contract.md— new ADR (next free number after 0008).docs/architecture/architecture.md— module-tree entries forrule.ts(drop thegetPromotablePathTokenMatcherreuse note),permission-manager.ts(drop the matcher sentence),bash-path-resolver.ts(probe-based promotion),token-classification.ts(renamed classifier),tool-call-gate-pipeline.ts(shrunkToolCallGateInputs),token-collection.ts(flag-value split),path-normalizerentry if listed; rework the model-judge prose (lines ~590–592) that describes #509 rule-driven promotion — the promoted-token-emits-the-same-descriptor composition claim survives, the raw-token-matcher description does not; add the completeness-contract pointer..pi/skills/package-pi-permission-system/SKILL.md(repo root) — rewrite the bare-filename promotion paragraph in Notes for Agents (probe semantics), and thegetPromotablePathTokenMatchermentions in the Testing section fixtures list.docs/configuration.md— grep hits for promotion/bare-token prose; update to probe semantics.test/helpers/session-fixtures.ts,test/helpers/gate-fixtures.ts— drop thegetPromotablePathTokenMatcherstubs andPathRuleTokenMatcherimports (makeGateInputs,makeFakePermissionManager).test/access-intent/bash/token-classification.test.ts,test/access-intent/bash/program.test.ts(therule-driven bare-token promotion (#509)describe block migrates to probe semantics with real tmpdir files/symlinks),test/access-intent/bash/token-collection.test.ts,test/permission-manager-unified.test.ts(matcher tests deleted),test/permission-resolver.test.ts,test/handlers/gates/tool-call-gate-pipeline.test.ts,test/composition-root.test.ts— updated per the TDD order.- No
package.jsonfileschanges (docs/decisions already ships).
Test Impact Analysis
- New tests enabled:
PathNormalizer.entryExistsunit tests (tmpdir: file, dir, symlink, dangling symlink, ENOENT); probe-promotion resolver/program tests with real symlinks (in-tree → external target, in-tree → in-tree denied target, dangling); flag-value split collection tests; end-to-end gate tests for both repro commands from the issue. - Tests made redundant: the #509 matcher-shaped tests (
promotes when the matcher says promotable,default no-op matcher, managergetPromotablePathTokenMatcherpattern-filter tests) — deleted with the mechanism; their behavioral intent (bare denied filename gated,git statussilent) is re-pinned probe-style. - Tests that stay: shape-classifier tests, cd-folding/pipeline-walk tests, #393 unknown-base tests, #533 MSYS tests, wrapper-flooring tests — all untouched surfaces.
Invariants at risk
- #509 no-blow-up (
git statusnever prompts under specific path rules) — pinned today by the program-test promotion block; the migrated probe tests must keep an explicitgit status-shaped case (ENOENT bare token dropped), plus a new case under explicitpath: {"*": "deny"}. - #58 universal-fallback-unrestricted guard in
describeBashPathGate— becomes the decision discipline for promoted tokens; add a test pinning that a promoted existing file with no explicitpathrule stays unrestricted (currently the guard is exercised only via shaped tokens). - #393 unknown-base conservatism — bare tokens after a non-literal
cdstay unpromoted; keep the existing literal-only test and add a probe-era assertion. - #533 win32 literal-only tokens — non-mount POSIX absolutes are shaped, never bare; probe branch requires
base.kind === "known"and a resolvable lexical value, so literal-only handling is untouched; existing tests stay. - Model-judge composition (architecture prose, phase 12): "a promoted token emits the same structured descriptor a prefixed path does" — preserved by construction (promoted candidates flow through the same
BashPathRuleCandidate/gate path); the prose update must keep this claim while replacing the rule-driven mechanism description. - ADR-0002 string boundary — the manager only loses a method; no
AccessPathimport is added anywhere near it. - #309 advisory parity — the advisory bash check is bash-surface only and does not consume the path projection; unaffected.
bash-path-extractorconsumers gain probe-consistent external paths (strictly more surfacing, never less).
TDD Order
- Spike (no product commit) — benchmark
lstatSyncper prelude-surviving bare token over the review-log command corpus; record numbers and go/no-go in the retro file. If the criterion fails, stop and re-plan with the config-gated contingency. docs(pi-permission-system): add ADR 0009 bash path projection completeness contract— the ADR frames the contract the following cycles pin; include the architecture-doc pointer to it.refactor(pi-permission-system): expose bare-token prelude classifier— red:token-classification.test.tscoversclassifyBareTokenCandidate(prelude-only semantics: flags/URLs/env-assignments/@scope/regex rejected, plain words returned); green: rename + drop the matcher parameter inside the classifier module only (bash-path-resolver.tsadapts at its call site by wrapping the still-injected predicate); no behavior change.refactor(pi-permission-system): add entryExists probe to PathNormalizer— red: normalizer tests (tmpdir file/dir/symlink/dangling/ENOENT, win32 flavor construction per skill guidance); green: lstat implementation. Pure addition, unwired.fix(pi-permission-system)!: gate existing bare-named files and symlinks in bash commands— red: program/resolver tests for the issue's repro (cat outside-linkwith a real tmpdir symlink → appears inexternalPathsandruleCandidateswith canonical target inmatchValues), thea_sym → denied-targetcase,git statussilence, explicit-*behavior, unknown-base conservatism; a gate-level test pinning the #58 guard for a promoted no-rule file; green: probe-based promoted branch inprojectRuleCandidates+ probe branch inprojectExternalPaths;BashPathResolverstill accepts (and now ignores) the injected predicate to keep this commit's blast radius inside the resolver. Migrate the#509program-test block in this step.BREAKING CHANGE:footer: bash commands referencing existing bare-named files or in-project symlinks are now gated bypathrules (canonical, symlink-resolved) and byexternal_directorywhen they resolve outside the working directory; previously a permissive bash rule could bypass both. Remediation: addexternal_directory/pathallow patterns for intended targets (both config surfaces exist today).refactor(pi-permission-system): delete the rule-driven promotion thread— removePathRuleTokenMatcher,getPromotablePathTokenMatcher(manager + session +ToolCallGateInputs), theparse/resolver parameters, bothNO_PROMOTIONs, fixture stubs, and the manager matcher tests;pnpm fallow dead-codeclean. Type-breaking removal, so all consumers and fixtures move in this one commit.fix(pi-permission-system)!: classify path values embedded in --opt=value tokens— red: collection tests (--file=/tmp/xvalue emitted,--format=jsonvalue emitted-but-bare, original flag token preserved,-o=xsingle-dash form, no split without=); program-level test for the issue'sgrep --file=/tmp/pi-permission-patterns targetrepro reachingexternalPaths; green: the split intoken-collection.ts.BREAKING CHANGE:footer: path-shaped values embedded in--opt=valuebash tokens are now extracted and gated by thepath/external_directorysurfaces.docs(pi-permission-system): update architecture and skill docs for probe-based path candidacy— architecture module-tree entries, model-judge prose rework,docs/configuration.md, and the package skill (.pi/skills/package-pi-permission-system/SKILL.md) per Module-Level Changes.
Risks and Mitigations
- Probe cost on hot bash paths — mitigated by the spike gate (step 1) with an explicit criterion and a named contingency; lstat runs only for prelude-surviving bare tokens with a known base.
- Prompt-noise regression — bounded by design: ENOENT tokens are dropped, in-tree promoted tokens are gated only by explicit rules (#58 guard), and external promotion matches the existing absolute-path behavior. The genuinely new prompts (existing bare file matching a rule; bare symlink escaping the tree) are the fix.
- Filesystem-state dependence — already part of the trust model since #493 canonicalization; stated explicitly in ADR 0009.
- Large test churn in steps 5–6 — split deliberately: step 5 changes behavior with the old thread still present-but-ignored; step 6 is a pure type-level deletion.
- Windows semantics — the probe operates on the resolved lexical absolute from
forBashToken, which already carries MSYS/drive-mount handling (#533); win32 tests construct awin32PathFlavornormalizer per the package's testing rule. --opt=valuefalse splits (e.g. a token like--date=%Y/%m/%d) — the value still passes the shape classifiers and the regex-metachar/URL prelude; a value like%Y/%m/%dis separator-bearing and would be rule-candidate classified, but matches no explicit rule and is dropped by the #58 guard; external classification requires an absolute/~/..shape, which format strings lack.
Open Questions
- Should the probe eventually distinguish file-type (symlink vs regular vs directory) for finer policy (e.g. gate only symlinks)? Deferred; the current design needs only existence, and type-based narrowing would weaken the bare-denied-filename parity.
- Whether
PATTERN_FIRST_COMMANDSshould also skip flag-value extraction for pattern-position flags (grep -e PATTERN) — today-e PATTERNis two tokens and the pattern is skipped positionally; no change needed unless a report shows otherwise. - The model-judge opaque-bash adjudicator (#620) remains the successor for argument-semantics false positives; nothing here blocks it.