10 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 306 | Evaluate commands inside command substitution and subshells against the permission rules |
Retro: #306 — Evaluate nested bash commands (command substitution, process substitution, subshells)
Stage: Planning (2026-06-02T00:33:17Z)
Session summary
Planned #306 as a consumer of the #308 BashCommand model: extend collectTopLevelCommandTexts in bash-program.ts into a context-aware recursive enumerator that descends command_substitution ($(…)/backticks), process_substitution (<(…)/>(…)), and subshell (( … )), emitting each nested command as an additional BashCommand tagged with its execution context, in addition to the never-weaker whole emit.
Confirmed AST shapes with a throwaway web-tree-sitter probe and settled the one real design choice (the context field) with the owner before writing the plan.
Plan committed as a 3-step TDD sequence (enumeration descent → context tag + message surfacing → docs).
Observations
- The owner chose to add the
contextfield and surface it in the deny reason + ask prompt (inside command substitution), and to scope the tag to the command-pattern surface only — deferring per-command path/context provenance for the external-directory / bash-path surfaces to #307, which already introduces the per-command path model. contextis added with its consumers in a single commit (step 2), not in step 1, becausepnpm fallow dead-codeflags a constructed-but-unread interface field (the exact trap the #308 retro called out forcontext/name/argv). Step 1 therefore keepsBashCommandone-field and lands the security fix (nested deny works as soon as the enumerator emits the inner units, since the handler already feedscommands()to the resolver).contextis optional and absent for top-level commands (no"top-level"union member). This confines test churn: existingcommands()and whole-PermissionCheckResultassertions stay green becausetoEqualtreats an absent property as equal toundefined. Result-levelcommandContextis likewise only set for nested winners.- The probe surfaced a non-obvious AST fact: when the whole command is a substitution (
$(a && b)alone),command_substitutionnests undercommand_name, not as a sibling argument — so the descent must search the entirecommandsubtree, whichcollectSubstitutionCommandsdoes. - Robust delimiter skipping uses
node.isNamed(a boolean property onweb-tree-sitter's node) rather than enumerating fragile anonymous token types ($(,),`,(,<(, …). This required addingreadonly isNamed: booleanto the localTSNodeinterface. BashCommandContextis placed insrc/types.ts(not the gate module) soPermissionCheckResultstays self-contained and the gate + presentation modules import it in the existing dependency direction.- Design-review check on the shared-interface change:
PermissionCheckResultgains one optional field read by two presentation modules and written by one resolver, riding the existing result-carries-context pattern (same ascommand/matchedPattern) — no new parameter threading, no LoD / output-argument smells. configuration.mddocuments the current limitation explicitly (nested contents "matched as part of their enclosing command rather than evaluated independently") — that prose and the "subshells … are not parsed" caveat are the required doc updates.- Carried forward from #308: these are
feat:commits (notrefactor:), so #306 will appear in the changelog normally; no explicit-close caveat needed for release-please.
Stage: Implementation — TDD (2026-06-02T00:54:01Z)
Session summary
Implemented #306 across three TDD cycles (two feat: code commits + one docs: commit) exactly as planned: step 1 added the enumeration descent (the security fix), step 2 added the context field end-to-end with its message consumers in one commit, step 3 updated configuration.md + architecture.md.
Test count went 1704 → 1716 (+12: 8 enumeration tests in step 1, 4 context/message tests in step 2).
pnpm run check, pnpm run lint, pnpm run test, and pnpm fallow dead-code (repo root, 203 entry points) all green; no lockfile change.
Observations
- No deviations from the plan — the file-by-file changes, the 3-step ordering, and the fallow-driven "field + consumer in one commit" split all held.
- The AST probe from planning paid off:
command_substitutionnesting undercommand_name(when the whole command is$(…)) is handled bycollectSubstitutionCommandssearching the full command subtree, andnode.isNamedcleanly skips every delimiter/operator token without enumerating fragile anonymous type strings. - Refined one planning detail during implementation:
NESTED_EXECUTION_CONTEXTSbecame aMap<string, BashCommandContext>(node-type → context) instead of aSet, socollectSubstitutionCommandsreads the context off the map rather than re-deriving it — decouples tree-sitter type strings from the union and avoids a cast. - Step 2 threaded an optional
contextparam throughcollectCommandsInto/descendCommandChildrenand added a tinymakeUnit(text, context)helper so top-level units stay{ text }(nocontext: undefined), keeping the existing top-levelcommands()and whole-PermissionCheckResultassertions green undertoEqual. - One mechanical hiccup: an
Editto theresolveBashCommandCheckJSDoc failed because theoldTextanchor started mid-line (Matching the whole string…is not a line start); re-anchored on the prior line and it applied. No rework. - Pre-completion reviewer verdict: PASS (all deterministic checks green; code-design, docs forward/reverse, Mermaid, and dead-code all PASS; no acceptance-criteria list in the issue, so that check was SKIP). No warnings.
Stage: Final Retrospective (2026-06-02T01:05:11Z)
Session summary
Shipped #306 end-to-end in one continuous session (plan → TDD → ship → retro): three commits (two feat:, one docs:) plus stage docs, all green through CI, issue closed, and release-please PR #310 merged to cut pi-permission-system-v9.1.0.
The implementation matched the plan exactly — zero deviations, pre-completion PASS with no warnings — because two throwaway web-tree-sitter AST probes during planning de-risked every AST-dependent decision before any plan text was committed.
Observations
What went well
- The disposable AST probes (
probe-ast.mjs,probe2.mjs) run during planning were the decisive win: they surfaced the non-obviouscommand_substitution-under-command_namenesting and confirmednode.isNamedas a clean delimiter filter, so the TDD stage hit zero AST surprises across nine enumeration tests. This is thetestingskill's "write a disposable exploratory script first to inspect the actual runtime shape" rule paying off concretely — the rule already exists and was followed. - The fallow trap was anticipated, not discovered: planning split the work so the
contextfield and its first reader land in the same commit (step 2), and I ranpnpm fallow dead-codefrom the repo root before committing step 2 rather than after — so the constructed-but-unread-field risk never materialized. ask_userwas used for exactly the two genuine design decisions (whether thecontextfield earns its keep; which surfaces carry it) and not for anything mechanical; both were answered cleanly and shaped the plan, and the second was preceded by a neutral surface-by-surface map per theask-user"gather evidence first" handshake.- Incremental verification was exemplary: targeted
vitestper Red/Green sub-step,pnpm run checkimmediately after each interface change, and fulltest+check+lint+fallowafter every step's commit — no end-of-session verification pile-up.
What caused friction (agent side)
other(mechanical) — the batchedEdittobash-command.tsfailed atomically on the first attempt because theresolveBashCommandCheckJSDoc anchor began mid-line (Matching the whole string…), which is not a unique line start. Impact: one re-read of the file and one retry; no rework, no wrong code. Self-identified immediately from the tool error.other(environment) — during shipping,git log | grep -oPfailed because macOS BSDgreplacks-P; recovered in one retry withgrep -Eo. Impact: one extra tool round-trip, no rework.
What caused friction (user side)
- None.
The user ran all four workflow stages back-to-back with no mid-stage correction; involvement was mechanical oversight plus the two
ask_userdesign decisions, not strategic redirection. Opportunity (not criticism): there was nothing to surface earlier — the two decisions genuinely needed the owner's judgment and were posed at the right moments.
Diagnostic details
- Model-performance correlation — the only subagent dispatch in the whole session was the
pre-completion-reviewer(44 tool uses, ~60k tokens) on judgment-heavy read-only code review; an appropriate match, no mismatch. Planning exploration was done directly (grep +Read+ AST probes) rather than via an Explore subagent, which suited a focused single-package change. - Escalation-delay tracking — no
rabbit-hole: both friction points resolved in a single retry; no sequence exceeded five tool calls on the same error. - Unused-tool detection — no gap.
colgrepwas loaded but unused; every search was exact-symbol (commands(),resolveBashCommandCheck,matchedPattern), sogrepwas the correct tool, and the AST probes covered the only genuinely unfamiliar data structure. - Feedback-loop gap analysis — no gap; verification ran incrementally after each change, and the
fallowgate ran from the repo root (203 entry points) before the at-risk commit rather than only at the end.
Changes made
packages/pi-permission-system/docs/retro/0306-evaluate-nested-bash-commands.md— appended this Final Retrospective stage entry. NoAGENTS.mdor prompt changes: the owner confirmed the session had no friction justifying a process change. Candidates considered and rejected: anEditline-anchor rule (one-off, no rework), a BSD-grep -Pportability note (environment-specific), and a new "add an interface field with its first consumer" rule (already covered by the package skill's maintenance-trap guidance and the speculative-re-export rule).