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

Harden Squad workflow bootstrap, command routing, review relays, and installed workflow guidance for deterministic hosted canaries.
7 changes: 4 additions & 3 deletions .squad-templates/skills/gh-aw-enlistment/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -389,7 +389,7 @@ gh aw add "bradygaster/squad/workflows@${SQUAD_SHA}"
# Safe-update report shows ONLY the two documented secrets + squad-init → approve once
gh aw compile --strict --approve
node .github/workflows/shared/squad-install-verifier.mjs --materialize-runtime
gh aw compile --strict # final, no --approve; only the bot-trigger warning remains
gh aw compile --strict # final, no --approve; exactly the two documented warnings remain
node .github/workflows/shared/squad-install-verifier.mjs \
--verify-install --source-revision "${SQUAD_SHA}" --strict-compile

Expand Down Expand Up @@ -446,8 +446,9 @@ gh pr merge --squash # auto-merge before human review. NEVER.
else is a STOP.
- ❌ **Treating `--approve` as the final compile.** Always finish with a plain
`gh aw compile --strict` (no `--approve`).
- ❌ **Tolerating extra warnings.** Only the `squad.md` bot-trigger warning is
allowed; every other warning or error halts the run.
- ❌ **Tolerating extra warnings.** Only the documented `squad-review.md`
`pull_request_target` advisory and `squad.md` bot-trigger warning are allowed;
every other warning or error halts the run.
- ❌ **Committing diagnostics.** Never commit `.github/aw/logs/` output; add the
log `.gitignore` if missing.
- ❌ **Clobbering existing workflows.** The install is additive; preserve unrelated
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -389,7 +389,7 @@ gh aw add "bradygaster/squad/workflows@${SQUAD_SHA}"
# Safe-update report shows ONLY the two documented secrets + squad-init → approve once
gh aw compile --strict --approve
node .github/workflows/shared/squad-install-verifier.mjs --materialize-runtime
gh aw compile --strict # final, no --approve; only the bot-trigger warning remains
gh aw compile --strict # final, no --approve; exactly the two documented warnings remain
node .github/workflows/shared/squad-install-verifier.mjs \
--verify-install --source-revision "${SQUAD_SHA}" --strict-compile

Expand Down Expand Up @@ -446,8 +446,9 @@ gh pr merge --squash # auto-merge before human review. NEVER.
else is a STOP.
- ❌ **Treating `--approve` as the final compile.** Always finish with a plain
`gh aw compile --strict` (no `--approve`).
- ❌ **Tolerating extra warnings.** Only the `squad.md` bot-trigger warning is
allowed; every other warning or error halts the run.
- ❌ **Tolerating extra warnings.** Only the documented `squad-review.md`
`pull_request_target` advisory and `squad.md` bot-trigger warning are allowed;
every other warning or error halts the run.
- ❌ **Committing diagnostics.** Never commit `.github/aw/logs/` output; add the
log `.gitignore` if missing.
- ❌ **Clobbering existing workflows.** The install is additive; preserve unrelated
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -389,7 +389,7 @@ gh aw add "bradygaster/squad/workflows@${SQUAD_SHA}"
# Safe-update report shows ONLY the two documented secrets + squad-init → approve once
gh aw compile --strict --approve
node .github/workflows/shared/squad-install-verifier.mjs --materialize-runtime
gh aw compile --strict # final, no --approve; only the bot-trigger warning remains
gh aw compile --strict # final, no --approve; exactly the two documented warnings remain
node .github/workflows/shared/squad-install-verifier.mjs \
--verify-install --source-revision "${SQUAD_SHA}" --strict-compile

Expand Down Expand Up @@ -446,8 +446,9 @@ gh pr merge --squash # auto-merge before human review. NEVER.
else is a STOP.
- ❌ **Treating `--approve` as the final compile.** Always finish with a plain
`gh aw compile --strict` (no `--approve`).
- ❌ **Tolerating extra warnings.** Only the `squad.md` bot-trigger warning is
allowed; every other warning or error halts the run.
- ❌ **Tolerating extra warnings.** Only the documented `squad-review.md`
`pull_request_target` advisory and `squad.md` bot-trigger warning are allowed;
every other warning or error halts the run.
- ❌ **Committing diagnostics.** Never commit `.github/aw/logs/` output; add the
log `.gitignore` if missing.
- ❌ **Clobbering existing workflows.** The install is additive; preserve unrelated
Expand Down
2 changes: 1 addition & 1 deletion scripts/gh-aw-hosted-e2e.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -611,7 +611,7 @@ export function waitForBaseControlledReviewCanary(
]);
if (run.event !== 'pull_request_target'
|| run.path !== '.github/workflows/squad-review.lock.yml'
|| run.head_sha !== castPr.headSha
|| run.head_sha !== castPr.baseSha
|| run.repository?.full_name !== target
|| run.display_title !== `Squad review — PR #${castPr.number}`
|| run.run_attempt < verdict.run_attempt) {
Expand Down
7 changes: 4 additions & 3 deletions templates/skills/gh-aw-enlistment/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -389,7 +389,7 @@ gh aw add "bradygaster/squad/workflows@${SQUAD_SHA}"
# Safe-update report shows ONLY the two documented secrets + squad-init → approve once
gh aw compile --strict --approve
node .github/workflows/shared/squad-install-verifier.mjs --materialize-runtime
gh aw compile --strict # final, no --approve; only the bot-trigger warning remains
gh aw compile --strict # final, no --approve; exactly the two documented warnings remain
node .github/workflows/shared/squad-install-verifier.mjs \
--verify-install --source-revision "${SQUAD_SHA}" --strict-compile

Expand Down Expand Up @@ -446,8 +446,9 @@ gh pr merge --squash # auto-merge before human review. NEVER.
else is a STOP.
- ❌ **Treating `--approve` as the final compile.** Always finish with a plain
`gh aw compile --strict` (no `--approve`).
- ❌ **Tolerating extra warnings.** Only the `squad.md` bot-trigger warning is
allowed; every other warning or error halts the run.
- ❌ **Tolerating extra warnings.** Only the documented `squad-review.md`
`pull_request_target` advisory and `squad.md` bot-trigger warning are allowed;
every other warning or error halts the run.
- ❌ **Committing diagnostics.** Never commit `.github/aw/logs/` output; add the
log `.gitignore` if missing.
- ❌ **Clobbering existing workflows.** The install is additive; preserve unrelated
Expand Down
26 changes: 26 additions & 0 deletions test/gh-aw-bootstrap-workflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -848,6 +848,10 @@ describe('automatic Squad bootstrap workflow', () => {
expect(lock).toContain('"payload_sha256"');
expect(lock).not.toMatch(/"materialize-bootstrap":\{"inputs":\{"payload":/);
expect(lock).toContain('reconstructBootstrapPayload(items[0])');
expect(lock).toContain("mkdtempSync(join(tmpdir(), 'squad-bootstrap-candidate-'))");
expect(lock).toContain('Bootstrap payload path crosses a symbolic link');
expect(lock.indexOf("validate(payload, 'placeholder')"))
.toBeLessThan(lock.indexOf('safeTarget(checkout, file.path)'));
expect(lock).toContain('mcp-cli');
expect(lock).toContain('--submit-envelope');
expect(parse(lock).jobs.agent.env.DEFAULT_BRANCH).toBe('${{ github.event.repository.default_branch }}');
Expand All @@ -865,6 +869,28 @@ describe('automatic Squad bootstrap workflow', () => {
expect(lock).not.toMatch(/\$\{\{[^}]*\\u00(?:26|3[cCeE])/);
}, 180000);

it('rejects a realistic source mutation that writes to the checkout before validation', () => {
const assertValidationBeforeCheckoutWrite = (source: string) => {
const validation = source.indexOf("validate(payload, 'placeholder')");
const candidateWrite = source.indexOf(
"const target = safeTarget(candidate, file.path)",
);
const checkoutWrite = source.indexOf(
"const target = safeTarget(checkout, file.path)",
);
expect(candidateWrite).toBeGreaterThan(-1);
expect(candidateWrite).toBeLessThan(validation);
expect(validation).toBeGreaterThan(-1);
expect(checkoutWrite).toBeGreaterThan(validation);
};
assertValidationBeforeCheckoutWrite(WORKFLOW);
const mutated = WORKFLOW.replace(
"const target = safeTarget(candidate, file.path);",
"const target = safeTarget(checkout, file.path);",
);
expect(() => assertValidationBeforeCheckoutWrite(mutated)).toThrow();
});

it('detects a realistic compiled-lock mutation that removes the default-branch gate', () => {
const mutated = WORKFLOW.replace(
"if: github.ref_name == github.event.repository.default_branch\n",
Expand Down
104 changes: 103 additions & 1 deletion test/gh-aw-command-parse.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import { pathToFileURL } from 'node:url';
import { join } from 'node:path';
import {
classifySquadCommand,
commandRequiresAuthorization,
enforceSquadCommandContract,
isAuthorizedPermission,
rejectionComment,
} from '../workflows/shared/squad-command-contract.mjs';

Expand Down Expand Up @@ -36,9 +38,10 @@ describe('gh-aw: shared /squad command contract (#1824)', () => {
])('%s call site', (_name, payload) => {
it.each([
['/squad cast', 'cast', null],
[' /squad cast ', 'cast', null],
[' /squad cast ', 'cast', null],
[' /squad cast ', 'cast', null],
['Context first.\n\n/squad status ', 'status', null],
['Please run /squad plan implementation', 'plan implementation', null],
['/squad research Focus only on proposal P1', 'research', null],
['/squad activate phase 2', 'activate', 2],
['/squad plan accept implementation phase 12', 'plan accept implementation', 12],
Expand Down Expand Up @@ -82,6 +85,27 @@ describe('gh-aw: shared /squad command contract (#1824)', () => {
});
},
);

it.each([
['Please run /squad plan implementation', 'embedded prose'],
['Use `/squad cast` after review.', 'inline code'],
['```text\n/squad cast\n```', 'fenced code'],
['~~~text\n/squad cast\n~~~', 'tilde-fenced code'],
['````text\n```\n/squad cast\n````', 'code after a shorter backtick fence'],
['~~~~text\n~~~\n/squad cast\n~~~~', 'code after a shorter tilde fence'],
['````text\n~~~~\n/squad cast\n````', 'code after a different fence delimiter'],
['````text\n```` trailing-info\n/squad cast\n````', 'code after a fence with trailing info'],
[' /squad cast', 'four-space-indented code'],
['\t/squad cast', 'tab-indented code'],
[' \t/squad cast', 'one-space-plus-tab-indented code'],
[' \t/squad cast', 'two-spaces-plus-tab-indented code'],
[' \t/squad cast', 'three-spaces-plus-tab-indented code'],
])('ignores %s instead of activating control-plane work', (body) => {
expect(classifySquadCommand(payload(body), 'issues')).toEqual({
status: 'none',
source: _name === 'issue body' ? 'issue' : 'comment',
});
});
});

describe('workflow_dispatch call site', () => {
Expand Down Expand Up @@ -186,6 +210,10 @@ describe('gh-aw: shared /squad command contract (#1824)', () => {
expect(DISCOVERY_WORKFLOW).toContain("startsWith(github.event.comment.body, '/squad ')");
expect(DISCOVERY_WORKFLOW).toContain('squad-command-contract.mjs');
expect(DISCOVERY_WORKFLOW).toContain('classifySquadCommand');
expect(DISCOVERY_WORKFLOW).toContain('commandRequiresAuthorization');
expect(DISCOVERY_WORKFLOW).toContain('getCollaboratorPermissionLevel');
expect(DISCOVERY_WORKFLOW).toContain('isAuthorizedPermission');
expect(DISCOVERY_WORKFLOW).toContain('No standalone Squad command was found');
expect(DISCOVERY_WORKFLOW).toContain('rejectionComment');
expect(DISCOVERY_WORKFLOW).toContain('github.rest.issues.createComment');
expect(DISCOVERY_WORKFLOW).toContain('core.setFailed(`Squad rejected command:');
Expand All @@ -194,6 +222,21 @@ describe('gh-aw: shared /squad command contract (#1824)', () => {
expect(DISCOVERY_WORKFLOW).toContain("command: result.argumentText || 'cast'");
});

it('keeps open modes public and fails mutating modes closed on collaborator permission', () => {
for (const mode of ['status', 'review', 'research', 'plan', 'revoke-improvement']) {
expect(commandRequiresAuthorization({ status: 'accepted', mode })).toBe(false);
}
for (const mode of ['cast', 'triage', 'plan implementation', 'implement']) {
expect(commandRequiresAuthorization({ status: 'accepted', mode })).toBe(true);
}
expect(isAuthorizedPermission('admin')).toBe(true);
expect(isAuthorizedPermission('maintain')).toBe(true);
expect(isAuthorizedPermission('write')).toBe(true);
expect(isAuthorizedPermission('triage')).toBe(false);
expect(isAuthorizedPermission('read')).toBe(false);
expect(isAuthorizedPermission('unresolved')).toBe(false);
});

it('realistic source mutation is caught by the accepted-command fixtures', async () => {
mkdirSync(TEST_ROOT, { recursive: true });
const root = mkdtempSync(join(TEST_ROOT, 'command-contract-mutant-'));
Expand All @@ -218,4 +261,63 @@ describe('gh-aw: shared /squad command contract (#1824)', () => {
rmSync(root, { recursive: true, force: true });
}
});

it('realistic fence-length mutation exposes a hidden command and is caught', async () => {
mkdirSync(TEST_ROOT, { recursive: true });
const root = mkdtempSync(join(TEST_ROOT, 'command-fence-mutant-'));
try {
const source = join(process.cwd(), 'workflows', 'shared', 'squad-command-contract.mjs');
const mutant = join(root, 'squad-command-contract-mutant.mjs');
cpSync(source, mutant);
const original = readFileSync(mutant, 'utf8');
const changed = original.replace(
' && closingFence[1].length >= fence.length) {',
' ) {',
);
expect(changed).not.toBe(original);
writeFileSync(mutant, changed);
const module = await import(`${pathToFileURL(mutant).href}?mutation=${Date.now()}`);
const hidden = comment('````text\n```\n/squad cast\n````');
expect(module.classifySquadCommand(hidden, 'issue_comment')).toMatchObject({
status: 'accepted',
mode: 'cast',
});
expect(classifySquadCommand(hidden, 'issue_comment')).toEqual({
status: 'none',
source: 'comment',
});
} finally {
rmSync(root, { recursive: true, force: true });
}
});

it('realistic CommonMark tab-indentation mutation exposes hidden commands and is caught', async () => {
mkdirSync(TEST_ROOT, { recursive: true });
const root = mkdtempSync(join(TEST_ROOT, 'command-indent-mutant-'));
try {
const source = join(process.cwd(), 'workflows', 'shared', 'squad-command-contract.mjs');
const mutant = join(root, 'squad-command-contract-mutant.mjs');
cpSync(source, mutant);
const original = readFileSync(mutant, 'utf8');
const changed = original.replace(
'/^(?: {4}| {0,3}\\t)/',
'/^(?: {4}|\\t)/',
);
expect(changed).not.toBe(original);
writeFileSync(mutant, changed);
const module = await import(`${pathToFileURL(mutant).href}?mutation=${Date.now()}`);
for (const body of [' \t/squad cast', ' \t/squad cast', ' \t/squad cast']) {
expect(module.classifySquadCommand(comment(body), 'issue_comment')).toMatchObject({
status: 'accepted',
mode: 'cast',
});
expect(classifySquadCommand(comment(body), 'issue_comment')).toEqual({
status: 'none',
source: 'comment',
});
}
} finally {
rmSync(root, { recursive: true, force: true });
}
});
});
12 changes: 9 additions & 3 deletions test/gh-aw-hosted-e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,7 @@ describe('Squad gh-aw hosted E2E controller', () => {

it.each([
'native-job-name', 'ui-context-name', 'wrong-check-id', 'wrong-head',
'failed-check', 'incomplete-check', 'foreign-app', 'foreign-app-id',
'wrong-run-head', 'failed-check', 'incomplete-check', 'foreign-app', 'foreign-app-id',
])('exercises the hosted canary API contract: %s', mutation => {
const target = 'owner/consumer';
const verdict = {
Expand All @@ -257,7 +257,8 @@ describe('Squad gh-aw hosted E2E controller', () => {
workflow_path: '.github/workflows/squad-review.lock.yml', run_id: 31, run_attempt: 1,
};
const run = {
id: 31, event: verdict.event, path: verdict.workflow_path, head_sha: CAST_SHA,
id: 31, event: verdict.event, path: verdict.workflow_path,
head_sha: currentCastPr.baseSha,
repository: { full_name: target }, run_attempt: 1,
display_title: `Squad review — PR #${currentCastPr.number}`,
};
Expand All @@ -273,6 +274,7 @@ describe('Squad gh-aw hosted E2E controller', () => {
case 'ui-context-name': check.name = 'Squad Review / review'; break;
case 'wrong-check-id': check.id = 124; break;
case 'wrong-head': check.head_sha = TARGET_SHA; break;
case 'wrong-run-head': run.head_sha = CAST_SHA; break;
case 'failed-check': check.conclusion = 'failure'; break;
case 'incomplete-check': check.status = 'in_progress'; break;
case 'foreign-app': check.app.slug = 'other'; break;
Expand Down Expand Up @@ -304,10 +306,14 @@ describe('Squad gh-aw hosted E2E controller', () => {
expect(JSON.parse(readFileSync(
resolve(evidence, 'base-controlled-review-canary.json'), 'utf8',
)).check.name).toBe('review');
} else if (mutation === 'wrong-run-head') {
expect(execute).toThrow('not bound to the base-controlled workflow run');
} else {
expect(execute).toThrow('not bound to the trusted review job');
}
expect(routes.at(-1)).toBe(`repos/${target}/check-runs/123`);
expect(routes.at(-1)).toBe(mutation === 'wrong-run-head'
? `repos/${target}/actions/runs/31`
: `repos/${target}/check-runs/123`);
} finally {
rmSync(evidence, { recursive: true, force: true });
}
Expand Down
6 changes: 3 additions & 3 deletions test/gh-aw-quality.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3961,8 +3961,8 @@ describe('gh-aw: canonical package integrity contract', () => {
expect(mutable).not.toContain(actionReference);
expect(createHash('sha256').update(normalizeCompiledLock(mutable, revisionA)).digest('hex'))
.toBe(sourceBinding === 'workflow'
? '1aa070ca330d8f8f9f874855c75db410851b30a38efc7543090b697352eda1d2'
: 'bf54175720e6e9ab3fe5d5e3054c28f3a69595c95cdf50e56f9b30988158270f');
? '8456d8129241269c107cdf4b16ae079e89a98ef944152df36f53ec0decf3f30d'
: 'bac4d8b90269d827fc9e626dd02c1b536bccde7105e9037971740054cda4c19b');
expect(() => validateCompilerActionPins(mutable)).toThrow(/invalid immutable action pin/);
writeFileSync(lockPath, mutable);
expect(verifyInstall(root).failures.join('\n'))
Expand Down Expand Up @@ -4016,7 +4016,7 @@ describe('gh-aw: canonical package integrity contract', () => {
compile();
const unpinned = readText(lockPath);
expect(createHash('sha256').update(normalizeCompiledLock(unpinned, revisionA)).digest('hex'))
.toBe('bf54175720e6e9ab3fe5d5e3054c28f3a69595c95cdf50e56f9b30988158270f');
.toBe('bac4d8b90269d827fc9e626dd02c1b536bccde7105e9037971740054cda4c19b');
expect(verifyInstall(root).failures.join('\n')).toContain('Installed digest mismatch');

const seedPins = () => spawnSync(process.execPath, ['--input-type=module', '-e', seed!], {
Expand Down
4 changes: 2 additions & 2 deletions test/gh-aw-research-online.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,8 +197,8 @@ describe('gh-aw: /squad research online-documentation capability', () => {

it('mirrors the Online sources disclosure into the §3.2 research template of the planning ontology', () => {
const ontology = readText(PLANNING_ONTOLOGY);
const start = ontology.indexOf('### 3.2 Research Findings');
expect(start, '§3.2 Research Findings template must exist in the planning ontology').toBeGreaterThan(-1);
const start = ontology.indexOf('### 3.2 Squad Research');
expect(start, '§3.2 Squad Research template must exist in the planning ontology').toBeGreaterThan(-1);
// Scope to the §3.2 template block (up to the next ### heading) so a stray
// match elsewhere in the file can't satisfy this.
const rest = ontology.slice(start);
Expand Down
Loading
Loading