14 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 645 | pi-permission-system: Bash path gates miss bare symlink operands and paths embedded in flags |
Retro: #645 — Bash path gates miss bare symlink operands and paths embedded in flags
Stage: Planning (2026-07-24T21:04:26Z)
Session summary
Verified both reported bypasses by tracing tokens through token-classification.ts / bash-path-resolver.ts: a bare in-project symlink (cat outside-link) never reaches canonicalization because #509 promotion matches only the raw token against specific non-* path rules, and a --file=/tmp/x value is dropped by the leading-- prelude.
This is a third-party issue (author marcoscale98), so direction was confirmed across three ask_user rounds; the operator's meta-question ("what architecture change would make this class of problem easier?") reframed the plan from a targeted patch into a structural redesign.
Plan committed as docs/plans/0645-bash-bare-token-flag-path-gates.md.
Observations
- Chosen design — existence probe: token classification is three-valued (definitely-path / definitely-not / unknown); "unknown" bare tokens are resolved by
lstat(PathNormalizer.entryExists) instead of by consulting the ruleset. Candidacy from the filesystem, decision from explicit rules or the external boundary — never the universal fallback. This deletes the entire #509 matcher thread (PathRuleTokenMatcher,getPromotablePathTokenMatcher, five-layer threading) rather than generalizing it. - Key discovery:
describeBashPathGatealready implements the needed decision discipline — thematchedPattern === undefinedguard (issue #58 in prose) treats universal-default-only matches as unrestricted, andpermission-manager.tssetsmatchedPatternonly forconfig/sessionlayers. Promoted tokens therefore need no new flag or manager consult. - Decision path across
ask_userrounds: round 1 chose bare-symlink-first with "full read-tool parity"; round 2 surfaced that literal parity + default-ask universal would prompt on every bare word, and the operator narrowed to rule-scoped gating; round 3 (after the reframing analysis) switched to the existence probe, added the ADR (0009, completeness contract), folded the flag-value case back in (it is token preprocessing, not classification —--opt=valuesplit at collection), and required a performance spike using review-log commands before implementation. - No follow-up issues filed: everything (both cases, ADR, spike) folded into #645 per the operator's "make the change easy" note.
- Breaking: two
fix(pi-permission-system)!:commits planned (bare-token gating; flag-value gating), each with its ownBREAKING CHANGE:footer; remediation via existingpath/external_directoryallow patterns was verified against the config surface. - Risk noted for implementation: steps 5–6 are deliberately split (behavior change with the old thread present-but-ignored, then a pure type-level deletion) to bound test churn; the
#509program-test promotion block migrates to tmpdir-symlink fixtures in step 5. - Spike gate: p95 added cost < 1 ms/command; contingency (config-gated probe) named in the plan if it fails.
Stage: Implementation — TDD (2026-07-24T22:15:00Z)
Session summary
Executed all 8 steps of the plan's TDD Order plus one preparatory tidying, landing 8 commits.
The existence probe replaced #509's rule-driven promotion, --opt=value values are split at collection, the five-layer matcher thread is deleted, and ADR 0009 records the completeness contract.
Test count 2570 → 2593 (+23); check, root lint, full test, and fallow dead-code all green.
Observations
- Spike passed with 23× headroom (plan step 1): over 2358 deduped real bash commands from the permission review log, 3535 prelude-surviving bare tokens (1.50/command), probe p95 0.0427 ms/command against a < 1 ms criterion; total probe cost 19.2% of the already-paid tree-sitter parse. The more valuable number was selectivity: only 118 of 3535 (3.3%) bare tokens named an existing entry, which empirically confirms the design's claim that the probe drops ~97% of bare words and so cannot become a prompt firehose. That figure is now cited in ADR 0009.
- Tidy-First earned its keep: the assessor found a genuine landmine neither the plan nor the planning session had spotted —
program.test.tsandpath-normalizer.test.tsboth fully replacednode:fswith a singlerealpathSyncstub, so the firstlstatSynccall would have thrownTypeErroracross dozens of unrelated tests and turned step 5's red into noise. Landed as a preparatorytest:commit (d955190a). Its rejected-as-scope-creep list was correctly scoped (declined a non-target file, the AST walk, and splitting a large test file), so the boundary held on this dispatch. - The same landmine recurred one module over:
path-normalizer.test.tsalso stubbednode:oswith onlyhomedir, sotmpdir()was undefined. Caught during step 4 rather than by the assessor (which was scoped tonode:fs); folded into step 4 since HEAD had moved past the prep commit andgit rebase -iis off-limits here. - Deviation from the assessor (deliberate): its second recommendation — extracting
test/helpers/tmp-fixture.tsas a standalone preparatory commit — was folded into step 4 instead. A fixture with no consumer would have been dead code until step 4, andpnpm fallow dead-codegates this repo. - Design refinement during step 5: took the assessor's optional suggestion and gave both projections a single shared
probeBareToken, plus extractedcollectIfExternalfromprojectExternalPaths. A promoted token is now identical whether it is matched againstpathrules or tested against the cwd boundary, which is the property that makes the two bypasses one fix rather than two. - The
--opt=valuesplit could not live where first assumed: post-filtering the collected token list would have missed the reportedgrep --file=…case exactly, because a pattern-first command's collector classifies a flag asregular-flagand never emits it. The split therefore reads the argument nodes directly incollectCommandTokens. Worth remembering:PATTERN_FIRST_COMMANDSswallows flag tokens. - Two composition-root tests failed as designed, not by accident: the migrated #509 tests never created
id_rsa/key.pemon disk, so the probe correctly declined to promote them. Re-pinned probe-style (create the file, assert deny) and supplemented with a test asserting the narrowing itself — a bare token naming nothing is not gated even under a matching deny rule. - Both issue repros are now pinned end-to-end in
composition-root.test.tsunder a permissivecat */grep *bash rule, alongside a new test for the #58 guard (an existing bare file with no explicit rule stays unrestricted), which the plan flagged as covered only via shaped tokens. - Hit two AGENTS.md traps: miscounted a decorative
─rule in atoken-collection.tsoldText(fixed by restoring the rule to its sibling's 78-char width), and emitted strayoldText2/newText2keys in one architecture-doc edit — silently ignored, so the block count had to be reconciled against intended edits, exactly as documented. - Orphan import caught only by biome's warning level: deleting the manager matcher tests left
createInMemoryPolicyLoaderunused;pnpm run lintstill exits 0 on it, so it was found by readingbiome checkoutput directly. The testing skill warns about precisely this. - Pre-completion reviewer: WARN — sole finding was the then-missing TDD stage entry (this one).
All seven named at-risk invariants verified as pinned or untouched; both
BREAKING CHANGE:remediations confirmed to exist in the real config surface.
Stage: Final Retrospective (2026-07-24T23:05:00Z)
Session summary
One continuous session carried #645 from planning through ship: a third-party bug report of two bash path-gate bypasses became a structural redesign (existence probe replaces #509's rule-driven promotion) plus a flag-value split and ADR 0009.
Shipped as pi-permission-system-v23.0.0 (major, two fix!: commits) with the release-please PR merged and publish CI green.
Execution was notably clean — the friction was a handful of self-caught mechanical slips, no rework or rabbit-holes.
Observations
What went well
- The performance spike earned its place in the plan, and its real payload was selectivity, not speed.
Plan step 1 benchmarked the
lstatprobe over 2358 real logged commands: p95 0.043 ms/command (23× under the 1 ms gate) — but the decisive number was that only 118 of 3535 bare tokens (3.3%) named an existing entry. That empirically validated the design's central claim (candidacy-from-filesystem cannot become a prompt firehose), and the figure now lives in ADR 0009 rather than a scratch file. A spike that gates a design decision and leaves a durable artifact is the pattern worth repeating. - Tidy-First's first high-value catch.
The
tidy-first-assessorfound a landmine neither the plan nor the planning session saw:program.test.tsandpath-normalizer.test.tsfully replacednode:fswith a lonerealpathSyncstub, so the probe's firstlstatSyncwould have thrownTypeErroracross dozens of unrelated tests — turning step 5's Red into noise. Landed as preparatory commitd955190a; its rejected-as-scope-creep list stayed correctly scoped (declined a non-target file, the AST walk, a large-file split), so the boundary held on this dispatch. - Three
ask_userrounds genuinely bent the design. Round 2 surfaced that "literal read-tool parity" under a default-askuniversal would prompt on every bare word; round 3 switched to the existence probe. The correction happened at planning — in prose, before any code — which is exactly where a third-party issue's "whether/in-what-form" ambiguity should be resolved. - Incremental verification throughout (feedback-loop lens: no gap).
Every TDD cycle ran its affected file Red→Green,
tscafter each interface change, and the full suite at cycle boundaries; the deliberate steps 5→6 split (behavior change with the old thread present-but-ignored, then a pure type-level deletion) bounded test churn as planned.
What caused friction (agent side)
instruction-violation(self-identified) — hit two already-documentedEdit-tool traps fromAGENTS.md§ "Edit tool batches": miscounted a decorative─rule intoken-collection.ts(biome parse error), and emitted strayoldText2/newText2keys in an architecture-doc edit (silently ignored). Impact: ~2–3 extra tool calls each, no rework beyond the fix. Both rules already exist and are crisp; the failure is salience-at-edit-time, not a missing rule — no doc change would help, so none is proposed.missing-context— the full-replacement mock landmine recurred one module over:path-normalizer.test.tsalso stubbednode:oswith onlyhomedir, so the fixture'stmpdir()wasundefined. The assessor was scoped tonode:fsand did not generalize to sibling builtins. Impact: one extra fix inside step 4, no separate commit. This is the one friction point with a reusable lesson (see Changes made).other(macOS fixture detail) — the twoexternalPathssymlink tests first failed on/var→/private/var: the outside tmpdir was not canonicalized whileboundaryValue()correctly was. Impact: ~2 tool calls, fixed test-side with acanonicalDirhelper.other(biome warning-level orphan) — deleting the matcher tests orphanedcreateInMemoryPolicyLoader;noUnusedImportsis warning-level solintstayed green. Caught by readingbiome checkoutput directly, exactly as the testing skill's existing note advises. Impact: one edit.
What caused friction (user side)
- None material.
The division was ideal: strategic judgment concentrated in the three planning
ask_userrounds, then fully autonomous execution through TDD and ship. The one design pivot (round 2 → round 3) was surfaced by the agent as a dedicated question rather than requiring a user catch — the gate working as intended, not friction.
Diagnostic details
- Model-performance correlation — orchestration ran on
claude-opus-4-8(judgment-heavy: threeask_userdesign rounds, 8-step TDD, ship coordination); both subagents (tidy-first-assessor,pre-completion-reviewer) ran onanthropic/claude-sonnet-5per their frontmatter — appropriate for bounded read-only assessment/review. No mismatch. - Escalation-delay — no sequence exceeded 5 consecutive tool calls on one error; every mechanical slip resolved in 1–3 calls. No subagent-dispatch or user-ask was warranted-but-skipped.
- Unused-tool — none.
The two subagents that would help (tidy-first, pre-completion) were both dispatched; the codebase was already deeply understood from planning, so
Explore/colgrepwere not needed. - Feedback-loop — verification was incremental throughout; no end-only batching.
Changes made
.pi/skills/testing/SKILL.md— added a rule to the vi.mock and hoisting section: avi.mock("node:*")factory returning an object literal replaces the module, so an omitted sibling export (lstatSync,tmpdir) becomesundefinedand throwsTypeErrorin unrelated tests; spreadvi.importActualto stub one export while keeping the rest. Complements the existingnode:*default-key rule directly above it.- Considered but not landed (recorded above under "Considered but not proposed"): no
AGENTS.mdEdit-trap change (rules already exist and are crisp), no macOS-tmpdir package-skill note (marginal;tmp-fixture.tscentralizes it), no widening of thetidy-first-assessorscope (its change-scoped discipline is a feature).