Skip to content

Commit c51281d

Browse files
bradygasterCopilot
andauthored
fix(gh-aw): verify installed workflow source bindings (#2147)
* fix(gh-aw): verify installed workflow source bindings Closes #2146 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(gh-aw): reject extra workflow newline Closes #2146 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * chore(gh-aw): regenerate restacked workflow manifest Closes #2146 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 8f9e049 commit c51281d

6 files changed

Lines changed: 201 additions & 28 deletions

File tree

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
"@bradygaster/squad-cli": patch
3+
"@bradygaster/squad-sdk": patch
4+
---
5+
6+
Verify installed workflow resources against their trusted installer source binding,
7+
revision, ownership provenance, and canonical digest while preserving exact-byte
8+
verification for shared runtime and skill resources.

‎test/gh-aw-verify-resource.test.ts‎

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import { afterEach, describe, expect, it } from 'vitest';
2+
import { createHash } from 'node:crypto';
3+
import { spawnSync } from 'node:child_process';
4+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
5+
import { dirname, join, resolve } from 'node:path';
6+
import {
7+
CONTRACT_DESTINATION,
8+
OWNERSHIP_DESTINATION,
9+
PACKAGE_NAME,
10+
} from '../workflows/shared/squad-install-verifier.mjs';
11+
import { createFirstInstallFixture } from './helpers/gh-aw-install-fixture.js';
12+
13+
const revision = 'a'.repeat(40);
14+
const verifier = resolve('workflows/shared/squad-install-verifier.mjs');
15+
const workspaceRoot = resolve('.test-workspaces');
16+
const roots: string[] = [];
17+
const workflowMutations: ReadonlyArray<
18+
readonly [string, (text: string, source: string) => string]
19+
> = [
20+
['owner', (text, source) =>
21+
text.replace(source, source.replace('bradygaster/', 'attacker/'))],
22+
['repo', (text, source) =>
23+
text.replace(source, source.replace('/squad/', '/other/'))],
24+
['workflow', (text, source) =>
25+
text.replace(source, source.replace('squad-review.md', 'squad.md'))],
26+
['revision', (text, source) =>
27+
text.replace(source, source.replace(revision, 'b'.repeat(40)))],
28+
['suffix', (text, source) => text.replace(source, `${source}/extra`)],
29+
['missing', (text, source) => text.replace(`${source}\n`, '')],
30+
['duplicate', (text, source) => text.replace(source, `${source}\n${source}`)],
31+
['misplaced', (text, source) =>
32+
text.replace(`${source}\n---\n`, `---\n${source}\n`)],
33+
['extra final newline', text => `${text}\n`],
34+
['content', text => text.replace('Squad Review', 'Tampered Squad Review')],
35+
];
36+
37+
function write(root: string, path: string, content: string | Buffer): void {
38+
const destination = join(root, path);
39+
mkdirSync(dirname(destination), { recursive: true });
40+
writeFileSync(destination, content);
41+
}
42+
43+
function makeConsumer(sourceBinding: 'workflow' | 'package' = 'workflow'): string {
44+
mkdirSync(workspaceRoot, { recursive: true });
45+
const root = mkdtempSync(join(workspaceRoot, 'gh-aw-verify-resource-'));
46+
roots.push(root);
47+
const fixture = createFirstInstallFixture(revision, sourceBinding);
48+
for (const [path, content] of fixture.consumerFiles) write(root, path, content);
49+
write(root, OWNERSHIP_DESTINATION, `${JSON.stringify(fixture.provenance, null, 2)}\n`);
50+
return root;
51+
}
52+
53+
function runVerify(root: string, destination: string) {
54+
return spawnSync(process.execPath, [
55+
verifier,
56+
'--root', root,
57+
'--verify-resource', destination,
58+
], { encoding: 'utf8' });
59+
}
60+
61+
function updateOwnedDigest(root: string, destination: string): void {
62+
const ownershipPath = join(root, OWNERSHIP_DESTINATION);
63+
const ownership = JSON.parse(readFileSync(ownershipPath, 'utf8'));
64+
const entry = ownership.files.find(
65+
(candidate: { destination: string }) => candidate.destination === destination,
66+
);
67+
if (!entry) throw new Error(`Missing ownership entry for ${destination}`);
68+
entry.sha256 = createHash('sha256').update(readFileSync(join(root, destination))).digest('hex');
69+
writeFileSync(ownershipPath, `${JSON.stringify(ownership, null, 2)}\n`);
70+
}
71+
72+
afterEach(() => {
73+
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
74+
});
75+
76+
describe('gh-aw: consumer resource verification', () => {
77+
it.each(['workflow', 'package'] as const)(
78+
'exercises the shipped --verify-resource CLI against a clean %s-bound install',
79+
sourceBinding => {
80+
const root = makeConsumer(sourceBinding);
81+
const destination = '.github/workflows/squad-review.md';
82+
const result = runVerify(root, destination);
83+
expect(result.status, result.stderr).toBe(0);
84+
expect(result.stdout.trim()).toMatch(/^[0-9a-f]{64}$/);
85+
},
86+
);
87+
88+
it('accepts only the documented final-newline variation', () => {
89+
const root = makeConsumer();
90+
const destination = '.github/workflows/squad-review.md';
91+
const original = readFileSync(join(root, destination), 'utf8');
92+
expect(original.endsWith('\n')).toBe(true);
93+
writeFileSync(join(root, destination), original.slice(0, -1));
94+
updateOwnedDigest(root, destination);
95+
const result = runVerify(root, destination);
96+
expect(result.status, result.stderr).toBe(0);
97+
});
98+
99+
it.each(workflowMutations)('rejects %s workflow resource mutation through the CLI', (label, mutate) => {
100+
const root = makeConsumer();
101+
const destination = '.github/workflows/squad-review.md';
102+
const source = `source: ${PACKAGE_NAME}/package/squad-review.md@${revision}`;
103+
const original = readFileSync(join(root, destination), 'utf8');
104+
const modified = mutate(original, source);
105+
expect(modified, label).not.toBe(original);
106+
writeFileSync(join(root, destination), modified);
107+
updateOwnedDigest(root, destination);
108+
const result = runVerify(root, destination);
109+
expect(result.status, `${label}: ${result.stdout}${result.stderr}`).toBe(1);
110+
expect(result.stderr, label).toMatch(/source binding|digest mismatch/);
111+
});
112+
113+
it.each([
114+
['shared runtime', '.github/workflows/shared/squad-review-guard.mjs'],
115+
['skill', '.github/skills/gh-aw-enlistment/SKILL.md'],
116+
] as const)('verifies %s by raw exact digest without ownership metadata', (_label, destination) => {
117+
const root = makeConsumer();
118+
rmSync(join(root, OWNERSHIP_DESTINATION));
119+
expect(runVerify(root, destination).status).toBe(0);
120+
writeFileSync(join(root, destination), Buffer.concat([
121+
readFileSync(join(root, destination)),
122+
Buffer.from('\n'),
123+
]));
124+
const result = runVerify(root, destination);
125+
expect(result.status).toBe(1);
126+
expect(result.stderr).toContain(`Squad resource digest mismatch for ${destination}.`);
127+
});
128+
129+
it('fails closed when installed provenance does not bind the installed revision', () => {
130+
const root = makeConsumer();
131+
const ownershipPath = join(root, OWNERSHIP_DESTINATION);
132+
const ownership = JSON.parse(readFileSync(ownershipPath, 'utf8'));
133+
ownership.source = `${PACKAGE_NAME}@${'b'.repeat(40)}`;
134+
writeFileSync(ownershipPath, `${JSON.stringify(ownership, null, 2)}\n`);
135+
const result = runVerify(root, '.github/workflows/squad-review.md');
136+
expect(result.status).toBe(1);
137+
expect(result.stderr).toContain(`Package ownership source must be ${PACKAGE_NAME}@${revision}.`);
138+
});
139+
140+
it('fails closed when the installed contract is absent', () => {
141+
const root = makeConsumer();
142+
rmSync(join(root, CONTRACT_DESTINATION));
143+
const result = runVerify(root, '.github/workflows/squad-review.md');
144+
expect(result.status).toBe(1);
145+
expect(result.stderr).toContain(`Required file is missing: ${CONTRACT_DESTINATION}`);
146+
});
147+
});

‎workflows/package/squad-bootstrap.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,7 @@ pre-agent-steps:
152152
node --check "$path" >/dev/null
153153
}
154154
# BEGIN GENERATED RESOURCE DIGESTS
155-
check_hash "$install_verifier" "cf474be9b04d339f7e7a18c65e776b8a53e84bea5b4b85abfe65ed11f7b782ce"
155+
check_hash "$install_verifier" "da3f2ef1fc7efc8676eb654643bd0d0c0e59606538eeb2ebe7cf25792c63d1c1"
156156
check_hash "$cast_validator" "c6d0b92aac71dc6f6d5727cac418a323b0bc9c12047400faa12d96150d548ada"
157157
check_hash "$bootstrap_validator" "1af6ab267eaf1148501a949b8b9ac65a1929130b03a1d7f757b156fd2de63fbb"
158158
# END GENERATED RESOURCE DIGESTS

‎workflows/shared/squad-install-verifier.mjs‎

Lines changed: 40 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -703,30 +703,45 @@ function verifyOwnership(root, contract, expectedRevision) {
703703
return record.resolvedCommit;
704704
}
705705

706+
function digestMatchesWithFinalNewlineTolerance(content, expectedDigest) {
707+
return [content, `${content}\n`]
708+
.some(candidate => sha256(Buffer.from(candidate)) === expectedDigest);
709+
}
710+
711+
function verifyWorkflowSourceBinding(root, entry, revision) {
712+
const installed = readRequired(root, entry.destination);
713+
const text = installed.toString('utf8');
714+
const lines = text.split('\n');
715+
const frontmatterEnd = lines[0] === '---' ? lines.indexOf('---', 1) : -1;
716+
const sourceIndexes = lines
717+
.map((line, index) => line.startsWith('source:') ? index : -1)
718+
.filter(index => index >= 0);
719+
const sourceBindings = new Map([
720+
[`source: ${PACKAGE_NAME}@${revision}`, entry.package_lock_sha256],
721+
[`source: bradygaster/squad/${entry.source}@${revision}`, entry.lock_sha256],
722+
]);
723+
const sourceIndex = sourceIndexes[0];
724+
const sourceBinding = lines[sourceIndex];
725+
if (frontmatterEnd < 0 || sourceIndexes.length !== 1
726+
|| sourceIndex !== frontmatterEnd - 1 || !sourceBindings.has(sourceBinding)) {
727+
throw new Error(`Installed source binding is invalid for ${entry.destination}.`);
728+
}
729+
lines.splice(sourceIndex, 1);
730+
if (!digestMatchesWithFinalNewlineTolerance(lines.join('\n'), entry.source_sha256)) {
731+
throw new Error(`Installed digest mismatch for ${entry.destination}.`);
732+
}
733+
return {
734+
digest: sha256(installed),
735+
lockDigest: sourceBindings.get(sourceBinding),
736+
};
737+
}
738+
706739
function verifyInstalledBytes(root, contract, revision) {
707740
const lockDigests = new Map();
708741
for (const entry of contract.workflows) {
709-
const installed = readRequired(root, entry.destination);
710-
const text = installed.toString('utf8');
711-
const { frontmatter } = splitWorkflow(text, entry.destination);
712-
const sourceLines = frontmatter.split('\n').filter(line => /^source:/.test(line));
713-
const sourceBinding = sourceLines[0];
714-
const sourceBindings = new Map([
715-
[`source: ${PACKAGE_NAME}@${revision}`, entry.package_lock_sha256],
716-
[`source: bradygaster/squad/${entry.source}@${revision}`, entry.lock_sha256],
717-
]);
718-
if (sourceLines.length !== 1 || !sourceBindings.has(sourceBinding)
719-
|| !frontmatter.endsWith(`\n${sourceBinding}`)) {
720-
throw new Error(`Installed source binding is invalid for ${entry.destination}.`);
721-
}
722-
const canonicalText = text.replace(`\n${sourceBinding}\n---\n`, '\n---\n');
723-
const canonical = Buffer.from(canonicalText);
724-
const canonicalWithFinalNewline = Buffer.from(`${canonicalText}\n`);
725-
if (![sha256(canonical), sha256(canonicalWithFinalNewline)].includes(entry.source_sha256)) {
726-
throw new Error(`Installed digest mismatch for ${entry.destination}.`);
727-
}
742+
const verified = verifyWorkflowSourceBinding(root, entry, revision);
728743
// Select from the verified source, never accept whichever lock digest happens to match.
729-
lockDigests.set(entry.name, sourceBindings.get(sourceBinding));
744+
lockDigests.set(entry.name, verified.lockDigest);
730745
}
731746
for (const entry of contract.skills) {
732747
if (fileDigest(root, entry.destination) !== entry.sha256) {
@@ -941,9 +956,12 @@ export function verifyResource(root, destination) {
941956
const entry = [...contract.shared_runtime, ...contract.workflows, ...contract.skills]
942957
.find((candidate) => candidate.destination === destination);
943958
if (!entry) throw new Error(`Resource is not registered in the trusted Squad contract: ${destination}`);
959+
if ('source_sha256' in entry) {
960+
const revision = verifyOwnership(root, contract);
961+
return verifyWorkflowSourceBinding(root, entry, revision).digest;
962+
}
944963
const actual = fileDigest(root, destination);
945-
const expected = entry.source_sha256 ?? entry.sha256;
946-
if (actual !== expected) throw new Error(`Squad resource digest mismatch for ${destination}.`);
964+
if (actual !== entry.sha256) throw new Error(`Squad resource digest mismatch for ${destination}.`);
947965
return actual;
948966
}
949967

‎workflows/squad-bootstrap.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ pre-agent-steps:
159159
node --check "$path" >/dev/null
160160
}
161161
# BEGIN GENERATED RESOURCE DIGESTS
162-
check_hash "$install_verifier" "cf474be9b04d339f7e7a18c65e776b8a53e84bea5b4b85abfe65ed11f7b782ce"
162+
check_hash "$install_verifier" "da3f2ef1fc7efc8676eb654643bd0d0c0e59606538eeb2ebe7cf25792c63d1c1"
163163
check_hash "$cast_validator" "c6d0b92aac71dc6f6d5727cac418a323b0bc9c12047400faa12d96150d548ada"
164164
check_hash "$bootstrap_validator" "1af6ab267eaf1148501a949b8b9ac65a1929130b03a1d7f757b156fd2de63fbb"
165165
# END GENERATED RESOURCE DIGESTS

‎workflows/squad-workflows.manifest.json‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -68,9 +68,9 @@
6868
"source": "workflows/package/squad-bootstrap.md",
6969
"destination": ".github/workflows/squad-bootstrap.md",
7070
"lock": ".github/workflows/squad-bootstrap.lock.yml",
71-
"source_sha256": "a266ed20e76b751e5527b49c9d3517482504a0fd6a6704d04531b57f99dbbaa1",
72-
"lock_sha256": "3642cfb42e6b6230354fb245f84bf369d4db29051a6db184c93147504acc221d",
73-
"package_lock_sha256": "a2a635dd98427b713c843e7baf22f98a01a0688f450bb2fc993b1fcaa960fb4e"
71+
"source_sha256": "e1f45d8e0038550f437602a00fa40c2629ed28f5295afb40179fe68c1fe27e30",
72+
"lock_sha256": "a72aa20fc6ce95ca5dbab08d0531c7d670c93bb129d1931b6ff471571eefe386",
73+
"package_lock_sha256": "5be4f342fd4593e70ca39825e24469a19895949aece7364686d1d2aaedd0ce8c"
7474
},
7575
{
7676
"name": "squad-command-router",
@@ -97,7 +97,7 @@
9797
"package_destination": ".github/workflows/shared/squad-install-verifier.mjs",
9898
"destination": ".github/workflows/shared/squad-install-verifier.mjs",
9999
"ownership": "manifest",
100-
"sha256": "cf474be9b04d339f7e7a18c65e776b8a53e84bea5b4b85abfe65ed11f7b782ce"
100+
"sha256": "da3f2ef1fc7efc8676eb654643bd0d0c0e59606538eeb2ebe7cf25792c63d1c1"
101101
},
102102
{
103103
"path": "shared/squad-command-contract.mjs",

0 commit comments

Comments
 (0)