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
8 changes: 8 additions & 0 deletions .changeset/fail-closed-gh-aw-resource-verification.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@bradygaster/squad-cli": patch
"@bradygaster/squad-sdk": patch
---

Verify installed workflow resources against their trusted installer source binding,
revision, ownership provenance, and canonical digest while preserving exact-byte
verification for shared runtime and skill resources.
147 changes: 147 additions & 0 deletions test/gh-aw-verify-resource.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { afterEach, describe, expect, it } from 'vitest';
import { createHash } from 'node:crypto';
import { spawnSync } from 'node:child_process';
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import {
CONTRACT_DESTINATION,
OWNERSHIP_DESTINATION,
PACKAGE_NAME,
} from '../workflows/shared/squad-install-verifier.mjs';
import { createFirstInstallFixture } from './helpers/gh-aw-install-fixture.js';

const revision = 'a'.repeat(40);
const verifier = resolve('workflows/shared/squad-install-verifier.mjs');
const workspaceRoot = resolve('.test-workspaces');
const roots: string[] = [];
const workflowMutations: ReadonlyArray<
readonly [string, (text: string, source: string) => string]
> = [
['owner', (text, source) =>
text.replace(source, source.replace('bradygaster/', 'attacker/'))],
['repo', (text, source) =>
text.replace(source, source.replace('/squad/', '/other/'))],
['workflow', (text, source) =>
text.replace(source, source.replace('squad-review.md', 'squad.md'))],
['revision', (text, source) =>
text.replace(source, source.replace(revision, 'b'.repeat(40)))],
['suffix', (text, source) => text.replace(source, `${source}/extra`)],
['missing', (text, source) => text.replace(`${source}\n`, '')],
['duplicate', (text, source) => text.replace(source, `${source}\n${source}`)],
['misplaced', (text, source) =>
text.replace(`${source}\n---\n`, `---\n${source}\n`)],
['extra final newline', text => `${text}\n`],
['content', text => text.replace('Squad Review', 'Tampered Squad Review')],
];

function write(root: string, path: string, content: string | Buffer): void {
const destination = join(root, path);
mkdirSync(dirname(destination), { recursive: true });
writeFileSync(destination, content);
}

function makeConsumer(sourceBinding: 'workflow' | 'package' = 'workflow'): string {
mkdirSync(workspaceRoot, { recursive: true });
const root = mkdtempSync(join(workspaceRoot, 'gh-aw-verify-resource-'));
roots.push(root);
const fixture = createFirstInstallFixture(revision, sourceBinding);
for (const [path, content] of fixture.consumerFiles) write(root, path, content);
write(root, OWNERSHIP_DESTINATION, `${JSON.stringify(fixture.provenance, null, 2)}\n`);
return root;
}

function runVerify(root: string, destination: string) {
return spawnSync(process.execPath, [
verifier,
'--root', root,
'--verify-resource', destination,
], { encoding: 'utf8' });
}

function updateOwnedDigest(root: string, destination: string): void {
const ownershipPath = join(root, OWNERSHIP_DESTINATION);
const ownership = JSON.parse(readFileSync(ownershipPath, 'utf8'));
const entry = ownership.files.find(
(candidate: { destination: string }) => candidate.destination === destination,
);
if (!entry) throw new Error(`Missing ownership entry for ${destination}`);
entry.sha256 = createHash('sha256').update(readFileSync(join(root, destination))).digest('hex');
writeFileSync(ownershipPath, `${JSON.stringify(ownership, null, 2)}\n`);
}

afterEach(() => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
});

describe('gh-aw: consumer resource verification', () => {
it.each(['workflow', 'package'] as const)(
'exercises the shipped --verify-resource CLI against a clean %s-bound install',
sourceBinding => {
const root = makeConsumer(sourceBinding);
const destination = '.github/workflows/squad-review.md';
const result = runVerify(root, destination);
expect(result.status, result.stderr).toBe(0);
expect(result.stdout.trim()).toMatch(/^[0-9a-f]{64}$/);
},
);

it('accepts only the documented final-newline variation', () => {
const root = makeConsumer();
const destination = '.github/workflows/squad-review.md';
const original = readFileSync(join(root, destination), 'utf8');
expect(original.endsWith('\n')).toBe(true);
writeFileSync(join(root, destination), original.slice(0, -1));
updateOwnedDigest(root, destination);
const result = runVerify(root, destination);
expect(result.status, result.stderr).toBe(0);
});

it.each(workflowMutations)('rejects %s workflow resource mutation through the CLI', (label, mutate) => {
const root = makeConsumer();
const destination = '.github/workflows/squad-review.md';
const source = `source: ${PACKAGE_NAME}/package/squad-review.md@${revision}`;
const original = readFileSync(join(root, destination), 'utf8');
const modified = mutate(original, source);
expect(modified, label).not.toBe(original);
writeFileSync(join(root, destination), modified);
updateOwnedDigest(root, destination);
const result = runVerify(root, destination);
expect(result.status, `${label}: ${result.stdout}${result.stderr}`).toBe(1);
expect(result.stderr, label).toMatch(/source binding|digest mismatch/);
});

it.each([
['shared runtime', '.github/workflows/shared/squad-review-guard.mjs'],
['skill', '.github/skills/gh-aw-enlistment/SKILL.md'],
] as const)('verifies %s by raw exact digest without ownership metadata', (_label, destination) => {
const root = makeConsumer();
rmSync(join(root, OWNERSHIP_DESTINATION));
expect(runVerify(root, destination).status).toBe(0);
writeFileSync(join(root, destination), Buffer.concat([
readFileSync(join(root, destination)),
Buffer.from('\n'),
]));
const result = runVerify(root, destination);
expect(result.status).toBe(1);
expect(result.stderr).toContain(`Squad resource digest mismatch for ${destination}.`);
});

it('fails closed when installed provenance does not bind the installed revision', () => {
const root = makeConsumer();
const ownershipPath = join(root, OWNERSHIP_DESTINATION);
const ownership = JSON.parse(readFileSync(ownershipPath, 'utf8'));
ownership.source = `${PACKAGE_NAME}@${'b'.repeat(40)}`;
writeFileSync(ownershipPath, `${JSON.stringify(ownership, null, 2)}\n`);
const result = runVerify(root, '.github/workflows/squad-review.md');
expect(result.status).toBe(1);
expect(result.stderr).toContain(`Package ownership source must be ${PACKAGE_NAME}@${revision}.`);
});

it('fails closed when the installed contract is absent', () => {
const root = makeConsumer();
rmSync(join(root, CONTRACT_DESTINATION));
const result = runVerify(root, '.github/workflows/squad-review.md');
expect(result.status).toBe(1);
expect(result.stderr).toContain(`Required file is missing: ${CONTRACT_DESTINATION}`);
});
});
2 changes: 1 addition & 1 deletion workflows/package/squad-bootstrap.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ pre-agent-steps:
node --check "$path" >/dev/null
}
# BEGIN GENERATED RESOURCE DIGESTS
check_hash "$install_verifier" "cf474be9b04d339f7e7a18c65e776b8a53e84bea5b4b85abfe65ed11f7b782ce"
check_hash "$install_verifier" "da3f2ef1fc7efc8676eb654643bd0d0c0e59606538eeb2ebe7cf25792c63d1c1"
check_hash "$cast_validator" "c6d0b92aac71dc6f6d5727cac418a323b0bc9c12047400faa12d96150d548ada"
check_hash "$bootstrap_validator" "1af6ab267eaf1148501a949b8b9ac65a1929130b03a1d7f757b156fd2de63fbb"
# END GENERATED RESOURCE DIGESTS
Expand Down
62 changes: 40 additions & 22 deletions workflows/shared/squad-install-verifier.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -703,30 +703,45 @@ function verifyOwnership(root, contract, expectedRevision) {
return record.resolvedCommit;
}

function digestMatchesWithFinalNewlineTolerance(content, expectedDigest) {
return [content, `${content}\n`]
.some(candidate => sha256(Buffer.from(candidate)) === expectedDigest);
}
Comment thread
bradygaster marked this conversation as resolved.
Resolved

function verifyWorkflowSourceBinding(root, entry, revision) {
const installed = readRequired(root, entry.destination);
const text = installed.toString('utf8');
const lines = text.split('\n');
const frontmatterEnd = lines[0] === '---' ? lines.indexOf('---', 1) : -1;
const sourceIndexes = lines
.map((line, index) => line.startsWith('source:') ? index : -1)
.filter(index => index >= 0);
const sourceBindings = new Map([
[`source: ${PACKAGE_NAME}@${revision}`, entry.package_lock_sha256],
[`source: bradygaster/squad/${entry.source}@${revision}`, entry.lock_sha256],
]);
const sourceIndex = sourceIndexes[0];
const sourceBinding = lines[sourceIndex];
if (frontmatterEnd < 0 || sourceIndexes.length !== 1
|| sourceIndex !== frontmatterEnd - 1 || !sourceBindings.has(sourceBinding)) {
throw new Error(`Installed source binding is invalid for ${entry.destination}.`);
}
lines.splice(sourceIndex, 1);
if (!digestMatchesWithFinalNewlineTolerance(lines.join('\n'), entry.source_sha256)) {
throw new Error(`Installed digest mismatch for ${entry.destination}.`);
}
return {
digest: sha256(installed),
lockDigest: sourceBindings.get(sourceBinding),
};
}

function verifyInstalledBytes(root, contract, revision) {
const lockDigests = new Map();
for (const entry of contract.workflows) {
const installed = readRequired(root, entry.destination);
const text = installed.toString('utf8');
const { frontmatter } = splitWorkflow(text, entry.destination);
const sourceLines = frontmatter.split('\n').filter(line => /^source:/.test(line));
const sourceBinding = sourceLines[0];
const sourceBindings = new Map([
[`source: ${PACKAGE_NAME}@${revision}`, entry.package_lock_sha256],
[`source: bradygaster/squad/${entry.source}@${revision}`, entry.lock_sha256],
]);
if (sourceLines.length !== 1 || !sourceBindings.has(sourceBinding)
|| !frontmatter.endsWith(`\n${sourceBinding}`)) {
throw new Error(`Installed source binding is invalid for ${entry.destination}.`);
}
const canonicalText = text.replace(`\n${sourceBinding}\n---\n`, '\n---\n');
const canonical = Buffer.from(canonicalText);
const canonicalWithFinalNewline = Buffer.from(`${canonicalText}\n`);
if (![sha256(canonical), sha256(canonicalWithFinalNewline)].includes(entry.source_sha256)) {
throw new Error(`Installed digest mismatch for ${entry.destination}.`);
}
const verified = verifyWorkflowSourceBinding(root, entry, revision);
// Select from the verified source, never accept whichever lock digest happens to match.
lockDigests.set(entry.name, sourceBindings.get(sourceBinding));
lockDigests.set(entry.name, verified.lockDigest);
}
for (const entry of contract.skills) {
if (fileDigest(root, entry.destination) !== entry.sha256) {
Expand Down Expand Up @@ -941,9 +956,12 @@ export function verifyResource(root, destination) {
const entry = [...contract.shared_runtime, ...contract.workflows, ...contract.skills]
.find((candidate) => candidate.destination === destination);
if (!entry) throw new Error(`Resource is not registered in the trusted Squad contract: ${destination}`);
if ('source_sha256' in entry) {
const revision = verifyOwnership(root, contract);
return verifyWorkflowSourceBinding(root, entry, revision).digest;
}
const actual = fileDigest(root, destination);
const expected = entry.source_sha256 ?? entry.sha256;
if (actual !== expected) throw new Error(`Squad resource digest mismatch for ${destination}.`);
if (actual !== entry.sha256) throw new Error(`Squad resource digest mismatch for ${destination}.`);
return actual;
}

Expand Down
2 changes: 1 addition & 1 deletion workflows/squad-bootstrap.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ pre-agent-steps:
node --check "$path" >/dev/null
}
# BEGIN GENERATED RESOURCE DIGESTS
check_hash "$install_verifier" "cf474be9b04d339f7e7a18c65e776b8a53e84bea5b4b85abfe65ed11f7b782ce"
check_hash "$install_verifier" "da3f2ef1fc7efc8676eb654643bd0d0c0e59606538eeb2ebe7cf25792c63d1c1"
check_hash "$cast_validator" "c6d0b92aac71dc6f6d5727cac418a323b0bc9c12047400faa12d96150d548ada"
check_hash "$bootstrap_validator" "1af6ab267eaf1148501a949b8b9ac65a1929130b03a1d7f757b156fd2de63fbb"
# END GENERATED RESOURCE DIGESTS
Expand Down
8 changes: 4 additions & 4 deletions workflows/squad-workflows.manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,9 @@
"source": "workflows/package/squad-bootstrap.md",
"destination": ".github/workflows/squad-bootstrap.md",
"lock": ".github/workflows/squad-bootstrap.lock.yml",
"source_sha256": "a266ed20e76b751e5527b49c9d3517482504a0fd6a6704d04531b57f99dbbaa1",
"lock_sha256": "3642cfb42e6b6230354fb245f84bf369d4db29051a6db184c93147504acc221d",
"package_lock_sha256": "a2a635dd98427b713c843e7baf22f98a01a0688f450bb2fc993b1fcaa960fb4e"
"source_sha256": "e1f45d8e0038550f437602a00fa40c2629ed28f5295afb40179fe68c1fe27e30",
"lock_sha256": "a72aa20fc6ce95ca5dbab08d0531c7d670c93bb129d1931b6ff471571eefe386",
"package_lock_sha256": "5be4f342fd4593e70ca39825e24469a19895949aece7364686d1d2aaedd0ce8c"
},
{
"name": "squad-command-router",
Expand All @@ -97,7 +97,7 @@
"package_destination": ".github/workflows/shared/squad-install-verifier.mjs",
"destination": ".github/workflows/shared/squad-install-verifier.mjs",
"ownership": "manifest",
"sha256": "cf474be9b04d339f7e7a18c65e776b8a53e84bea5b4b85abfe65ed11f7b782ce"
"sha256": "da3f2ef1fc7efc8676eb654643bd0d0c0e59606538eeb2ebe7cf25792c63d1c1"
},
{
"path": "shared/squad-command-contract.mjs",
Expand Down
Loading