diff --git a/.changeset/fail-closed-gh-aw-resource-verification.md b/.changeset/fail-closed-gh-aw-resource-verification.md new file mode 100644 index 000000000..fe1ae8aa5 --- /dev/null +++ b/.changeset/fail-closed-gh-aw-resource-verification.md @@ -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. diff --git a/test/gh-aw-verify-resource.test.ts b/test/gh-aw-verify-resource.test.ts new file mode 100644 index 000000000..f2418ad46 --- /dev/null +++ b/test/gh-aw-verify-resource.test.ts @@ -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}`); + }); +}); diff --git a/workflows/package/squad-bootstrap.md b/workflows/package/squad-bootstrap.md index d50413902..c7c3dfc82 100644 --- a/workflows/package/squad-bootstrap.md +++ b/workflows/package/squad-bootstrap.md @@ -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 diff --git a/workflows/shared/squad-install-verifier.mjs b/workflows/shared/squad-install-verifier.mjs index 34d952d10..7bd39b8d3 100644 --- a/workflows/shared/squad-install-verifier.mjs +++ b/workflows/shared/squad-install-verifier.mjs @@ -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); +} + +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) { @@ -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; } diff --git a/workflows/squad-bootstrap.md b/workflows/squad-bootstrap.md index 540f137dc..4e088bc4c 100644 --- a/workflows/squad-bootstrap.md +++ b/workflows/squad-bootstrap.md @@ -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 diff --git a/workflows/squad-workflows.manifest.json b/workflows/squad-workflows.manifest.json index ce9c2c539..fe56b2195 100644 --- a/workflows/squad-workflows.manifest.json +++ b/workflows/squad-workflows.manifest.json @@ -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", @@ -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",