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

220 lines
25 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
issue: 645
issue_title: "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.ts``BashProgram.parse(command, normalizer, isPromotablePathToken?, options?)`; the promotion parameter is deleted.
- `src/permission-manager.ts``getPromotablePathTokenMatcher` 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:
```typescript
// 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):
```typescript
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=json``json` — 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 `classifyPromotedRuleCandidate``classifyBareTokenCandidate`; 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.ts``parse` 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.ts``parse` 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_PROMOTION`s, 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.
[#58]: https://github.com/gotgenes/pi-packages/issues/58
[#309]: https://github.com/gotgenes/pi-packages/issues/309
[#393]: https://github.com/gotgenes/pi-packages/issues/393
[#493]: https://github.com/gotgenes/pi-packages/issues/493
[#509]: https://github.com/gotgenes/pi-packages/issues/509
[#533]: https://github.com/gotgenes/pi-packages/issues/533
[#620]: https://github.com/gotgenes/pi-packages/issues/620