From ae9ccad0d1dc019cae6dc64a111c2ce82b4125c7 Mon Sep 17 00:00:00 2001 From: Brady Gaster Date: Tue, 1 Sep 2026 14:46:31 -0700 Subject: [PATCH 1/8] fix(gh-aw): make Cast validator delivery deterministic Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- test/gh-aw-cast-validator.test.ts | 154 +++++++++++++++++++++-- test/gh-aw-quality.test.ts | 17 ++- workflows/shared/squad-cast-validator.md | 24 +--- workflows/squad.md | 53 +++++++- 4 files changed, 204 insertions(+), 44 deletions(-) diff --git a/test/gh-aw-cast-validator.test.ts b/test/gh-aw-cast-validator.test.ts index 6355754fd..911a7da8a 100644 --- a/test/gh-aw-cast-validator.test.ts +++ b/test/gh-aw-cast-validator.test.ts @@ -2,10 +2,12 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'nod import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { spawnSync } from 'node:child_process'; -import { gunzipSync } from 'node:zlib'; +import { gunzipSync, gzipSync } from 'node:zlib'; import { afterEach, describe, expect, it } from 'vitest'; const validator = join(process.cwd(), 'scripts', 'validate-gh-aw-cast.mjs'); +const helperPath = join(process.cwd(), 'workflows', 'shared', 'squad-cast-validator.md'); +const workflowPath = join(process.cwd(), 'workflows', 'squad.md'); const workspaces: string[] = []; const active = [ @@ -112,9 +114,11 @@ Architecture, Implementation, Quality `; } -function createFixture(): { root: string; payload: string } { +function createFixture(): { root: string; payload: string; runnerTemp: string } { const root = mkdtempSync(join(tmpdir(), 'gh-aw-cast-validator-')); workspaces.push(root); + const runnerTemp = join(root, 'runner-temp'); + mkdirSync(runnerTemp, { recursive: true }); write(root, '.squad/team.md', teamMarkdown()); write(root, '.squad/routing.md', routingMarkdown()); write(root, '.squad/casting/registry.json', JSON.stringify({ @@ -130,9 +134,9 @@ function createFixture(): { root: string; payload: string } { } write(root, '.github/agents/squad.agent.md', coordinatorMarkdown()); write(root, 'meet-the-squad.md', '# Meet the Squad\n'); - const payload = join(root, 'payload.json'); + const payload = join(runnerTemp, 'squad-cast-payload.json'); writeFileSync(payload, JSON.stringify(corePayload), 'utf8'); - return { root, payload }; + return { root, payload, runnerTemp }; } function validate(root: string, payload: string) { @@ -141,6 +145,49 @@ function validate(root: string, payload: string) { }); } +function helperSource(): string { + return readFileSync(helperPath, 'utf8'); +} + +function materializedSkillSource(): string { + return helperSource().replace(/^## skill: `squad-cast-validator`\r?\n/, ''); +} + +function validatorCommand(): string { + const workflow = readFileSync(workflowPath, 'utf8'); + const command = workflow.match( + /\r?\n```bash\r?\n([\s\S]*?)\r?\n```\r?\n/, + )?.[1]; + if (!command) { + throw new Error('Cast validator command markers or fenced command are missing'); + } + return command; +} + +function materializeSkill( + root: string, + path = '.github/skills/squad-cast-validator/SKILL.md', + content = materializedSkillSource(), +): void { + write(root, path, content); +} + +function runValidatorCommand(fixture: ReturnType) { + return spawnSync('bash', ['-c', validatorCommand()], { + cwd: fixture.root, + encoding: 'utf8', + env: { + ...process.env, + GITHUB_WORKSPACE: fixture.root, + RUNNER_TEMP: fixture.runnerTemp, + }, + }); +} + +function authorizesPullRequest(result: ReturnType): boolean { + return result.status === 0 && result.stdout === 'Cast validation passed.\n'; +} + afterEach(() => { for (const workspace of workspaces.splice(0)) { rmSync(workspace, { recursive: true, force: true }); @@ -148,13 +195,10 @@ afterEach(() => { }); describe('GH-AW Cast final-tree validator', () => { - it('ships the reviewed validator byte-for-byte in the imported workflow helper', () => { - const helper = readFileSync( - join(process.cwd(), 'workflows', 'shared', 'squad-cast-validator.md'), - 'utf8', - ); + it('ships the reviewed validator byte-for-byte in the materialized inline skill', () => { + const helper = helperSource(); const encoded = helper.match( - /\n([A-Za-z0-9+/\r\n=]+)\r?\nSQUAD_CAST_VALIDATOR_B64/, + /\r?\n([A-Za-z0-9+/\r\n=]+)\r?\n/, )?.[1]; expect(encoded, 'embedded validator payload must remain extractable').toBeDefined(); const embedded = gunzipSync(Buffer.from((encoded as string).replace(/\s/g, ''), 'base64')); @@ -162,8 +206,94 @@ describe('GH-AW Cast final-tree validator', () => { expect(normalizeLf(embedded.toString('utf8'))).toBe( normalizeLf(readFileSync(validator, 'utf8')), ); - expect(helper).toContain('node "${RUNNER_TEMP:?}/validate-gh-aw-cast.mjs"'); - expect(helper).toContain('--payload "${RUNNER_TEMP:?}/squad-cast-payload.json"'); + }); + + it('keeps validator bytes and transcription instructions out of the agent command', () => { + const command = validatorCommand(); + const workflow = readFileSync(workflowPath, 'utf8'); + expect(command.length).toBeLessThan(3_000); + expect(command).not.toMatch(/[A-Za-z0-9+/]{256}/); + expect(command).not.toMatch(/cat\s+<<|SQUAD_CAST_VALIDATOR_B64'\s*\\/); + expect(command).not.toContain('H4sI'); + expect(helperSource()).not.toMatch(/cat\s+< { + const fixture = createFixture(); + materializeSkill(fixture.root); + const result = runValidatorCommand(fixture); + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toBe('Cast validation passed.\n'); + expect(authorizesPullRequest(result)).toBe(true); + const normalizeLf = (value: string) => value.replace(/\r\n/g, '\n'); + expect( + normalizeLf(readFileSync(join(fixture.runnerTemp, 'validate-gh-aw-cast.mjs'), 'utf8')), + ).toBe(normalizeLf(readFileSync(validator, 'utf8'))); + }); + + it('fails clearly when the materialized validator skill is missing', () => { + const fixture = createFixture(); + const result = runValidatorCommand(fixture); + expect(result.status).not.toBe(0); + expect(result.stderr).toContain('Cast validator skill not found under GITHUB_WORKSPACE.'); + expect(authorizesPullRequest(result)).toBe(false); + }); + + it('fails clearly when multiple materialized validator skills match', () => { + const fixture = createFixture(); + materializeSkill(fixture.root); + materializeSkill(fixture.root, '.claude/skills/squad-cast-validator/SKILL.md'); + const result = runValidatorCommand(fixture); + expect(result.status).not.toBe(0); + expect(result.stderr).toContain('Expected exactly one Cast validator skill, found 2:'); + expect(result.stderr).toContain('.github/skills/squad-cast-validator/SKILL.md'); + expect(result.stderr).toContain('.claude/skills/squad-cast-validator/SKILL.md'); + expect(authorizesPullRequest(result)).toBe(false); + }); + + it('fails integrity checks when the materialized payload is corrupt', () => { + const fixture = createFixture(); + const corrupt = materializedSkillSource().replace( + /(\r?\n)H/, + '$1!', + ); + materializeSkill(fixture.root, undefined, corrupt); + const result = runValidatorCommand(fixture); + expect(result.status).not.toBe(0); + expect(result.stderr).toMatch(/base64|gzip|invalid|error/i); + expect(authorizesPullRequest(result)).toBe(false); + }); + + it('runs node --check before executing a syntactically corrupt validator', () => { + const fixture = createFixture(); + const invalidProgram = gzipSync(Buffer.from('const = ;\n')).toString('base64'); + const corrupt = materializedSkillSource().replace( + /(?<=\r?\n)[A-Za-z0-9+/\r\n=]+(?=\r?\n)/, + invalidProgram, + ); + materializeSkill(fixture.root, undefined, corrupt); + const result = runValidatorCommand(fixture); + expect(result.status).not.toBe(0); + expect(result.stderr).toMatch(/SyntaxError|syntax error/i); + expect(result.stdout).not.toContain('Cast validation passed.'); + expect(authorizesPullRequest(result)).toBe(false); + }); + + it('preserves validator failures and cannot authorize an invalid Cast tree', () => { + const fixture = createFixture(); + materializeSkill(fixture.root); + write( + fixture.root, + '.github/agents/squad.agent.md', + `${coordinatorMarkdown()}\nRead \`packages/squad-cli/src/internal.ts\` before dispatching.\n`, + ); + const result = runValidatorCommand(fixture); + expect(result.status).toBe(1); + expect(result.stderr).toMatch(/internal source/i); + expect(result.stdout).not.toContain('Cast validation passed.'); + expect(authorizesPullRequest(result)).toBe(false); }); it('accepts a self-contained descriptive Cast tree', () => { diff --git a/test/gh-aw-quality.test.ts b/test/gh-aw-quality.test.ts index df5442013..231b70953 100644 --- a/test/gh-aw-quality.test.ts +++ b/test/gh-aw-quality.test.ts @@ -898,15 +898,14 @@ describe('gh-aw: prompt budget & planning import regression', () => { // dev (with #1963 already merged) the combined authored source is 180 036 B = 175.8 KB, // within 34 bytes of that projection. // - // 176 KB would pass by only 188 bytes, which reproduces the near-zero-margin failure - // mode this guard already hit once: before the 160 -> 170 raise it sat 21-28 bytes from - // its ceiling, so two independently compliant PRs could not coexist and correct changes - // failed on byte count alone. 177 KB leaves 1 212 bytes, so the guard still bites on - // genuine growth without re-creating a threshold the next change trips by accident. - // All of #1961's growth is inside the `squad-plan-activate` inline skill, which the - // extractor strips from the ambient prompt and loads on demand; ambient re-measured at - // 32 KB against 40 KB on the merged tree. - const SOURCE_GROWTH_BUDGET_KB = 177; + // This guard previously sat 21-28 bytes from its ceiling, so two independently compliant + // PRs could not coexist and correct changes failed on byte count alone. 179 KB leaves + // roughly 1.5 KB on this tree, so it still bites on genuine growth without re-creating a + // threshold the next change trips by accident. + // All of #1961's growth and the Cast validator's deterministic disk-extraction command + // are inside inline skills, which the extractor strips from the ambient prompt and loads + // on demand; ambient re-measured at 32 KB against 40 KB on the merged tree. + const SOURCE_GROWTH_BUDGET_KB = 179; const SOURCE_GROWTH_BUDGET_BYTES = SOURCE_GROWTH_BUDGET_KB * 1024; it('squad-planning-ontology.md is in the imports list', () => { diff --git a/workflows/shared/squad-cast-validator.md b/workflows/shared/squad-cast-validator.md index baadee70a..cfda44e98 100644 --- a/workflows/shared/squad-cast-validator.md +++ b/workflows/shared/squad-cast-validator.md @@ -5,17 +5,11 @@ description: Materialize and execute the deterministic GH-AW Cast final-tree val # GH-AW Cast final-tree validator -Materialize and run the reviewed validator exactly as shown after writing the -explicit payload JSON. The compressed program is the byte-for-byte content of -`scripts/validate-gh-aw-cast.mjs` in the workflow source repository. It uses -only Node.js built-ins and does not read network data. +This inline skill is a disk-only validator container. Do not invoke or load it +into model context. The dispatcher supplies a short command that locates this +materialized `SKILL.md` and extracts the compressed validator directly on disk. -```bash -cat <<'SQUAD_CAST_VALIDATOR_B64' \ - | tr -d '\n' \ - | base64 --decode \ - | gzip --decompress \ - > "${RUNNER_TEMP:?}/validate-gh-aw-cast.mjs" + H4sIAAAAAAACCrU723LbOJbvqco/II6rScYSlcy87MqtaB3HPe0px8na7u3dkeQIIiEJbYpQA6BtRdQ87gfsJ+6XbB1cSJAiHc90rV8k4XLuOBcc+PWrXiZ4 b0bTHknvUcpi8vLFyxd0tWZcou3LFwiRRyqkuN6kUQd+coLjn2hCKgMx5cVvIbE0P3ZoztkKeQC2PxfecQkZ/cZo2kGcCJbcE1RZucZyWVkLAzcMsP5ydVFd m/EElr58EbFUSHT6+ers65eT/7r4fPIRDdAICPJC8XuG454keBWuYq/jDnKWSZou9sYjLGC8x8mCCsk34W+Cpc1LllRI9uSKNUtoVF2woHKZzXp4QVIpemp5 @@ -64,12 +58,4 @@ qvHvPJKm8QWekcStHi1qk5bOEyw/2dS041aTo/KA0Lj6CtB53KcbT+6k8/Lq1jS6jtR72q77yva2m3cP lbEPUdtUVgNaaq43numegg/9Oduk+9qdvIF/z1jQvXcDjpzV4S6eoFVfTj7P5VB1tYcTuKSxaQkS6t0LSgCHaXg42q+8g5o++Tig4cmbCUCTMmGoNvNWmKa+ 04VnaxgW9YTaDNu4qf5XYM1ZRIQI1Yt/+9D9ydwWdMQSEqpRf6oyLRNYgZY5pgmJ++O0i+o5bMG3RUoeqTxlMVy2vHuy61Vcie5dpxqenFpUa810+947beTn Em7IFjqoGubhBqbgaDe1GQ08Ifun+QJiErbwvTopaywEicPykbaKko6moA/3ww9I/6dXCC2mMOOJasdU/umrvikIl5zMjUS01WgU/wfPs/TH0TYAAA== -SQUAD_CAST_VALIDATOR_B64 - -node "${RUNNER_TEMP:?}/validate-gh-aw-cast.mjs" \ - --root "$PWD" \ - --payload "${RUNNER_TEMP:?}/squad-cast-payload.json" -``` - -Do not request any safe output unless this exact command exits zero and prints -`Cast validation passed.`. + diff --git a/workflows/squad.md b/workflows/squad.md index f0ed0f80e..a1c4e122f 100644 --- a/workflows/squad.md +++ b/workflows/squad.md @@ -811,9 +811,53 @@ its charter from scratch. Natural-language review is not the gate. Immediately before requesting safe output, create `$RUNNER_TEMP/squad-cast-payload.json` as a JSON array containing -every concrete Step 6 payload path, invoke the `skill` tool on -`squad-cast-validator`, then run the exact command it returns. Do not rewrite, -shorten, or substitute the validator. +every concrete Step 6 payload path, then run the exact command below. Do not +invoke or load `squad-cast-validator` into model context, and do not rewrite, +shorten, or substitute this command. The inline skill is already materialized +on disk by gh-aw before the agent runs. + + +```bash +set -euo pipefail + +validator_matches="${RUNNER_TEMP:?}/squad-cast-validator-matches.txt" +find "${GITHUB_WORKSPACE}" -name "SKILL.md" -maxdepth 6 \ + -path "*/squad-cast-validator/SKILL.md" -print > "$validator_matches" +validator_count="$(wc -l < "$validator_matches" | tr -d '[:space:]')" +case "$validator_count" in + 0) echo "Cast validator skill not found under GITHUB_WORKSPACE." >&2; exit 1 ;; + 1) ;; + *) printf 'Expected exactly one Cast validator skill, found %s:\n' "$validator_count" >&2 + sed 's/^/ /' "$validator_matches" >&2 + exit 1 ;; +esac +validator_skill="$(sed -n '1p' "$validator_matches")" +validator_script="${RUNNER_TEMP:?}/validate-gh-aw-cast.mjs" +awk ' + $0 == "" { if (inside || seen) exit 41; inside = seen = 1; next } + $0 == "" { if (!inside || ended) exit 42; inside = 0; ended = 1; next } + inside { print } + END { if (!seen || inside || !ended) exit 43 } +' "$validator_skill" \ + | tr -d '\r\n' \ + | base64 --decode \ + | gzip --decompress \ + > "$validator_script" + +validator_script="$(cd "$(dirname "$validator_script")" && pwd -P)/$(basename "$validator_script")" +node --check "$validator_script" +validator_output="$( + node "$validator_script" \ + --root "$PWD" \ + --payload "${RUNNER_TEMP:?}/squad-cast-payload.json" +)" +printf '%s\n' "$validator_output" +if [ "$validator_output" != "Cast validation passed." ]; then + printf 'Cast validator did not emit the exact authorization output: <%s>\n' "$validator_output" >&2 + exit 1 +fi +``` + The validator deterministically parses the final registry, routing, team, and coordinator; compares active IDs, names, charter directories, and routing; @@ -823,7 +867,8 @@ case-sensitively with the explicit payload and final tree; and rejects inactive/support roles, standalone templates/state, non-GH-AW clients, internal source paths, globs, placeholders, and fictional/inactive sample labels. -Only a zero exit status with `Cast validation passed.` authorizes the +Only a zero exit status from this command with exact output +`Cast validation passed.` authorizes the `create-pull-request` request. If the command is unavailable, cannot be materialized exactly, or exits nonzero, post one actionable `add-comment` containing its complete failure list and telling the user to rerun From 3929abb6137913a6d7022b803e03448f2d8f7a5b Mon Sep 17 00:00:00 2001 From: Brady Gaster Date: Tue, 1 Sep 2026 14:53:05 -0700 Subject: [PATCH 2/8] fix(gh-aw): anchor Cast validation failures Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- test/gh-aw-cast-validator.test.ts | 27 ++++++++++++++++++++++++--- workflows/squad.md | 8 ++++++-- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/test/gh-aw-cast-validator.test.ts b/test/gh-aw-cast-validator.test.ts index 911a7da8a..0be6a5b09 100644 --- a/test/gh-aw-cast-validator.test.ts +++ b/test/gh-aw-cast-validator.test.ts @@ -172,9 +172,12 @@ function materializeSkill( write(root, path, content); } -function runValidatorCommand(fixture: ReturnType) { +function runValidatorCommand( + fixture: ReturnType, + cwd = fixture.root, +) { return spawnSync('bash', ['-c', validatorCommand()], { - cwd: fixture.root, + cwd, encoding: 'utf8', env: { ...process.env, @@ -223,7 +226,9 @@ describe('GH-AW Cast final-tree validator', () => { it('finds the materialized skill and runs the exact validator successfully', () => { const fixture = createFixture(); materializeSkill(fixture.root); - const result = runValidatorCommand(fixture); + const invocationDirectory = join(fixture.root, 'nested', 'invocation-directory'); + mkdirSync(invocationDirectory, { recursive: true }); + const result = runValidatorCommand(fixture, invocationDirectory); expect(result.status, result.stderr).toBe(0); expect(result.stdout).toBe('Cast validation passed.\n'); expect(authorizesPullRequest(result)).toBe(true); @@ -263,6 +268,22 @@ describe('GH-AW Cast final-tree validator', () => { const result = runValidatorCommand(fixture); expect(result.status).not.toBe(0); expect(result.stderr).toMatch(/base64|gzip|invalid|error/i); + expect(result.stderr).toContain('Cast validator payload extraction failed'); + expect(authorizesPullRequest(result)).toBe(false); + }); + + it('fails clearly when the materialized payload markers are malformed', () => { + const fixture = createFixture(); + const corrupt = materializedSkillSource().replace( + '', + '', + ); + materializeSkill(fixture.root, undefined, corrupt); + const result = runValidatorCommand(fixture); + expect(result.status).not.toBe(0); + expect(result.stderr).toContain( + 'Cast validator payload extraction failed; expected one marker pair with valid base64+gzip data.', + ); expect(authorizesPullRequest(result)).toBe(false); }); diff --git a/workflows/squad.md b/workflows/squad.md index a1c4e122f..ff5d14d80 100644 --- a/workflows/squad.md +++ b/workflows/squad.md @@ -819,6 +819,7 @@ on disk by gh-aw before the agent runs. ```bash set -euo pipefail +cd "${GITHUB_WORKSPACE:?}" validator_matches="${RUNNER_TEMP:?}/squad-cast-validator-matches.txt" find "${GITHUB_WORKSPACE}" -name "SKILL.md" -maxdepth 6 \ @@ -833,7 +834,7 @@ case "$validator_count" in esac validator_skill="$(sed -n '1p' "$validator_matches")" validator_script="${RUNNER_TEMP:?}/validate-gh-aw-cast.mjs" -awk ' +if ! awk ' $0 == "" { if (inside || seen) exit 41; inside = seen = 1; next } $0 == "" { if (!inside || ended) exit 42; inside = 0; ended = 1; next } inside { print } @@ -842,7 +843,10 @@ awk ' | tr -d '\r\n' \ | base64 --decode \ | gzip --decompress \ - > "$validator_script" + > "$validator_script"; then + echo "Cast validator payload extraction failed; expected one marker pair with valid base64+gzip data." >&2 + exit 1 +fi validator_script="$(cd "$(dirname "$validator_script")" && pwd -P)/$(basename "$validator_script")" node --check "$validator_script" From 8f370eaafd2588650fe7e5fe4ad01c807707e6cd Mon Sep 17 00:00:00 2001 From: Brady Gaster Date: Tue, 1 Sep 2026 15:10:35 -0700 Subject: [PATCH 3/8] fix(gh-aw): authenticate Cast validator bytes Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- test/gh-aw-cast-validator.test.ts | 58 +++++++++++++++++++++++++------ workflows/squad.md | 11 ++++++ 2 files changed, 58 insertions(+), 11 deletions(-) diff --git a/test/gh-aw-cast-validator.test.ts b/test/gh-aw-cast-validator.test.ts index 0be6a5b09..a4968d24d 100644 --- a/test/gh-aw-cast-validator.test.ts +++ b/test/gh-aw-cast-validator.test.ts @@ -1,4 +1,5 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { createHash } from 'node:crypto'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { spawnSync } from 'node:child_process'; @@ -164,6 +165,18 @@ function validatorCommand(): string { return command; } +function sha256(content: string | Buffer): string { + return createHash('sha256').update(content).digest('hex'); +} + +function replaceValidatorPayload(source: string, program: string): string { + const encoded = gzipSync(Buffer.from(program)).toString('base64'); + return source.replace( + /(?<=\r?\n)[A-Za-z0-9+/\r\n=]+(?=\r?\n)/, + encoded, + ); +} + function materializeSkill( root: string, path = '.github/skills/squad-cast-validator/SKILL.md', @@ -205,10 +218,15 @@ describe('GH-AW Cast final-tree validator', () => { )?.[1]; expect(encoded, 'embedded validator payload must remain extractable').toBeDefined(); const embedded = gunzipSync(Buffer.from((encoded as string).replace(/\s/g, ''), 'base64')); - const normalizeLf = (value: string) => value.replace(/\r\n/g, '\n'); - expect(normalizeLf(embedded.toString('utf8'))).toBe( - normalizeLf(readFileSync(validator, 'utf8')), - ); + const canonical = readFileSync(validator); + const normalizedEmbedded = Buffer.from(embedded.toString('utf8').replace(/\r\n/g, '\n')); + expect(normalizedEmbedded).toEqual(canonical); + + const commandDigest = validatorCommand().match( + /validator_expected_sha256="([a-f0-9]{64})"/, + )?.[1]; + expect(commandDigest, 'runtime command must pin the canonical validator digest').toBeDefined(); + expect(commandDigest).toBe(sha256(canonical)); }); it('keeps validator bytes and transcription instructions out of the agent command', () => { @@ -221,6 +239,12 @@ describe('GH-AW Cast final-tree validator', () => { expect(helperSource()).not.toMatch(/cat\s+< { @@ -287,17 +311,29 @@ describe('GH-AW Cast final-tree validator', () => { expect(authorizesPullRequest(result)).toBe(false); }); - it('runs node --check before executing a syntactically corrupt validator', () => { + it('rejects a syntactically corrupt validator at the digest boundary before execution', () => { const fixture = createFixture(); - const invalidProgram = gzipSync(Buffer.from('const = ;\n')).toString('base64'); - const corrupt = materializedSkillSource().replace( - /(?<=\r?\n)[A-Za-z0-9+/\r\n=]+(?=\r?\n)/, - invalidProgram, - ); + const corrupt = replaceValidatorPayload(materializedSkillSource(), 'const = ;\n'); materializeSkill(fixture.root, undefined, corrupt); const result = runValidatorCommand(fixture); expect(result.status).not.toBe(0); - expect(result.stderr).toMatch(/SyntaxError|syntax error/i); + expect(result.stderr).toContain('Cast validator SHA-256 mismatch'); + expect(result.stdout).not.toContain('Cast validation passed.'); + expect(authorizesPullRequest(result)).toBe(false); + }); + + it('rejects a syntactically valid impostor that prints the exact success sentinel', () => { + const fixture = createFixture(); + const impostor = replaceValidatorPayload( + materializedSkillSource(), + "console.log('Cast validation passed.');\n", + ); + materializeSkill(fixture.root, undefined, impostor); + const result = runValidatorCommand(fixture); + expect(result.status).not.toBe(0); + expect(result.stderr).toMatch( + /Cast validator SHA-256 mismatch: expected 82aa5620d81e26513658fbde210b0f8d2ac3bc7572e672b421aaa17a2832e8cc, got [a-f0-9]{64}\./, + ); expect(result.stdout).not.toContain('Cast validation passed.'); expect(authorizesPullRequest(result)).toBe(false); }); diff --git a/workflows/squad.md b/workflows/squad.md index ff5d14d80..11d3bd277 100644 --- a/workflows/squad.md +++ b/workflows/squad.md @@ -843,12 +843,23 @@ if ! awk ' | tr -d '\r\n' \ | base64 --decode \ | gzip --decompress \ + | sed 's/\r$//' \ > "$validator_script"; then echo "Cast validator payload extraction failed; expected one marker pair with valid base64+gzip data." >&2 exit 1 fi validator_script="$(cd "$(dirname "$validator_script")" && pwd -P)/$(basename "$validator_script")" +validator_expected_sha256="82aa5620d81e26513658fbde210b0f8d2ac3bc7572e672b421aaa17a2832e8cc" +validator_actual_sha256="$( + node -e 'const c=require("node:crypto"),f=require("node:fs");process.stdout.write(c.createHash("sha256").update(f.readFileSync(process.argv[1])).digest("hex"))' \ + "$validator_script" +)" +if [ "$validator_actual_sha256" != "$validator_expected_sha256" ]; then + printf 'Cast validator SHA-256 mismatch: expected %s, got %s.\n' \ + "$validator_expected_sha256" "$validator_actual_sha256" >&2 + exit 1 +fi node --check "$validator_script" validator_output="$( node "$validator_script" \ From 9144bd9e4adf8a5b39400aa23b8379ab53d1ae61 Mon Sep 17 00:00:00 2001 From: Brady Gaster Date: Tue, 1 Sep 2026 15:17:38 -0700 Subject: [PATCH 4/8] fix(gh-aw): accept CRLF validator markers Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- test/gh-aw-cast-validator.test.ts | 6 +++++- workflows/squad.md | 1 + 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/test/gh-aw-cast-validator.test.ts b/test/gh-aw-cast-validator.test.ts index a4968d24d..173b28df1 100644 --- a/test/gh-aw-cast-validator.test.ts +++ b/test/gh-aw-cast-validator.test.ts @@ -249,7 +249,11 @@ describe('GH-AW Cast final-tree validator', () => { it('finds the materialized skill and runs the exact validator successfully', () => { const fixture = createFixture(); - materializeSkill(fixture.root); + materializeSkill( + fixture.root, + undefined, + materializedSkillSource().replace(/\n/g, '\r\n'), + ); const invocationDirectory = join(fixture.root, 'nested', 'invocation-directory'); mkdirSync(invocationDirectory, { recursive: true }); const result = runValidatorCommand(fixture, invocationDirectory); diff --git a/workflows/squad.md b/workflows/squad.md index 11d3bd277..037a5d097 100644 --- a/workflows/squad.md +++ b/workflows/squad.md @@ -835,6 +835,7 @@ esac validator_skill="$(sed -n '1p' "$validator_matches")" validator_script="${RUNNER_TEMP:?}/validate-gh-aw-cast.mjs" if ! awk ' + { sub(/\r$/, "", $0) } $0 == "" { if (inside || seen) exit 41; inside = seen = 1; next } $0 == "" { if (!inside || ended) exit 42; inside = 0; ended = 1; next } inside { print } From 3dd8c6dd4d1b54d40d876749bfbe1e98b55ad714 Mon Sep 17 00:00:00 2001 From: Brady Gaster Date: Tue, 1 Sep 2026 15:34:17 -0700 Subject: [PATCH 5/8] test: avoid variable-length validator lookbehind Use capture groups when replacing the embedded validator payload so the portability regression fixture does not depend on variable-length lookbehind support.\n\nCo-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- test/gh-aw-cast-validator.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/gh-aw-cast-validator.test.ts b/test/gh-aw-cast-validator.test.ts index 173b28df1..95a61f570 100644 --- a/test/gh-aw-cast-validator.test.ts +++ b/test/gh-aw-cast-validator.test.ts @@ -172,8 +172,8 @@ function sha256(content: string | Buffer): string { function replaceValidatorPayload(source: string, program: string): string { const encoded = gzipSync(Buffer.from(program)).toString('base64'); return source.replace( - /(?<=\r?\n)[A-Za-z0-9+/\r\n=]+(?=\r?\n)/, - encoded, + /(\r?\n)[A-Za-z0-9+/\r\n=]+(\r?\n)/, + `$1${encoded}$2`, ); } From 8b1755cf21f6db38eaf0def1fb09b5aecdd62592 Mon Sep 17 00:00:00 2001 From: Brady Gaster Date: Tue, 1 Sep 2026 15:39:13 -0700 Subject: [PATCH 6/8] fix: order Cast validator find options Place the depth limit before predicates so validator discovery remains bounded and deterministic across find implementations. Add a regression assertion for the command shape.\n\nCo-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- test/gh-aw-cast-validator.test.ts | 3 +++ workflows/squad.md | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/test/gh-aw-cast-validator.test.ts b/test/gh-aw-cast-validator.test.ts index 95a61f570..b3ac1c222 100644 --- a/test/gh-aw-cast-validator.test.ts +++ b/test/gh-aw-cast-validator.test.ts @@ -239,6 +239,9 @@ describe('GH-AW Cast final-tree validator', () => { expect(helperSource()).not.toMatch(/cat\s+< "$validator_matches" validator_count="$(wc -l < "$validator_matches" | tr -d '[:space:]')" case "$validator_count" in From 7d4d19c3aaf2e2dcea4ba89bb39294b095787b63 Mon Sep 17 00:00:00 2001 From: Brady Gaster Date: Tue, 1 Sep 2026 15:42:33 -0700 Subject: [PATCH 7/8] test: clarify Cast command budget source State explicitly that the command markers are authored inside the squad-cast inline skill, which gh-aw extracts from the ambient prompt.\n\nCo-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- test/gh-aw-quality.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/test/gh-aw-quality.test.ts b/test/gh-aw-quality.test.ts index 7b39835c5..430363dad 100644 --- a/test/gh-aw-quality.test.ts +++ b/test/gh-aw-quality.test.ts @@ -920,8 +920,9 @@ describe('gh-aw: prompt budget & planning import regression', () => { // left, so the guard still bites on genuine growth. // // Raised 181 -> 183 KB by the Cast validator's deterministic disk-extraction and - // SHA-256 authentication command. The command remains inside the `squad-cast` - // inline skill, so gh-aw strips it from the ambient prompt and loads it on demand. + // SHA-256 authentication command. That command is authored between marker comments + // inside the `squad-cast` inline skill in workflows/squad.md; gh-aw strips the full + // skill from the ambient prompt and loads it on demand. const SOURCE_GROWTH_BUDGET_KB = 183; const SOURCE_GROWTH_BUDGET_BYTES = SOURCE_GROWTH_BUDGET_KB * 1024; From e7b8bf2434da36ab03f957b7342440cc2705038e Mon Sep 17 00:00:00 2001 From: Brady Gaster Date: Tue, 1 Sep 2026 15:49:53 -0700 Subject: [PATCH 8/8] test: resolve workflow shell on Windows Use the shared POSIX-shell resolver for Git for Windows while retaining bash execution on POSIX hosts for the workflow's pipefail contract.\n\nCo-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- test/gh-aw-cast-validator.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/gh-aw-cast-validator.test.ts b/test/gh-aw-cast-validator.test.ts index b3ac1c222..6461c58e8 100644 --- a/test/gh-aw-cast-validator.test.ts +++ b/test/gh-aw-cast-validator.test.ts @@ -5,6 +5,7 @@ import { dirname, join } from 'node:path'; import { spawnSync } from 'node:child_process'; import { gunzipSync, gzipSync } from 'node:zlib'; import { afterEach, describe, expect, it } from 'vitest'; +import { requirePosixShell } from './posix-shell'; const validator = join(process.cwd(), 'scripts', 'validate-gh-aw-cast.mjs'); const helperPath = join(process.cwd(), 'workflows', 'shared', 'squad-cast-validator.md'); @@ -189,7 +190,8 @@ function runValidatorCommand( fixture: ReturnType, cwd = fixture.root, ) { - return spawnSync('bash', ['-c', validatorCommand()], { + const shell = process.platform === 'win32' ? requirePosixShell() : 'bash'; + return spawnSync(shell, ['-c', validatorCommand()], { cwd, encoding: 'utf8', env: {