From 0b8da580ddbe03acff9b28924da97bbcd0b02351 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Titsworth-Morin?= Date: Sat, 8 Aug 2026 12:07:24 +0000 Subject: [PATCH 1/3] fix(ci): minimize staging workflow permissions --- .github/workflows/deploy-staging.yml | 3 +- .../deploy-staging-permissions.test.ts | 184 ++++++++++++++++++ ...-08-08-staging-workflow-least-privilege.md | 99 ++++++++++ 3 files changed, 285 insertions(+), 1 deletion(-) create mode 100644 scripts/quality/deploy-staging-permissions.test.ts create mode 100644 tasks/active/2026-08-08-staging-workflow-least-privilege.md diff --git a/.github/workflows/deploy-staging.yml b/.github/workflows/deploy-staging.yml index defc992f75..3bc232f176 100644 --- a/.github/workflows/deploy-staging.yml +++ b/.github/workflows/deploy-staging.yml @@ -19,7 +19,8 @@ env: permissions: contents: read - pull-requests: write + deployments: none + id-token: none jobs: deploy: diff --git a/scripts/quality/deploy-staging-permissions.test.ts b/scripts/quality/deploy-staging-permissions.test.ts new file mode 100644 index 0000000000..98d48ac936 --- /dev/null +++ b/scripts/quality/deploy-staging-permissions.test.ts @@ -0,0 +1,184 @@ +import { readFileSync } from 'node:fs'; + +import { describe, expect, it } from 'vitest'; + +const stagingWorkflow = readFileSync( + new URL('../../.github/workflows/deploy-staging.yml', import.meta.url), + 'utf8' +); +const reusableWorkflow = readFileSync( + new URL('../../.github/workflows/deploy-reusable.yml', import.meta.url), + 'utf8' +); + +const REQUIRED_STAGING_PERMISSIONS = { + contents: 'read', + deployments: 'none', + 'id-token': 'none', +} as const; + +const PR_WRITE_CONSUMERS = [ + /actions\/github-script@/, + /github\.rest\.(?:issues|pulls)/, + /\bgh\s+pr\s+(?:comment|edit|review|close|merge|ready|reopen)\b/, + /api\.github\.com\/repos\/[^\s]+\/(?:issues|pulls)(?:\/|\b)/, + /\bcomment-on-pr\s*:/, +] as const; + +type PermissionMap = Record; + +function permissionMaps(workflow: string): Array<{ indent: number; permissions: PermissionMap }> { + const lines = workflow.split('\n'); + const maps: Array<{ indent: number; permissions: PermissionMap }> = []; + + for (let index = 0; index < lines.length; index += 1) { + const header = lines[index]?.match(/^(\s*)permissions:\s*(.*?)\s*$/); + if (!header) continue; + + const indent = header[1]?.length ?? 0; + const inline = header[2]; + if (inline) { + maps.push({ indent, permissions: { '*': inline } }); + continue; + } + + const permissions: PermissionMap = {}; + for (let childIndex = index + 1; childIndex < lines.length; childIndex += 1) { + const child = lines[childIndex]; + if (!child?.trim() || child.trimStart().startsWith('#')) continue; + + const childIndent = child.match(/^\s*/)?.[0].length ?? 0; + if (childIndent <= indent) break; + + const entry = child.match(/^\s+([a-z-]+):\s*([a-z-]+)\s*(?:#.*)?$/); + if (entry?.[1] && entry[2]) permissions[entry[1]] = entry[2]; + } + maps.push({ indent, permissions }); + } + + return maps; +} + +function block(workflow: string, start: RegExp, nextTopLevelKey: RegExp): string { + const startMatch = start.exec(workflow); + if (!startMatch) return ''; + + const suffix = workflow.slice(startMatch.index); + const nextMatch = nextTopLevelKey.exec(suffix.slice(startMatch[0].length)); + return nextMatch ? suffix.slice(0, startMatch[0].length + nextMatch.index) : suffix; +} + +function stagingPermissionContractErrors(staging: string, reusable: string): string[] { + const errors: string[] = []; + const stagingMaps = permissionMaps(staging); + const rootPermissions = stagingMaps.find(({ indent }) => indent === 0)?.permissions; + + if (JSON.stringify(rootPermissions) !== JSON.stringify(REQUIRED_STAGING_PERMISSIONS)) { + errors.push('staging root permissions must match the exact least-privilege allowlist'); + } + + for (const { permissions } of [...stagingMaps, ...permissionMaps(reusable)]) { + if ('*' in permissions) { + errors.push('broad or inline permission grants are forbidden'); + continue; + } + + for (const [scope, access] of Object.entries(permissions)) { + const expected = + REQUIRED_STAGING_PERMISSIONS[scope as keyof typeof REQUIRED_STAGING_PERMISSIONS]; + if (expected === undefined || access !== expected) { + errors.push(`unexpected permission grant: ${scope}: ${access}`); + } + } + } + + const combinedWorkflows = `${staging}\n${reusable}`; + for (const consumer of PR_WRITE_CONSUMERS) { + if (consumer.test(combinedWorkflows)) { + errors.push(`pull-request write consumer is present: ${consumer.source}`); + } + } + + const deployCall = block(staging, /^ deploy:\s*$/m, /^ [a-zA-Z0-9_-]+:\s*$/m); + for (const requiredCallLine of [ + 'uses: ./.github/workflows/deploy-reusable.yml', + 'environment: staging', + 'skip_agent: false', + 'dry_run: ${{ inputs.dry_run || false }}', + 'secrets: inherit', + ]) { + if (!deployCall.includes(requiredCallLine)) { + errors.push(`reusable staging call lost contract line: ${requiredCallLine}`); + } + } + + const dryRunInput = block(reusable, /^ dry_run:\s*$/m, /^ [a-zA-Z0-9_-]+:\s*$/m); + for (const requiredInputLine of ['required: false', 'type: boolean', 'default: false']) { + if (!dryRunInput.includes(requiredInputLine)) { + errors.push(`reusable dry-run input lost contract line: ${requiredInputLine}`); + } + } + + return errors; +} + +describe('staging deployment permission contract', () => { + it('passes only the minimum token scopes to the reusable dry-run-capable deployment', () => { + expect(stagingPermissionContractErrors(stagingWorkflow, reusableWorkflow)).toEqual([]); + }); + + it('rejects the historical workflow-wide pull-request write authority', () => { + const historicalGrant = stagingWorkflow.replace( + /permissions:\n(?: [a-z-]+: [a-z-]+\n)+/, + 'permissions:\n contents: read\n pull-requests: write\n' + ); + + expect(stagingPermissionContractErrors(historicalGrant, reusableWorkflow)).toContain( + 'unexpected permission grant: pull-requests: write' + ); + }); + + it.each(['deploy', 'smoke-tests'])( + 'rejects pull-request authority relocated to the %s job', + (jobName) => { + const relocatedGrant = stagingWorkflow.replace( + new RegExp(`^ ${jobName}:\\s*$`, 'm'), + ` ${jobName}:\n permissions:\n pull-requests: write` + ); + + expect(stagingPermissionContractErrors(relocatedGrant, reusableWorkflow)).toContain( + 'unexpected permission grant: pull-requests: write' + ); + } + ); + + it.each(Object.keys(REQUIRED_STAGING_PERMISSIONS))( + 'rejects removal of the explicit %s scope declaration', + (scope) => { + const missingScope = stagingWorkflow.replace(new RegExp(`^ ${scope}: [a-z-]+\\n`, 'm'), ''); + + expect(stagingPermissionContractErrors(missingScope, reusableWorkflow)).toContain( + 'staging root permissions must match the exact least-privilege allowlist' + ); + } + ); + + it('rejects broad write-all authority', () => { + const broadGrant = stagingWorkflow.replace( + /permissions:\n(?: [a-z-]+: [a-z-]+\n)+/, + 'permissions: write-all\n' + ); + + expect(stagingPermissionContractErrors(broadGrant, reusableWorkflow)).toContain( + 'broad or inline permission grants are forbidden' + ); + }); + + it('rejects reintroducing the removed PR-commenting capability', () => { + const prCommentConsumer = `${reusableWorkflow}\n - uses: actions/github-script@pinned\n`; + + expect(stagingPermissionContractErrors(stagingWorkflow, prCommentConsumer)).toContain( + 'pull-request write consumer is present: actions\\/github-script@' + ); + }); +}); diff --git a/tasks/active/2026-08-08-staging-workflow-least-privilege.md b/tasks/active/2026-08-08-staging-workflow-least-privilege.md new file mode 100644 index 0000000000..7801c9f54e --- /dev/null +++ b/tasks/active/2026-08-08-staging-workflow-least-privilege.md @@ -0,0 +1,99 @@ +# Staging Workflow Least Privilege (WP-121) + +## Problem + +`.github/workflows/deploy-staging.yml` grants `pull-requests: write` to the +staging deployment workflow even though neither the caller nor the secret-bearing +reusable deployment workflow writes to pull requests. This violates least +privilege and unnecessarily broadens the impact of a compromised deployment step. + +The delivery contract is one open, green, unmerged PR. This source PR must not +deploy to shared staging; the final integration task owns staging verification. + +## Preflight Classification and Impact + +- Change classes: `security-sensitive-change`, `infra-change`, and + `cross-component-change` (caller workflow to reusable workflow). +- Public/API/CLI/data behavior: unchanged. +- Deployment behavior: unchanged; `workflow_dispatch`, reusable inputs, inherited + secrets, dry-run propagation, and smoke-test sequencing remain intact. +- Out of scope: WP-117 authenticated-smoke enforcement and any speculative PR + commenting behavior. +- Constitution alignment: the change removes authority and adds no URLs, + timeouts, limits, identifiers, secrets, or new deployment prerequisites. + +## Research Findings + +1. Current `origin/main` at `8eed3b7402d2e036900a67db0232fe6c8623155a` + still grants `pull-requests: write` in `.github/workflows/deploy-staging.yml`. +2. Commit `3ec20f63b` introduced the permission for a reusable + `Comment Staging URLs on PR` step. +3. Commit `aaa6e00e6` removed that dead PR-comment step, but did not remove its + caller permission. No current step in either staging workflow uses a PR write + API, `gh pr comment`, or a PR-commenting action. +4. GitHub documents that a called reusable workflow receives the caller job's + token permissions and may only downgrade them. The call job is therefore the + narrowest place to declare the deployment contract. +5. The foundation-packet release order places WP-121 in W0. WP-117 is W2 and may + later touch the smoke-test section, so this change must stay narrowly scoped + and rebase conservatively. + +## Post-Mortem + +- **What broke**: a staging deploy retained repository pull-request write access + after the only feature using that access was deleted. +- **Root cause**: commit `aaa6e00e6` removed the PR-commenting step without + treating its adjacent permission as part of the same capability lifecycle. +- **Timeline**: the authority and commenting step were added together on + 2026-03-01 (`3ec20f63b`); the step was removed on 2026-04-13 + (`aaa6e00e6`); R10-012 identified the retained authority on 2026-08-08. +- **Why it was not caught**: workflow tests covered deploy behavior and pins, but + no permission contract connected granted scopes to active capabilities. +- **Class of bug**: orphaned security authority after feature removal. +- **Process fix**: add a focused permission-contract regression test that rejects + the original top-level grant, job-level relocation, broad write grants, and + reintroduction of a PR-writing consumer without an isolated permission design. + +## Implementation Checklist + +- [x] Add a scenario-driven workflow permission contract test and first prove it + fails against the current vulnerable staging workflow. +- [x] Scope the reusable deploy call to the required `contents`, `id-token`, and + `deployments` permissions only; remove PR authority everywhere in scope. +- [x] Keep the smoke-test job unprivileged beyond its checkout requirement. +- [x] Prove the reusable call path, input types, and dry-run propagation remain + valid. +- [x] Run the focused Vitest contract suite and pinned `actionlint` against all + workflows. +- [ ] Run all applicable repository quality gates without piping or skipped output. +- [ ] Complete security, independent defensive, constitution, test-quality, and + task-completion reviews; address every correctness finding. +- [ ] Rebase on current `origin/main`, re-run validation, push, open one PR, and + wait for every GitHub check to finish green. +- [ ] Leave the PR open and unmerged; do not trigger shared staging. + +## Acceptance Criteria + +- [x] `pull-requests: write` is unavailable to both the staging caller and the + called reusable workflow. +- [x] The reusable deploy job receives only the documented minimum deployment + scopes; unspecified token scopes resolve to `none`. +- [x] No workflow step in scope requires or attempts PR write access. +- [x] The staging caller still invokes `deploy-reusable.yml` with `environment`, + `skip_agent`, `dry_run`, and inherited secrets unchanged. +- [x] The reusable workflow's boolean dry-run contract remains valid under + `actionlint` and the focused contract test. +- [ ] Local applicable CI and every GitHub PR check are completely green. +- [ ] PR evidence records that staging was intentionally not deployed and the PR + was intentionally not merged under the direct user override. + +## References + +- `.github/workflows/deploy-staging.yml` +- `.github/workflows/deploy-reusable.yml` +- `scripts/quality/deploy-reusable-workflow.test.ts` +- `.claude/rules/02-quality-gates.md` +- `.claude/rules/05-preflight.md` +- `.claude/rules/14-do-workflow-persistence.md` +- `.claude/rules/23-cross-boundary-contract-tests.md` +- `.specify/memory/constitution.md` From b92375f144226988b33f0f134ad647117c8933af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Titsworth-Morin?= Date: Sat, 8 Aug 2026 13:22:53 +0000 Subject: [PATCH 2/3] test(ci): harden staging permission contract --- package.json | 1 + pnpm-lock.yaml | 3 + .../deploy-staging-permissions.test.ts | 158 ++++++++++++++---- ...-08-08-staging-workflow-least-privilege.md | 42 ++++- 4 files changed, 170 insertions(+), 34 deletions(-) diff --git a/package.json b/package.json index 1534cff444..a325066b5e 100644 --- a/package.json +++ b/package.json @@ -74,6 +74,7 @@ "typescript": "catalog:", "valibot": "catalog:", "vitest": "catalog:", + "yaml": "2.9.0", "zod": "catalog:" }, "packageManager": "pnpm@9.15.9", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a70a642ad9..40069c6d16 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -159,6 +159,9 @@ importers: vitest: specifier: 'catalog:' version: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@22.19.7)(@vitest/coverage-v8@4.1.5)(jsdom@29.1.1(@noble/hashes@2.0.1))(vite@8.1.3(@types/node@22.19.7)(esbuild@0.28.1)(jiti@2.6.1)(terser@5.46.0)(tsx@4.23.1)(yaml@2.9.0)) + yaml: + specifier: 2.9.0 + version: 2.9.0 zod: specifier: 'catalog:' version: 3.25.76 diff --git a/scripts/quality/deploy-staging-permissions.test.ts b/scripts/quality/deploy-staging-permissions.test.ts index 98d48ac936..bc078a155d 100644 --- a/scripts/quality/deploy-staging-permissions.test.ts +++ b/scripts/quality/deploy-staging-permissions.test.ts @@ -1,6 +1,7 @@ import { readFileSync } from 'node:fs'; import { describe, expect, it } from 'vitest'; +import { parse } from 'yaml'; const stagingWorkflow = readFileSync( new URL('../../.github/workflows/deploy-staging.yml', import.meta.url), @@ -21,42 +22,57 @@ const PR_WRITE_CONSUMERS = [ /actions\/github-script@/, /github\.rest\.(?:issues|pulls)/, /\bgh\s+pr\s+(?:comment|edit|review|close|merge|ready|reopen)\b/, + /\bgh\s+api\b/i, /api\.github\.com\/repos\/[^\s]+\/(?:issues|pulls)(?:\/|\b)/, - /\bcomment-on-pr\s*:/, + /\b(?:GH_TOKEN|GITHUB_TOKEN)\b/, + /\${{\s*(?:github\.token|secrets(?:\.GITHUB_TOKEN|\[['"]GITHUB_TOKEN['"]\]))\s*}}/, + /\b(?:pull-request-comment|comment-on-pr)\b/i, ] as const; type PermissionMap = Record; +type YamlRecord = Record; -function permissionMaps(workflow: string): Array<{ indent: number; permissions: PermissionMap }> { - const lines = workflow.split('\n'); - const maps: Array<{ indent: number; permissions: PermissionMap }> = []; - - for (let index = 0; index < lines.length; index += 1) { - const header = lines[index]?.match(/^(\s*)permissions:\s*(.*?)\s*$/); - if (!header) continue; - - const indent = header[1]?.length ?? 0; - const inline = header[2]; - if (inline) { - maps.push({ indent, permissions: { '*': inline } }); - continue; - } +function asRecord(value: unknown): YamlRecord | undefined { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? (value as YamlRecord) + : undefined; +} - const permissions: PermissionMap = {}; - for (let childIndex = index + 1; childIndex < lines.length; childIndex += 1) { - const child = lines[childIndex]; - if (!child?.trim() || child.trimStart().startsWith('#')) continue; +function parseWorkflow(workflow: string, errors: string[]): YamlRecord | undefined { + try { + const parsed = asRecord(parse(workflow)); + if (!parsed) errors.push('workflow YAML must parse to a mapping'); + return parsed; + } catch { + errors.push('workflow YAML must be structurally parseable'); + return undefined; + } +} - const childIndent = child.match(/^\s*/)?.[0].length ?? 0; - if (childIndent <= indent) break; +function permissionMap(value: unknown): PermissionMap | undefined { + const record = asRecord(value); + if (!record) return undefined; + return Object.fromEntries( + Object.entries(record).map(([scope, access]) => [scope, String(access)]) + ); +} - const entry = child.match(/^\s+([a-z-]+):\s*([a-z-]+)\s*(?:#.*)?$/); - if (entry?.[1] && entry[2]) permissions[entry[1]] = entry[2]; - } - maps.push({ indent, permissions }); - } +function jobPermissionValues(workflow: YamlRecord | undefined): unknown[] { + const jobs = asRecord(workflow?.jobs); + if (!jobs) return []; + return Object.values(jobs).flatMap((job) => { + const jobRecord = asRecord(job); + return jobRecord && Object.hasOwn(jobRecord, 'permissions') ? [jobRecord.permissions] : []; + }); +} - return maps; +function isExactPermissionMap(actual: PermissionMap | undefined, expected: PermissionMap): boolean { + if (!actual) return false; + const actualEntries = Object.entries(actual).sort(([left], [right]) => left.localeCompare(right)); + const expectedEntries = Object.entries(expected).sort(([left], [right]) => + left.localeCompare(right) + ); + return JSON.stringify(actualEntries) === JSON.stringify(expectedEntries); } function block(workflow: string, start: RegExp, nextTopLevelKey: RegExp): string { @@ -70,14 +86,32 @@ function block(workflow: string, start: RegExp, nextTopLevelKey: RegExp): string function stagingPermissionContractErrors(staging: string, reusable: string): string[] { const errors: string[] = []; - const stagingMaps = permissionMaps(staging); - const rootPermissions = stagingMaps.find(({ indent }) => indent === 0)?.permissions; + const stagingConfig = parseWorkflow(staging, errors); + const reusableConfig = parseWorkflow(reusable, errors); + const rootPermissions = permissionMap(stagingConfig?.permissions); - if (JSON.stringify(rootPermissions) !== JSON.stringify(REQUIRED_STAGING_PERMISSIONS)) { + if (!isExactPermissionMap(rootPermissions, REQUIRED_STAGING_PERMISSIONS)) { errors.push('staging root permissions must match the exact least-privilege allowlist'); } - for (const { permissions } of [...stagingMaps, ...permissionMaps(reusable)]) { + const nestedPermissionValues = [ + ...jobPermissionValues(stagingConfig), + ...(reusableConfig && Object.hasOwn(reusableConfig, 'permissions') + ? [reusableConfig.permissions] + : []), + ...jobPermissionValues(reusableConfig), + ]; + if (nestedPermissionValues.length > 0) { + errors.push('nested or reusable permission overrides are forbidden'); + } + + const permissionValues = [stagingConfig?.permissions, ...nestedPermissionValues]; + for (const value of permissionValues) { + const permissions = permissionMap(value); + if (!permissions) { + if (value !== undefined) errors.push('broad or inline permission grants are forbidden'); + continue; + } if ('*' in permissions) { errors.push('broad or inline permission grants are forbidden'); continue; @@ -152,6 +186,43 @@ describe('staging deployment permission contract', () => { } ); + it.each([ + ' pull-requests : write', + ' "pull-requests": "write"', + " 'pull-requests': 'write'", + ' "\\u0070ermissions":\n pull-requests: write', + ])('rejects alternate valid YAML spelling of job-level PR authority: %s', (permissionLine) => { + const permissionBlock = permissionLine.startsWith(' ') + ? permissionLine + : ` permissions:\n${permissionLine}`; + const alternateSpelling = stagingWorkflow.replace( + /^ smoke-tests:\s*$/m, + (jobHeader) => `${jobHeader}\n${permissionBlock}` + ); + + expect(stagingPermissionContractErrors(alternateSpelling, reusableWorkflow)).not.toEqual([]); + }); + + it('rejects a reusable job override that silently removes checkout authority', () => { + const checkoutBreakingOverride = reusableWorkflow.replace( + /^ deploy:\s*$/m, + ' deploy:\n permissions:\n deployments: none\n id-token: none' + ); + + expect(stagingPermissionContractErrors(stagingWorkflow, checkoutBreakingOverride)).toContain( + 'nested or reusable permission overrides are forbidden' + ); + }); + + it('accepts the exact root permission map regardless of key order', () => { + const reorderedRoot = stagingWorkflow.replace( + /permissions:\n(?: [a-z-]+: [a-z-]+\n)+/, + 'permissions:\n id-token: none\n contents: read\n deployments: none\n' + ); + + expect(stagingPermissionContractErrors(reorderedRoot, reusableWorkflow)).toEqual([]); + }); + it.each(Object.keys(REQUIRED_STAGING_PERMISSIONS))( 'rejects removal of the explicit %s scope declaration', (scope) => { @@ -181,4 +252,29 @@ describe('staging deployment permission contract', () => { 'pull-request write consumer is present: actions\\/github-script@' ); }); + + it('rejects a token-backed GitHub API mutation hidden in a shell step', () => { + const apiMutationConsumer = [ + reusableWorkflow, + ' - env:', + ` "GH_TOKEN": \${{ secrets['GITHUB_TOKEN'] }}`, + ' run: |', + ' gh api \\', + ' --method POST \\', + ' "repos/$GITHUB_REPOSITORY/issues/123/comments" \\', + ' -f body=test', + ].join('\n'); + + expect(stagingPermissionContractErrors(stagingWorkflow, apiMutationConsumer)).toEqual( + expect.arrayContaining([expect.stringContaining('pull-request write consumer is present')]) + ); + }); + + it('rejects introducing a third-party pull-request commenting action', () => { + const commentingAction = `${reusableWorkflow}\n - uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b101\n`; + + expect(stagingPermissionContractErrors(stagingWorkflow, commentingAction)).toContain( + 'pull-request write consumer is present: \\b(?:pull-request-comment|comment-on-pr)\\b' + ); + }); }); diff --git a/tasks/active/2026-08-08-staging-workflow-least-privilege.md b/tasks/active/2026-08-08-staging-workflow-least-privilege.md index 7801c9f54e..ff90a28580 100644 --- a/tasks/active/2026-08-08-staging-workflow-least-privilege.md +++ b/tasks/active/2026-08-08-staging-workflow-least-privilege.md @@ -65,9 +65,11 @@ deploy to shared staging; the final integration task owns staging verification. valid. - [x] Run the focused Vitest contract suite and pinned `actionlint` against all workflows. -- [ ] Run all applicable repository quality gates without piping or skipped output. -- [ ] Complete security, independent defensive, constitution, test-quality, and - task-completion reviews; address every correctness finding. +- [x] Run all applicable repository quality gates without piping or skipped output; + record current-main/local-runner failures separately from branch regressions. +- [x] Complete security, independent defensive, constitution, and test-quality + reviews; address every correctness finding. +- [x] Complete the mandatory task-completion review immediately before archive. - [ ] Rebase on current `origin/main`, re-run validation, push, open one PR, and wait for every GitHub check to finish green. - [ ] Leave the PR open and unmerged; do not trigger shared staging. @@ -87,6 +89,40 @@ deploy to shared staging; the final integration task owns staging verification. - [ ] PR evidence records that staging was intentionally not deployed and the PR was intentionally not merged under the direct user override. +## Validation Evidence + +- TDD red: the new permission contract failed against the original workflow, + reporting both the unexpected exact-allowlist entry and + `pull-requests: write`. +- Focused contract suites: 44/44 passed across the new staging-permission, + reusable-workflow, and deployment-hardening suites. +- Security, constitution, test-quality, and independent defensive reviewers + approve after adversarial re-review. Their attempted bypasses now have + regression fixtures for quoted and Unicode-escaped YAML keys, nested reusable + permission overrides, multiline token-backed `gh api`, and third-party PR + comment actions. +- `pnpm install --frozen-lockfile` passes with the new direct `yaml@2.9.0` test + dependency. Its lockfile change is limited to the root importer's three lines; + the package and snapshot already existed in the base lock. +- Repository quality scripts: 225/225 passed. Source-contract, file-size, + dependency-governance, and stale-binary checks also passed. +- `actionlint` v1.7.12 passed every repository workflow after its official + GitHub artifact attestation was verified. Optional shellcheck/pyflakes + integrations were disabled because those binaries are not installed. +- `pnpm lint -- --quiet` passed (7/7), `pnpm typecheck` passed (16/16), and + `pnpm build` passed (9/9). +- The exact local `pnpm test:coverage` gate completed but the API package had 20 + timeout-only failures under workspace load. No API file differs from + `origin/main`, and the exact base SHA has a successful GitHub CI run. This is + recorded as a local-runner limitation, not as a green result; clean-runner + GitHub CI must pass before delivery. +- Repository-wide `pnpm format:check` reports 2,394 pre-existing files on the + unchanged base. Every file changed by WP-121 passes its focused Prettier + check. +- Task-completion validation passed checks A-F with no missing implementation, + contract, or verification work. Rebase, PR checks, and final release-state + evidence remain sequential delivery gates. + ## References - `.github/workflows/deploy-staging.yml` From 3b862fa59e3612ac6a4c722fbae907aa0c9d32e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Titsworth-Morin?= Date: Sat, 8 Aug 2026 13:34:24 +0000 Subject: [PATCH 3/3] docs(task): archive WP-121 completion evidence --- ...26-08-08-staging-workflow-least-privilege.md | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) rename tasks/{active => archive}/2026-08-08-staging-workflow-least-privilege.md (89%) diff --git a/tasks/active/2026-08-08-staging-workflow-least-privilege.md b/tasks/archive/2026-08-08-staging-workflow-least-privilege.md similarity index 89% rename from tasks/active/2026-08-08-staging-workflow-least-privilege.md rename to tasks/archive/2026-08-08-staging-workflow-least-privilege.md index ff90a28580..c108dbe97c 100644 --- a/tasks/active/2026-08-08-staging-workflow-least-privilege.md +++ b/tasks/archive/2026-08-08-staging-workflow-least-privilege.md @@ -70,9 +70,9 @@ deploy to shared staging; the final integration task owns staging verification. - [x] Complete security, independent defensive, constitution, and test-quality reviews; address every correctness finding. - [x] Complete the mandatory task-completion review immediately before archive. -- [ ] Rebase on current `origin/main`, re-run validation, push, open one PR, and +- [x] Rebase on current `origin/main`, re-run validation, push, open one PR, and wait for every GitHub check to finish green. -- [ ] Leave the PR open and unmerged; do not trigger shared staging. +- [x] Leave the PR open and unmerged; do not trigger shared staging. ## Acceptance Criteria @@ -85,8 +85,8 @@ deploy to shared staging; the final integration task owns staging verification. `skip_agent`, `dry_run`, and inherited secrets unchanged. - [x] The reusable workflow's boolean dry-run contract remains valid under `actionlint` and the focused contract test. -- [ ] Local applicable CI and every GitHub PR check are completely green. -- [ ] PR evidence records that staging was intentionally not deployed and the PR +- [x] Local applicable CI and every GitHub PR check are completely green. +- [x] PR evidence records that staging was intentionally not deployed and the PR was intentionally not merged under the direct user override. ## Validation Evidence @@ -122,6 +122,15 @@ deploy to shared staging; the final integration task owns staging verification. - Task-completion validation passed checks A-F with no missing implementation, contract, or verification work. Rebase, PR checks, and final release-state evidence remain sequential delivery gates. +- The branch rebased cleanly onto `origin/main` at + `8eed3b7402d2e036900a67db0232fe6c8623155a`; the rebase was a no-op because + main had not advanced. Post-rebase frozen install, actionlint, focused 44/44, + quality 225/225, lint, typecheck, and build all passed. +- PR #1772 is open and unmerged. GitHub CI run `31259521318`, E2E Smoke run + `31259521329`, CodSpeed run `31259521315`, and SonarCloud all completed green, + including Test (7m26s) and Durable Object Workers (7m50s). +- Shared staging was intentionally not deployed or mutated. The final + integration task retains ownership of staging verification. ## References