diff --git a/docs/src/content/docs/guide/gh-aw.md b/docs/src/content/docs/guide/gh-aw.md index dc594f02b..62e56f614 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,74 @@ 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 receives a `Squad implementation provenance:` comment using schema +`https://bradygaster.github.io/squad/schemas/implementation-provenance/v1`. +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 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: + +- **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. 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:** `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, 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 +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 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 | 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..f62cbd062 --- /dev/null +++ b/test-fixtures/implementation-provenance/multi-goal-replacement-v1.json @@ -0,0 +1,44 @@ +{ + "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", + "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", + "run_id": 70002, + "run_attempt": 2, + "event": "workflow_dispatch" + }, + "pull_request": { + "repository": "octo/example", + "number": 102, + "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..241f00f8a --- /dev/null +++ b/test-fixtures/implementation-provenance/valid-v1.json @@ -0,0 +1,34 @@ +{ + "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", + "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", + "run_id": 70001, + "run_attempt": 1, + "event": "workflow_dispatch" + }, + "pull_request": { + "repository": "octo/example", + "number": 101, + "head_ref": "squad/implement-42-example" + }, + "goals": [ + { + "repository": "octo/example", + "issue": 42, + "relationship": "closes" + } + ], + "replaces": [] +} 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 new file mode 100644 index 000000000..f875f4175 --- /dev/null +++ b/test/gh-aw-implementation-provenance.test.ts @@ -0,0 +1,637 @@ +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, + enforceImplementationProvenanceSafeOutputs, + evaluateImplementationProvenanceItems, + extractImplementationProvenance, + implementationDispatchReceipt, + implementationSessionId, + validateImplementationProvenance, + validateWorkerIdentity, + verifyReplacementEvidence, +} from '../workflows/shared/squad-implementation-provenance.mjs'; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +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(worker = 'squad-implement-worker') { + return { + user: { login: 'github-actions[bot]', type: 'Bot' }, + body: implementationDispatchReceipt({ + repository: 'octo/example', + issueNumber: 42, + worker, + sessionId: 'squad-implementation-session/v1/12345/67890', + workflow: '.github/workflows/squad.lock.yml', + runId: 67890, + runAttempt: 1, + }), + }; +} + +function identityFetch(route: string, worker = 'squad-implement-worker') { + 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(worker)]; + 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, + }; +} + +function provenanceCommentItem( + body: string, + overrides: Record = {}, +) { + return { + type: 'add_comment', + item_number: '#aw_impl42', + body, + ...overrides, + }; +} + +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 implementWorker = read('workflows/squad-implement-worker.md'); + const dependencyWorker = read('workflows/squad-deps-worker.md'); + const dispatcher = read('workflows/squad.md'); + + it('publishes only actual PR numbers and rejects unresolved sentinels', () => { + expect(schema.$id).toBe(PROVENANCE_SCHEMA); + expect(schema.properties.pull_request.properties.number).toEqual({ + type: 'integer', + minimum: 1, + }); + expect(validateImplementationProvenance(fixture)).toEqual([]); + expect(validateImplementationProvenance(multiGoal)).toEqual([]); + + 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 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' }, + ]); + 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('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: [pullItem()], + repository: 'octo/example', + issueNumber: 42, + namespace: 'implement', + runId: 70001, + requireLegacyMarker: true, + }).ok).toBe(false); + expect(evaluateImplementationProvenanceItems({ + items: [pullItem(), recordItem({ nested: true })], + repository: 'octo/example', + issueNumber: 42, + namespace: 'implement', + runId: 70001, + requireLegacyMarker: true, + }).violations).toContainEqual({ + kind: 'implementation-provenance-record-invalid', + }); + expect(evaluateImplementationProvenanceItems({ + items: [recordItem(), pullItem()], + repository: 'octo/example', + issueNumber: 42, + namespace: 'implement', + runId: 70001, + 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-premature-durable-evidence', + }); + }); + + it('rejects every provenance-like add_comment targeting the temporary PR', () => { + const candidates = [ + `${PROVENANCE_LABEL}\n\`\`\`json\n{"schema_version":"1"}\n`, + `${PROVENANCE_LABEL}\n\`\`\`json\n{"schema_version":"1"}\n\`\`\``, + '{"schema_version":"1"}', + bodyFor({ ...fixture, unknown: true }), + `${PROVENANCE_LABEL}\n\`\`\`json\n${JSON.stringify(fixture)}\n\`\`\``, + ]; + for (const body of candidates) { + const result = evaluateImplementationProvenanceItems({ + items: [pullItem(), recordItem(), provenanceCommentItem(body)], + repository: 'octo/example', + issueNumber: 42, + namespace: 'implement', + runId: 70001, + requireLegacyMarker: true, + }); + expect(result.ok).toBe(false); + expect(result.violations).toContainEqual({ + kind: 'implementation-provenance-comment-candidate-invalid', + }); + } + + const duplicate = evaluateImplementationProvenanceItems({ + items: [ + pullItem(), + recordItem(), + provenanceCommentItem(bodyFor(fixture)), + provenanceCommentItem(`${PROVENANCE_LABEL}\nnot-json`), + ], + repository: 'octo/example', + issueNumber: 42, + namespace: 'implement', + runId: 70001, + requireLegacyMarker: true, + }); + expect(duplicate.violations).toEqual(expect.arrayContaining([ + { kind: 'implementation-provenance-comment-candidate-invalid' }, + { kind: 'implementation-provenance-comment-candidate-ambiguous' }, + ])); + + expect(evaluateImplementationProvenanceItems({ + items: [ + pullItem(), + recordItem(), + provenanceCommentItem('ordinary implementation status'), + ], + repository: 'octo/example', + issueNumber: 42, + namespace: 'implement', + runId: 70001, + requireLegacyMarker: true, + }).ok).toBe(true); + }); + + 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('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', + }, + }; + 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}`); + }; + await expect(verifyReplacementEvidence({ + replacements: [{ repository: 'octo/example', number: 101 }], + repository: 'octo/example', + originIssue: 42, + sessionId: 'squad-implementation-session/v1/12345/67890', + 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, + }); + const malformed = `${PROVENANCE_LABEL}\n\`\`\`json\n{"schema_version":"1"}\n`; + for (const botComments of [ + [], + [ + { user: { login: 'github-actions[bot]' }, body: malformed }, + ], + [ + { + user: { login: 'github-actions[bot]' }, + body: `${PROVENANCE_LABEL}\n\`\`\`json\n{"schema_version":"1"}\n\`\`\``, + }, + ], + [ + { user: { login: 'github-actions[bot]' }, body: bodyFor(replacement) }, + { user: { login: 'github-actions[bot]' }, body: bodyFor(replacement) }, + ], + [ + { user: { login: 'github-actions[bot]' }, body: bodyFor(replacement) }, + { user: { login: 'github-actions[bot]' }, body: malformed }, + ], + [ + { user: { login: 'github-actions[bot]' }, body: bodyFor({ ...replacement, unknown: true }) }, + ], + ]) { + 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') + ? botComments + : 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('post-create handler refuses provenance-like add_comment candidates in raw output', async () => { + const comments: string[] = []; + const outputItems = [ + pullItem(), + recordItem(), + provenanceCommentItem(`${PROVENANCE_LABEL}\n\`\`\`json\n{"schema_version":"1"}\n`), + ]; + await expect(emitImplementationProvenanceComment({ + item: outputItems[1], + items: outputItems, + resolvedTemporaryIds: { + aw_impl42: { repo: 'octo/example', number: 202 }, + }, + env: env(), + 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); + }, + createComment: async (_repository, _number, body) => comments.push(body), + })).rejects.toThrow('implementation-provenance-comment-candidate-invalid'); + expect(comments).toEqual([]); + }); + + it('compiles both handler paths and fails production-shaped comment candidates closed', async () => { + 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 }); + for (const workflowId of ['squad-implement-worker', 'squad-deps-worker']) { + execFileSync( + 'gh', + ['aw', 'compile', workflowId, '--strict', '--no-check-update'], + { cwd: workspace, stdio: 'pipe', timeout: 60_000 }, + ); + const lock = readFileSync(resolve(workflowDir, `${workflowId}.lock.yml`), 'utf8'); + const guard = lock.indexOf( + workflowId === 'squad-implement-worker' + ? 'name: Enforce implement provenance before any output' + : 'name: Enforce dependency implementation provenance before any output', + ); + const handler = lock.indexOf( + 'safe_output_script_record_implementation_provenance.cjs', + ); + const process = lock.indexOf('name: Process Safe Outputs'); + expect(guard).toBeGreaterThan(-1); + expect(handler).toBeGreaterThan(guard); + expect(process).toBeGreaterThan(handler); + expect(lock.slice(handler, process)).toContain( + 'return provenance.emitImplementationProvenanceComment({', + ); + expect(lock.slice(guard, process)).toContain('GH_AW_AGENT_OUTPUT'); + expect(lock.slice(guard, process)).toContain( + 'enforceImplementationProvenanceSafeOutputs(', + ); + expect(lock).toContain('GH_AW_SAFE_OUTPUT_SCRIPTS'); + expect(lock).toContain('require_temporary_id'); + expect(lock).toContain("ref: ${{ github.workflow_sha }}"); + + const dependency = workflowId === 'squad-deps-worker'; + const malformed = provenanceCommentItem( + `${PROVENANCE_LABEL}\n\`\`\`json\n{"schema_version":"1"}\n`, + ); + const adversarialComments = [ + [malformed], + [provenanceCommentItem( + `${PROVENANCE_LABEL}\n\`\`\`json\n{"schema_version":"1"}\n\`\`\``, + )], + [provenanceCommentItem(bodyFor({ ...fixture, unknown: true }))], + [ + provenanceCommentItem(bodyFor(fixture)), + provenanceCommentItem(bodyFor(fixture)), + ], + [ + provenanceCommentItem(bodyFor(fixture)), + malformed, + ], + ]; + for (const comments of adversarialComments) { + const result = await enforceImplementationProvenanceSafeOutputs(env({ + SQUAD_IMPLEMENT_WORKER: workflowId, + SQUAD_IMPLEMENT_WORKFLOW: `.github/workflows/${workflowId}.lock.yml`, + SQUAD_IMPLEMENT_NAMESPACE: dependency ? 'deps' : 'implement', + SQUAD_IMPLEMENT_REQUIRE_LEGACY_MARKER: dependency ? 'false' : 'true', + }), { + readItems: () => [ + { + ...pullItem(), + branch: dependency + ? 'squad/deps-42-example' + : 'squad/implement-42-example', + }, + recordItem(), + ...comments, + ], + fetchJson: async route => identityFetch(route, workflowId), + }); + expect(result.ok).toBe(false); + expect(result.violations).toContainEqual({ + kind: 'implementation-provenance-comment-candidate-invalid', + }); + } + } + + const lockPath = resolve(workflowDir, 'squad-implement-worker.lock.yml'); + 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..16bfde9e0 100644 --- a/test/gh-aw-quality.test.ts +++ b/test/gh-aw-quality.test.ts @@ -635,6 +635,8 @@ 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', + 'shared/implementation-provenance-v1.schema.json', ]; const builtinResources = [ 'shared/builtins/scribe-charter.md', @@ -715,16 +717,20 @@ describe('gh-aw: clean install runtime resource closure', () => { 'squad-improvement-worker', 'squad-bootstrap', ]; - const expectedRuntimeModules = [ + const expectedRuntimeResources = [ + 'shared/implementation-provenance-v1.schema.json', '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', ]; - function runtimeModuleReferences(text: string): string[] { - return [...text.matchAll(/(?:\.github\/workflows\/)?(shared\/[A-Za-z0-9._/-]+\.mjs)/g)] + function runtimeResourceReferences(text: string): string[] { + return [...text.matchAll( + /(?:\.github\/workflows\/)?(shared\/(?:[A-Za-z0-9._/-]+\.mjs|implementation-provenance-v1\.schema\.json))/g, + )] .map(match => match[1]); } @@ -770,21 +776,21 @@ describe('gh-aw: clean install runtime resource closure', () => { return workflowDir; } - it('emits every shared runtime module referenced by all seven sources and locks', () => { + it('emits every shared runtime resource referenced by all seven sources and locks', () => { const workflowDir = createCleanInstalledTarget(); const references = new Set(); for (const workflowName of workflowNames) { for (const extension of ['md', 'lock.yml']) { const file = join(workflowDir, `${workflowName}.${extension}`); expect(existsSync(file), `${file} must exist in the installed target`).toBe(true); - for (const reference of runtimeModuleReferences(readText(file))) { + for (const reference of runtimeResourceReferences(readText(file))) { references.add(reference); } } } const sortedReferences = [...references].sort(); - expect(sortedReferences).toEqual(expectedRuntimeModules); + expect(sortedReferences).toEqual(expectedRuntimeResources); assertRuntimeClosure(workflowDir, sortedReferences); for (const modulePath of sortedReferences) { diff --git a/test/gh-aw-retro-workflow.test.ts b/test/gh-aw-retro-workflow.test.ts index 93082c10d..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,9 +1616,18 @@ 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', + '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( + 'squad-implementation-session/v1/', + ); expect(RETRO.replace(/\s+/g, ' ')).toContain('Do not supply `aw_context`'); }); @@ -1736,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); @@ -1750,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):/); @@ -2782,6 +2789,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 +2973,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..a3e0c591c --- /dev/null +++ b/workflows/shared/implementation-provenance-v1.schema.json @@ -0,0 +1,120 @@ +{ + "$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", + "session_origin", + "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]*$" + }, + "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, + "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": { "enum": ["workflow_dispatch", "pull_request"] } + } + }, + "pull_request": { + "type": "object", + "additionalProperties": false, + "required": ["repository", "number", "head_ref"], + "properties": { + "repository": { + "type": "string", + "pattern": "^[^/\\s]+/[^/\\s]+$" + }, + "number": { "type": "integer", "minimum": 1 }, + "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..cea6ee861 --- /dev/null +++ b/workflows/shared/squad-implementation-provenance.mjs @@ -0,0 +1,750 @@ +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 = + '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]*$/; +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', + 'schema_version', + 'producer', + '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', + '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 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); +const provenanceLikeBody = body => { + const text = normalizeText(body); + return /Squad implementation provenance/i.test(text) || + text.includes(PROVENANCE_SCHEMA) || + /"(?:schema|schema_version|producer|origin_issue|implementation_session_id|session_origin|workflow_run|pull_request|goals|replaces)"\s*:/.test(text); +}; + +function provenanceCommentCandidates(comments) { + return comments.filter(comment => + (comment?.user?.login ?? comment?.author) === BOT && + provenanceLikeBody(comment?.body)); +} + +function authoritativeProvenanceFromComments(comments, expected) { + const candidates = provenanceCommentCandidates(comments); + if (candidates.length !== 1) return null; + const payload = extractImplementationProvenance(candidates[0].body); + return payload && validateImplementationProvenance(payload, expected).length === 0 + ? payload + : null; +} + +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 = []; + 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; + } +} + +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 (!repositoryName(value.repository)) { + 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.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) || + !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) || + !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' }); + } + 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) { + if (wanted !== undefined && actual !== wanted) { + violations.push({ kind: 'implementation-provenance-runtime-mismatch', field }); + } + } + 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 provenanceCommentsForPull(items, pull) { + const id = normalizeTemporaryId(pull.temporary_id); + return items.filter(item => item.type === 'add_comment' && + normalizeTemporaryId(item.item_number) === id && + provenanceLikeBody(item.body)); +} + +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, + 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' }); + } + 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 commentCandidates = provenanceCommentsForPull(items, pull); + if (commentCandidates.length > 0) { + violations.push({ kind: 'implementation-provenance-comment-candidate-invalid' }); + } + if (commentCandidates.length > 1) { + violations.push({ kind: 'implementation-provenance-comment-candidate-ambiguous' }); + } + 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 lines = normalizeText(pull.body).split('\n'); + const exact = runId + ? lines.filter(line => line === marker).length === 1 + : lines.filter(line => new RegExp(`^$`).test(line)).length === 1; + if (!exact || (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', + 'implementation_session_origin_workflow', + 'implementation_session_origin_run_id', + 'implementation_session_origin_run_attempt', +]); 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; @@ -439,7 +447,15 @@ export function parseDispatchMarker(comment, issueNumber) { } const targetOf = item => String(item?.item_number ?? item?.issue_number ?? item?.pr_number ?? ''); -export function evaluateRetroDispatchOutputs({ items = [], autoImplementEnabled = false, maxDispatch = 3, actionTemporaryIds } = {}) { +export function evaluateRetroDispatchOutputs({ + items = [], + autoImplementEnabled = false, + maxDispatch = 3, + actionTemporaryIds, + repositoryId, + runId, + runAttempt, +} = {}) { const violations = []; const targets = []; if (!Array.isArray(items)) return { ok: false, enforced: true, violations: [{ kind: 'unreadable-agent-output' }], targets }; @@ -470,6 +486,15 @@ export function evaluateRetroDispatchOutputs({ items = [], autoImplementEnabled if (Object.keys(inputs).some(key => !DISPATCH_INPUT_KEYS.includes(key))) violations.push({ kind: 'dispatch-input-not-allowed' }); if (inputs.request_origin !== RETRO_ORIGIN) violations.push({ kind: 'dispatch-origin-not-declared' }); if (!FINGERPRINT.test(inputs.retro_action_key)) violations.push({ kind: 'dispatch-action-key-malformed' }); + const expectedSession = repositoryId && runId + ? `squad-implementation-session/v1/${repositoryId}/${runId}` + : null; + if (expectedSession && (inputs.implementation_session_id !== expectedSession || + inputs.implementation_session_origin_workflow !== RETRO_WORKFLOW_PATH || + String(inputs.implementation_session_origin_run_id) !== String(runId) || + String(inputs.implementation_session_origin_run_attempt) !== String(runAttempt))) { + violations.push({ kind: 'dispatch-session-origin-invalid' }); + } const target = inputs.issue_number; if (typeof target !== 'string' || (!isNumericId(target) && !/^#aw_[a-z0-9_]{3,12}$/.test(target))) { violations.push({ kind: 'dispatch-target-not-resolvable' }); @@ -779,7 +804,14 @@ export async function enforceRetroSafeOutputs(env = process.env, { if (!plan || plan.run_id !== env.GITHUB_RUN_ID || plan.repository !== env.GITHUB_REPOSITORY) { return { ok: false, enforced: true, violations: [{ kind: 'trusted-plan-unavailable' }] }; } - const result = evaluateRetroDispatchOutputs({ items, autoImplementEnabled, actionTemporaryIds: plan.auto_implement_new_action_ids }); + const result = evaluateRetroDispatchOutputs({ + items, + autoImplementEnabled, + actionTemporaryIds: plan.auto_implement_new_action_ids, + repositoryId: env.GITHUB_REPOSITORY_ID, + runId: env.GITHUB_RUN_ID, + runAttempt: env.GITHUB_RUN_ATTEMPT, + }); const violations = [...result.violations, ...evaluateRetroPlanOutputs(items, plan)]; if (!result.ok || !result.targets.length) return { ...result, ok: !violations.length, violations }; if (env.GITHUB_REF !== `refs/heads/${env.SQUAD_RETRO_DEFAULT_BRANCH}`) violations.push({ kind: 'dispatch-off-default-branch' }); diff --git a/workflows/squad-deps-worker.md b/workflows/squad-deps-worker.md index d719919d2..23f7812d0 100644 --- a/workflows/squad-deps-worker.md +++ b/workflows/squad-deps-worker.md @@ -13,6 +13,24 @@ 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 + implementation_session_origin_workflow: + description: Immutable dispatcher workflow path that minted the session + required: true + type: string + implementation_session_origin_run_id: + description: Authoritative dispatcher run that minted the session + required: true + type: string + implementation_session_origin_run_attempt: + description: Authoritative dispatcher run attempt that minted the session + required: true + type: string aw_context: description: Originating agentic workflow context required: false @@ -22,6 +40,7 @@ permissions: copilot-requests: write issues: read pull-requests: read + actions: read concurrency: group: "squad-deps-${{ github.event.inputs.issue_number }}" cancel-in-progress: false @@ -35,17 +54,138 @@ network: - node imports: - shared/squad.md +resources: + - shared/squad-implementation-provenance.mjs + - shared/implementation-provenance-v1.schema.json tools: edit: bash: true github: mode: gh-proxy toolsets: [default] +pre-agent-steps: + - name: Checkout executing workflow commit for the identity guard + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.workflow_sha }} + persist-credentials: false + path: .squad-pre-agent-trusted-base + - name: Validate authoritative dispatcher identity + shell: bash + env: + GITHUB_TOKEN: ${{ github.token }} + GITHUB_REPOSITORY_ID: ${{ github.event.repository.id }} + SQUAD_IMPLEMENT_WORKER: squad-deps-worker + 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_DISPATCHER_WORKFLOW: ${{ github.event.inputs.implementation_session_origin_workflow }} + SQUAD_IMPLEMENT_DISPATCHER_RUN_ID: ${{ github.event.inputs.implementation_session_origin_run_id }} + SQUAD_IMPLEMENT_DISPATCHER_RUN_ATTEMPT: ${{ github.event.inputs.implementation_session_origin_run_attempt }} + run: | + set -euo pipefail + node "${GITHUB_WORKSPACE:?}/.squad-pre-agent-trusted-base/.github/workflows/shared/squad-implementation-provenance.mjs" --worker-identity safe-outputs: + steps: + - name: Checkout executing workflow commit for the implementation provenance guard + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.workflow_sha }} + 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 }} + GITHUB_REPOSITORY_ID: ${{ github.event.repository.id }} + SQUAD_IMPLEMENT_WORKER: squad-deps-worker + SQUAD_IMPLEMENT_ISSUE_NUMBER: ${{ github.event.inputs.issue_number }} + SQUAD_IMPLEMENT_SESSION_ID: ${{ github.event.inputs.implementation_session_id }} + SQUAD_IMPLEMENT_DISPATCHER_WORKFLOW: ${{ github.event.inputs.implementation_session_origin_workflow }} + SQUAD_IMPLEMENT_DISPATCHER_RUN_ID: ${{ github.event.inputs.implementation_session_origin_run_id }} + SQUAD_IMPLEMENT_DISPATCHER_RUN_ATTEMPT: ${{ github.event.inputs.implementation_session_origin_run_attempt }} + 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 fetchJson = async (route, fields) => + (await github.request(`GET /${route}`, fields)).data; + const result = await provenance.enforceImplementationProvenanceSafeOutputs( + process.env, + { fetchJson }, + ); + 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.'); + env: + GITHUB_REPOSITORY_ID: ${{ github.event.repository.id }} + SQUAD_IMPLEMENT_WORKER: squad-deps-worker + SQUAD_IMPLEMENT_ISSUE_NUMBER: ${{ github.event.inputs.issue_number }} + SQUAD_IMPLEMENT_SESSION_ID: ${{ github.event.inputs.implementation_session_id }} + SQUAD_IMPLEMENT_DISPATCHER_WORKFLOW: ${{ github.event.inputs.implementation_session_origin_workflow }} + SQUAD_IMPLEMENT_DISPATCHER_RUN_ID: ${{ github.event.inputs.implementation_session_origin_run_id }} + SQUAD_IMPLEMENT_DISPATCHER_RUN_ATTEMPT: ${{ github.event.inputs.implementation_session_origin_run_attempt }} + SQUAD_IMPLEMENT_WORKFLOW: .github/workflows/squad-deps-worker.lock.yml + SQUAD_IMPLEMENT_NAMESPACE: deps + SQUAD_IMPLEMENT_REQUIRE_LEGACY_MARKER: "false" + scripts: + record-implementation-provenance: + description: >- + Emit authoritative implementation provenance as a comment after the + referenced pull request has been created. + inputs: + pull_request: + description: Temporary ID of the create_pull_request output + required: true + type: string + goals_json: + description: JSON array of explicit issue goals for the pull request + required: true + type: string + replaces_json: + description: JSON array of verified pull requests replaced by this pull request + required: true + type: string + 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 fetchJson = async (route, fields) => + (await github.request(`GET /${route}`, fields)).data; + return provenance.emitImplementationProvenanceComment({ + item, + resolvedTemporaryIds, + env: process.env, + fetchJson, + createComment: async (repository, issueNumber, body) => { + const [owner, repo] = repository.split('/'); + await github.rest.issues.createComment({ + owner, + repo, + issue_number: issueNumber, + body, + }); + }, + }); create-pull-request: title-prefix: "[squad-deps] " labels: [squad] max: 1 + require-temporary-id: true allowed-base-branches: - "squad/*" allowed-branches: @@ -198,6 +338,17 @@ 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 is not PR-body text. Give the `create_pull_request` call a + unique `temporary_id`, then immediately call + `record_implementation_provenance` with `pull_request` set to that temporary + ID, `goals_json` containing a JSON array with the primary closing goal plus + any other explicit goals, and `replaces_json` containing a JSON array of only + verified earlier Squad PRs from this + same repository, origin issue, and implementation session. The trusted + handler runs after PR creation, resolves the actual PR number, re-fetches all + replacement evidence, and writes the schema payload as a PR comment. Never + put `Squad implementation provenance:`, `"number": "self"`, or an unresolved + temporary ID in the PR body. - 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..389df05de 100644 --- a/workflows/squad-implement-worker.md +++ b/workflows/squad-implement-worker.md @@ -11,6 +11,24 @@ 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 + implementation_session_origin_workflow: + description: Immutable dispatcher workflow path that minted the session + required: true + type: string + implementation_session_origin_run_id: + description: Authoritative dispatcher run that minted the session + required: true + type: string + implementation_session_origin_run_attempt: + description: Authoritative dispatcher run attempt that minted the session + required: true + type: string request_origin: description: >- Origin of an automated dispatch. Omitted for /squad implement and for @@ -62,6 +80,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 @@ -75,20 +95,26 @@ pre-agent-steps: # therefore has to be refused here, and a `request_origin` claim has to be # corroborated against the injected `aw_context` before any work starts. # The pull-request continuation is also fail-closed here. Its body and head - # ref are untrusted until a guard loaded from the default branch proves one + # ref are untrusted until a guard loaded from the executing workflow commit proves one # exact standalone marker, one exact branch, and equal numeric issue IDs. - - name: Checkout trusted base for the pre-agent provenance guard + - name: Checkout executing workflow commit for the pre-agent provenance guard uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: refs/heads/${{ github.event.repository.default_branch }} + ref: ${{ github.workflow_sha }} persist-credentials: false path: .squad-pre-agent-trusted-base - name: Validate dispatch inputs and declared origin shell: bash env: GITHUB_TOKEN: ${{ github.token }} + GITHUB_REPOSITORY_ID: ${{ github.event.repository.id }} + SQUAD_IMPLEMENT_WORKER: squad-implement-worker 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_DISPATCHER_WORKFLOW: ${{ github.event.inputs.implementation_session_origin_workflow }} + SQUAD_IMPLEMENT_DISPATCHER_RUN_ID: ${{ github.event.inputs.implementation_session_origin_run_id }} + SQUAD_IMPLEMENT_DISPATCHER_RUN_ATTEMPT: ${{ github.event.inputs.implementation_session_origin_run_attempt }} 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 }} @@ -97,8 +123,12 @@ pre-agent-steps: SQUAD_IMPLEMENT_PULL_HEAD_REF: ${{ github.event.pull_request.head.ref }} SQUAD_IMPLEMENT_PULL_HEAD_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }} SQUAD_IMPLEMENT_PULL_CREATED_AT: ${{ github.event.pull_request.created_at }} + SQUAD_IMPLEMENT_PULL_NUMBER: ${{ github.event.pull_request.number }} + SQUAD_IMPLEMENT_PULL_MERGED: ${{ github.event.pull_request.merged }} + SQUAD_IMPLEMENT_PULL_BASE_REF: ${{ github.event.pull_request.base.ref }} run: | set -euo pipefail + node "${GITHUB_WORKSPACE:?}/.squad-pre-agent-trusted-base/.github/workflows/shared/squad-implementation-provenance.mjs" --worker-identity node "${GITHUB_WORKSPACE:?}/.squad-pre-agent-trusted-base/.github/workflows/shared/squad-retro-provenance.mjs" --implement-inputs safe-outputs: # THE authoritative output boundary for dispatch provenance. gh-aw injects @@ -119,7 +149,8 @@ safe-outputs: # Merge-refill runs repeat the exact marker/branch validation here so a # dispatch cannot be processed if the pre-agent boundary is ever bypassed. steps: - # UNCONDITIONAL and explicitly pinned to the default branch, because + # UNCONDITIONAL and pinned to the immutable commit containing the workflow + # definition that GitHub is executing, because # neither property holds for the checkout gh-aw emits for this job: # * it is conditional on `contains(needs.agent.outputs.output_types, # 'create_pull_request')`, so on a comment-only, refusal, or noop run @@ -129,26 +160,33 @@ safe-outputs: # continuation that is `refs/pull/N/merge`, i.e. pull-request-authored # content. Guard code read from there enforces whatever that pull # request said it should. - # `refs/heads/` is explicit so an empty `default_branch` fails the checkout - # (fail closed) instead of silently falling back to the triggering ref. # `persist-credentials: false` and a dedicated `path:` keep this a # read-only side materialization: it never touches the workspace root # checkout, the `origin` remote, or the credentials the create-pull-request # handler pushes with, and it is never the same-repo checkout that handler # operates in. - - name: Checkout trusted base for the provenance guard + - name: Checkout executing workflow commit for the provenance guard uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: refs/heads/${{ github.event.repository.default_branch }} + ref: ${{ github.workflow_sha }} persist-credentials: false path: .squad-trusted-base - name: Enforce implement provenance before any output uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: GITHUB_TOKEN: ${{ github.token }} + GITHUB_REPOSITORY_ID: ${{ github.event.repository.id }} GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} + SQUAD_IMPLEMENT_WORKER: squad-implement-worker 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_DISPATCHER_WORKFLOW: ${{ github.event.inputs.implementation_session_origin_workflow }} + SQUAD_IMPLEMENT_DISPATCHER_RUN_ID: ${{ github.event.inputs.implementation_session_origin_run_id }} + SQUAD_IMPLEMENT_DISPATCHER_RUN_ATTEMPT: ${{ github.event.inputs.implementation_session_origin_run_attempt }} + 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 }} @@ -157,17 +195,37 @@ safe-outputs: SQUAD_IMPLEMENT_PULL_HEAD_REF: ${{ github.event.pull_request.head.ref }} SQUAD_IMPLEMENT_PULL_HEAD_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }} SQUAD_IMPLEMENT_PULL_CREATED_AT: ${{ github.event.pull_request.created_at }} + SQUAD_IMPLEMENT_PULL_NUMBER: ${{ github.event.pull_request.number }} + SQUAD_IMPLEMENT_PULL_MERGED: ${{ github.event.pull_request.merged }} + SQUAD_IMPLEMENT_PULL_BASE_REF: ${{ github.event.pull_request.base.ref }} with: script: | const nodePath = require('node:path'); const { pathToFileURL } = require('node:url'); - // Guard code comes from the default-branch checkout above, never - // from the run's own workspace. + // Guard code comes from the immutable workflow-commit checkout above. const trustedRoot = nodePath.join(process.env.GITHUB_WORKSPACE, '.squad-trusted-base'); const guard = await import(pathToFileURL(nodePath.join( 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 fetchJson = async (route, fields) => + (await github.request(`GET /${route}`, fields)).data; + const provenanceResult = + await implementationProvenance.enforceImplementationProvenanceSafeOutputs( + process.env, + { fetchJson }, + ); + 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}`); @@ -175,10 +233,70 @@ safe-outputs: } for (const line of guard.describeViolations(result.violations)) core.error(`refused: ${line}`); core.setFailed('Squad implement provenance guard refused this run.'); + env: + GITHUB_REPOSITORY_ID: ${{ github.event.repository.id }} + SQUAD_IMPLEMENT_WORKER: squad-implement-worker + SQUAD_IMPLEMENT_ISSUE_NUMBER: ${{ github.event.inputs.issue_number }} + SQUAD_IMPLEMENT_SESSION_ID: ${{ github.event.inputs.implementation_session_id }} + SQUAD_IMPLEMENT_DISPATCHER_WORKFLOW: ${{ github.event.inputs.implementation_session_origin_workflow }} + SQUAD_IMPLEMENT_DISPATCHER_RUN_ID: ${{ github.event.inputs.implementation_session_origin_run_id }} + SQUAD_IMPLEMENT_DISPATCHER_RUN_ATTEMPT: ${{ github.event.inputs.implementation_session_origin_run_attempt }} + SQUAD_IMPLEMENT_WORKFLOW: .github/workflows/squad-implement-worker.lock.yml + SQUAD_IMPLEMENT_NAMESPACE: implement + SQUAD_IMPLEMENT_REQUIRE_LEGACY_MARKER: "true" + SQUAD_IMPLEMENT_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + SQUAD_IMPLEMENT_PULL_NUMBER: ${{ github.event.pull_request.number }} + SQUAD_IMPLEMENT_PULL_MERGED: ${{ github.event.pull_request.merged }} + SQUAD_IMPLEMENT_PULL_BASE_REF: ${{ github.event.pull_request.base.ref }} + SQUAD_IMPLEMENT_PULL_HEAD_REF: ${{ github.event.pull_request.head.ref }} + scripts: + record-implementation-provenance: + description: >- + Emit authoritative implementation provenance as a comment after the + referenced pull request has been created. + inputs: + pull_request: + description: Temporary ID of the create_pull_request output + required: true + type: string + goals_json: + description: JSON array of explicit issue goals for the pull request + required: true + type: string + replaces_json: + description: JSON array of verified pull requests replaced by this pull request + required: true + type: string + 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 fetchJson = async (route, fields) => + (await github.request(`GET /${route}`, fields)).data; + return provenance.emitImplementationProvenanceComment({ + item, + resolvedTemporaryIds, + env: process.env, + fetchJson, + createComment: async (repository, issueNumber, body) => { + const [owner, repo] = repository.split('/'); + await github.rest.issues.createComment({ + owner, + repo, + issue_number: issueNumber, + body, + }); + }, + }); create-pull-request: title-prefix: "[squad] " labels: [squad] max: 1 + require-temporary-id: true # Explicit rather than relying on gh-aw's own default: this worker is now # also reachable from squad-retro's opt-in auto-dispatch (untrusted-origin # action issues), so the draft-only guarantee for every pull request this @@ -558,6 +676,17 @@ 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 is not PR-body text. Give the `create_pull_request` call a + unique `temporary_id`, then immediately call + `record_implementation_provenance` with `pull_request` set to that temporary + ID, `goals_json` containing a JSON array with the primary closing goal plus + any other explicit goals, and `replaces_json` containing a JSON array of only + verified earlier Squad PRs from this + same repository, origin issue, and implementation session. The trusted + handler runs after PR creation, resolves the actual PR number, re-fetches all + replacement evidence, and writes the schema payload as a PR comment. Never + put `Squad implementation provenance:`, `"number": "self"`, or an unresolved + temporary ID in the PR body. - 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..807ec2aae 100644 --- a/workflows/squad-retro.md +++ b/workflows/squad-retro.md @@ -96,26 +96,24 @@ safe-outputs: # open, bot-authored, non-proposal `squad-retro-action` issue carrying the # exact key the dispatch claims. steps: - # UNCONDITIONAL and explicitly pinned to the default branch. squad-retro + # UNCONDITIONAL and explicitly pinned to the executing workflow commit. + # squad-retro # has no `create-pull-request`, so gh-aw adds no checkout to this job at # all; without this one the `import` below would fail on every run and take # the whole output batch — including refusal comments — down with it. # - # `ref:` is spelled out rather than left to actions/checkout's default, - # which would materialize the TRIGGERING ref: a run started from any other - # branch would then authorize itself with that branch's - # `.squad/config.json` and that branch's guard code. `refs/heads/` is - # explicit so an empty `default_branch` fails the checkout (fail closed) - # instead of silently falling back to the triggering ref, and so a tag - # sharing the branch name can never win the lookup. + # `github.workflow_sha` is the immutable commit containing the workflow file + # GitHub selected for this run. Leaving `ref:` implicit would materialize the + # triggering ref and let mutable or pull-request-authored code authorize the + # dispatch. # # `persist-credentials: false` keeps this a read-only materialization, and # `path:` keeps it out of the workspace root so it can never be mistaken # for the repository checkout a safe-output handler operates on. - - name: Checkout trusted base for the dispatch guard + - name: Checkout executing workflow commit for the dispatch guard uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - ref: refs/heads/${{ github.event.repository.default_branch }} + ref: ${{ github.workflow_sha }} persist-credentials: false path: .squad-trusted-base - name: Read sealed retrospective plan @@ -127,6 +125,7 @@ safe-outputs: uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: GITHUB_TOKEN: ${{ github.token }} + GITHUB_REPOSITORY_ID: ${{ github.event.repository.id }} GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} SQUAD_RETRO_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} SQUAD_RETRO_PLAN_PATH: ${{ runner.temp }}/squad-retro-plan/squad-retro-context.json @@ -135,8 +134,8 @@ safe-outputs: const nodePath = require('node:path'); const { pathToFileURL } = require('node:url'); // Everything the guard trusts — its own code and the config that - // enables the relay — is read from the default-branch checkout - // above, never from the run's own workspace. + // enables the relay — is read from the immutable workflow-commit + // checkout above, never from the run's own workspace. const trustedRoot = nodePath.join(process.env.GITHUB_WORKSPACE, '.squad-trusted-base'); const guard = await import(pathToFileURL(nodePath.join( trustedRoot, @@ -645,7 +644,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 seven typed inputs and nothing else: ```json @@ -654,7 +653,11 @@ 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 }}", + "implementation_session_origin_workflow": ".github/workflows/squad-retro.lock.yml", + "implementation_session_origin_run_id": "${{ github.run_id }}", + "implementation_session_origin_run_attempt": "{current-GITHUB_RUN_ATTEMPT-integer}" } } ``` diff --git a/workflows/squad.md b/workflows/squad.md index b8ec4e1c2..71e7ac1f4 100644 --- a/workflows/squad.md +++ b/workflows/squad.md @@ -53,6 +53,8 @@ resources: - shared/squad-improvement-gate.mjs - shared/squad-retro-evidence.mjs - shared/squad-retro-provenance.mjs + - shared/squad-implementation-provenance.mjs + - shared/implementation-provenance-v1.schema.json - shared/builtins/scribe-charter.md - shared/builtins/ralph-charter.md - shared/builtins/rai-charter.md @@ -1338,11 +1340,8 @@ description: Dispatch implementation work to the dependency or general worker. Implement mode dispatches an isolated worker for a regular issue. Explicit, dependency-only Wave 1 work routes to `squad-deps-worker`; every other task routes to `squad-implement-worker`, whose manifest protection remains unchanged. -When invoked on a parent (initiative or epic), this mode descends the sub-issue -hierarchy to the **leaf tasks** and dispatches workers for up to three currently -unblocked leaf tasks. The general worker relays merged implementation pull -requests back to this mode so it can automatically refill the parent's available -slots. +For a parent, descend to **leaf tasks** and dispatch up to three unblocked +leaves. Merged implementation PRs relay back here to refill available slots. **Acknowledge:** Post `🤖 Squad is preparing implementation…` using the `add-comment` safe-output. @@ -1359,26 +1358,24 @@ slots. hierarchy (initiative → epic → task), not just immediate children. Also include open issues whose body contains a `Parent: #{ancestor-issue-number}` line for any ancestor, for compatibility with older plans. -4. Identify the **leaf tasks**: open descendants that have **no sub-issues at - all** — neither open nor closed — and are not labeled `epic` or `initiative`. - Intermediate parents (initiatives and epics that only group other issues) - are never dispatched to a worker — only leaf tasks are implemented. Use "no - sub-issues at all" rather than "no *open* sub-issues": an epic whose children - have all been implemented and closed stays open until someone closes it, and - an open-children-only test would reclassify that drained epic as a leaf and - dispatch a worker against a grouping issue. That is the #1758 defect 2 - failure shape reappearing at the end of an epic's life, and it is reachable - whenever a refill scan descends from the root across sibling epics. +4. Identify the **leaf tasks**: open descendants with no sub-issues (open or closed) + and no `epic` or `initiative` label. Intermediate parents are never dispatched to a worker; + checking only open children would misclassify a drained parent + (#1758). 5. If the target has one or more open leaf descendants, treat the target as a parent and follow the Epic Dispatch procedure below over the leaf-task set. Do not implement the parent body directly. 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. -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. +7. For a leaf target, post the exact five-line receipt below before calling the + selected worker with the four session fields in the Epic Dispatch JSON. + +```text +Squad-Implementation-Dispatch: ${{ github.repository }}#{issue-number} worker={squad-implement-worker-or-squad-deps-worker} +Implementation-Session: squad-implementation-session/v1/${{ github.event.repository.id }}/${{ github.run_id }} +Dispatcher-Workflow: .github/workflows/squad.lock.yml +Dispatcher-Run: ${{ github.run_id }} +Dispatcher-Attempt: {current-GITHUB_RUN_ATTEMPT-integer} +``` ##### Dependency Route Decision [MANDATORY — fail closed] @@ -1435,16 +1432,20 @@ 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 }}", + "implementation_session_origin_workflow": ".github/workflows/squad.lock.yml", + "implementation_session_origin_run_id": "${{ github.run_id }}", + "implementation_session_origin_run_attempt": "{current-GITHUB_RUN_ATTEMPT-integer}" } ``` -Never call the generic `dispatch_workflow` tool. Never emit a dispatch without a -non-empty numeric `issue_number`. 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 -denial; do not reroute it to the general worker. +Never call the generic `dispatch_workflow` tool. Before each selected-worker +call, post the five-line receipt with its worker and numeric +`GITHUB_RUN_ATTEMPT`. Never emit a dispatch without a numeric `issue_number`, +the exact session ID, and all three dispatcher-origin inputs above. Never call both workers for one issue. +Report success only after the selected tool succeeds. A dependency-config denial +leaves its slot unused; do not reroute it to the general worker. Post a comment on the target listing the dispatched leaf tasks, blocked leaf tasks, the worker selected for each dispatch, dependency tasks denied by config,