Files
my-pi/pi-permission-system/docs/plans/0645-bash-bare-token-flag-path-gates.md
T

25 KiB
Raw Blame History

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:

  1. A bare operand (cat outside-link, where outside-link is 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-* path deny/ask rule — so a symlink whose target is denied, or any bare token under only-wildcard/external_directory policies, never reaches canonicalization or either path gate.
  2. A path embedded in an option (grep --file=/tmp/patterns target) is rejected by the shared rejectNonPathToken prelude (leading -) before the embedded /tmp/patterns can 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 path rules for in-tree targets, and by the external_directory surface when the canonical form resolves outside the working tree.
  • Close bypass 2: a --opt=value token has its value split out at collection time and classified by the existing shape classifiers, so --file=/tmp/patterns reaches both path surfaces while --format=json stays 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 path deny/ask rule (or resolves outside the tree), and commands carrying path-shaped --opt=value values, are now gated where they were previously allowed by a permissive bash rule. Commits use fix(pi-permission-system)!: with BREAKING 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/git are 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 shared rejectNonPathToken prelude. classifyPromotedRuleCandidate implements #509 raw-token promotion and is replaced by this change.
  • src/access-intent/bash/bash-path-resolver.ts — walks the AST once, projecting externalPaths (strict classifier) and ruleCandidates (broad classifier + promotion fallback); holds the injected PathNormalizer and the #509 isPromotablePathToken predicate.
  • src/access-intent/bash/token-collection.ts — collects argument/redirect tokens; owns PATTERN_FIRST_COMMANDS (embryonic per-command knowledge: grep/sed pattern args are skipped). The --opt=value split lands here.
  • src/access-intent/bash/program.tsBashProgram.parse(command, normalizer, isPromotablePathToken?, options?); the promotion parameter is deleted.
  • src/permission-manager.tsgetPromotablePathTokenMatcher builds the #509 matcher; deleted. Constraint (AGENTS/ADR-0002): the manager stays string-based and must not import AccessPath; 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 and ToolCallGateInputs thread of the matcher; both shrink.
  • src/handlers/gates/bash-path.ts — the bash path gate already implements the decision discipline this design needs: a candidate whose check has matchedPattern === undefined (only the synthesized universal default matched — permission-manager.ts sets matchedPattern only for config/session-layer rules) is treated as unrestricted (#58). Promoted tokens inherit this guard with no new flag.
  • src/path/canonicalize-path.ts — best-effort realpathSync canonicalization (#493); already makes filesystem state part of the decision input, so an lstat probe adds nothing new to the trust model.
  • src/handlers/gates/bash-path-extractor.ts — secondary BashProgram.parse caller (no matcher today); gains probe behavior automatically once the resolver owns it.
  • Operator decisions (three ask_user rounds): (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-link exists (lstat) → promoted → AccessPath canonicalization resolves the symlink → /tmp/pi-permission-test-secret → the bash external_directory gate and the path gate both see it. Bypass 1 closed.
  • git status./status ENOENT → dropped. No prompt noise; the #509 no-blow-up property is preserved and improved — even under an explicit path: {"*": "deny"}, only bare words naming real files are gated.
  • a_sym → .some.secret under path: {".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)

  • path surface: promoted candidates enter pathRuleCandidates() like ./-prefixed tokens; the existing #58 guard in describeBashPathGate (matchedPattern === undefined → unrestricted) already scopes the decision to explicit config/session rules. No promoted flag, no manager consult, no new result field.
  • external_directory surface: a promoted candidate whose canonical boundary form resolves outside the tree joins externalPaths and is gated exactly like cat /tmp/x is today — including the universal fallback (ask by default). This makes bare symlinks consistent with absolute paths: one rule to explain.
  • Unknown effective base (#393): a bare token after a non-literal cd cannot 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=jsonjson — bare, ./json almost 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 command fields from the permission review log (<globalLogsDir>/pi-permission-system-permission-review.jsonl, see REVIEW_LOG_FILENAME in src/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, 13 bare tokens per command).
  • Contingency if the criterion fails (not expected): gate the probe behind "any explicit path/external_directory restriction 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=value embedded values, existing bare entries (the probe), literal-cd base 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 — add entryExists(absolutePath): boolean (lstat-based; delegates fs to the same edge as canonicalization).
  • src/access-intent/bash/token-classification.ts — rename classifyPromotedRuleCandidateclassifyBareTokenCandidate; drop the isPromotable parameter and the PathRuleTokenMatcher import; update module JSDoc (three-valued framing, probe pointer).
  • src/access-intent/bash/token-collection.ts--opt=value split emitting the value token; unit-visible via collectCommandTokens/collectPathCandidateTokens.
  • src/access-intent/bash/bash-path-resolver.ts — constructor loses isPromotablePathToken and NO_PROMOTION; projectRuleCandidates promoted branch becomes probe-based; projectExternalPaths gains the probe branch for bare tokens (known base only); class JSDoc updated.
  • src/access-intent/bash/program.tsparse signature shrinks to (command, normalizer, options?); JSDoc updated.
  • src/permission-manager.ts — delete getPromotablePathTokenMatcher (interface + implementation), NO_PROMOTION, and now-unused imports (PathRuleTokenMatcher; wildcardMatch/pathMatchOptions if unused after removal).
  • src/permission-session.ts — delete the delegate method and PathRuleTokenMatcher import.
  • src/handlers/gates/tool-call-gate-pipeline.ts — remove getPromotablePathTokenMatcher from ToolCallGateInputs; update the parse call.
  • src/types.ts — delete PathRuleTokenMatcher.
  • src/handlers/gates/bash-path-extractor.tsparse call 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 for rule.ts (drop the getPromotablePathTokenMatcher reuse 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 (shrunk ToolCallGateInputs), token-collection.ts (flag-value split), path-normalizer entry if listed; rework the model-judge prose (lines ~590592) 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 the getPromotablePathTokenMatcher mentions 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 the getPromotablePathTokenMatcher stubs and PathRuleTokenMatcher imports (makeGateInputs, makeFakePermissionManager).
  • test/access-intent/bash/token-classification.test.ts, test/access-intent/bash/program.test.ts (the rule-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.json files changes (docs/decisions already ships).

Test Impact Analysis

  1. New tests enabled: PathNormalizer.entryExists unit 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.
  2. Tests made redundant: the #509 matcher-shaped tests (promotes when the matcher says promotable, default no-op matcher, manager getPromotablePathTokenMatcher pattern-filter tests) — deleted with the mechanism; their behavioral intent (bare denied filename gated, git status silent) is re-pinned probe-style.
  3. 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 status never prompts under specific path rules) — pinned today by the program-test promotion block; the migrated probe tests must keep an explicit git status-shaped case (ENOENT bare token dropped), plus a new case under explicit path: {"*": "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 explicit path rule stays unrestricted (currently the guard is exercised only via shaped tokens).
  • #393 unknown-base conservatism — bare tokens after a non-literal cd stay 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 AccessPath import 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-extractor consumers gain probe-consistent external paths (strictly more surfacing, never less).

TDD Order

  1. Spike (no product commit) — benchmark lstatSync per 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.
  2. 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.
  3. refactor(pi-permission-system): expose bare-token prelude classifier — red: token-classification.test.ts covers classifyBareTokenCandidate (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.ts adapts at its call site by wrapping the still-injected predicate); no behavior change.
  4. 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.
  5. 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-link with a real tmpdir symlink → appears in externalPaths and ruleCandidates with canonical target in matchValues), the a_sym → denied-target case, git status silence, explicit-* behavior, unknown-base conservatism; a gate-level test pinning the #58 guard for a promoted no-rule file; green: probe-based promoted branch in projectRuleCandidates + probe branch in projectExternalPaths; BashPathResolver still accepts (and now ignores) the injected predicate to keep this commit's blast radius inside the resolver. Migrate the #509 program-test block in this step. BREAKING CHANGE: footer: bash commands referencing existing bare-named files or in-project symlinks are now gated by path rules (canonical, symlink-resolved) and by external_directory when they resolve outside the working directory; previously a permissive bash rule could bypass both. Remediation: add external_directory/path allow patterns for intended targets (both config surfaces exist today).
  6. refactor(pi-permission-system): delete the rule-driven promotion thread — remove PathRuleTokenMatcher, getPromotablePathTokenMatcher (manager + session + ToolCallGateInputs), the parse/resolver parameters, both NO_PROMOTIONs, fixture stubs, and the manager matcher tests; pnpm fallow dead-code clean. Type-breaking removal, so all consumers and fixtures move in this one commit.
  7. fix(pi-permission-system)!: classify path values embedded in --opt=value tokens — red: collection tests (--file=/tmp/x value emitted, --format=json value emitted-but-bare, original flag token preserved, -o=x single-dash form, no split without =); program-level test for the issue's grep --file=/tmp/pi-permission-patterns target repro reaching externalPaths; green: the split in token-collection.ts. BREAKING CHANGE: footer: path-shaped values embedded in --opt=value bash tokens are now extracted and gated by the path/external_directory surfaces.
  8. 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 56 — 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 a win32PathFlavor normalizer per the package's testing rule.
  • --opt=value false 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/%d is 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_COMMANDS should also skip flag-value extraction for pattern-position flags (grep -e PATTERN) — today -e PATTERN is 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.