Skip to content

Enforce the gh-aw shell input security contract with a gate over compiled workflow output #1834

Closed

Description

@bradygaster

Summary

PR #1832 (issue #1824) adds a shell input security contract to workflows/squad.md, authored by RETRO. It defines the mandatory channel for attacker-controlled GitHub event text and names four forbidden anti-patterns.

The contract is declared but not enforced. This issue tracks building the gate.

Deliberately scoped out of #1832: that PR is a regression rework under an active Diff Size Guard, and a new gate over compiled gh-aw YAML is materially larger. Filing it separately so the requirement is tracked rather than aspirational.

The contract being enforced

Mandatory channel: attacker-controlled GitHub event text (issue bodies, issue-comment bodies, issue titles, PR titles/bodies) MUST reach the shell only through named step/job env: variables, read via quoted shell parameter expansion.

Forbidden anti-patterns — deliberately named so they are greppable:

Token Rule
UNTRUSTED_TEMPLATE_IN_RUN No ${{ github.event.* }} (or expressions derived from it) directly inside a run: block. Unsafe even inside shell quotes — Actions template expansion happens before the shell starts, so shell quoting cannot save you.
UNTRUSTED_COMMAND_STRING No building shell syntax from attacker text: no eval, source, generated script text, or bash -c / sh -c command strings containing the body.
UNTRUSTED_PRINTF_FORMAT Body must never be printf's first argument. Format slot must be a literal ('%s\n'); body belongs in an argument slot.
UNTRUSTED_AWK_PROGRAM_OR_VAR No interpolating attacker text into an awk program string, and no raw body through awk -v — awk -v performs escape-sequence processing on values and can mutate parser input. Body reaches awk on stdin, program stays static and single-quoted.

Full contract with per-hop requirements: workflows/squad.md, section Shell input security contract [MANDATORY] (added in #1832).

Why the gate must read compiled output, not markdown

This is the crux. gh-aw compiles markdown into workflow YAML. Rule 1 — "the compiled workflow must not contain attacker-controlled ${{ github.event... }} expressions in any run: block" — is a property of the compiled artifact. A markdown-only linter cannot observe it.

RETRO stated the residual risk plainly during authoring:

The real gh-aw compiled output is absent from this repo, so compiler-channel compliance remains unmeasured.

So the gate has a prerequisite: compiled gh-aw output must be available to CI (committed lock, build step, or equivalent). Establishing that is part of this issue. If it turns out compiled output genuinely cannot be produced in CI, say so and downgrade the contract's verification paragraph to match reality rather than leaving a requirement nothing can check.

Acceptance criteria

  1. The gate inspects compiled gh-aw workflow YAML, not just workflows/*.md. If compiled output is unavailable, the gate fails loudly rather than passing vacuously.

  2. It fails when any compiled run: block contains attacker-controlled GitHub event expressions.

  3. It fails when parser code passes a body variable as a printf format, into eval / bash -c, or into an awk program or awk -v.

  4. Positive-control fixture, required. RETRO's wording, and it is the acceptance bar:

    A gate that cannot turn red on a fixture containing run: printf '%s\n' "${{ github.event.issue.body }}" is not a valid gate.

    The fixture must live in the repo and be exercised by the gate's own test, so the gate is proven capable of failing rather than assumed to be.

  5. The failure message names the violated anti-pattern token and the offending file and line. A status-only failure is insufficient — per fix(gh-aw): fail loudly when /squad parses no command (#1824) #1832's mutation testing, a generic-shaped diagnostic passes a status-only assertion clean.

  6. The remediation text the gate prints must be a command that can actually observe the violation. Per .squad/decisions.md: the instructions a gate prints are part of the gate.

Related

Same defect cluster as #1824, #1812, #1801, #1822, #1827, #1833 — gates that structurally cannot observe the failure they exist to catch. Standing decision (.squad/decisions.md, 2026-08-20): a permanently green — or permanently red — gate is equivalent to no gate.

Contract authored by RETRO during #1832 review. Injection finding originally raised by the automated PR reviewer on #1832 and adjudicated NON-BLOCKING by FIDO after measuring 8 payloads through the live pipeline with 0 side effects — the runtime pipeline is safe today; this gate exists to keep it that way.

Activity

  1. bradygaster commented on Aug 24, 2026

    @bradygaster
    CollaboratorAuthor

    Design update from #1842 / PR #1849 — this issue is no longer budget-constrained.

    The premise that the shell-input security contract (workflows/squad.md L245-299, 2,695 bytes) had to be deleted came from the prompt-budget gate reporting ~26 bytes of headroom. That gate was wrong on both sides:

    • The "hard 100 KB gh-aw prompt ceiling" it cited does not exist. 102400 is defaultRepoMemoryMaxFileSize in gh-aw pkg/workflow/repo_memory.go — a repo-memory file cap, unrelated to prompts.
    • It summed raw source, but gh-aw strips every inline ## skill: block at setup/interpolation (gh-aw .github/aw/skills.md). ~65 KB of the 97 KB it measured never enters the initial request. Actual ambient prompt: 31.8 KB.

    Real headroom is ~70 KB, not 26 bytes.

    What changes here: the security contract can move into a ## skill: block rather than being removed. It stays authored, versioned, and loadable on demand — the agent pulls it when it is about to construct shell input, which is arguably better placement than ambient anyway.

    And #1834 is no longer a prerequisite for #1842 — that dependency existed only to reclaim bytes.

    One constraint if you do convert it to a skill block: the section body must not contain ## headings unless you add an explicit ## end skill: `name` marker. gh-aw closes an unterminated block at the next H2 and silently discards everything between that boundary and the next skill marker. The discards no content during extraction test in test/gh-aw-quality.test.ts guards this, but it is worth knowing before you write it.

  2. bradygaster commented on Aug 24, 2026

    @bradygaster
    CollaboratorAuthor

    Prerequisite finding: compiled output is unavailable to CI today, and the existing compile gate silently skips

    Investigated the prerequisite this issue calls out ("compiled gh-aw output must be available to CI ... Establishing that is part of this issue"). Measured, not assumed:

    1. A gh aw compile gate already exists — and it is a no-op in CI.

    test/gh-aw-quality.test.ts:1092:

    const ghAwAvailable = spawnSync('gh', ['aw', '--version'], { encoding: 'utf8' }).status === 0;
    it.skipIf(!ghAwAvailable)('strict-compiles and preserves prompt/config behavior', ...)

    No CI job installs the gh aw extension (grep of .github/workflows/ for gh aw / gh-aw / extension install matches only agentics-maintenance.yml, which is a generated agentic workflow, not a test job). So ghAwAvailable is false on every CI run and the test skips rather than fails — a vacuous pass.

    That is this issue's own defect class, already present in the repo: a gate that structurally cannot observe the failure it exists to catch.

    2. No .lock.yml is committed here. Sources live in workflows/; locks are compiled in the consumer's repo. So there is no compiled artifact on disk for a gate to read either.

    Consequence for scope: acceptance criterion 1 ("the gate inspects compiled gh-aw workflow YAML ... If compiled output is unavailable, the gate fails loudly rather than passing vacuously") cannot be met by a linter alone. It requires new CI infrastructure — install the gh aw extension, compile workflows/* into a scratch workspace, and treat absence of compiled output as failure.

    High-leverage note: that one enabling step is shared work. It simultaneously (a) activates the existing skipped compile gate above, (b) unblocks this issue's contract gate, and (c) satisfies the follow-up promised in PR #1854 (#1827) — linting the emitted .lock.yml files, which today are never linted by anything because they are never produced in CI.

    Deferred by owner decision this session to prioritize the E2E milestone verdict (M2). Filing this comment so the prerequisite is recorded rather than rediscovered.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    gh-awGitHub Agentic Workflows integrationpriority:p1This sprintsecuritytriagedIssue has been triagedwave:1-nextWave 1 — immediate next sprint

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions