Plan: Structural bash analysis for the workflow guards (#14)
On this page
TL;DR
The guards match text; the shell executes structure. Five real incidents came
from that gap. This plan gives @gadhs/pi-workflow a tree-sitter view of every
bash command, rewrites the five failing checks against it, and keeps the old
matchers as a test-time differential oracle — structural is authoritative from
day one, with the raw-string --no-verify backstop retained permanently as
defense in depth.
On approval this document is committed verbatim to
docs/modules/ROOT/pages/plans/structural-bash.adoc (nav: Active) and #14
gets the link. Work follows issue → feature/structural-bash → MR (Closes
#14).
Why: five incidents, one root cause
| Incident | Failing matcher | Failure mode |
|---|---|---|
Reflection pause fired on prose mentioning git commands (≥5 times) |
|
Matches inside quoted strings and heredoc bodies |
Stage-and-commit refusal permanently blocks heredocs whose prose names both |
|
Cannot tell commands from data |
|
Raw substring scan |
Fires on test fixtures and documentation |
#31: one denied unit vetoed innocent /tmp reads beside it |
Whole-line veto on unit deny |
No per-command attribution |
|
|
Value-taking flags defeat |
Verified facts this plan stands on (2026-08-31, sources in #14)
-
gotgenes runs
web-tree-sitter+tree-sitter-bashas WASM — no native build — warmed atbefore_agent_start, whole-string fallback when cold (node_modules/@gotgenes/pi-permission-system/src/access-intent/bash/parser.ts). We use the same two packages as our own dependencies and copy the warm/fallback discipline. -
gotgenes' structural intel does NOT reach authorizers (bash asks carry one evidence entry:
full command), so nothing here can ride their data. -
All five incidents are guard-side. Scope is
@gadhs/pi-workflowonly.
Design
New module: packages/pi-workflow/bash-structure.ts
Dependencies added to pi-workflow’s package.json: web-tree-sitter and
tree-sitter-bash, pinned to the same majors gotgenes uses (^0.26 / ^0.25 —
read its package.json at implementation time and match).
analyze(command: string) -> {
status: "parsed" | "unavailable", // unavailable = cold/failed WASM
complexity: "simple" | "complex", // complex = any substitution,
// expansion, control flow, eval
commands: [{
argv: (string | null)[], // null = non-literal (expansion)
redirects: string[],
wrapperChain: string[], // ["timeout","300"] outer-to-inner
span: { start: number, end: number} // byte offsets in the input
}],
}
Rules for consumers, stated once here and enforced in review:
-
nullin argv means UNKNOWN — every consumer must treat unknown as the conservative case for its own check. -
status: "unavailable"means the consumer MUST behave byte-identically to today’s text matcher. Structural analysis may only narrow false positives, never widen false negatives.
Warm at session_start in pi-workflow’s index.ts (same place the setup
drift check runs); synchronous accessor thereafter.
The five rewrites, in packages/pi-workflow/git-guard.ts
| Check | Structural form |
|---|---|
|
Any parsed command node whose argv[0] is |
|
True only when the parsed command SEQUENCE contains both a stage and a commit command. Heredoc/string content is invisible to it. |
|
|
|
argv-based: the value of |
Co-Authored-By splice |
Positioned by the commit command’s |
The old matchers become the differential oracle — not shipped
The current text implementations move to
packages/pi-workflow/test/legacy-matchers.ts (test-only; the files
allowlist already excludes test/). The chaos suite
(test/git-guard-chaos.test.ts, 500 seeded compounds) runs BOTH
implementations and fails on any divergence not in an enumerated allowlist
where every entry cites which of the five incidents it re-litigates. Two
categories only: documented old-matcher bug (structural is right) or
new-parser failure (build fails). No third bucket.
What does NOT ship, and why
-
No field shadow mode. The issue’s original acceptance imported Claude Code’s shadow rollout, which exists because their parser change faced millions of unknown users. Ours faces this agency, is covered by the 500-compound differential oracle plus the incident regressions, and keeps the deny-side raw backstop in production permanently. A two-release soak is pre-1.0 ceremony; the differential oracle is the same evidence, earlier.
-
No judge/pi-modes changes. Complexity-as-judge-fact and scratch-path predicates are phase 2, gated on the upstream ask (expose path candidates as bash-ask evidence — drafted, channel-gated).
-
No deterministic defer on "complex". Half of legitimate commands contain
$(…); deterministic defer is the cd-friction incident again.
Increments
Each is a commit through the normal review; the plan’s Status lines update as they land (living-spec rule).
-
bash-structure.ts+ WASM warm/fallback + its unit suite (test/bash-structure.test.ts: parse shapes, wrapper chains, argv nulls, cold-parser behavior). No guard touched.
Status: Done (2026-09-01) — 16 contract tests; redirects taught us redirected_statement wraps the command -
Legacy matchers extracted to
test/legacy-matchers.ts; chaos suite made differential (both paths, enumerated allowlist, two categories).
Status: Done (2026-09-01) — 505 commands x 3 predicates, zero unexplained; the allowlist became CODE (structure-proves-prose), not a list -
The five rewrites, one commit each if reviewably small, with the incident regression + its true-positive twin per rewrite.
Status: Done (2026-09-01) — all five rewrites live: three predicates structural-first, findCommits/extractCommitMessage on one shared flag walk, trailer spliced at parser byte spans; the pre-commit probe caught a time-wrapper fail-open the corpus lacked coverage for (wrapper parity now pinned, wrapped true-positives in the differential) -
Docs (
modes.adocguards section: structural analysis + fallback story) and CHANGELOG; release rides the next cut.
Status: Done (2026-09-01) — modes.adoc gained "How the guards read a command line"; CHANGELOG Unreleased entry written; release rides the next cut as planned
Risks
-
WASM cold-start at first guard hit — mitigated by session_start warm and the byte-identical
unavailablefallback; pinned by tests. -
tree-sitter-bash disagrees with real bash on an edge — the differential oracle makes any disagreement a visible test failure, and deny-side backstops mean a parse failure cannot weaken the
--no-verifyrefusal. -
Divergence allowlist rots — every entry must cite an incident class; review refuses uncited entries (stated in the test header).
Conventions checklist
-
Durable copy:
docs/modules/ROOT/pages/plans/structural-bash.adocon approval; nav under Active;task plan-lintvocabulary used above. -
Contextless reviewer passes per the delivery protocol: round 1 findings incorporated into this revision; further rounds until a fresh reviewer finds nothing material, before implementation starts.
-
No upstream security code touched; new deps are the two parsers only.
-
Pre-1.0: no compatibility machinery — the retained raw backstop is safety layering and is kept forever, not until a version.