Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions scripts/validate-gh-aw-cast.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@ const BUILTIN_DISPLAY_NAMES = {
const REQUIRED_BUILTIN_CHARTERS = REQUIRED_BUILTIN_IDS
.map((id) => `.squad/agents/${id}/charter.md`)
.sort();
// The canonical resource a gh-aw "Materialize canonical built-in support
// agents" step copies from verbatim before the agent ever runs. The final
// emitted `.squad/agents/{id}/charter.md` must remain byte-identical to it —
// this is the only way to prove a `squad init` or agent rewrite never
// clobbered the materialized built-in after the deterministic copy step.
const BUILTIN_CANONICAL_DIR = '.github/workflows/shared/builtins';
const BUILTIN_SECTION_HEADING = '## Built-in Support Agents';
const CAST_SOURCES_HEADING = '## Cast sources';
const BUILTIN_NAME_ROW_PATTERN = /^\|\s*(Scribe|Ralph|Rai|Fact Checker)\s*\|/gmi;
Expand Down Expand Up @@ -69,6 +75,45 @@ function readText(root, relativePath) {
return readFileSync(join(root, ...relativePath.split('/')), 'utf8').replace(/\r\n/g, '\n');
}

/** Raw file bytes, with no text normalization, for true byte-for-byte comparison. */
function readBytes(root, relativePath) {
return readFileSync(join(root, ...relativePath.split('/')));
}

/**
* Every materialized built-in charter must remain byte-for-byte identical to
* the canonical resource shipped with the workflow. A deterministic step
* copies the canonical resource verbatim before the agent runs; if `squad
* init` or the Cast agent later rewrites, paraphrases, or reinterprets one of
* these files, this is the only check that catches the divergence.
*/
function validateBuiltinCharterFidelity(root, errors) {
for (const id of REQUIRED_BUILTIN_IDS) {
const canonicalPath = `${BUILTIN_CANONICAL_DIR}/${id}-charter.md`;
const materializedPath = `.squad/agents/${id}/charter.md`;
let canonicalBytes;
try {
canonicalBytes = readBytes(root, canonicalPath);
} catch (error) {
errors.push(`builtin: canonical resource ${canonicalPath} for "${id}" is missing or unreadable (${error.message})`);
continue;
}
let materializedBytes;
try {
materializedBytes = readBytes(root, materializedPath);
} catch (error) {
errors.push(`builtin: materialized charter ${materializedPath} for "${id}" is missing or unreadable (${error.message})`);
continue;
}
if (!canonicalBytes.equals(materializedBytes)) {
errors.push(
`builtin: ${materializedPath} is not byte-identical to the canonical resource `
+ `${canonicalPath} — built-in charters must never be regenerated, edited, or reinterpreted`,
);
}
}
}

function normalizePayloadPath(value) {
if (
typeof value !== 'string'
Expand Down Expand Up @@ -333,6 +378,8 @@ export function validateCastTree({ root, payloadPath }) {
}
}

validateBuiltinCharterFidelity(root, errors);

let team = '';
let routing = '';
let coordinator = '';
Expand Down
61 changes: 58 additions & 3 deletions test/gh-aw-cast-validator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,13 @@ const builtins = [
{ id: 'fact-checker', name: 'Fact Checker' },
];

/** Byte-for-byte canonical content of a built-in charter, as shipped with the workflow. */
function builtinCanonicalContent(id: string): Buffer {
return readFileSync(join(process.cwd(), 'workflows', 'shared', 'builtins', `${id}-charter.md`));
}

const builtinCanonicalRelativePath = '.github/workflows/shared/builtins';

const corePayload = [
'.squad/team.md',
'.squad/routing.md',
Expand All @@ -38,7 +45,7 @@ const corePayload = [
'meet-the-squad.md',
];

function write(root: string, path: string, content: string): void {
function write(root: string, path: string, content: string | Buffer): void {
const fullPath = join(root, ...path.split('/'));
mkdirSync(dirname(fullPath), { recursive: true });
writeFileSync(fullPath, content, 'utf8');
Expand Down Expand Up @@ -171,7 +178,12 @@ function createFixture(): { root: string; payload: string; runnerTemp: string }
write(root, `.squad/agents/${member.id}/charter.md`, `# ${member.name} — ${member.role}\n`);
}
for (const builtin of builtins) {
write(root, `.squad/agents/${builtin.id}/charter.md`, `# ${builtin.name}\n`);
const canonical = builtinCanonicalContent(builtin.id);
// Mirrors the gh-aw resource materialization: the canonical charter is
// installed under .github/workflows/shared/builtins/, then copied
// verbatim to .squad/agents/{id}/charter.md by a deterministic step.
write(root, `${builtinCanonicalRelativePath}/${builtin.id}-charter.md`, canonical);
write(root, `.squad/agents/${builtin.id}/charter.md`, canonical);
}
write(root, '.github/agents/squad.agent.md', coordinatorMarkdown());
write(root, 'meet-the-squad.md', '# Meet the Squad\n');
Expand Down Expand Up @@ -417,7 +429,7 @@ describe('GH-AW Cast final-tree validator', () => {
const result = runValidatorCommand(fixture);
expect(result.status).not.toBe(0);
expect(result.stderr).toMatch(
/Cast validator SHA-256 mismatch: expected 5b8e1432de63c78488ee7ff6da4e67ea0c91044582109f678153c231704a0097, got [a-f0-9]{64}\./,
/Cast validator SHA-256 mismatch: expected f0c79694d9832c53070f059d4bff181a8ccd857e1be49d24b8d5b72ed8887251, got [a-f0-9]{64}\./,
);
expect(result.stdout).not.toContain('Cast validation passed.');
expect(authorizesPullRequest(result)).toBe(false);
Expand Down Expand Up @@ -625,6 +637,49 @@ describe('GH-AW Cast final-tree validator', () => {
expect(result.stderr).toContain('watcher');
});

it('rejects a built-in charter that has been regenerated or edited (byte mismatch vs. canonical resource)', () => {
const fixture = createFixture();
write(
fixture.root,
'.squad/agents/ralph/charter.md',
`${builtinCanonicalContent('ralph').toString('utf8')}\n<!-- reinterpreted by the Cast agent -->\n`,
);
const result = validate(fixture.root, fixture.payload);
expect(result.status).toBe(1);
expect(result.stderr).toMatch(
/builtin: \.squad\/agents\/ralph\/charter\.md is not byte-identical to the canonical resource \.github\/workflows\/shared\/builtins\/ralph-charter\.md/i,
);
});

it('rejects a built-in charter with a trivial byte-level divergence (trailing newline) from the canonical resource', () => {
const fixture = createFixture();
write(fixture.root, '.squad/agents/scribe/charter.md', `${builtinCanonicalContent('scribe').toString('utf8')}\n`);
const result = validate(fixture.root, fixture.payload);
expect(result.status).toBe(1);
expect(result.stderr).toMatch(/builtin: \.squad\/agents\/scribe\/charter\.md is not byte-identical/i);
});

it('rejects a Cast tree whose canonical built-in resource is missing from .github/workflows/shared/builtins', () => {
const fixture = createFixture();
rmSync(join(fixture.root, builtinCanonicalRelativePath, 'fact-checker-charter.md'), { force: true });
const result = validate(fixture.root, fixture.payload);
expect(result.status).toBe(1);
expect(result.stderr).toMatch(
/builtin: canonical resource \.github\/workflows\/shared\/builtins\/fact-checker-charter\.md for "fact-checker" is missing or unreadable/i,
);
});

it('accepts a built-in charter that is byte-identical to the canonical resource for all four built-ins', () => {
const fixture = createFixture();
const result = validate(fixture.root, fixture.payload);
expect(result.status, result.stderr).toBe(0);
for (const builtin of builtins) {
expect(
readFileSync(join(fixture.root, '.squad', 'agents', builtin.id, 'charter.md')),
).toEqual(builtinCanonicalContent(builtin.id));
}
});

it('rejects a built-in registered as an active specialist in the casting registry', () => {
const fixture = createFixture();
write(fixture.root, '.squad/casting/registry.json', JSON.stringify({
Expand Down
57 changes: 55 additions & 2 deletions test/gh-aw-quality.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1506,14 +1506,67 @@ describe('gh-aw: compiled workflow shell input security contract', () => {
'--payload "${GITHUB_WORKSPACE:?}/.github/workflows/squad-cast-payload.json"',
);
expect(normalizedRunnerStep).not.toContain('RUNNER_TEMP');
expect(normalizedRunnerStep).toContain('validator_expected_sha256="5b8e1432de63c78488ee7ff6da4e67ea0c91044582109f678153c231704a0097"');
expect(normalizedRunnerStep).toContain('validator_expected_sha256="f0c79694d9832c53070f059d4bff181a8ccd857e1be49d24b8d5b72ed8887251"');
expect(normalizedRunnerStep).toContain("outcome: 'cast_failure'");
expect(normalizedRunnerStep).toContain('chmod 500 "$validator_runner"');
// Prepared as a pre-agent-step (see the built-in fidelity ordering test below):
// it must run AFTER the base-branch/ambient restores that can reintroduce a
// stale committed .squad snapshot, and still before the agent turn begins.
expect(compiled.indexOf('name: Restore inline skills from activation artifact')).toBeLessThan(
compiled.indexOf('name: Prepare deterministic Cast validator runner'),
);
expect(compiled.indexOf('name: Prepare deterministic Cast validator runner')).toBeLessThan(
compiled.indexOf('name: Restore inline skills from activation artifact'),
compiled.indexOf('name: Execute GitHub Copilot CLI'),
);
}, 20000);

it('materializes built-in charters after init and every late restore, and before the Cast agent turn', () => {
const compiled = lockText();

// Job-level guarantee: the agent job cannot start until the activation
// job (which runs `squad init --preset default` when no cast exists yet)
// has completed.
expect(compiled).toMatch(/\n {2}agent:\n {4}needs: activation\n/);
const activationJobIndex = compiled.indexOf('\n activation:\n');
const agentJobIndex = compiled.indexOf('\n agent:\n');
expect(activationJobIndex).toBeGreaterThan(-1);
expect(agentJobIndex).toBeGreaterThan(activationJobIndex);

const initIndex = compiled.indexOf('name: Initialize Squad team');
const materializeIndex = compiled.indexOf('name: Materialize canonical built-in support agents');
expect(initIndex).toBeGreaterThan(-1);
expect(materializeIndex).toBeGreaterThan(initIndex);

// Materialization is a pre-agent-step: it must run strictly after every
// restore step that can reintroduce a stale committed `.squad/` snapshot
// late in the job (base-branch restore, both ambient-folder restores,
// inline sub-agent/skill restores), and strictly before the Cast agent
// turn (Copilot CLI execution) starts.
for (const priorRestore of [
'name: Restore Squad state from activation artifact',
'name: Restore agent config folders from base branch',
'name: Restore inline sub-agents from activation artifact',
'name: Restore inline skills from activation artifact',
]) {
const restoreIndex = compiled.indexOf(priorRestore);
expect(restoreIndex, `${priorRestore} must be present`).toBeGreaterThan(-1);
expect(materializeIndex, `${priorRestore} must precede materialization`).toBeGreaterThan(restoreIndex);
}
// Both occurrences of the unconditional ambient-folder restore (gh-aw emits
// it once after the activation-artifact download and again after PR
// checkout) must precede materialization too.
const ambientRestoreIndices = [...compiled.matchAll(/name: Restore ambient folders from activation artifact/g)]
.map((match) => match.index ?? -1);
expect(ambientRestoreIndices.length).toBeGreaterThanOrEqual(2);
for (const restoreIndex of ambientRestoreIndices) {
expect(materializeIndex).toBeGreaterThan(restoreIndex);
}

const agentExecutionIndex = compiled.indexOf('name: Execute GitHub Copilot CLI');
expect(agentExecutionIndex).toBeGreaterThan(-1);
expect(agentExecutionIndex).toBeGreaterThan(materializeIndex);
}, 20000);

it('keeps the v0.87.10 completion hook neutral when a custom safe job fails', () => {
const compiled = lockText();
const completionStep = compiled.match(
Expand Down
47 changes: 47 additions & 0 deletions workflows/shared/squad-cast-validator.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@ const BUILTIN_DISPLAY_NAMES = {
const REQUIRED_BUILTIN_CHARTERS = REQUIRED_BUILTIN_IDS
.map((id) => `.squad/agents/${id}/charter.md`)
.sort();
// The canonical resource a gh-aw "Materialize canonical built-in support
// agents" step copies from verbatim before the agent ever runs. The final
// emitted `.squad/agents/{id}/charter.md` must remain byte-identical to it —
// this is the only way to prove a `squad init` or agent rewrite never
// clobbered the materialized built-in after the deterministic copy step.
const BUILTIN_CANONICAL_DIR = '.github/workflows/shared/builtins';
const BUILTIN_SECTION_HEADING = '## Built-in Support Agents';
const CAST_SOURCES_HEADING = '## Cast sources';
const BUILTIN_NAME_ROW_PATTERN = /^\|\s*(Scribe|Ralph|Rai|Fact Checker)\s*\|/gmi;
Expand Down Expand Up @@ -69,6 +75,45 @@ function readText(root, relativePath) {
return readFileSync(join(root, ...relativePath.split('/')), 'utf8').replace(/\r\n/g, '\n');
}

/** Raw file bytes, with no text normalization, for true byte-for-byte comparison. */
function readBytes(root, relativePath) {
return readFileSync(join(root, ...relativePath.split('/')));
}

/**
* Every materialized built-in charter must remain byte-for-byte identical to
* the canonical resource shipped with the workflow. A deterministic step
* copies the canonical resource verbatim before the agent runs; if `squad
* init` or the Cast agent later rewrites, paraphrases, or reinterprets one of
* these files, this is the only check that catches the divergence.
*/
function validateBuiltinCharterFidelity(root, errors) {
for (const id of REQUIRED_BUILTIN_IDS) {
const canonicalPath = `${BUILTIN_CANONICAL_DIR}/${id}-charter.md`;
const materializedPath = `.squad/agents/${id}/charter.md`;
let canonicalBytes;
try {
canonicalBytes = readBytes(root, canonicalPath);
} catch (error) {
errors.push(`builtin: canonical resource ${canonicalPath} for "${id}" is missing or unreadable (${error.message})`);
continue;
}
let materializedBytes;
try {
materializedBytes = readBytes(root, materializedPath);
} catch (error) {
errors.push(`builtin: materialized charter ${materializedPath} for "${id}" is missing or unreadable (${error.message})`);
continue;
}
if (!canonicalBytes.equals(materializedBytes)) {
errors.push(
`builtin: ${materializedPath} is not byte-identical to the canonical resource `
+ `${canonicalPath} — built-in charters must never be regenerated, edited, or reinterpreted`,
);
}
}
}

function normalizePayloadPath(value) {
if (
typeof value !== 'string'
Expand Down Expand Up @@ -333,6 +378,8 @@ export function validateCastTree({ root, payloadPath }) {
}
}

validateBuiltinCharterFidelity(root, errors);

let team = '';
let routing = '';
let coordinator = '';
Expand Down
8 changes: 6 additions & 2 deletions workflows/squad.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,11 @@ tools:
github:
mode: gh-proxy
toolsets: [default]
steps:
# pre-agent-steps (not steps:): runs after gh-aw's native base-branch/ambient
# restores that can reintroduce a stale committed .squad/ snapshot late in the
# job, so this stays the last writer of the four built-in charters before the
# agent turn begins.
pre-agent-steps:
- name: Materialize canonical built-in support agents
shell: bash
run: |
Expand Down Expand Up @@ -121,7 +125,7 @@ steps:
fi
validator_script="$(cd "$(dirname "$validator_script")" && pwd -P)/$(basename "$validator_script")"

validator_expected_sha256="5b8e1432de63c78488ee7ff6da4e67ea0c91044582109f678153c231704a0097"
validator_expected_sha256="f0c79694d9832c53070f059d4bff181a8ccd857e1be49d24b8d5b72ed8887251"
: > "$stderr_file"
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"))' \
Expand Down
Loading