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
186 changes: 180 additions & 6 deletions test/gh-aw-implement-workflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,8 +222,8 @@ describe('gh-aw implement workflows', () => {
expect(continuationSection(worker)).toContain(
'^<!-- squad:implement issue=([1-9][0-9]*) run=([1-9][0-9]*) -->$',
);
expect(continuationSection(worker)).toContain(
'issue number to equal the marker\'s issue number',
expect(continuationSection(worker)).toMatch(
/issue\s+number to equal the marker's issue number/,
);
});

Expand Down Expand Up @@ -398,7 +398,7 @@ describe('gh-aw implement worker: retro-origin provenance enforcement', () => {
}))).toContain('issue-number-not-numeric');
});

it('leaves a manual dispatch and the merge continuation untouched', () => {
it('leaves a manual dispatch untouched and validates merge continuation provenance', () => {
const manual = guard.evaluateImplementDispatchInputs({
eventName: 'workflow_dispatch',
issueNumber: '42',
Expand All @@ -407,9 +407,112 @@ describe('gh-aw implement worker: retro-origin provenance enforcement', () => {
});
expect(manual.ok).toBe(true);
expect(manual.origin).toBe('manual');
const merged = guard.evaluateImplementDispatchInputs({ eventName: 'pull_request' });
const merged = guard.evaluateImplementDispatchInputs({
eventName: 'pull_request',
pullRequestBody: 'Closes #42\n\n<!-- squad:implement issue=42 run=7 -->',
pullRequestHeadRef: 'squad/implement-42-fix-the-widget',
});
expect(merged.ok).toBe(true);
expect(merged.enforced).toBe(false);
expect(merged.enforced).toBe(true);
expect(merged.origin).toBe('merge-continuation');
expect(merged.issue_number).toBe(42);
expect(merged.run_id).toBe(7);
});

it('fails merge continuation closed on missing, duplicate, fenced, malformed, or mismatched evidence', () => {
const evaluate = (body: unknown, headRef: unknown) => guard.evaluateImplementDispatchInputs({
eventName: 'pull_request',
pullRequestBody: body,
pullRequestHeadRef: headRef,
});
expect(kinds(evaluate('', 'squad/implement-42-fix'))).toContain('merge-provenance-marker-invalid');
expect(kinds(evaluate([
'<!-- squad:implement issue=42 run=7 -->',
'<!-- squad:implement issue=42 run=8 -->',
].join('\n'), 'squad/implement-42-fix'))).toContain('merge-provenance-marker-ambiguous');
expect(kinds(evaluate([
'```text',
'<!-- squad:implement issue=42 run=7 -->',
'```',
].join('\n'), 'squad/implement-42-fix'))).toContain('merge-provenance-marker-invalid');
expect(kinds(evaluate(
'prose <!-- squad:implement issue=42 run=7 -->',
'squad/implement-42-fix',
))).toContain('merge-provenance-marker-invalid');
expect(kinds(evaluate(
'<!-- squad:implement issue=42 run=7 -->',
'squad/implement-43-fix',
))).toContain('merge-provenance-issue-mismatch');
expect(kinds(evaluate(
'<!-- squad:implement issue=42 run=7 -->',
'squad/implement-42-Fix',
))).toContain('merge-provenance-branch-invalid');
expect(kinds(evaluate(undefined, undefined))).toEqual(expect.arrayContaining([
'merge-provenance-body-unreadable',
'merge-provenance-branch-unreadable',
]));
});

it('refuses merge dispatch output when runtime provenance no longer validates', async () => {
const result = await guard.enforceImplementSafeOutputs({
SQUAD_IMPLEMENT_EVENT_NAME: 'pull_request',
SQUAD_IMPLEMENT_PULL_BODY: '<!-- squad:implement issue=42 run=7 -->',
SQUAD_IMPLEMENT_PULL_HEAD_REF: 'squad/implement-43-forged',
} as never, {
readItems: () => [{
type: 'dispatch_workflow',
workflow_name: 'squad',
inputs: { command: 'implement', issue_number: '100' },
}],
});
expect(result.ok).toBe(false);
expect(result.enforced).toBe(true);
expect(kinds(result)).toContain('merge-provenance-issue-mismatch');
});

it('binds merge continuation to a successful same-repository implement run', async () => {
const env = {
GITHUB_REPOSITORY: REPO,
SQUAD_IMPLEMENT_DEFAULT_BRANCH: BRANCH,
SQUAD_IMPLEMENT_EVENT_NAME: 'pull_request',
SQUAD_IMPLEMENT_PULL_BODY: '<!-- squad:implement issue=42 run=7 -->',
SQUAD_IMPLEMENT_PULL_HEAD_REF: 'squad/implement-42-fix',
SQUAD_IMPLEMENT_PULL_HEAD_REPOSITORY: REPO,
SQUAD_IMPLEMENT_PULL_CREATED_AT: '2026-09-14T18:00:30Z',
};
const trustedRun = {
id: 7,
path: guard.IMPLEMENT_WORKFLOW_PATH,
event: 'workflow_dispatch',
status: 'completed',
conclusion: 'success',
head_branch: BRANCH,
repository: { full_name: REPO },
head_repository: { full_name: REPO },
run_started_at: '2026-09-14T18:00:00Z',
updated_at: '2026-09-14T18:01:00Z',
};
const valid = await guard.validateImplementOrigin(env, async () => trustedRun);
expect(valid.ok).toBe(true);
expect(valid.origin).toBe('merge-continuation');

const fork = await guard.validateImplementOrigin({
...env,
SQUAD_IMPLEMENT_PULL_HEAD_REPOSITORY: 'attacker/fork',
}, async () => trustedRun);
expect(kinds(fork)).toContain('merge-provenance-head-repository-mismatch');

const copiedAfterRun = await guard.validateImplementOrigin({
...env,
SQUAD_IMPLEMENT_PULL_CREATED_AT: '2026-09-14T18:02:00Z',
}, async () => trustedRun);
expect(kinds(copiedAfterRun)).toContain('merge-provenance-run-window-mismatch');

const unrelatedRun = await guard.validateImplementOrigin(env, async () => ({
...trustedRun,
path: '.github/workflows/other.lock.yml',
}));
expect(kinds(unrelatedRun)).toContain('merge-provenance-run-untrusted');
});

it('refuses an unknown or half-declared origin instead of falling back to manual', () => {
Expand Down Expand Up @@ -763,13 +866,84 @@ describe('gh-aw implement worker: retro-origin provenance enforcement', () => {
it('wires the guard into a pre-agent step and the safe-outputs boundary', () => {
expect(worker).toContain('shared/squad-retro-provenance.mjs');
expect(worker).toContain('--implement-inputs');
expect(worker).toContain('Checkout trusted base for the pre-agent provenance guard');
expect(worker).toContain('.squad-pre-agent-trusted-base/.github/workflows/shared/squad-retro-provenance.mjs');
expect(worker).toContain('SQUAD_IMPLEMENT_PULL_BODY');
expect(worker).toContain('SQUAD_IMPLEMENT_PULL_HEAD_REF');
expect(worker).toContain('SQUAD_IMPLEMENT_PULL_HEAD_REPOSITORY');
expect(worker).toContain('SQUAD_IMPLEMENT_PULL_CREATED_AT');
const steps = worker.match(/^ {2}steps:\r?\n([\s\S]*?)^ {2}create-pull-request:/m)?.[1] ?? '';
expect(steps).toContain('enforceImplementSafeOutputs');
expect(steps).toContain('core.setFailed');
expect(steps).toContain('SQUAD_IMPLEMENT_AW_CONTEXT');
expect(steps).toContain('SQUAD_IMPLEMENT_DEFAULT_BRANCH');
expect(steps).toContain('SQUAD_IMPLEMENT_PULL_BODY');
expect(steps).toContain('SQUAD_IMPLEMENT_PULL_HEAD_REF');
expect(steps).toContain('SQUAD_IMPLEMENT_PULL_HEAD_REPOSITORY');
expect(steps).toContain('SQUAD_IMPLEMENT_PULL_CREATED_AT');
});

it('compiles the merge gate before the agent and before safe-output processing', () => {
const lock = compiledWorkerLock();
const preCheckout = lock.indexOf('name: Checkout trusted base for the pre-agent provenance guard');
const preGuard = lock.indexOf('name: Validate dispatch inputs and declared origin');
const agent = lock.indexOf('name: Execute GitHub Copilot CLI');
expect(preCheckout).toBeGreaterThan(-1);
expect(preGuard).toBeGreaterThan(preCheckout);
expect(agent).toBeGreaterThan(preGuard);
const preAgentContract = lock.slice(preCheckout, agent);
const preGuardEnd = preAgentContract.indexOf('name: Download container images');
expect(preGuardEnd).toBeGreaterThan(-1);
const preGateSteps = preAgentContract.slice(0, preGuardEnd);
expect(preAgentContract).toContain('ref: refs/heads/${{ github.event.repository.default_branch }}');
expect(preAgentContract).toContain('persist-credentials: false');
expect(preAgentContract).toContain('SQUAD_IMPLEMENT_PULL_BODY');
expect(preAgentContract).toContain('SQUAD_IMPLEMENT_PULL_HEAD_REF');
expect(preAgentContract).toContain('SQUAD_IMPLEMENT_PULL_HEAD_REPOSITORY');
expect(preAgentContract).toContain('SQUAD_IMPLEMENT_PULL_CREATED_AT');
expect(preGateSteps).not.toMatch(/\n\s+(if|continue-on-error):/);

const safeJob = safeOutputsJob(lock);
const safeGuard = safeJob.indexOf('name: Enforce implement provenance before any output');
const process = safeJob.indexOf('name: Process Safe Outputs');
expect(safeGuard).toBeGreaterThan(-1);
expect(process).toBeGreaterThan(safeGuard);
expect(safeJob.slice(safeGuard, process)).toContain('SQUAD_IMPLEMENT_PULL_BODY');
expect(safeJob.slice(safeGuard, process)).toContain('SQUAD_IMPLEMENT_PULL_HEAD_REF');
expect(safeJob.slice(safeGuard, process)).toContain('SQUAD_IMPLEMENT_PULL_HEAD_REPOSITORY');
expect(safeJob.slice(safeGuard, process)).toContain('SQUAD_IMPLEMENT_PULL_CREATED_AT');

}, 60000);

it('detects mutations that remove either merge-provenance boundary', () => {
const required = [
'Checkout trusted base for the pre-agent provenance guard',
'SQUAD_IMPLEMENT_PULL_BODY',
'SQUAD_IMPLEMENT_PULL_HEAD_REF',
'SQUAD_IMPLEMENT_PULL_HEAD_REPOSITORY',
'SQUAD_IMPLEMENT_PULL_CREATED_AT',
'.squad-pre-agent-trusted-base/.github/workflows/shared/squad-retro-provenance.mjs',
'Enforce implement provenance before any output',
];
const missing = (text: string) => required.filter(value => !text.includes(value));
expect(missing(worker)).toEqual([]);
for (const value of required) {
expect(
missing(worker.replaceAll(value, 'REMOVED')),
`removing ${value} must break the source contract`,
).toContain(value);
}

const compiled = compiledWorkerLock();
expect(missing(compiled)).toEqual([]);
for (const value of required) {
expect(
missing(compiled.replaceAll(value, 'REMOVED')),
`removing ${value} must break the compiled contract`,
).toContain(value);
}
}, 60000);

// -------------------------------------------------------------------------
// FIDO blocker 2: the guard module was imported from GITHUB_WORKSPACE, whose
// only checkout in this job is one gh-aw emits ONLY for `create_pull_request`
Expand All @@ -783,7 +957,7 @@ describe('gh-aw implement worker: retro-origin provenance enforcement', () => {
const job = safeOutputsJob(compiledWorkerLock());

const trustedIndex = job.indexOf('name: Checkout trusted base for the provenance guard');
const guardIndex = job.indexOf('name: Enforce retro-origin provenance before any output');
const guardIndex = job.indexOf('name: Enforce implement provenance before any output');
const processIndex = job.indexOf('name: Process Safe Outputs');
expect(trustedIndex).toBeGreaterThan(-1);
expect(guardIndex).toBeGreaterThan(trustedIndex);
Expand Down
7 changes: 4 additions & 3 deletions test/gh-aw-review-workflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ function provenanceRows(workflow: string): string[] {
function installOrders(markdown: string): string[][] {
const uncommented = markdown.replace(/^#\s?/gm, '');
return [...uncommented.matchAll(/gh aw add \\\n((?:\s+bradygaster\/squad\/workflows\/[^\n]+\n?)+)/g)]
.map(block => [...block[1].matchAll(/bradygaster\/squad\/workflows\/([^@\s\\]+\.md)@(?:dev|\$\{SQUAD_SHA\})/g)]
.map(block => [...block[1].matchAll(/bradygaster\/squad\/workflows\/([^@\s\\]+\.md)@(?:dev|\$\{SQUAD_(?:SHA|WORKFLOW_SHA)\})/g)]
.map(match => match[1]));
}

Expand Down Expand Up @@ -212,15 +212,16 @@ describe('gh-aw advisory Squad reviewer', () => {
execFileSync('git', ['init', '--quiet'], { cwd: workspace });
const version = spawnSync('gh', ['aw', '--version'], { encoding: 'utf8', timeout: 15000 });
expect(version.status).toBe(0);
expect(`${version.stdout}${version.stderr}`.trim()).toMatch(/\bv0\.87\.10$/);
const compilerVersion = `${version.stdout}${version.stderr}`.trim();
expect(compilerVersion).toMatch(/\bv0\.(?:8[7-9]|9[0-9])\.[0-9]+$/);
for (const name of installOrder) {
const started = performance.now();
const result = spawnSync(
'gh',
['aw', 'compile', name.slice(0, -3), '--strict', '--approve', '--no-check-update'],
{ cwd: workspace, encoding: 'utf8', stdio: 'pipe', timeout: 60000 },
);
console.log(`strict-compile ${name} v0.87.10 status=${result.status} duration_ms=${Math.round(performance.now() - started)}`);
console.log(`strict-compile ${name} ${compilerVersion} status=${result.status} duration_ms=${Math.round(performance.now() - started)}`);
const diagnostics = `${result.stdout}\n${result.stderr}`;
expect(result.error, `failed to launch gh aw for ${name}`).toBeUndefined();
expect(result.status, `strict compile failed for ${name}:\n${diagnostics}`).toBe(0);
Expand Down
95 changes: 93 additions & 2 deletions workflows/shared/squad-retro-provenance.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,13 @@ export const ACTION_LABEL = 'squad-retro-action';
export const PROPOSAL_LABEL = 'squad-retro-proposal';
export const RETRO_ORIGIN = 'squad-retro';
export const IMPLEMENT_WORKFLOW = 'squad-implement-worker';
export const IMPLEMENT_WORKFLOW_PATH = '.github/workflows/squad-implement-worker.lock.yml';
export const RETRO_WORKFLOW_PATH = '.github/workflows/squad-retro.lock.yml';
export const FINGERPRINT = /^(?:fail|review):[0-9a-f]{16}$/;
export const TEMPORARY_ID = /^#?aw_[A-Za-z0-9_]{3,12}$/i;
export const NUMERIC_ID = /^[1-9][0-9]*$/;
export const IMPLEMENT_PULL_MARKER = /^<!-- squad:implement issue=([1-9][0-9]*) run=([1-9][0-9]*) -->$/;
export const IMPLEMENT_PULL_BRANCH = /^squad\/implement-([1-9][0-9]*)-[a-z0-9][a-z0-9-]*$/;
export const DISPATCH_INPUT_KEYS = Object.freeze(['issue_number', 'request_origin', 'retro_action_key']);
export const IMPLEMENT_PULL_SCAN_MAX_PAGES = 5;
export const ACTION_COMMENT_MAX_PAGES = 2;
Expand Down Expand Up @@ -175,6 +178,62 @@ export function deriveActionTemporaryIds(fingerprints) {
export const normalizeTemporaryId = value => String(value ?? '').replace(/^#/, '').toLowerCase();
export const isTemporaryId = value => TEMPORARY_ID.test(String(value ?? ''));

export function parseImplementMergeProvenance(body, headRef) {
const violations = [];
if (typeof body !== 'string') violations.push({ kind: 'merge-provenance-body-unreadable' });
if (typeof headRef !== 'string') violations.push({ kind: 'merge-provenance-branch-unreadable' });
if (violations.length) return { ok: false, enforced: true, origin: 'merge-continuation', violations };

const text = normalizeText(body);
const markerOccurrences = text.match(/<!-- squad:implement\b/g) || [];
const matches = [];
let fence = null;
for (const line of text.split('\n')) {
if (fence) {
if (markdownFenceClose(line, fence)) fence = null;
continue;
}
const opening = markdownFenceOpen(line);
if (opening) {
fence = opening;
continue;
}
const match = IMPLEMENT_PULL_MARKER.exec(line);
if (match) matches.push(match);
}

if (markerOccurrences.length > 1 || matches.length > 1) {
violations.push({ kind: 'merge-provenance-marker-ambiguous' });
} else if (markerOccurrences.length !== 1 || matches.length !== 1) {
violations.push({ kind: 'merge-provenance-marker-invalid' });
}

const branch = IMPLEMENT_PULL_BRANCH.exec(headRef);
if (!branch) violations.push({ kind: 'merge-provenance-branch-invalid' });
if (violations.length) return { ok: false, enforced: true, origin: 'merge-continuation', violations };

const markerIssue = matches[0][1];
const markerRun = matches[0][2];
const branchIssue = branch[1];
if (!isNumericId(markerIssue) || !isNumericId(markerRun) || !isNumericId(branchIssue)) {
violations.push({ kind: 'merge-provenance-number-invalid' });
} else if (markerIssue !== branchIssue) {
violations.push({
kind: 'merge-provenance-issue-mismatch',
marker_issue: Number(markerIssue),
branch_issue: Number(branchIssue),
});
}
return {
ok: !violations.length,
enforced: true,
origin: 'merge-continuation',
issue_number: Number(markerIssue),
run_id: Number(markerRun),
violations,
};
}

// gh-aw removes HTML comments in prose, but preserves visible fenced text.
// Accept only this complete, explicit envelope, not markers in arbitrary code.
export function retroActionPullMarker(issueNumber, actionKey) {
Expand Down Expand Up @@ -448,8 +507,11 @@ export function evaluateRetroDispatchOutputs({ items = [], autoImplementEnabled

export function evaluateImplementDispatchInputs({
eventName = '', issueNumber = '', requestOrigin = '', retroActionKey = '',
awContext = '', repository = '', defaultBranch = '',
awContext = '', repository = '', defaultBranch = '', pullRequestBody, pullRequestHeadRef,
} = {}) {
if (eventName === 'pull_request') {
return parseImplementMergeProvenance(pullRequestBody, pullRequestHeadRef);
}
if (eventName !== 'workflow_dispatch') return { ok: true, enforced: false, origin: 'event', violations: [] };
const violations = [];
if (!isNumericId(issueNumber)) violations.push({ kind: 'issue-number-not-numeric' });
Expand Down Expand Up @@ -548,8 +610,36 @@ export async function validateImplementOrigin(env, fetchJson = (route, fields) =
issueNumber: env.SQUAD_IMPLEMENT_ISSUE_NUMBER, requestOrigin: env.SQUAD_IMPLEMENT_REQUEST_ORIGIN,
retroActionKey: env.SQUAD_IMPLEMENT_RETRO_ACTION_KEY, awContext: env.SQUAD_IMPLEMENT_AW_CONTEXT,
repository: env.GITHUB_REPOSITORY, defaultBranch: env.SQUAD_IMPLEMENT_DEFAULT_BRANCH,
pullRequestBody: env.SQUAD_IMPLEMENT_PULL_BODY,
pullRequestHeadRef: env.SQUAD_IMPLEMENT_PULL_HEAD_REF,
});
if (!result.ok || result.origin !== RETRO_ORIGIN) return result;
if (!result.ok) return result;
if (result.origin === 'merge-continuation') {
const violations = [...result.violations];
if (!env.GITHUB_REPOSITORY || env.SQUAD_IMPLEMENT_PULL_HEAD_REPOSITORY !== env.GITHUB_REPOSITORY) {
violations.push({ kind: 'merge-provenance-head-repository-mismatch' });
}
const run = await fetchJson(`repos/${env.GITHUB_REPOSITORY}/actions/runs/${result.run_id}`, {});
if (Number(run?.id) !== result.run_id ||
run.path !== IMPLEMENT_WORKFLOW_PATH ||
run.event !== 'workflow_dispatch' ||
run.status !== 'completed' ||
run.conclusion !== 'success' ||
run.head_branch !== env.SQUAD_IMPLEMENT_DEFAULT_BRANCH ||
Comment on lines +624 to +628
run.repository?.full_name !== env.GITHUB_REPOSITORY ||
run.head_repository?.full_name !== env.GITHUB_REPOSITORY) {
violations.push({ kind: 'merge-provenance-run-untrusted' });
}
const createdAt = Date.parse(env.SQUAD_IMPLEMENT_PULL_CREATED_AT || '');
const runStartedAt = Date.parse(run?.run_started_at || '');
const runCompletedAt = Date.parse(run?.updated_at || '');
if (!Number.isFinite(createdAt) || !Number.isFinite(runStartedAt) || !Number.isFinite(runCompletedAt) ||
createdAt < runStartedAt || createdAt > runCompletedAt) {
violations.push({ kind: 'merge-provenance-run-window-mismatch' });
}
return { ...result, ok: !violations.length, violations };
}
if (result.origin !== RETRO_ORIGIN) return result;
const { violations, caller } = result;
if (env.GITHUB_ACTOR !== BOT || env.GITHUB_REF !== `refs/heads/${env.SQUAD_IMPLEMENT_DEFAULT_BRANCH}`) {
violations.push({ kind: 'retro-origin-platform-mismatch' });
Expand Down Expand Up @@ -603,6 +693,7 @@ export async function enforceImplementSafeOutputs(env = process.env, {
if (items === null) return { ok: false, enforced: true, violations: [{ kind: 'unreadable-agent-output' }] };
const origin = await validateImplementOrigin(env, fetchJson);
if (!origin.ok) return origin;
if (origin.origin === 'merge-continuation') return origin;
if (origin.origin !== RETRO_ORIGIN) return { ...origin, enforced: false, reason: 'not-retro-originated' };
const pulls = evaluateRetroPullRequestItems({ items, issueNumber: origin.issue_number, actionKey: origin.action_key });
const violations = [...pulls.violations];
Expand Down
Loading
Loading