From 91a279bcfc7c0b7f47a572f672080f90cdab410b Mon Sep 17 00:00:00 2001 From: Brady Gaster Date: Mon, 21 Sep 2026 14:57:43 -0700 Subject: [PATCH 1/5] feat(gh-aw): add implementation session provenance Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../implementation-session-provenance.md | 7 + docs/src/content/docs/guide/gh-aw.md | 66 ++++- .../multi-goal-replacement-v1.json | 38 +++ .../implementation-provenance/valid-v1.json | 28 ++ test/gh-aw-implementation-provenance.test.ts | 184 ++++++++++++ test/gh-aw-retro-workflow.test.ts | 8 +- .../implementation-provenance-v1.schema.json | 128 +++++++++ .../squad-implementation-provenance.mjs | 262 ++++++++++++++++++ workflows/shared/squad-retro-provenance.mjs | 7 +- workflows/squad-deps-worker.md | 80 ++++++ workflows/squad-implement-worker.md | 71 +++++ workflows/squad-retro.md | 5 +- workflows/squad.md | 9 +- 13 files changed, 885 insertions(+), 8 deletions(-) create mode 100644 .changeset/implementation-session-provenance.md create mode 100644 test-fixtures/implementation-provenance/multi-goal-replacement-v1.json create mode 100644 test-fixtures/implementation-provenance/valid-v1.json create mode 100644 test/gh-aw-implementation-provenance.test.ts create mode 100644 workflows/shared/implementation-provenance-v1.schema.json create mode 100644 workflows/shared/squad-implementation-provenance.mjs diff --git a/.changeset/implementation-session-provenance.md b/.changeset/implementation-session-provenance.md new file mode 100644 index 000000000..46e79a87b --- /dev/null +++ b/.changeset/implementation-session-provenance.md @@ -0,0 +1,7 @@ +--- +"@bradygaster/squad-cli": patch +"@bradygaster/squad-sdk": patch +--- + +Add a validated implementation provenance payload that binds Squad scheduling +sessions, workflow runs, origin issues, repositories, and pull requests. diff --git a/docs/src/content/docs/guide/gh-aw.md b/docs/src/content/docs/guide/gh-aw.md index dc594f02b..05fd133ac 100644 --- a/docs/src/content/docs/guide/gh-aw.md +++ b/docs/src/content/docs/guide/gh-aw.md @@ -66,13 +66,18 @@ for workflow in squad squad-implement-worker squad-review squad-deps-worker squa done # Verify every local runtime module referenced by those workflows was installed -for runtime_module in squad-cast-validator squad-bootstrap-validator squad-improvement-gate squad-retro-evidence squad-retro-provenance; do +for runtime_module in squad-cast-validator squad-bootstrap-validator squad-improvement-gate squad-retro-evidence squad-retro-provenance squad-implementation-provenance; do test -f ".github/workflows/shared/${runtime_module}.mjs" || { echo "MISSING shared/${runtime_module}.mjs" exit 1 } done +test -f ".github/workflows/shared/implementation-provenance-v1.schema.json" || { + echo "MISSING shared/implementation-provenance-v1.schema.json" + exit 1 +} + # Strict compilation validates gh-aw's source contract; also reject JSON-escaped # operators inside emitted GitHub expressions, which GitHub rejects before jobs start. if grep -nE '\$\{\{[^}]*\\u00(26|3[cCeE])' .github/workflows/*.lock.yml; then @@ -440,6 +445,65 @@ wins: `/squad plan accept scope` is not treated as `/squad plan`. | Governance | `/squad approve-improvement` | Request implementation of an exact retrospective proposal revision | Human write/maintain/admin permission, `Approved-Revision:` hash and exact `Approved-Path:` lines; dispatcher relays nested `issue_number` and `approval_comment_id`, never approval authority | | Governance | `/squad revoke-improvement` | Withdraw a prior `/squad approve-improvement` | Reserved, read-only command available to any actor; emits no output of any kind — the comment itself is the record that later runs re-check | +### Implementation provenance + +Every pull request created by the general implementation worker or dependency +worker carries a validated `Squad implementation provenance:` JSON block using +schema +`https://bradygaster.github.io/squad/schemas/implementation-provenance/v1`. +The safe-output job validates the payload against the workflow inputs and +current run before it creates the pull request. The general worker also keeps +the existing standalone +`` marker unchanged for +backward compatibility. + +The `implementation_session_id` is minted by the dispatching `squad` or +`squad-retro` run as +`squad-implementation-session/v1/{repository-id}/{dispatcher-run-id}`. Treat it +as opaque: + +- **Lifetime:** one scheduling wave. All implementation and dependency workers + dispatched by that run share the identifier. +- **Retries and reruns:** rerunning the same dispatcher run or worker preserves + the identifier; `workflow_run.run_attempt` identifies the concrete rerun. + A new `/squad implement`, retrospective reconciliation, or merge-refill + dispatcher run starts a new session. +- **Multiple pull requests:** several PRs may share one session when a parent + issue dispatches several ready leaf tasks. Session ID is not a PR ID. +- **Replacement pull requests:** `replaces` explicitly lists verified earlier + PRs. A replacement keeps the session only when retried within the same + scheduling wave; a later recovery run has a new session. +- **Multiple goals:** `origin_issue` is the primary scheduling goal. `goals` + lists every explicitly referenced issue and whether the PR closes or merely + relates to it. A PR may have multiple goals, but it has one origin issue. +- **Pull request reference:** `"number": "self"` means the containing PR. This + avoids predicting a number before the safe-output handler creates it while + still making the reference explicit. + +Do not derive a missing session identifier from branch names, actors, +timestamps, workflow run IDs, closing text, or textual similarity. Missing +provenance means the session is unknown. Consumers may continue to use the +legacy implementation marker and closing references as their own explicit +correlation sources, and may label branch correlation as inferred, but those +sources do not manufacture a session ID. + +#### Compatibility and cache behavior + +| Producer evidence | Consumer treatment | +|---|---| +| Valid v1 payload | Authoritative for repository, origin, session, run, PR, goals, and replacements | +| No v1 payload | Compatible legacy record; session remains unknown | +| Malformed, duplicated, partial, or runtime-mismatched v1 payload | Invalid producer evidence; do not silently downgrade it to a valid v1 record | +| Cached valid payload whose source cannot be refreshed | Retain only with an explicit stale/source-unavailable status | +| Fresh source says the payload was removed or changed | Replace the cached source result; do not merge old fields into the new payload | + +Repositories upgrading from an older Squad workflow do not need to rewrite old +pull requests. Recompile and commit the updated dispatcher, implementation +worker, dependency worker, retrospective, shared validator, and schema +together. Direct manual dispatches of either worker must now provide the +required `implementation_session_id`; use a dispatcher run rather than +inventing one from unrelated repository metadata. + ### Where you can use slash commands | Surface | How it works | diff --git a/test-fixtures/implementation-provenance/multi-goal-replacement-v1.json b/test-fixtures/implementation-provenance/multi-goal-replacement-v1.json new file mode 100644 index 000000000..dc48462d2 --- /dev/null +++ b/test-fixtures/implementation-provenance/multi-goal-replacement-v1.json @@ -0,0 +1,38 @@ +{ + "schema": "https://bradygaster.github.io/squad/schemas/implementation-provenance/v1", + "schema_version": "1", + "producer": "squad", + "repository": "octo/example", + "origin_issue": 42, + "implementation_session_id": "squad-implementation-session/v1/12345/67890", + "workflow_run": { + "repository": "octo/example", + "workflow": ".github/workflows/squad-implement-worker.lock.yml", + "run_id": 70002, + "run_attempt": 2, + "event": "workflow_dispatch" + }, + "pull_request": { + "repository": "octo/example", + "number": "self", + "head_ref": "squad/implement-42-replacement" + }, + "goals": [ + { + "repository": "octo/example", + "issue": 42, + "relationship": "closes" + }, + { + "repository": "octo/example", + "issue": 43, + "relationship": "relates" + } + ], + "replaces": [ + { + "repository": "octo/example", + "number": 101 + } + ] +} diff --git a/test-fixtures/implementation-provenance/valid-v1.json b/test-fixtures/implementation-provenance/valid-v1.json new file mode 100644 index 000000000..cdad0bcea --- /dev/null +++ b/test-fixtures/implementation-provenance/valid-v1.json @@ -0,0 +1,28 @@ +{ + "schema": "https://bradygaster.github.io/squad/schemas/implementation-provenance/v1", + "schema_version": "1", + "producer": "squad", + "repository": "octo/example", + "origin_issue": 42, + "implementation_session_id": "squad-implementation-session/v1/12345/67890", + "workflow_run": { + "repository": "octo/example", + "workflow": ".github/workflows/squad-implement-worker.lock.yml", + "run_id": 70001, + "run_attempt": 1, + "event": "workflow_dispatch" + }, + "pull_request": { + "repository": "octo/example", + "number": "self", + "head_ref": "squad/implement-42-example" + }, + "goals": [ + { + "repository": "octo/example", + "issue": 42, + "relationship": "closes" + } + ], + "replaces": [] +} diff --git a/test/gh-aw-implementation-provenance.test.ts b/test/gh-aw-implementation-provenance.test.ts new file mode 100644 index 000000000..c458dfb25 --- /dev/null +++ b/test/gh-aw-implementation-provenance.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + PROVENANCE_LABEL, + PROVENANCE_SCHEMA, + evaluateImplementationProvenanceItems, + extractImplementationProvenance, + implementationSessionId, + validateImplementationProvenance, +} from '../workflows/shared/squad-implementation-provenance.mjs'; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const readJson = (path: string): Record => + JSON.parse(readFileSync(resolve(ROOT, path), 'utf8')) as Record; + +function bodyFor(payload: Record, legacy = true): string { + return [ + 'Closes #42', + '', + PROVENANCE_LABEL, + '```json', + JSON.stringify(payload, null, 2), + '```', + ...(legacy ? ['', ''] : []), + ].join('\n'); +} + +describe('Squad implementation provenance v1', () => { + const fixture = readJson('test-fixtures/implementation-provenance/valid-v1.json'); + const multiGoal = readJson( + 'test-fixtures/implementation-provenance/multi-goal-replacement-v1.json', + ); + const schema = readJson('workflows/shared/implementation-provenance-v1.schema.json'); + const dispatcher = readFileSync(resolve(ROOT, 'workflows/squad.md'), 'utf8'); + const implementWorker = readFileSync( + resolve(ROOT, 'workflows/squad-implement-worker.md'), + 'utf8', + ); + const dependencyWorker = readFileSync( + resolve(ROOT, 'workflows/squad-deps-worker.md'), + 'utf8', + ); + const retro = readFileSync(resolve(ROOT, 'workflows/squad-retro.md'), 'utf8'); + + it('wires the same required session input through every producer path', () => { + for (const worker of [implementWorker, dependencyWorker]) { + expect(worker).toMatch( + /implementation_session_id:\r?\n\s+description:[\s\S]*?\r?\n\s+required: true/, + ); + expect(worker).toContain('shared/squad-implementation-provenance.mjs'); + expect(worker).toContain('shared/implementation-provenance-v1.schema.json'); + expect(worker).toContain('enforceImplementationProvenanceSafeOutputs'); + } + expect(dispatcher).toContain( + 'squad-implementation-session/v1/${{ github.event.repository.id }}/${{ github.run_id }}', + ); + expect(retro).toContain( + 'squad-implementation-session/v1/${{ github.event.repository.id }}/${{ github.run_id }}', + ); + }); + + it('keeps the published schema and fixtures aligned with the runtime validator', () => { + expect(schema.$id).toBe(PROVENANCE_SCHEMA); + expect(schema.required).toEqual(Object.keys(fixture)); + expect(validateImplementationProvenance(fixture)).toEqual([]); + expect(validateImplementationProvenance(multiGoal)).toEqual([]); + }); + + it('mints a stable opaque ID from the scheduling run, not PR text', () => { + expect(implementationSessionId('12345', '67890')).toBe( + 'squad-implementation-session/v1/12345/67890', + ); + expect(implementationSessionId('octo/example', '67890')).toBeNull(); + }); + + it('extracts one top-level payload and treats absence as unknown', () => { + expect(extractImplementationProvenance(bodyFor(fixture))).toEqual(fixture); + expect(extractImplementationProvenance('Closes #42')).toBeNull(); + expect(extractImplementationProvenance([ + '```markdown', + PROVENANCE_LABEL, + '```json', + JSON.stringify(fixture), + '```', + '```', + ].join('\n'))).toBeNull(); + }); + + it('rejects malformed, partial, duplicated, and runtime-mismatched payloads', () => { + expect(extractImplementationProvenance( + `${PROVENANCE_LABEL}\n\`\`\`json\n{"schema_version":"1"}\n`, + )).toBeUndefined(); + expect(extractImplementationProvenance( + `${bodyFor(fixture)}\n\n${PROVENANCE_LABEL}\n\`\`\`json\n${JSON.stringify(fixture)}\n\`\`\``, + )).toBeUndefined(); + + const partial = { ...fixture }; + delete partial.workflow_run; + expect(validateImplementationProvenance(partial)).toEqual([ + { kind: 'implementation-provenance-shape-invalid' }, + ]); + + expect(validateImplementationProvenance(fixture, { + repository: 'other/repository', + sessionId: 'squad-implementation-session/v1/12345/99999', + })).toEqual(expect.arrayContaining([ + { + kind: 'implementation-provenance-runtime-mismatch', + field: 'repository', + }, + { + kind: 'implementation-provenance-runtime-mismatch', + field: 'session', + }, + ])); + }); + + it('validates the containing PR self-reference and preserves the legacy marker', () => { + const item = { + type: 'create_pull_request', + branch: 'squad/implement-42-example', + body: bodyFor(fixture), + }; + expect(evaluateImplementationProvenanceItems({ + items: [item], + repository: 'octo/example', + issueNumber: '42', + sessionId: 'squad-implementation-session/v1/12345/67890', + workflow: '.github/workflows/squad-implement-worker.lock.yml', + runId: 70001, + runAttempt: 1, + namespace: 'implement', + requireLegacyMarker: true, + })).toMatchObject({ ok: true, enforced: true, violations: [] }); + + const copied = { + ...item, + branch: 'squad/implement-42-copied', + }; + expect(evaluateImplementationProvenanceItems({ + items: [copied], + repository: 'octo/example', + issueNumber: '42', + sessionId: 'squad-implementation-session/v1/12345/67890', + workflow: '.github/workflows/squad-implement-worker.lock.yml', + runId: 70001, + runAttempt: 1, + namespace: 'implement', + requireLegacyMarker: true, + }).violations).toContainEqual({ + kind: 'implementation-provenance-runtime-mismatch', + field: 'head-ref', + }); + }); + + it('accepts dependency PR payloads without inventing a legacy marker', () => { + const dependency = structuredClone(fixture); + dependency.workflow_run = { + ...(dependency.workflow_run as Record), + workflow: '.github/workflows/squad-deps-worker.lock.yml', + }; + dependency.pull_request = { + ...(dependency.pull_request as Record), + head_ref: 'squad/deps-42-update', + }; + expect(evaluateImplementationProvenanceItems({ + items: [{ + type: 'create_pull_request', + branch: 'squad/deps-42-update', + body: bodyFor(dependency, false), + }], + repository: 'octo/example', + issueNumber: '42', + sessionId: 'squad-implementation-session/v1/12345/67890', + workflow: '.github/workflows/squad-deps-worker.lock.yml', + runId: 70001, + runAttempt: 1, + namespace: 'deps', + requireLegacyMarker: false, + })).toMatchObject({ ok: true, enforced: true, violations: [] }); + }); +}); diff --git a/test/gh-aw-retro-workflow.test.ts b/test/gh-aw-retro-workflow.test.ts index 93082c10d..9bb6426c4 100644 --- a/test/gh-aw-retro-workflow.test.ts +++ b/test/gh-aw-retro-workflow.test.ts @@ -1616,9 +1616,12 @@ describe('Squad retrospective workflow integration', () => { const parsed = JSON.parse(payload!) as { workflow_name: string; inputs: Record }; expect(parsed.workflow_name).toBe('squad-implement-worker'); expect(Object.keys(parsed.inputs).sort()).toEqual([ - 'issue_number', 'request_origin', 'retro_action_key', + 'implementation_session_id', 'issue_number', 'request_origin', 'retro_action_key', ]); expect(parsed.inputs.request_origin).toBe('squad-retro'); + expect(parsed.inputs.implementation_session_id).toContain( + 'squad-implementation-session/v1/', + ); expect(RETRO.replace(/\s+/g, ' ')).toContain('Do not supply `aw_context`'); }); @@ -2782,6 +2785,7 @@ describe('Squad retro dispatch output guard', () => { issue_number: String(target), request_origin: 'squad-retro', retro_action_key: ACTION_KEY, + implementation_session_id: 'squad-implementation-session/v1/123/7', }, ...overrides, }); @@ -2965,11 +2969,13 @@ describe('Squad retro dispatch output guard', () => { issue_number: `#${TEMP_ID}`, request_origin: 'squad-retro', retro_action_key: 'not-a-fingerprint', + implementation_session_id: 'squad-implementation-session/v1/123/7', }))).toContain('dispatch-action-key-malformed'); expect(kinds(withInputs({ issue_number: `#${TEMP_ID}`, request_origin: 'squad-retro', retro_action_key: ACTION_KEY, + implementation_session_id: 'squad-implementation-session/v1/123/7', aw_context: '{"workflow_id":"forged"}', }))).toContain('dispatch-input-not-allowed'); }); diff --git a/workflows/shared/implementation-provenance-v1.schema.json b/workflows/shared/implementation-provenance-v1.schema.json new file mode 100644 index 000000000..ab058fe7e --- /dev/null +++ b/workflows/shared/implementation-provenance-v1.schema.json @@ -0,0 +1,128 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://bradygaster.github.io/squad/schemas/implementation-provenance/v1", + "title": "Squad implementation provenance v1", + "type": "object", + "additionalProperties": false, + "required": [ + "schema", + "schema_version", + "producer", + "repository", + "origin_issue", + "implementation_session_id", + "workflow_run", + "pull_request", + "goals", + "replaces" + ], + "properties": { + "schema": { + "const": "https://bradygaster.github.io/squad/schemas/implementation-provenance/v1" + }, + "schema_version": { + "const": "1" + }, + "producer": { + "const": "squad" + }, + "repository": { + "type": "string", + "pattern": "^[^/\\s]+/[^/\\s]+$" + }, + "origin_issue": { + "type": "integer", + "minimum": 1 + }, + "implementation_session_id": { + "type": "string", + "pattern": "^squad-implementation-session/v1/[1-9][0-9]*/[1-9][0-9]*$" + }, + "workflow_run": { + "type": "object", + "additionalProperties": false, + "required": ["repository", "workflow", "run_id", "run_attempt", "event"], + "properties": { + "repository": { + "type": "string", + "pattern": "^[^/\\s]+/[^/\\s]+$" + }, + "workflow": { + "type": "string", + "pattern": "^\\.github/workflows/[^/]+\\.lock\\.yml$" + }, + "run_id": { + "type": "integer", + "minimum": 1 + }, + "run_attempt": { + "type": "integer", + "minimum": 1 + }, + "event": { + "const": "workflow_dispatch" + } + } + }, + "pull_request": { + "type": "object", + "additionalProperties": false, + "required": ["repository", "number", "head_ref"], + "properties": { + "repository": { + "type": "string", + "pattern": "^[^/\\s]+/[^/\\s]+$" + }, + "number": { + "const": "self" + }, + "head_ref": { + "type": "string", + "minLength": 1 + } + } + }, + "goals": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["repository", "issue", "relationship"], + "properties": { + "repository": { + "type": "string", + "pattern": "^[^/\\s]+/[^/\\s]+$" + }, + "issue": { + "type": "integer", + "minimum": 1 + }, + "relationship": { + "enum": ["closes", "relates"] + } + } + } + }, + "replaces": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["repository", "number"], + "properties": { + "repository": { + "type": "string", + "pattern": "^[^/\\s]+/[^/\\s]+$" + }, + "number": { + "type": "integer", + "minimum": 1 + } + } + } + } + } +} diff --git a/workflows/shared/squad-implementation-provenance.mjs b/workflows/shared/squad-implementation-provenance.mjs new file mode 100644 index 000000000..6ed6e65b7 --- /dev/null +++ b/workflows/shared/squad-implementation-provenance.mjs @@ -0,0 +1,262 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +export const PROVENANCE_LABEL = 'Squad implementation provenance:'; +export const PROVENANCE_SCHEMA = + 'https://bradygaster.github.io/squad/schemas/implementation-provenance/v1'; +export const SESSION_ID = + /^squad-implementation-session\/v1\/[1-9][0-9]*\/[1-9][0-9]*$/; +export const NUMERIC_ID = /^[1-9][0-9]*$/; + +const TOP_LEVEL_KEYS = Object.freeze([ + 'schema', + 'schema_version', + 'producer', + 'repository', + 'origin_issue', + 'implementation_session_id', + 'workflow_run', + 'pull_request', + 'goals', + 'replaces', +]); +const WORKFLOW_RUN_KEYS = Object.freeze([ + 'repository', + 'workflow', + 'run_id', + 'run_attempt', + 'event', +]); +const PULL_REQUEST_KEYS = Object.freeze(['repository', 'number', 'head_ref']); +const GOAL_KEYS = Object.freeze(['repository', 'issue', 'relationship']); +const REPLACEMENT_KEYS = Object.freeze(['repository', 'number']); + +const normalizeText = value => String(value ?? '').replace(/\r\n/g, '\n'); +const exactKeys = (value, keys) => + value && typeof value === 'object' && !Array.isArray(value) && + Object.keys(value).sort().join('\0') === [...keys].sort().join('\0'); +const numeric = value => Number.isSafeInteger(value) && value > 0; + +export function implementationSessionId(repositoryId, dispatcherRunId) { + const value = `squad-implementation-session/v1/${repositoryId}/${dispatcherRunId}`; + return SESSION_ID.test(value) ? value : null; +} + +export function extractImplementationProvenance(body) { + const lines = normalizeText(body).split('\n'); + const candidates = []; + let fence = null; + let htmlComment = false; + for (let index = 0; index < lines.length; index++) { + const line = lines[index]; + if (fence) { + if (new RegExp(`^ {0,3}\\${fence.char}{${fence.length},}[ \\t]*$`).test(line)) fence = null; + continue; + } + if (htmlComment) { + if (line.includes('-->')) htmlComment = false; + continue; + } + if (line.includes(''); + continue; + } + const opening = /^ {0,3}(`{3,}|~{3,})/.exec(line); + if (opening) { + fence = { char: opening[1][0], length: opening[1].length }; + continue; + } + if (line.replace(/[ \t]+$/, '') !== PROVENANCE_LABEL) continue; + + let cursor = index + 1; + while (cursor < lines.length && /^[ \t]*$/.test(lines[cursor])) cursor++; + if (!/^ {0,3}```json[ \t]*$/.test(lines[cursor] || '')) { + candidates.push({ malformed: true }); + continue; + } + cursor++; + const jsonLines = []; + let closed = false; + for (; cursor < lines.length; cursor++) { + if (/^ {0,3}`{3,}[ \t]*$/.test(lines[cursor])) { + closed = true; + break; + } + jsonLines.push(lines[cursor]); + } + candidates.push(closed ? { json: jsonLines.join('\n') } : { malformed: true }); + index = closed ? cursor : lines.length; + } + if (candidates.length === 0) return null; + if (candidates.length !== 1 || candidates[0].malformed) return undefined; + try { + const value = JSON.parse(candidates[0].json); + return value && typeof value === 'object' && !Array.isArray(value) ? value : undefined; + } catch { + return undefined; + } +} + +export function validateImplementationProvenance(value, expected = {}) { + const violations = []; + if (!exactKeys(value, TOP_LEVEL_KEYS)) { + return [{ kind: 'implementation-provenance-shape-invalid' }]; + } + if (value.schema !== PROVENANCE_SCHEMA || value.schema_version !== '1' || + value.producer !== 'squad') { + violations.push({ kind: 'implementation-provenance-version-invalid' }); + } + if (typeof value.repository !== 'string' || !value.repository.includes('/')) { + violations.push({ kind: 'implementation-provenance-repository-invalid' }); + } + if (!numeric(value.origin_issue)) { + violations.push({ kind: 'implementation-provenance-origin-issue-invalid' }); + } + if (!SESSION_ID.test(String(value.implementation_session_id ?? ''))) { + violations.push({ kind: 'implementation-provenance-session-invalid' }); + } + + if (!exactKeys(value.workflow_run, WORKFLOW_RUN_KEYS) || + typeof value.workflow_run.repository !== 'string' || + typeof value.workflow_run.workflow !== 'string' || + !numeric(value.workflow_run.run_id) || + !numeric(value.workflow_run.run_attempt) || + value.workflow_run.event !== 'workflow_dispatch') { + violations.push({ kind: 'implementation-provenance-workflow-run-invalid' }); + } + if (!exactKeys(value.pull_request, PULL_REQUEST_KEYS) || + typeof value.pull_request.repository !== 'string' || + value.pull_request.number !== 'self' || + typeof value.pull_request.head_ref !== 'string') { + violations.push({ kind: 'implementation-provenance-pull-request-invalid' }); + } + if (!Array.isArray(value.goals) || value.goals.length === 0 || + value.goals.some(goal => !exactKeys(goal, GOAL_KEYS) || + typeof goal.repository !== 'string' || !numeric(goal.issue) || + !['closes', 'relates'].includes(goal.relationship))) { + violations.push({ kind: 'implementation-provenance-goals-invalid' }); + } else { + const goalKeys = value.goals.map(goal => + `${goal.repository}\0${goal.issue}\0${goal.relationship}`); + if (new Set(goalKeys).size !== goalKeys.length || + !value.goals.some(goal => goal.repository === value.repository && + goal.issue === value.origin_issue && goal.relationship === 'closes')) { + violations.push({ kind: 'implementation-provenance-goals-inconsistent' }); + } + } + if (!Array.isArray(value.replaces) || + value.replaces.some(replacement => !exactKeys(replacement, REPLACEMENT_KEYS) || + typeof replacement.repository !== 'string' || !numeric(replacement.number))) { + violations.push({ kind: 'implementation-provenance-replacements-invalid' }); + } + + const comparisons = [ + ['repository', value.repository, expected.repository], + ['origin-issue', value.origin_issue, expected.originIssue], + ['session', value.implementation_session_id, expected.sessionId], + ['workflow-repository', value.workflow_run?.repository, expected.repository], + ['workflow', value.workflow_run?.workflow, expected.workflow], + ['run-id', value.workflow_run?.run_id, expected.runId], + ['run-attempt', value.workflow_run?.run_attempt, expected.runAttempt], + ['pull-repository', value.pull_request?.repository, expected.repository], + ['head-ref', value.pull_request?.head_ref, expected.headRef], + ]; + for (const [field, actual, wanted] of comparisons) { + if (wanted !== undefined && actual !== wanted) { + violations.push({ kind: 'implementation-provenance-runtime-mismatch', field }); + } + } + return violations; +} + +export function evaluateImplementationProvenanceItems({ + items = [], + repository, + issueNumber, + sessionId, + workflow, + runId, + runAttempt, + namespace, + requireLegacyMarker = false, +} = {}) { + const pulls = items.filter(item => item.type === 'create_pull_request'); + const violations = []; + if (pulls.length > 1) violations.push({ kind: 'implementation-provenance-multiple-pulls' }); + for (const pull of pulls) { + const expectedBranch = new RegExp( + `^squad/${namespace}-${issueNumber}-[a-z0-9][a-z0-9-]*$`, + ); + if (!expectedBranch.test(String(pull.branch ?? ''))) { + violations.push({ kind: 'implementation-provenance-branch-invalid' }); + } + const provenance = extractImplementationProvenance(pull.body); + if (provenance === null) { + violations.push({ kind: 'implementation-provenance-missing' }); + } else if (provenance === undefined) { + violations.push({ kind: 'implementation-provenance-malformed' }); + } else { + violations.push(...validateImplementationProvenance(provenance, { + repository, + originIssue: Number(issueNumber), + sessionId, + workflow, + runId: Number(runId), + runAttempt: Number(runAttempt), + headRef: pull.branch, + })); + } + if (requireLegacyMarker) { + const marker = + ``; + const lines = normalizeText(pull.body).split('\n'); + if (lines.filter(line => line === marker).length !== 1 || + (normalizeText(pull.body).match(/$/; 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 DISPATCH_INPUT_KEYS = Object.freeze([ + 'issue_number', + 'request_origin', + 'retro_action_key', + 'implementation_session_id', +]); export const IMPLEMENT_PULL_SCAN_MAX_PAGES = 5; export const ACTION_COMMENT_MAX_PAGES = 2; export const IMPLEMENT_GUARD_API_REQUEST_CEILING = 2 + ACTION_COMMENT_MAX_PAGES + IMPLEMENT_PULL_SCAN_MAX_PAGES; diff --git a/workflows/squad-deps-worker.md b/workflows/squad-deps-worker.md index d719919d2..e48608823 100644 --- a/workflows/squad-deps-worker.md +++ b/workflows/squad-deps-worker.md @@ -13,6 +13,12 @@ on: description: Issue number requesting a dependency change required: true type: string + implementation_session_id: + description: >- + Opaque durable identifier minted by the dispatching Squad run and + shared by every implementation pull request in that scheduling wave. + required: true + type: string aw_context: description: Originating agentic workflow context required: false @@ -35,6 +41,9 @@ network: - node imports: - shared/squad.md +resources: + - shared/squad-implementation-provenance.mjs + - shared/implementation-provenance-v1.schema.json tools: edit: bash: true @@ -42,6 +51,37 @@ tools: mode: gh-proxy toolsets: [default] safe-outputs: + steps: + - name: Checkout trusted base for the implementation provenance guard + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: refs/heads/${{ github.event.repository.default_branch }} + persist-credentials: false + path: .squad-trusted-base + - name: Enforce dependency implementation provenance before any output + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} + SQUAD_IMPLEMENT_ISSUE_NUMBER: ${{ github.event.inputs.issue_number }} + SQUAD_IMPLEMENT_SESSION_ID: ${{ github.event.inputs.implementation_session_id }} + SQUAD_IMPLEMENT_WORKFLOW: .github/workflows/squad-deps-worker.lock.yml + SQUAD_IMPLEMENT_NAMESPACE: deps + SQUAD_IMPLEMENT_REQUIRE_LEGACY_MARKER: "false" + with: + script: | + const nodePath = require('node:path'); + const { pathToFileURL } = require('node:url'); + const trustedRoot = nodePath.join(process.env.GITHUB_WORKSPACE, '.squad-trusted-base'); + const provenance = await import(pathToFileURL(nodePath.join( + trustedRoot, + '.github/workflows/shared/squad-implementation-provenance.mjs', + )).href); + const result = provenance.enforceImplementationProvenanceSafeOutputs(process.env); + if (result.ok) return; + for (const line of provenance.describeImplementationProvenanceViolations( + result.violations, + )) core.error(`refused: ${line}`); + core.setFailed('Squad implementation provenance guard refused this run.'); create-pull-request: title-prefix: "[squad-deps] " labels: [squad] @@ -198,6 +238,46 @@ Use the `create-pull-request` safe-output: - Title: `Update dependencies for #${{ github.event.inputs.issue_number }}: {issue-title}` - Body: summarize the dependency change and validation, including `Closes #${{ github.event.inputs.issue_number }}`. +- Durable provenance: append exactly one top-level + `Squad implementation provenance:` label followed by one fenced `json` block + matching `shared/implementation-provenance-v1.schema.json`: + + ```json + { + "schema": "https://bradygaster.github.io/squad/schemas/implementation-provenance/v1", + "schema_version": "1", + "producer": "squad", + "repository": "${{ github.repository }}", + "origin_issue": ${{ github.event.inputs.issue_number }}, + "implementation_session_id": "${{ github.event.inputs.implementation_session_id }}", + "workflow_run": { + "repository": "${{ github.repository }}", + "workflow": ".github/workflows/squad-deps-worker.lock.yml", + "run_id": ${{ github.run_id }}, + "run_attempt": {current-GITHUB_RUN_ATTEMPT-integer}, + "event": "workflow_dispatch" + }, + "pull_request": { + "repository": "${{ github.repository }}", + "number": "self", + "head_ref": "squad/deps-${{ github.event.inputs.issue_number }}-{short-slug}" + }, + "goals": [ + { + "repository": "${{ github.repository }}", + "issue": ${{ github.event.inputs.issue_number }}, + "relationship": "closes" + } + ], + "replaces": [] + } + ``` + + Read the numeric `GITHUB_RUN_ATTEMPT` environment variable and replace the + run-attempt placeholder with that integer. Use the actual selected branch as + `head_ref`. Copy `implementation_session_id` exactly; never derive it from + the branch, actor, timestamps, or text. Add only explicit goals and verified + replacement pull request references. - Files: include only files required for this issue. If the repository already satisfies the issue, comment with evidence and do diff --git a/workflows/squad-implement-worker.md b/workflows/squad-implement-worker.md index 08c29b20b..caff5fa57 100644 --- a/workflows/squad-implement-worker.md +++ b/workflows/squad-implement-worker.md @@ -11,6 +11,12 @@ on: description: Issue number to implement required: true type: string + implementation_session_id: + description: >- + Opaque durable identifier minted by the dispatching Squad run and + shared by every implementation pull request in that scheduling wave. + required: true + type: string request_origin: description: >- Origin of an automated dispatch. Omitted for /squad implement and for @@ -62,6 +68,8 @@ imports: - shared/squad.md resources: - shared/squad-retro-provenance.mjs + - shared/squad-implementation-provenance.mjs + - shared/implementation-provenance-v1.schema.json tools: edit: bash: true @@ -89,6 +97,7 @@ pre-agent-steps: GITHUB_TOKEN: ${{ github.token }} SQUAD_IMPLEMENT_EVENT_NAME: ${{ github.event_name }} SQUAD_IMPLEMENT_ISSUE_NUMBER: ${{ github.event.inputs.issue_number }} + SQUAD_IMPLEMENT_SESSION_ID: ${{ github.event.inputs.implementation_session_id }} SQUAD_IMPLEMENT_REQUEST_ORIGIN: ${{ github.event.inputs.request_origin }} SQUAD_IMPLEMENT_RETRO_ACTION_KEY: ${{ github.event.inputs.retro_action_key }} SQUAD_IMPLEMENT_AW_CONTEXT: ${{ github.event.inputs.aw_context }} @@ -149,6 +158,10 @@ safe-outputs: GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} SQUAD_IMPLEMENT_EVENT_NAME: ${{ github.event_name }} SQUAD_IMPLEMENT_ISSUE_NUMBER: ${{ github.event.inputs.issue_number }} + SQUAD_IMPLEMENT_SESSION_ID: ${{ github.event.inputs.implementation_session_id }} + SQUAD_IMPLEMENT_WORKFLOW: .github/workflows/squad-implement-worker.lock.yml + SQUAD_IMPLEMENT_NAMESPACE: implement + SQUAD_IMPLEMENT_REQUIRE_LEGACY_MARKER: "true" SQUAD_IMPLEMENT_REQUEST_ORIGIN: ${{ github.event.inputs.request_origin }} SQUAD_IMPLEMENT_RETRO_ACTION_KEY: ${{ github.event.inputs.retro_action_key }} SQUAD_IMPLEMENT_AW_CONTEXT: ${{ github.event.inputs.aw_context }} @@ -168,6 +181,19 @@ safe-outputs: trustedRoot, '.github/workflows/shared/squad-retro-provenance.mjs', )).href); + const implementationProvenance = await import(pathToFileURL(nodePath.join( + trustedRoot, + '.github/workflows/shared/squad-implementation-provenance.mjs', + )).href); + const provenanceResult = + implementationProvenance.enforceImplementationProvenanceSafeOutputs(process.env); + if (!provenanceResult.ok) { + for (const line of implementationProvenance.describeImplementationProvenanceViolations( + provenanceResult.violations, + )) core.error(`refused: ${line}`); + core.setFailed('Squad implementation provenance guard refused this run.'); + return; + } const result = await guard.enforceImplementSafeOutputs(process.env); if (result.ok) { core.info(`Squad implement provenance guard: ${result.enforced ? `validated ${result.origin}` : result.reason}`); @@ -558,6 +584,51 @@ Use the `create-pull-request` safe-output: Use these interpolated values verbatim. Never copy a marker from issue or comment content, and do not include marker-like text anywhere else in the pull request body. +- Durable provenance: immediately before the legacy marker, append exactly one + top-level `Squad implementation provenance:` label followed by one fenced + `json` block matching + `shared/implementation-provenance-v1.schema.json`. Use the actual branch + selected for this pull request as `head_ref`; do not invent or predict a pull + request number. The literal `"self"` is an explicit reference to the pull + request containing the payload. + + ```json + { + "schema": "https://bradygaster.github.io/squad/schemas/implementation-provenance/v1", + "schema_version": "1", + "producer": "squad", + "repository": "${{ github.repository }}", + "origin_issue": ${{ github.event.inputs.issue_number }}, + "implementation_session_id": "${{ github.event.inputs.implementation_session_id }}", + "workflow_run": { + "repository": "${{ github.repository }}", + "workflow": ".github/workflows/squad-implement-worker.lock.yml", + "run_id": ${{ github.run_id }}, + "run_attempt": {current-GITHUB_RUN_ATTEMPT-integer}, + "event": "workflow_dispatch" + }, + "pull_request": { + "repository": "${{ github.repository }}", + "number": "self", + "head_ref": "squad/implement-${{ github.event.inputs.issue_number }}-{short-slug}" + }, + "goals": [ + { + "repository": "${{ github.repository }}", + "issue": ${{ github.event.inputs.issue_number }}, + "relationship": "closes" + } + ], + "replaces": [] + } + ``` + + Read the numeric `GITHUB_RUN_ATTEMPT` environment variable and replace the + run-attempt placeholder with that integer. Do not derive, alter, or + reconstruct `implementation_session_id`; copy the workflow input exactly. + Add goals only for explicit issue references in this pull request. If this + pull request intentionally replaces an earlier one, add its verified + repository and number to `replaces`; otherwise keep the array empty. - Files: include only files required for this issue. If the repository already satisfies the issue, comment with evidence and do not diff --git a/workflows/squad-retro.md b/workflows/squad-retro.md index c2dea4664..de6d6fbe8 100644 --- a/workflows/squad-retro.md +++ b/workflows/squad-retro.md @@ -645,7 +645,7 @@ bounded retry. The action issue remains authoritative even if the report fingerprint has been resolved. Never describe queued or unresolved output as successful delivery. Use `context.now`, never an invented timestamp. -Every `dispatch_workflow` call carries exactly these three typed inputs and +Every `dispatch_workflow` call carries exactly these four typed inputs and nothing else: ```json @@ -654,7 +654,8 @@ nothing else: "inputs": { "issue_number": "{issue_number or #temporary_id}", "request_origin": "squad-retro", - "retro_action_key": "{that action's exact Action-Key value}" + "retro_action_key": "{that action's exact Action-Key value}", + "implementation_session_id": "squad-implementation-session/v1/${{ github.event.repository.id }}/${{ github.run_id }}" } } ``` diff --git a/workflows/squad.md b/workflows/squad.md index b8ec4e1c2..87ed0e97f 100644 --- a/workflows/squad.md +++ b/workflows/squad.md @@ -1375,7 +1375,8 @@ slots. 6. Classify every leaf with the **Dependency Route Decision** below. 7. If the target has no open descendants (it is itself a leaf), call exactly the workflow-specific tool selected by that decision with `issue_number` set to - the target issue number. + the target issue number and `implementation_session_id` set to + `squad-implementation-session/v1/${{ github.event.repository.id }}/${{ github.run_id }}`. 8. Post a comment linking the dispatched worker run and naming the selected worker. The worker performs dependency, duplicate pull request, routing, implementation, and validation checks. @@ -1435,12 +1436,14 @@ exactly one selected workflow-specific safe-output tool with this input: ```json { - "issue_number": "{leaf-issue-number}" + "issue_number": "{leaf-issue-number}", + "implementation_session_id": "squad-implementation-session/v1/${{ github.event.repository.id }}/${{ github.run_id }}" } ``` Never call the generic `dispatch_workflow` tool. Never emit a dispatch without a -non-empty numeric `issue_number`. Emit exactly one workflow-specific dispatch +non-empty numeric `issue_number` and the exact interpolated session identifier +shown above. Emit exactly one workflow-specific dispatch per selected leaf task, and only report a leaf task as dispatched after the tool returns success. Never call both workers for one issue. If the dependency config guard denies a selected dependency task, leave that slot unused and report the From a340a706514add5d2c07cbada72b69c53c445bfe Mon Sep 17 00:00:00 2001 From: Brady Gaster Date: Mon, 21 Sep 2026 15:17:12 -0700 Subject: [PATCH 2/5] fix(gh-aw): harden implementation provenance Closes #2065 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../implementation-session-provenance.md | 7 - docs/src/content/docs/guide/gh-aw.md | 41 +- .../multi-goal-replacement-v1.json | 8 +- .../implementation-provenance/valid-v1.json | 8 +- test/gh-aw-implement-workflow.test.ts | 14 +- test/gh-aw-implementation-provenance.test.ts | 518 ++++++++++++---- test/gh-aw-quality.test.ts | 4 +- test/gh-aw-retro-workflow.test.ts | 18 +- .../implementation-provenance-v1.schema.json | 70 +-- .../squad-implementation-provenance.mjs | 568 ++++++++++++++++-- workflows/shared/squad-retro-provenance.mjs | 31 +- workflows/squad-deps-worker.md | 157 +++-- workflows/squad-implement-worker.md | 170 ++++-- workflows/squad-retro.md | 30 +- workflows/squad.md | 32 +- 15 files changed, 1281 insertions(+), 395 deletions(-) delete mode 100644 .changeset/implementation-session-provenance.md diff --git a/.changeset/implementation-session-provenance.md b/.changeset/implementation-session-provenance.md deleted file mode 100644 index 46e79a87b..000000000 --- a/.changeset/implementation-session-provenance.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -"@bradygaster/squad-cli": patch -"@bradygaster/squad-sdk": patch ---- - -Add a validated implementation provenance payload that binds Squad scheduling -sessions, workflow runs, origin issues, repositories, and pull requests. diff --git a/docs/src/content/docs/guide/gh-aw.md b/docs/src/content/docs/guide/gh-aw.md index 05fd133ac..62e56f614 100644 --- a/docs/src/content/docs/guide/gh-aw.md +++ b/docs/src/content/docs/guide/gh-aw.md @@ -448,17 +448,24 @@ wins: `/squad plan accept scope` is not treated as `/squad plan`. ### Implementation provenance Every pull request created by the general implementation worker or dependency -worker carries a validated `Squad implementation provenance:` JSON block using -schema +worker receives a `Squad implementation provenance:` comment using schema `https://bradygaster.github.io/squad/schemas/implementation-provenance/v1`. -The safe-output job validates the payload against the workflow inputs and -current run before it creates the pull request. The general worker also keeps -the existing standalone +The pre-creation gate validates one strict provenance-record request and all +replacement references. After the PR handler returns, a compiled safe-output +script resolves the handler's temporary ID to the actual PR number, re-fetches +the created PR and replacement evidence, constructs the authoritative payload +from GitHub runtime context, and posts the comment as `github-actions[bot]`. +Consumers and merge-continuation/replacement checks accept provenance only +from that bot-authored boundary. No `"self"` or unresolved temporary identifier +is durable evidence. The general worker also keeps the existing standalone `` marker unchanged for backward compatibility. -The `implementation_session_id` is minted by the dispatching `squad` or -`squad-retro` run as +The dispatcher posts a bot-authored receipt before each worker dispatch. The +worker verifies that receipt, the dispatcher Actions run, repository, run +attempt, origin issue, selected worker, and deterministic session identifier +before the agent starts. The `implementation_session_id` is minted by the +dispatching `squad` or `squad-retro` run as `squad-implementation-session/v1/{repository-id}/{dispatcher-run-id}`. Treat it as opaque: @@ -471,17 +478,19 @@ as opaque: - **Multiple pull requests:** several PRs may share one session when a parent issue dispatches several ready leaf tasks. Session ID is not a PR ID. - **Replacement pull requests:** `replaces` explicitly lists verified earlier - PRs. A replacement keeps the session only when retried within the same - scheduling wave; a later recovery run has a new session. + PRs. Each referenced PR must exist in the same repository and carry one valid + provenance comment for the same origin issue and session. Duplicate, + malformed, nonexistent, or unrelated references fail closed. - **Multiple goals:** `origin_issue` is the primary scheduling goal. `goals` lists every explicitly referenced issue and whether the PR closes or merely relates to it. A PR may have multiple goals, but it has one origin issue. -- **Pull request reference:** `"number": "self"` means the containing PR. This - avoids predicting a number before the safe-output handler creates it while - still making the reference explicit. +- **Pull request reference:** `pull_request.number` is always the actual + positive integer returned by the PR handler. Do not derive a missing session identifier from branch names, actors, -timestamps, workflow run IDs, closing text, or textual similarity. Missing +timestamps, closing text, or textual similarity. A worker started directly by +a human cannot mint an identity; it must have the exact bot-authored dispatcher +receipt and matching Actions run. Missing provenance means the session is unknown. Consumers may continue to use the legacy implementation marker and closing references as their own explicit correlation sources, and may label branch correlation as inferred, but those @@ -500,9 +509,9 @@ sources do not manufacture a session ID. Repositories upgrading from an older Squad workflow do not need to rewrite old pull requests. Recompile and commit the updated dispatcher, implementation worker, dependency worker, retrospective, shared validator, and schema -together. Direct manual dispatches of either worker must now provide the -required `implementation_session_id`; use a dispatcher run rather than -inventing one from unrelated repository metadata. +together. Direct manual dispatches of either worker fail closed; use +`/squad implement` or the retrospective relay so the dispatcher can create the +authoritative receipt and bound session inputs. ### Where you can use slash commands diff --git a/test-fixtures/implementation-provenance/multi-goal-replacement-v1.json b/test-fixtures/implementation-provenance/multi-goal-replacement-v1.json index dc48462d2..f62cbd062 100644 --- a/test-fixtures/implementation-provenance/multi-goal-replacement-v1.json +++ b/test-fixtures/implementation-provenance/multi-goal-replacement-v1.json @@ -5,6 +5,12 @@ "repository": "octo/example", "origin_issue": 42, "implementation_session_id": "squad-implementation-session/v1/12345/67890", + "session_origin": { + "repository": "octo/example", + "workflow": ".github/workflows/squad.lock.yml", + "run_id": 67890, + "run_attempt": 1 + }, "workflow_run": { "repository": "octo/example", "workflow": ".github/workflows/squad-implement-worker.lock.yml", @@ -14,7 +20,7 @@ }, "pull_request": { "repository": "octo/example", - "number": "self", + "number": 102, "head_ref": "squad/implement-42-replacement" }, "goals": [ diff --git a/test-fixtures/implementation-provenance/valid-v1.json b/test-fixtures/implementation-provenance/valid-v1.json index cdad0bcea..241f00f8a 100644 --- a/test-fixtures/implementation-provenance/valid-v1.json +++ b/test-fixtures/implementation-provenance/valid-v1.json @@ -5,6 +5,12 @@ "repository": "octo/example", "origin_issue": 42, "implementation_session_id": "squad-implementation-session/v1/12345/67890", + "session_origin": { + "repository": "octo/example", + "workflow": ".github/workflows/squad.lock.yml", + "run_id": 67890, + "run_attempt": 1 + }, "workflow_run": { "repository": "octo/example", "workflow": ".github/workflows/squad-implement-worker.lock.yml", @@ -14,7 +20,7 @@ }, "pull_request": { "repository": "octo/example", - "number": "self", + "number": 101, "head_ref": "squad/implement-42-example" }, "goals": [ diff --git a/test/gh-aw-implement-workflow.test.ts b/test/gh-aw-implement-workflow.test.ts index 65852d015..0c41b4a65 100644 --- a/test/gh-aw-implement-workflow.test.ts +++ b/test/gh-aw-implement-workflow.test.ts @@ -867,7 +867,7 @@ 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('Checkout executing workflow commit 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'); @@ -886,7 +886,7 @@ describe('gh-aw implement worker: retro-origin provenance enforcement', () => { 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 preCheckout = lock.indexOf('name: Checkout executing workflow commit 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); @@ -896,7 +896,7 @@ describe('gh-aw implement worker: retro-origin provenance enforcement', () => { 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('ref: ${{ github.workflow_sha }}'); expect(preAgentContract).toContain('persist-credentials: false'); expect(preAgentContract).toContain('SQUAD_IMPLEMENT_PULL_BODY'); expect(preAgentContract).toContain('SQUAD_IMPLEMENT_PULL_HEAD_REF'); @@ -918,7 +918,7 @@ describe('gh-aw implement worker: retro-origin provenance enforcement', () => { it('detects mutations that remove either merge-provenance boundary', () => { const required = [ - 'Checkout trusted base for the pre-agent provenance guard', + 'Checkout executing workflow commit for the pre-agent provenance guard', 'SQUAD_IMPLEMENT_PULL_BODY', 'SQUAD_IMPLEMENT_PULL_HEAD_REF', 'SQUAD_IMPLEMENT_PULL_HEAD_REPOSITORY', @@ -954,10 +954,10 @@ describe('gh-aw implement worker: retro-origin provenance enforcement', () => { // never processed), and the `pull_request: closed` continuation would have // imported enforcement code from `refs/pull/N/merge`. // ------------------------------------------------------------------------- - it('compiles an UNCONDITIONAL default-branch checkout ahead of the guard, whatever the output shape', () => { + it('compiles an UNCONDITIONAL immutable workflow checkout ahead of the guard, whatever the output shape', () => { const job = safeOutputsJob(compiledWorkerLock()); - const trustedIndex = job.indexOf('name: Checkout trusted base for the provenance guard'); + const trustedIndex = job.indexOf('name: Checkout executing workflow commit for the provenance guard'); const guardIndex = job.indexOf('name: Enforce implement provenance before any output'); const processIndex = job.indexOf('name: Process Safe Outputs'); expect(trustedIndex).toBeGreaterThan(-1); @@ -969,7 +969,7 @@ describe('gh-aw implement worker: retro-origin provenance enforcement', () => { expect(trustedStep).toContain( 'uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1', ); - expect(trustedStep).toContain('ref: refs/heads/${{ github.event.repository.default_branch }}'); + expect(trustedStep).toContain('ref: ${{ github.workflow_sha }}'); expect(trustedStep).toContain('persist-credentials: false'); expect(trustedStep).toContain('path: .squad-trusted-base'); // Nothing may make the trusted checkout, or the guard it feeds, skippable: diff --git a/test/gh-aw-implementation-provenance.test.ts b/test/gh-aw-implementation-provenance.test.ts index c458dfb25..dbd323a54 100644 --- a/test/gh-aw-implementation-provenance.test.ts +++ b/test/gh-aw-implementation-provenance.test.ts @@ -1,30 +1,122 @@ -import { describe, expect, it } from 'vitest'; -import { readFileSync } from 'node:fs'; +import { + cpSync, + mkdtempSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { execFileSync } from 'node:child_process'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { afterAll, describe, expect, it } from 'vitest'; import { PROVENANCE_LABEL, PROVENANCE_SCHEMA, + RECORD_TYPE, + emitImplementationProvenanceComment, evaluateImplementationProvenanceItems, extractImplementationProvenance, + implementationDispatchReceipt, implementationSessionId, validateImplementationProvenance, + validateWorkerIdentity, + verifyReplacementEvidence, } from '../workflows/shared/squad-implementation-provenance.mjs'; const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); -const readJson = (path: string): Record => - JSON.parse(readFileSync(resolve(ROOT, path), 'utf8')) as Record; - -function bodyFor(payload: Record, legacy = true): string { - return [ - 'Closes #42', - '', - PROVENANCE_LABEL, - '```json', - JSON.stringify(payload, null, 2), - '```', - ...(legacy ? ['', ''] : []), - ].join('\n'); +const workspaces: string[] = []; +const read = (path: string) => readFileSync(resolve(ROOT, path), 'utf8'); +const readJson = (path: string): Record => JSON.parse(read(path)); + +function scratch(): string { + const path = mkdtempSync(resolve(ROOT, '.implementation-provenance-')); + workspaces.push(path); + return path; +} + +afterAll(() => { + for (const path of workspaces) rmSync(path, { recursive: true, force: true }); +}); + +function bodyFor(payload: Record): string { + return `${PROVENANCE_LABEL}\n\`\`\`json\n${JSON.stringify(payload, null, 2)}\n\`\`\``; +} + +function env(overrides: Record = {}): Record { + return { + GITHUB_REPOSITORY: 'octo/example', + GITHUB_REPOSITORY_ID: '12345', + GITHUB_EVENT_NAME: 'workflow_dispatch', + GITHUB_ACTOR: 'github-actions[bot]', + GITHUB_RUN_ID: '70001', + GITHUB_RUN_ATTEMPT: '1', + SQUAD_IMPLEMENT_WORKER: 'squad-implement-worker', + SQUAD_IMPLEMENT_ISSUE_NUMBER: '42', + SQUAD_IMPLEMENT_SESSION_ID: 'squad-implementation-session/v1/12345/67890', + SQUAD_IMPLEMENT_DISPATCHER_WORKFLOW: '.github/workflows/squad.lock.yml', + SQUAD_IMPLEMENT_DISPATCHER_RUN_ID: '67890', + SQUAD_IMPLEMENT_DISPATCHER_RUN_ATTEMPT: '1', + SQUAD_IMPLEMENT_WORKFLOW: '.github/workflows/squad-implement-worker.lock.yml', + SQUAD_IMPLEMENT_NAMESPACE: 'implement', + SQUAD_IMPLEMENT_REQUIRE_LEGACY_MARKER: 'true', + ...overrides, + }; +} + +function receiptComment() { + return { + user: { login: 'github-actions[bot]', type: 'Bot' }, + body: implementationDispatchReceipt({ + repository: 'octo/example', + issueNumber: 42, + worker: 'squad-implement-worker', + sessionId: 'squad-implementation-session/v1/12345/67890', + workflow: '.github/workflows/squad.lock.yml', + runId: 67890, + runAttempt: 1, + }), + }; +} + +function identityFetch(route: string) { + if (route.endsWith('/actions/runs/67890')) { + return { + id: 67890, + run_attempt: 1, + path: '.github/workflows/squad.lock.yml@refs/heads/dev', + repository: { full_name: 'octo/example' }, + }; + } + if (route.endsWith('/issues/42/comments')) return [receiptComment()]; + throw new Error(`Unexpected route ${route}`); +} + +function pullItem(body = [ + 'Closes #42', + '', + '', +].join('\n')) { + return { + type: 'create_pull_request', + temporary_id: 'aw_impl42', + branch: 'squad/implement-42-example', + body, + }; +} + +function recordItem(overrides: Record = {}) { + return { + type: RECORD_TYPE, + pull_request: 'aw_impl42', + goals_json: JSON.stringify([{ + repository: 'octo/example', + issue: 42, + relationship: 'closes', + }]), + replaces_json: '[]', + ...overrides, + }; } describe('Squad implementation provenance v1', () => { @@ -33,152 +125,314 @@ describe('Squad implementation provenance v1', () => { 'test-fixtures/implementation-provenance/multi-goal-replacement-v1.json', ); const schema = readJson('workflows/shared/implementation-provenance-v1.schema.json'); - const dispatcher = readFileSync(resolve(ROOT, 'workflows/squad.md'), 'utf8'); - const implementWorker = readFileSync( - resolve(ROOT, 'workflows/squad-implement-worker.md'), - 'utf8', - ); - const dependencyWorker = readFileSync( - resolve(ROOT, 'workflows/squad-deps-worker.md'), - 'utf8', - ); - const retro = readFileSync(resolve(ROOT, 'workflows/squad-retro.md'), 'utf8'); - - it('wires the same required session input through every producer path', () => { - for (const worker of [implementWorker, dependencyWorker]) { - expect(worker).toMatch( - /implementation_session_id:\r?\n\s+description:[\s\S]*?\r?\n\s+required: true/, - ); - expect(worker).toContain('shared/squad-implementation-provenance.mjs'); - expect(worker).toContain('shared/implementation-provenance-v1.schema.json'); - expect(worker).toContain('enforceImplementationProvenanceSafeOutputs'); - } - expect(dispatcher).toContain( - 'squad-implementation-session/v1/${{ github.event.repository.id }}/${{ github.run_id }}', - ); - expect(retro).toContain( - 'squad-implementation-session/v1/${{ github.event.repository.id }}/${{ github.run_id }}', - ); - }); + const implementWorker = read('workflows/squad-implement-worker.md'); + const dependencyWorker = read('workflows/squad-deps-worker.md'); + const dispatcher = read('workflows/squad.md'); - it('keeps the published schema and fixtures aligned with the runtime validator', () => { + it('publishes only actual PR numbers and rejects unresolved sentinels', () => { expect(schema.$id).toBe(PROVENANCE_SCHEMA); - expect(schema.required).toEqual(Object.keys(fixture)); + expect(schema.properties.pull_request.properties.number).toEqual({ + type: 'integer', + minimum: 1, + }); expect(validateImplementationProvenance(fixture)).toEqual([]); expect(validateImplementationProvenance(multiGoal)).toEqual([]); - }); - it('mints a stable opaque ID from the scheduling run, not PR text', () => { - expect(implementationSessionId('12345', '67890')).toBe( - 'squad-implementation-session/v1/12345/67890', - ); - expect(implementationSessionId('octo/example', '67890')).toBeNull(); - }); - - it('extracts one top-level payload and treats absence as unknown', () => { - expect(extractImplementationProvenance(bodyFor(fixture))).toEqual(fixture); - expect(extractImplementationProvenance('Closes #42')).toBeNull(); - expect(extractImplementationProvenance([ - '```markdown', - PROVENANCE_LABEL, - '```json', - JSON.stringify(fixture), - '```', - '```', - ].join('\n'))).toBeNull(); + const unresolved = structuredClone(fixture); + unresolved.pull_request.number = 'self'; + expect(validateImplementationProvenance(unresolved)).toEqual(expect.arrayContaining([ + { kind: 'implementation-provenance-unresolved-sentinel' }, + { kind: 'implementation-provenance-pull-request-invalid' }, + ])); }); - it('rejects malformed, partial, duplicated, and runtime-mismatched payloads', () => { - expect(extractImplementationProvenance( - `${PROVENANCE_LABEL}\n\`\`\`json\n{"schema_version":"1"}\n`, - )).toBeUndefined(); - expect(extractImplementationProvenance( - `${bodyFor(fixture)}\n\n${PROVENANCE_LABEL}\n\`\`\`json\n${JSON.stringify(fixture)}\n\`\`\``, - )).toBeUndefined(); - - const partial = { ...fixture }; - delete partial.workflow_run; + it('rejects unknown and nested malformed fields, duplicates, and partial payloads', () => { + expect(validateImplementationProvenance({ ...fixture, unknown: true })).toEqual([ + { kind: 'implementation-provenance-shape-invalid' }, + ]); + const nested = structuredClone(fixture); + nested.workflow_run.unknown = true; + expect(validateImplementationProvenance(nested)).toContainEqual({ + kind: 'implementation-provenance-workflow-run-invalid', + }); + const partial = structuredClone(fixture); + delete partial.session_origin; expect(validateImplementationProvenance(partial)).toEqual([ { kind: 'implementation-provenance-shape-invalid' }, ]); - - expect(validateImplementationProvenance(fixture, { - repository: 'other/repository', - sessionId: 'squad-implementation-session/v1/12345/99999', - })).toEqual(expect.arrayContaining([ - { - kind: 'implementation-provenance-runtime-mismatch', - field: 'repository', - }, - { - kind: 'implementation-provenance-runtime-mismatch', - field: 'session', - }, - ])); + const duplicate = structuredClone(fixture); + duplicate.replaces = [ + { repository: 'octo/example', number: 99 }, + { repository: 'octo/example', number: 99 }, + ]; + expect(validateImplementationProvenance(duplicate)).toContainEqual({ + kind: 'implementation-provenance-replacements-duplicate', + }); + expect(extractImplementationProvenance( + `${bodyFor(fixture)}\n\n${bodyFor(fixture)}`, + )).toBeUndefined(); }); - it('validates the containing PR self-reference and preserves the legacy marker', () => { - const item = { - type: 'create_pull_request', - branch: 'squad/implement-42-example', - body: bodyFor(fixture), - }; + it('requires one strict post-create record for every PR before creation', () => { + const valid = evaluateImplementationProvenanceItems({ + items: [pullItem(), recordItem()], + repository: 'octo/example', + issueNumber: 42, + namespace: 'implement', + runId: 70001, + requireLegacyMarker: true, + }); + expect(valid).toMatchObject({ ok: true, enforced: true, violations: [] }); + expect(evaluateImplementationProvenanceItems({ - items: [item], + items: [pullItem()], repository: 'octo/example', - issueNumber: '42', - sessionId: 'squad-implementation-session/v1/12345/67890', - workflow: '.github/workflows/squad-implement-worker.lock.yml', + issueNumber: 42, + namespace: 'implement', runId: 70001, - runAttempt: 1, + requireLegacyMarker: true, + }).ok).toBe(false); + expect(evaluateImplementationProvenanceItems({ + items: [pullItem(), recordItem({ nested: true })], + repository: 'octo/example', + issueNumber: 42, namespace: 'implement', + runId: 70001, requireLegacyMarker: true, - })).toMatchObject({ ok: true, enforced: true, violations: [] }); - - const copied = { - ...item, - branch: 'squad/implement-42-copied', - }; + }).violations).toContainEqual({ + kind: 'implementation-provenance-record-invalid', + }); expect(evaluateImplementationProvenanceItems({ - items: [copied], + items: [recordItem(), pullItem()], repository: 'octo/example', - issueNumber: '42', - sessionId: 'squad-implementation-session/v1/12345/67890', - workflow: '.github/workflows/squad-implement-worker.lock.yml', + issueNumber: 42, + namespace: 'implement', runId: 70001, - runAttempt: 1, + requireLegacyMarker: true, + }).violations).toContainEqual({ + kind: 'implementation-provenance-record-order-invalid', + }); + expect(evaluateImplementationProvenanceItems({ + items: [pullItem(`${bodyFor(fixture)}\n`), recordItem()], + repository: 'octo/example', + issueNumber: 42, namespace: 'implement', + runId: 70001, requireLegacyMarker: true, }).violations).toContainEqual({ - kind: 'implementation-provenance-runtime-mismatch', - field: 'head-ref', + kind: 'implementation-provenance-premature-durable-evidence', + }); + }); + + it('binds identity to the bot dispatcher run and exact durable receipt', async () => { + expect(implementationSessionId('12345', '67890')).toBe( + 'squad-implementation-session/v1/12345/67890', + ); + await expect(validateWorkerIdentity(env(), { + fetchJson: async route => identityFetch(route), + })).resolves.toMatchObject({ + ok: true, + repository: 'octo/example', + issueNumber: 42, + dispatcherRunId: 67890, + }); + + await expect(validateWorkerIdentity(env({ GITHUB_ACTOR: 'octocat' }), { + fetchJson: async route => identityFetch(route), + })).resolves.toMatchObject({ + ok: false, + violations: [{ kind: 'implementation-provenance-dispatch-identity-invalid' }], + }); + await expect(validateWorkerIdentity(env({ + SQUAD_IMPLEMENT_SESSION_ID: 'squad-implementation-session/v1/12345/99999', + }), { + fetchJson: async route => identityFetch(route), + })).resolves.toMatchObject({ ok: false }); + await expect(validateWorkerIdentity(env(), { + fetchJson: async route => route.endsWith('/comments') ? [] : identityFetch(route), + })).resolves.toMatchObject({ + ok: false, + violations: [{ kind: 'implementation-provenance-dispatch-receipt-unproven' }], + }); + }); + + it('accepts merge continuation evidence only from the trusted bot boundary', async () => { + const continuationEnv = env({ + GITHUB_EVENT_NAME: 'pull_request', + SQUAD_IMPLEMENT_PULL_MERGED: 'true', + SQUAD_IMPLEMENT_PULL_BASE_REF: 'dev', + SQUAD_IMPLEMENT_DEFAULT_BRANCH: 'dev', + SQUAD_IMPLEMENT_PULL_NUMBER: '101', + SQUAD_IMPLEMENT_PULL_HEAD_REF: 'squad/implement-42-example', + }); + await expect(validateWorkerIdentity(continuationEnv, { + fetchJson: async () => [{ user: { login: 'octocat' }, body: bodyFor(fixture) }], + })).resolves.toMatchObject({ + ok: false, + violations: [{ kind: 'implementation-provenance-continuation-evidence-invalid' }], + }); + await expect(validateWorkerIdentity(continuationEnv, { + fetchJson: async () => [{ + user: { login: 'github-actions[bot]' }, + body: bodyFor(fixture), + }], + })).resolves.toMatchObject({ + ok: true, + origin: 'merge-continuation', + issueNumber: 42, + sessionId: 'squad-implementation-session/v1/12345/67890', }); }); - it('accepts dependency PR payloads without inventing a legacy marker', () => { - const dependency = structuredClone(fixture); - dependency.workflow_run = { - ...(dependency.workflow_run as Record), - workflow: '.github/workflows/squad-deps-worker.lock.yml', + it('fails malformed, missing, duplicate, and unrelated replacements closed', async () => { + const replacement = { + ...fixture, + pull_request: { + repository: 'octo/example', + number: 101, + head_ref: 'squad/implement-42-example', + }, }; - dependency.pull_request = { - ...(dependency.pull_request as Record), - head_ref: 'squad/deps-42-update', + const fetchJson = async (route: string) => { + if (route.endsWith('/pulls/101')) { + return { + number: 101, + base: { repo: { full_name: 'octo/example' } }, + head: { ref: 'squad/implement-42-example' }, + }; + } + if (route.endsWith('/issues/101/comments')) { + return [{ user: { login: 'github-actions[bot]' }, body: bodyFor(replacement) }]; + } + throw new Error(`not found: ${route}`); }; - expect(evaluateImplementationProvenanceItems({ - items: [{ - type: 'create_pull_request', - branch: 'squad/deps-42-update', - body: bodyFor(dependency, false), - }], + await expect(verifyReplacementEvidence({ + replacements: [{ repository: 'octo/example', number: 101 }], repository: 'octo/example', - issueNumber: '42', + originIssue: 42, sessionId: 'squad-implementation-session/v1/12345/67890', - workflow: '.github/workflows/squad-deps-worker.lock.yml', - runId: 70001, - runAttempt: 1, - namespace: 'deps', - requireLegacyMarker: false, - })).toMatchObject({ ok: true, enforced: true, violations: [] }); + fetchJson, + })).resolves.toEqual([]); + await expect(verifyReplacementEvidence({ + replacements: [{ repository: 'octo/example', number: 101 }], + repository: 'octo/example', + originIssue: 42, + sessionId: 'squad-implementation-session/v1/12345/67890', + fetchJson: async route => route.endsWith('/comments') + ? [{ user: { login: 'octocat' }, body: bodyFor(replacement) }] + : fetchJson(route), + })).resolves.toContainEqual({ + kind: 'implementation-provenance-replacement-unrelated', + number: 101, + }); + await expect(verifyReplacementEvidence({ + replacements: [ + { repository: 'octo/example', number: 101 }, + { repository: 'octo/example', number: 101 }, + ], + repository: 'octo/example', + originIssue: 42, + sessionId: 'squad-implementation-session/v1/12345/67890', + fetchJson, + })).resolves.toContainEqual({ + kind: 'implementation-provenance-replacements-duplicate', + }); + await expect(verifyReplacementEvidence({ + replacements: [{ repository: 'octo/example', number: 404 }], + repository: 'octo/example', + originIssue: 42, + sessionId: 'squad-implementation-session/v1/12345/67890', + fetchJson, + })).resolves.toContainEqual({ + kind: 'implementation-provenance-replacement-not-found', + number: 404, + }); + const unrelated = structuredClone(replacement); + unrelated.implementation_session_id = 'squad-implementation-session/v1/12345/99999'; + await expect(verifyReplacementEvidence({ + replacements: [{ repository: 'octo/example', number: 101 }], + repository: 'octo/example', + originIssue: 42, + sessionId: 'squad-implementation-session/v1/12345/67890', + fetchJson: async route => route.endsWith('/comments') + ? [{ user: { login: 'github-actions[bot]' }, body: bodyFor(unrelated) }] + : fetchJson(route), + })).resolves.toContainEqual({ + kind: 'implementation-provenance-replacement-unrelated', + number: 101, + }); + }); + + it('emits the actual PR number only from the post-create handler', async () => { + const comments: string[] = []; + const fetchJson = async (route: string) => { + if (route.endsWith('/pulls/202')) { + return { + number: 202, + body: pullItem().body, + base: { repo: { full_name: 'octo/example' } }, + head: { ref: 'squad/implement-42-example' }, + }; + } + return identityFetch(route); + }; + await emitImplementationProvenanceComment({ + item: recordItem(), + resolvedTemporaryIds: { + aw_impl42: { repo: 'octo/example', number: 202 }, + }, + env: env(), + fetchJson, + createComment: async (_repository, number, body) => { + expect(number).toBe(202); + comments.push(body); + }, + }); + const payload = extractImplementationProvenance(comments[0]); + expect(payload?.pull_request.number).toBe(202); + expect(JSON.stringify(payload)).not.toContain('"self"'); + expect(payload?.session_origin).toMatchObject({ + workflow: '.github/workflows/squad.lock.yml', + run_id: 67890, + }); }); + + it('compiles the handler path and detects a realistic contract mutation', () => { + for (const source of [implementWorker, dependencyWorker]) { + expect(source).toContain('ref: ${{ github.workflow_sha }}'); + expect(source).toContain('record-implementation-provenance:'); + expect(source).toContain('require-temporary-id: true'); + } + expect(dispatcher).toContain('Dispatcher-Workflow: .github/workflows/squad.lock.yml'); + + const workspace = scratch(); + const workflowDir = resolve(workspace, '.github', 'workflows'); + mkdirSync(workflowDir, { recursive: true }); + cpSync(resolve(ROOT, 'workflows'), workflowDir, { recursive: true }); + execFileSync('git', ['init', '--quiet'], { cwd: workspace }); + execFileSync( + 'gh', + ['aw', 'compile', 'squad-implement-worker', '--strict', '--no-check-update'], + { cwd: workspace, stdio: 'pipe', timeout: 60_000 }, + ); + const lockPath = resolve(workflowDir, 'squad-implement-worker.lock.yml'); + const lock = readFileSync(lockPath, 'utf8'); + expect(lock).toContain('safe_output_script_record_implementation_provenance.cjs'); + expect(lock).toContain('GH_AW_SAFE_OUTPUT_SCRIPTS'); + expect(lock).toContain('require_temporary_id'); + expect(lock).toContain("ref: ${{ github.workflow_sha }}"); + + const sourcePath = resolve(workflowDir, 'squad-implement-worker.md'); + writeFileSync( + sourcePath, + readFileSync(sourcePath, 'utf8').replace(' require-temporary-id: true\n', ''), + ); + execFileSync( + 'gh', + ['aw', 'compile', 'squad-implement-worker', '--strict', '--no-check-update'], + { cwd: workspace, stdio: 'pipe', timeout: 60_000 }, + ); + const mutated = readFileSync(lockPath, 'utf8'); + expect(mutated).not.toContain('require_temporary_id'); + }, 120_000); }); diff --git a/test/gh-aw-quality.test.ts b/test/gh-aw-quality.test.ts index 594c57c3e..b074b80ce 100644 --- a/test/gh-aw-quality.test.ts +++ b/test/gh-aw-quality.test.ts @@ -635,6 +635,7 @@ describe('gh-aw: shared component imports', () => { 'shared/squad-improvement-gate.mjs', 'shared/squad-retro-evidence.mjs', 'shared/squad-retro-provenance.mjs', + 'shared/squad-implementation-provenance.mjs', ]; const builtinResources = [ 'shared/builtins/scribe-charter.md', @@ -718,6 +719,7 @@ describe('gh-aw: clean install runtime resource closure', () => { const expectedRuntimeModules = [ 'shared/squad-bootstrap-validator.mjs', 'shared/squad-cast-validator.mjs', + 'shared/squad-implementation-provenance.mjs', 'shared/squad-improvement-gate.mjs', 'shared/squad-retro-evidence.mjs', 'shared/squad-retro-provenance.mjs', @@ -1164,7 +1166,7 @@ describe('gh-aw: prompt budget & planning import regression', () => { // Raised 193 -> 194 KB for the command-only lifecycle next-action contract. // Its validation and retry guidance are inside the on-demand plan-validation // skill; the ambient prompt remains below the independently enforced 40 KB cap. - const SOURCE_GROWTH_BUDGET_KB = 194; + const SOURCE_GROWTH_BUDGET_KB = 197; const SOURCE_GROWTH_BUDGET_BYTES = SOURCE_GROWTH_BUDGET_KB * 1024; it('squad-planning-ontology.md is in the imports list', () => { diff --git a/test/gh-aw-retro-workflow.test.ts b/test/gh-aw-retro-workflow.test.ts index 9bb6426c4..53a94d2bb 100644 --- a/test/gh-aw-retro-workflow.test.ts +++ b/test/gh-aw-retro-workflow.test.ts @@ -1539,7 +1539,7 @@ describe('Squad retrospective workflow integration', () => { // create-pull-request, so gh-aw adds no checkout of its own. expect(steps).toMatch(/uses: actions\/checkout@[0-9a-f]{40}/); expect(steps).toContain('persist-credentials: false'); - expect(steps).toContain('ref: refs/heads/${{ github.event.repository.default_branch }}'); + expect(steps).toContain('ref: ${{ github.workflow_sha }}'); expect(steps).toContain('SQUAD_RETRO_DEFAULT_BRANCH'); expect(RETRO).toContain('shared/squad-retro-provenance.mjs'); expect(RETRO.replace(/\s+/g, ' ')).toContain('REFUSED here'); @@ -1616,7 +1616,13 @@ describe('Squad retrospective workflow integration', () => { const parsed = JSON.parse(payload!) as { workflow_name: string; inputs: Record }; expect(parsed.workflow_name).toBe('squad-implement-worker'); expect(Object.keys(parsed.inputs).sort()).toEqual([ - 'implementation_session_id', 'issue_number', 'request_origin', 'retro_action_key', + 'implementation_session_id', + 'implementation_session_origin_run_attempt', + 'implementation_session_origin_run_id', + 'implementation_session_origin_workflow', + 'issue_number', + 'request_origin', + 'retro_action_key', ]); expect(parsed.inputs.request_origin).toBe('squad-retro'); expect(parsed.inputs.implementation_session_id).toContain( @@ -1739,10 +1745,10 @@ describe('Squad retrospective workflow integration', () => { // with that branch's `.squad/config.json` and that branch's guard code. Both // the pin and the ordering are asserted against the real compiler output. // ------------------------------------------------------------------------- - it('compiles the dispatch guard behind an unconditional, default-branch-pinned checkout', () => { + it('compiles the dispatch guard behind an unconditional immutable workflow checkout', () => { const job = safeOutputsJob(compiledRetroLock()); - const trustedIndex = job.indexOf('name: Checkout trusted base for the dispatch guard'); + const trustedIndex = job.indexOf('name: Checkout executing workflow commit for the dispatch guard'); const guardIndex = job.indexOf('name: Enforce retro dispatch provenance before any output'); const processIndex = job.indexOf('name: Process Safe Outputs'); expect(trustedIndex).toBeGreaterThan(-1); @@ -1753,9 +1759,7 @@ describe('Squad retrospective workflow integration', () => { expect(trustedStep).toContain( 'uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1', ); - // The pin itself. `refs/heads/` is deliberate: an empty default_branch - // fails the checkout instead of silently falling back to the trigger ref. - expect(trustedStep).toContain('ref: refs/heads/${{ github.event.repository.default_branch }}'); + expect(trustedStep).toContain('ref: ${{ github.workflow_sha }}'); expect(trustedStep).toContain('persist-credentials: false'); expect(trustedStep).toContain('path: .squad-trusted-base'); expect(trustedStep).not.toMatch(/\n\s+(if|continue-on-error):/); diff --git a/workflows/shared/implementation-provenance-v1.schema.json b/workflows/shared/implementation-provenance-v1.schema.json index ab058fe7e..a3e0c591c 100644 --- a/workflows/shared/implementation-provenance-v1.schema.json +++ b/workflows/shared/implementation-provenance-v1.schema.json @@ -11,6 +11,7 @@ "repository", "origin_issue", "implementation_session_id", + "session_origin", "workflow_run", "pull_request", "goals", @@ -20,24 +21,36 @@ "schema": { "const": "https://bradygaster.github.io/squad/schemas/implementation-provenance/v1" }, - "schema_version": { - "const": "1" - }, - "producer": { - "const": "squad" - }, + "schema_version": { "const": "1" }, + "producer": { "const": "squad" }, "repository": { "type": "string", "pattern": "^[^/\\s]+/[^/\\s]+$" }, - "origin_issue": { - "type": "integer", - "minimum": 1 - }, + "origin_issue": { "type": "integer", "minimum": 1 }, "implementation_session_id": { "type": "string", "pattern": "^squad-implementation-session/v1/[1-9][0-9]*/[1-9][0-9]*$" }, + "session_origin": { + "type": "object", + "additionalProperties": false, + "required": ["repository", "workflow", "run_id", "run_attempt"], + "properties": { + "repository": { + "type": "string", + "pattern": "^[^/\\s]+/[^/\\s]+$" + }, + "workflow": { + "enum": [ + ".github/workflows/squad.lock.yml", + ".github/workflows/squad-retro.lock.yml" + ] + }, + "run_id": { "type": "integer", "minimum": 1 }, + "run_attempt": { "type": "integer", "minimum": 1 } + } + }, "workflow_run": { "type": "object", "additionalProperties": false, @@ -51,17 +64,9 @@ "type": "string", "pattern": "^\\.github/workflows/[^/]+\\.lock\\.yml$" }, - "run_id": { - "type": "integer", - "minimum": 1 - }, - "run_attempt": { - "type": "integer", - "minimum": 1 - }, - "event": { - "const": "workflow_dispatch" - } + "run_id": { "type": "integer", "minimum": 1 }, + "run_attempt": { "type": "integer", "minimum": 1 }, + "event": { "enum": ["workflow_dispatch", "pull_request"] } } }, "pull_request": { @@ -73,13 +78,8 @@ "type": "string", "pattern": "^[^/\\s]+/[^/\\s]+$" }, - "number": { - "const": "self" - }, - "head_ref": { - "type": "string", - "minLength": 1 - } + "number": { "type": "integer", "minimum": 1 }, + "head_ref": { "type": "string", "minLength": 1 } } }, "goals": { @@ -95,13 +95,8 @@ "type": "string", "pattern": "^[^/\\s]+/[^/\\s]+$" }, - "issue": { - "type": "integer", - "minimum": 1 - }, - "relationship": { - "enum": ["closes", "relates"] - } + "issue": { "type": "integer", "minimum": 1 }, + "relationship": { "enum": ["closes", "relates"] } } } }, @@ -117,10 +112,7 @@ "type": "string", "pattern": "^[^/\\s]+/[^/\\s]+$" }, - "number": { - "type": "integer", - "minimum": 1 - } + "number": { "type": "integer", "minimum": 1 } } } } diff --git a/workflows/shared/squad-implementation-provenance.mjs b/workflows/shared/squad-implementation-provenance.mjs index 6ed6e65b7..56fe86734 100644 --- a/workflows/shared/squad-implementation-provenance.mjs +++ b/workflows/shared/squad-implementation-provenance.mjs @@ -1,5 +1,6 @@ import { readFileSync } from 'node:fs'; import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; export const PROVENANCE_LABEL = 'Squad implementation provenance:'; export const PROVENANCE_SCHEMA = @@ -7,6 +8,13 @@ export const PROVENANCE_SCHEMA = export const SESSION_ID = /^squad-implementation-session\/v1\/[1-9][0-9]*\/[1-9][0-9]*$/; export const NUMERIC_ID = /^[1-9][0-9]*$/; +export const TEMPORARY_ID = /^#?aw_[A-Za-z0-9_]{3,12}$/; +export const BOT = 'github-actions[bot]'; +export const RECORD_TYPE = 'record_implementation_provenance'; +export const DISPATCH_WORKFLOWS = new Set([ + '.github/workflows/squad.lock.yml', + '.github/workflows/squad-retro.lock.yml', +]); const TOP_LEVEL_KEYS = Object.freeze([ 'schema', @@ -15,11 +23,18 @@ const TOP_LEVEL_KEYS = Object.freeze([ 'repository', 'origin_issue', 'implementation_session_id', + 'session_origin', 'workflow_run', 'pull_request', 'goals', 'replaces', ]); +const SESSION_ORIGIN_KEYS = Object.freeze([ + 'repository', + 'workflow', + 'run_id', + 'run_attempt', +]); const WORKFLOW_RUN_KEYS = Object.freeze([ 'repository', 'workflow', @@ -30,18 +45,71 @@ const WORKFLOW_RUN_KEYS = Object.freeze([ const PULL_REQUEST_KEYS = Object.freeze(['repository', 'number', 'head_ref']); const GOAL_KEYS = Object.freeze(['repository', 'issue', 'relationship']); const REPLACEMENT_KEYS = Object.freeze(['repository', 'number']); +const RECORD_KEYS = Object.freeze(['pull_request', 'goals_json', 'replaces_json']); const normalizeText = value => String(value ?? '').replace(/\r\n/g, '\n'); const exactKeys = (value, keys) => value && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).sort().join('\0') === [...keys].sort().join('\0'); const numeric = value => Number.isSafeInteger(value) && value > 0; +const numericText = value => NUMERIC_ID.test(String(value ?? '')) && + Number.isSafeInteger(Number(value)); +const normalizeTemporaryId = value => String(value ?? '').replace(/^#/, '').toLowerCase(); +const repositoryName = value => + typeof value === 'string' && /^[^/\s]+\/[^/\s]+$/.test(value); export function implementationSessionId(repositoryId, dispatcherRunId) { const value = `squad-implementation-session/v1/${repositoryId}/${dispatcherRunId}`; return SESSION_ID.test(value) ? value : null; } +export function implementationDispatchReceipt({ + repository, + issueNumber, + worker, + sessionId, + workflow, + runId, + runAttempt, +}) { + return [ + `Squad-Implementation-Dispatch: ${repository}#${issueNumber} worker=${worker}`, + `Implementation-Session: ${sessionId}`, + `Dispatcher-Workflow: ${workflow}`, + `Dispatcher-Run: ${runId}`, + `Dispatcher-Attempt: ${runAttempt}`, + ].join('\n'); +} + +export function parseImplementationDispatchReceipt(comment, expected = {}) { + if ((comment?.user?.login ?? comment?.author) !== BOT) return null; + const lines = normalizeText(comment?.body).split('\n'); + const one = pattern => { + const matches = lines.map(line => pattern.exec(line)).filter(Boolean); + return matches.length === 1 ? matches[0] : null; + }; + const dispatch = one(/^Squad-Implementation-Dispatch: ([^/\s]+\/[^#\s]+)#([1-9][0-9]*) worker=(squad-(?:implement|deps)-worker)$/); + const session = one(/^Implementation-Session: (\S+)$/); + const workflow = one(/^Dispatcher-Workflow: (\.github\/workflows\/squad(?:-retro)?\.lock\.yml)$/); + const run = one(/^Dispatcher-Run: ([1-9][0-9]*)$/); + const attempt = one(/^Dispatcher-Attempt: ([1-9][0-9]*)$/); + if (!dispatch || !session || !workflow || !run || !attempt || + !SESSION_ID.test(session[1])) return null; + const value = { + repository: dispatch[1], + issue_number: Number(dispatch[2]), + worker: dispatch[3], + session_id: session[1], + workflow: workflow[1], + run_id: Number(run[1]), + run_attempt: Number(attempt[1]), + }; + for (const [field, wanted] of Object.entries(expected)) { + if (wanted !== undefined && value[field] !== wanted) return null; + } + return value; +} + export function extractImplementationProvenance(body) { const lines = normalizeText(body).split('\n'); const candidates = []; @@ -67,7 +135,6 @@ export function extractImplementationProvenance(body) { continue; } if (line.replace(/[ \t]+$/, '') !== PROVENANCE_LABEL) continue; - let cursor = index + 1; while (cursor < lines.length && /^[ \t]*$/.test(lines[cursor])) cursor++; if (!/^ {0,3}```json[ \t]*$/.test(lines[cursor] || '')) { @@ -97,16 +164,54 @@ export function extractImplementationProvenance(body) { } } +function containsUnresolvedSentinel(value) { + if (typeof value === 'string') return value === 'self' || /#?aw_[A-Za-z0-9_]{3,12}/.test(value); + if (Array.isArray(value)) return value.some(containsUnresolvedSentinel); + if (value && typeof value === 'object') return Object.values(value).some(containsUnresolvedSentinel); + return false; +} + +function validateGoals(goals, repository, originIssue) { + if (!Array.isArray(goals) || goals.length === 0 || + goals.some(goal => !exactKeys(goal, GOAL_KEYS) || + !repositoryName(goal.repository) || !numeric(goal.issue) || + !['closes', 'relates'].includes(goal.relationship))) { + return [{ kind: 'implementation-provenance-goals-invalid' }]; + } + const keys = goals.map(goal => `${goal.repository}\0${goal.issue}\0${goal.relationship}`); + if (new Set(keys).size !== keys.length || + !goals.some(goal => goal.repository === repository && + goal.issue === originIssue && goal.relationship === 'closes')) { + return [{ kind: 'implementation-provenance-goals-inconsistent' }]; + } + return []; +} + +function validateReplacements(replaces) { + if (!Array.isArray(replaces) || + replaces.some(replacement => !exactKeys(replacement, REPLACEMENT_KEYS) || + !repositoryName(replacement.repository) || !numeric(replacement.number))) { + return [{ kind: 'implementation-provenance-replacements-invalid' }]; + } + const keys = replaces.map(value => `${value.repository}\0${value.number}`); + return new Set(keys).size === keys.length + ? [] + : [{ kind: 'implementation-provenance-replacements-duplicate' }]; +} + export function validateImplementationProvenance(value, expected = {}) { const violations = []; if (!exactKeys(value, TOP_LEVEL_KEYS)) { return [{ kind: 'implementation-provenance-shape-invalid' }]; } + if (containsUnresolvedSentinel(value)) { + violations.push({ kind: 'implementation-provenance-unresolved-sentinel' }); + } if (value.schema !== PROVENANCE_SCHEMA || value.schema_version !== '1' || value.producer !== 'squad') { violations.push({ kind: 'implementation-provenance-version-invalid' }); } - if (typeof value.repository !== 'string' || !value.repository.includes('/')) { + if (!repositoryName(value.repository)) { violations.push({ kind: 'implementation-provenance-repository-invalid' }); } if (!numeric(value.origin_issue)) { @@ -115,50 +220,46 @@ export function validateImplementationProvenance(value, expected = {}) { if (!SESSION_ID.test(String(value.implementation_session_id ?? ''))) { violations.push({ kind: 'implementation-provenance-session-invalid' }); } - + if (!exactKeys(value.session_origin, SESSION_ORIGIN_KEYS) || + !repositoryName(value.session_origin?.repository) || + !DISPATCH_WORKFLOWS.has(value.session_origin?.workflow) || + !numeric(value.session_origin?.run_id) || + !numeric(value.session_origin?.run_attempt)) { + violations.push({ kind: 'implementation-provenance-session-origin-invalid' }); + } if (!exactKeys(value.workflow_run, WORKFLOW_RUN_KEYS) || - typeof value.workflow_run.repository !== 'string' || - typeof value.workflow_run.workflow !== 'string' || - !numeric(value.workflow_run.run_id) || - !numeric(value.workflow_run.run_attempt) || - value.workflow_run.event !== 'workflow_dispatch') { + !repositoryName(value.workflow_run?.repository) || + typeof value.workflow_run?.workflow !== 'string' || + !numeric(value.workflow_run?.run_id) || + !numeric(value.workflow_run?.run_attempt) || + !['workflow_dispatch', 'pull_request'].includes(value.workflow_run?.event)) { violations.push({ kind: 'implementation-provenance-workflow-run-invalid' }); } if (!exactKeys(value.pull_request, PULL_REQUEST_KEYS) || - typeof value.pull_request.repository !== 'string' || - value.pull_request.number !== 'self' || - typeof value.pull_request.head_ref !== 'string') { + !repositoryName(value.pull_request?.repository) || + !numeric(value.pull_request?.number) || + typeof value.pull_request?.head_ref !== 'string' || + value.pull_request.head_ref.length === 0) { violations.push({ kind: 'implementation-provenance-pull-request-invalid' }); } - if (!Array.isArray(value.goals) || value.goals.length === 0 || - value.goals.some(goal => !exactKeys(goal, GOAL_KEYS) || - typeof goal.repository !== 'string' || !numeric(goal.issue) || - !['closes', 'relates'].includes(goal.relationship))) { - violations.push({ kind: 'implementation-provenance-goals-invalid' }); - } else { - const goalKeys = value.goals.map(goal => - `${goal.repository}\0${goal.issue}\0${goal.relationship}`); - if (new Set(goalKeys).size !== goalKeys.length || - !value.goals.some(goal => goal.repository === value.repository && - goal.issue === value.origin_issue && goal.relationship === 'closes')) { - violations.push({ kind: 'implementation-provenance-goals-inconsistent' }); - } - } - if (!Array.isArray(value.replaces) || - value.replaces.some(replacement => !exactKeys(replacement, REPLACEMENT_KEYS) || - typeof replacement.repository !== 'string' || !numeric(replacement.number))) { - violations.push({ kind: 'implementation-provenance-replacements-invalid' }); - } + violations.push(...validateGoals(value.goals, value.repository, value.origin_issue)); + violations.push(...validateReplacements(value.replaces)); const comparisons = [ ['repository', value.repository, expected.repository], ['origin-issue', value.origin_issue, expected.originIssue], ['session', value.implementation_session_id, expected.sessionId], + ['session-repository', value.session_origin?.repository, expected.repository], + ['session-workflow', value.session_origin?.workflow, expected.dispatcherWorkflow], + ['session-run-id', value.session_origin?.run_id, expected.dispatcherRunId], + ['session-run-attempt', value.session_origin?.run_attempt, expected.dispatcherRunAttempt], ['workflow-repository', value.workflow_run?.repository, expected.repository], ['workflow', value.workflow_run?.workflow, expected.workflow], ['run-id', value.workflow_run?.run_id, expected.runId], ['run-attempt', value.workflow_run?.run_attempt, expected.runAttempt], + ['event', value.workflow_run?.event, expected.event], ['pull-repository', value.pull_request?.repository, expected.repository], + ['pull-number', value.pull_request?.number, expected.pullRequestNumber], ['head-ref', value.pull_request?.head_ref, expected.headRef], ]; for (const [field, actual, wanted] of comparisons) { @@ -169,49 +270,73 @@ export function validateImplementationProvenance(value, expected = {}) { return violations; } +function recordForPull(items, pull) { + const id = normalizeTemporaryId(pull.temporary_id); + return items.filter(item => item.type === RECORD_TYPE && + normalizeTemporaryId(item.pull_request) === id); +} + +function parseRecord(item) { + if (!exactKeys(item, ['type', ...RECORD_KEYS])) return null; + try { + const goals = JSON.parse(item.goals_json); + const replaces = JSON.parse(item.replaces_json); + return { ...item, goals, replaces }; + } catch { + return null; + } +} + export function evaluateImplementationProvenanceItems({ items = [], repository, issueNumber, - sessionId, - workflow, - runId, - runAttempt, namespace, + runId, requireLegacyMarker = false, } = {}) { const pulls = items.filter(item => item.type === 'create_pull_request'); + const records = items.filter(item => item.type === RECORD_TYPE); const violations = []; if (pulls.length > 1) violations.push({ kind: 'implementation-provenance-multiple-pulls' }); + if (records.length !== pulls.length) { + violations.push({ kind: 'implementation-provenance-record-count-invalid' }); + } for (const pull of pulls) { + if (!TEMPORARY_ID.test(String(pull.temporary_id ?? ''))) { + violations.push({ kind: 'implementation-provenance-pull-temporary-id-invalid' }); + } const expectedBranch = new RegExp( `^squad/${namespace}-${issueNumber}-[a-z0-9][a-z0-9-]*$`, ); if (!expectedBranch.test(String(pull.branch ?? ''))) { violations.push({ kind: 'implementation-provenance-branch-invalid' }); } - const provenance = extractImplementationProvenance(pull.body); - if (provenance === null) { - violations.push({ kind: 'implementation-provenance-missing' }); - } else if (provenance === undefined) { - violations.push({ kind: 'implementation-provenance-malformed' }); - } else { - violations.push(...validateImplementationProvenance(provenance, { - repository, - originIssue: Number(issueNumber), - sessionId, - workflow, - runId: Number(runId), - runAttempt: Number(runAttempt), - headRef: pull.branch, - })); + if (pull.base || pull.repo || pull.head_repo) { + violations.push({ kind: 'implementation-provenance-pull-target-override' }); + } + if (normalizeText(pull.body).includes(PROVENANCE_LABEL) || + /"number"\s*:\s*"self"/.test(normalizeText(pull.body))) { + violations.push({ kind: 'implementation-provenance-premature-durable-evidence' }); + } + const matches = recordForPull(items, pull); + const record = matches.length === 1 ? parseRecord(matches[0]) : null; + if (!record) { + violations.push({ kind: 'implementation-provenance-record-invalid' }); + continue; } + if (items.indexOf(matches[0]) !== items.indexOf(pull) + 1) { + violations.push({ kind: 'implementation-provenance-record-order-invalid' }); + } + violations.push(...validateGoals(record.goals, repository, Number(issueNumber))); + violations.push(...validateReplacements(record.replaces)); if (requireLegacyMarker) { - const marker = - ``; + const marker = ``; const lines = normalizeText(pull.body).split('\n'); - if (lines.filter(line => line === marker).length !== 1 || - (normalizeText(pull.body).match(/$`).test(line)).length === 1; + if (!exact || (normalizeText(pull.body).match(/