---
issue: 301
issue_title: "Only first command in bash command chain is evaluated"
---

# Retro: #301 — Only first command in bash command chain is evaluated

## Stage: Planning (2026-06-01T20:26:00Z)

### Session summary

Planned the fix for the bash command-chain permission bypass: a chained command like `cd /path && npm install pkg` matches the whole string against `cd *` (allow) and never evaluates `npm *` (deny) against the second command.
Explored the permission path and confirmed the bash `path` and `external_directory` surfaces already decompose chains via tree-sitter; only the bash command-pattern surface matches the raw string.
A plan was written and committed (`docs/plans/0301-evaluate-bash-command-chains.md`), then the session pivoted to a refactor-first approach.

### Observations

- Key constraint: `PermissionManager.checkPermission()` is synchronous (public `PermissionsService` + RPC contracts) and the issue's reproduction test calls it directly, but robust chain decomposition needs async tree-sitter.
  The chosen mechanism (after architecture review) reuses the existing tree-sitter parse in the gate layer and checks each simple-command via the unchanged synchronous `checkPermission`, combining most-restrictively — `checkPermission` stays single-command and synchronous.
- The synchronous service API / RPC remain whole-string (advisory); the runtime gate — the real security boundary — is fully fixed.
  An async decompose-and-check service method is a possible follow-up.
- Scope decision: top-level chain operators only (`&&`, `||`, `;`, `|`, `&`, newlines).
  Nested command substitution and subshells are matched as their enclosing command's text — a documented known limitation, never weaker than today.
- Behavior change to call out in docs: config patterns that span a chain (e.g. `"cd * && npm *"`) no longer match as a unit once each command is evaluated independently.
- Pivot: in response to "what architectural changes would make this easier?", the owner chose Beck-style refactor-first.
  Issue #304 was filed to consolidate bash command analysis behind a `BashProgram` value object and a `pickMostRestrictive` helper.
  **#301 is now blocked on #304.**
  After #304 ships, #301 collapses to: add `BashProgram.topLevelCommands()`, add a bash command gate that evaluates each top-level command and selects with `pickMostRestrictive`, wire it into the tool-gate producer, and update `docs/configuration.md`.
- The committed `0301-…` plan still describes the heavier standalone-extractor approach (the owner chose to leave it as-is for now).
  It should be rewritten to the trivial dependent version once #304 lands.

### Diagnostic details

- **Escalation-delay tracking** — Reversed the initial mechanism recommendation (synchronous hand-rolled splitter) after the owner's architecture-review prompt revealed it would create a second bash decomposition that can diverge from the tree-sitter one; switched to the tree-sitter-gate approach before writing the plan, not after.

## Stage: Implementation — TDD (2026-06-01T21:16:29Z)

### Session summary

Executed the refreshed #301 plan on top of the locally-landed #304 refactor (`BashProgram` + `pickMostRestrictive`), neither yet shipped.
Four commits: added `BashProgram.topLevelCommands()` (chain decomposition in the single parse), `resolveBashCommandCheck` (`bash-command.ts`, most-restrictive over sub-commands), wired the async bash branch into the tool-gate producer, and documented the per-command semantics.
Full suite green (1704 tests); `check`, `lint`, and `fallow` clean; pre-completion reviewer returned PASS.

### Observations

- The fix stayed as small as the plan promised: `checkPermission` is untouched and synchronous; all async decomposition lives in the gate layer via `resolveBashCommandCheck`, and the existing `describeToolGate` `preCheck` seam carried the most-restrictive result with no interface changes.
- The integration test deliberately uses `echo start && npm install …` (no path-like tokens) so the bash path / external-directory gates produce nothing and the bash command-pattern gate is the sole blocker — isolating the behavior under test.
- `collectTopLevelCommandTexts` descends only `program`/`list`/`pipeline`/`redirected_statement`; subshells and command substitution emit whole (the documented top-level scope).
- The `?? checkPermission(whole)` fallback in `resolveBashCommandCheck` guarantees the empty-units case is never weaker than before.
- AST shapes for redirection, `&` background, and bare subshell were verified with a throwaway parse script before writing assertions (e.g. `npm install > out.txt` \u2192 `["npm install"]`, redirect target dropped).
- No fallow suppression needed for the new exports — fallow treats the test files as consumers, so `resolveBashCommandCheck` and `topLevelCommands()` were clean once their tests existed.

### Diagnostic details

- **Feedback-loop gap analysis** — `pnpm run check` was run immediately after Step 1 (constructor signature change) and Step 3 (producer closure change), per the plan's notes; both passed first try.

## Stage: Final Retrospective (2026-06-01T21:49:00Z)

### Session summary

Shipped #301 end-to-end: pushed the stacked #304 + #301 work to `main`, verified CI, closed #301, and merged the release-please PR to cut `pi-permission-system-v9.0.1`.
Verified the fix live against the reloaded extension (`echo leading-allowed && rm -rf /tmp/…` was correctly denied with the offending sub-command and `rm -rf *` pattern named).
The span across stages was a clean Beck-style arc: a planning-time architecture pivot split the work into a behavior-preserving refactor (#304) and a trivial dependent fix (#301), and the fix landed in four small commits exactly as predicted.

### Observations

#### What went well

- The refactor-first split paid off as designed: #301 reused the `describeToolGate` `preCheck` seam from #304 with zero interface changes, and `checkPermission` stayed synchronous.
  The cross-session prediction in the Planning stage ("#301 collapses to: add `topLevelCommands()` + a bash command gate + wiring + docs") matched the actual four commits.
- Live post-ship verification, not just tests: running the real chained command against the reloaded extension confirmed the production denial message and matched pattern.
  This is a stronger signal than green tests alone and caught nothing only because the implementation was already correct.
- Incremental verification was exemplary across both implementation stages — `pnpm run check` after each interface-changing step, full suite + `lint` + `fallow` per step, and a fresh-context pre-completion reviewer that returned PASS with zero WARNs on #301.

#### What caused friction (agent side)

- `premature-convergence` — the initial mechanism recommendation was a synchronous hand-rolled bash splitter.
  The agent flagged the "second decomposition that can diverge from tree-sitter" risk in its own `ask_user` option text but still recommended that option; only the user's "is something more fundamentally off?"
  question forced re-ranking toward reusing the tree-sitter parse.
  Impact: no rework (caught in planning before any code), but the agent under-weighted an architectural concern it had already identified.
- `instruction-violation` (tooling-caught) — the #301 TDD stage notes were appended with a quoted shell heredoc (`cat <<'EOF'`), so `\u2014` was written literally instead of em-dashes and a two-sentence line slipped in, tripping `rumdl` MD013.
  Impact: one fix cycle (a four-part `Edit`).
  Root cause: authoring markdown prose via a heredoc instead of the `Write`/`Edit` tools, which respect the one-sentence-per-line and literal-Unicode conventions.
- `instruction-violation` / process gap (user-caught) — stacking #304's commits under #301 and running `/ship-issue 301` once left #304 open.
  Release-please omitted the `refactor:` commits from the v9.0.1 changelog, so there was no reminder that a second issue had shipped.
  Impact: #304 sat open with released code until the user caught it in this retro; resolved by closing #304 manually (shipped in `pi-permission-system-v9.0.1`).
- `other` (tool usage) — two `Edit` calls were rejected for including a stray `oldText_was_unique_hint` property.
  Impact: two wasted calls, immediate retries, no rework.

#### What caused friction (user side)

- The single highest-leverage moment was the user's architecture-review question, posed as a redirecting question rather than a correction — it prevented a divergent-second-parser design and reframed the whole effort.
  Opportunity (agent side): internalize asking "what change would make this change easy?"
  during planning rather than waiting for the prompt.
- The decision to skip `/ship-issue 304` and stack it under #301 was efficient, but neither party surfaced at decision time that the stacked issue would still need closing.

### Diagnostic details

- **Model-performance correlation** — the only subagent dispatched was the `pre-completion-reviewer` on `anthropic/claude-sonnet-4-6` (218s, 33 tool uses) for the judgment-heavy review; appropriate match, no over- or under-powered assignment.
- **Escalation-delay tracking** — no `rabbit-hole`; the longest repeated-error streak was two (`Edit` schema rejection), resolved immediately.
- **Feedback-loop gap analysis** — verification ran incrementally after each step, not only at the end; no gaps.

### Changes made

1. Closed issue #304 as completed (shipped in `pi-permission-system-v9.0.1`, stacked under #301) — the loose end this retro surfaced.
2. `.pi/prompts/ship-issue.md` — added a sub-step to the "Close the issue" step to detect and close other issues whose work shipped in the same push (stacked enablers / `refactor:` commits release-please omits).
3. `.pi/skills/markdown-conventions/SKILL.md` — added a rule to author/append markdown with `Write`/`Edit` rather than shell heredocs.
