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)

guardRelevant regex /\bgit\b|\bglab\b/

Matches inside quoted strings and heredoc bodies

Stage-and-commit refusal permanently blocks heredocs whose prose names both

stagesAndCommits line/token scan

Cannot tell commands from data

noVerifyBackstop blocked writing its own test

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

timeout 300 git commit --no-verify invisible until a live bypass

stripWrappers flag-skipping heuristic

Value-taking flags defeat startsWith("-")

Verified facts this plan stands on (2026-08-31, sources in #14)

  • gotgenes runs web-tree-sitter + tree-sitter-bash as WASM — no native build — warmed at before_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-workflow only.

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:

  • null in 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

guardRelevant

Any parsed command node whose argv[0] is git or glab (wrapper chains unwrapped). Prose in strings/heredocs no longer matches.

stagesAndCommits

True only when the parsed command SEQUENCE contains both a stage and a commit command. Heredoc/string content is invisible to it.

noVerifyBackstop

--no-verify/-n-cluster inside a git-commit command’s argv or its wrapper chain. The RAW-STRING CHECK STAYS, permanently, as the unavailable/complex fallback — it is defense in depth, not a compatibility shim.

extractCommitMessage / -F containment

argv-based: the value of -m/-F read from the parsed command.

Co-Authored-By splice

Positioned by the commit command’s span, replacing separator surgery.

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).

  1. 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

  2. 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

  3. 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)

  4. Docs (modes.adoc guards 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 unavailable fallback; 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-verify refusal.

  • 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.adoc on approval; nav under Active; task plan-lint vocabulary 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.

Edit this page · latest