From 712e4cccc31ee6946e3aaa4979fdc1f0e7a770f3 Mon Sep 17 00:00:00 2001 From: leyoonafr Date: Mon, 27 Jul 2026 01:21:44 +0800 Subject: [PATCH 1/4] fix trusted verifier synchronization --- .github/workflows/issue-dev-loop-evidence.yml | 8 +- loops/issue-dev-loop/LOOP.md | 4 +- loops/issue-dev-loop/SKILL.md | 4 +- .../references/github-operations.md | 4 +- loops/issue-dev-loop/scripts/lib/evidence.mjs | 11 +- .../scripts/lib/github-identity.mjs | 4 + .../issue-dev-loop/scripts/lib/validation.mjs | 12 +- .../validate-candidate-control-plane.mjs | 47 +++++++- .../tests/github-identity-routing.test.mjs | 7 +- loops/issue-dev-loop/tests/runtime.test.mjs | 110 ++++++++++++++++++ 10 files changed, 193 insertions(+), 18 deletions(-) diff --git a/.github/workflows/issue-dev-loop-evidence.yml b/.github/workflows/issue-dev-loop-evidence.yml index 6b62c6ee..7948cf4b 100644 --- a/.github/workflows/issue-dev-loop-evidence.yml +++ b/.github/workflows/issue-dev-loop-evidence.yml @@ -105,16 +105,16 @@ jobs: - name: Protect trusted control plane if: steps.run.outputs.has_run == 'true' run: >- - node trusted/loops/issue-dev-loop/scripts/validate-candidate-control-plane.mjs --loop-root candidate/loops/issue-dev-loop --run-id "${{ steps.run.outputs.run_id }}" --base-sha "${{ steps.run.outputs.base_sha }}" --head-sha "${{ github.event.pull_request.head.sha }}" + node control/loops/issue-dev-loop/scripts/validate-candidate-control-plane.mjs --loop-root candidate/loops/issue-dev-loop --run-id "${{ steps.run.outputs.run_id }}" --base-sha "${{ steps.run.outputs.base_sha }}" --head-sha "${{ github.event.pull_request.head.sha }}" --trusted-control-sha "${{ github.event.pull_request.base.sha }}" - name: Protect append-only loop history if: steps.run.outputs.has_run == 'true' run: >- - node trusted/loops/issue-dev-loop/scripts/validate-history.mjs --loop-root candidate/loops/issue-dev-loop --base-ref "${{ steps.run.outputs.base_sha }}" + node control/loops/issue-dev-loop/scripts/validate-history.mjs --loop-root candidate/loops/issue-dev-loop --base-ref "${{ steps.run.outputs.base_sha }}" - name: Build trusted verifier image run: >- - docker build --tag echo-ui-loop-verifier:${{ github.run_id }}-${{ github.run_attempt }} --file trusted/loops/issue-dev-loop/scripts/verifier.Dockerfile trusted/loops/issue-dev-loop/scripts + docker build --tag echo-ui-loop-verifier:${{ github.run_id }}-${{ github.run_attempt }} --file control/loops/issue-dev-loop/scripts/verifier.Dockerfile control/loops/issue-dev-loop/scripts - name: Prepare isolated candidate and baseline volumes shell: bash @@ -191,7 +191,7 @@ jobs: - name: Generate exact-head manifest if: steps.run.outputs.has_run == 'true' && always() run: >- - node trusted/loops/issue-dev-loop/scripts/generate-evidence.mjs --loop-root candidate/loops/issue-dev-loop --run-id "${{ steps.run.outputs.run_id }}" --head-sha "${{ github.event.pull_request.head.sha }}" --trusted-workflow-sha "${{ steps.run.outputs.base_sha }}" --workflow-base-sha "${{ github.event.pull_request.base.sha }}" --workflow-run-sha "${{ github.event.pull_request.head.sha }}" --status "${{ steps.verify.outputs.verdict || 'blocked' }}" --baseline-status "${{ steps.verify.outputs.baseline_status || 'blocked' }}" --started-at "${{ steps.verify.outputs.started_at || github.event.pull_request.updated_at }}" --finished-at "${{ steps.verify.outputs.finished_at || github.event.pull_request.updated_at }}" --output "${RUNNER_TEMP}/issue-dev-evidence/manifest.json" + node control/loops/issue-dev-loop/scripts/generate-evidence.mjs --loop-root candidate/loops/issue-dev-loop --run-id "${{ steps.run.outputs.run_id }}" --head-sha "${{ github.event.pull_request.head.sha }}" --trusted-workflow-sha "${{ steps.run.outputs.base_sha }}" --workflow-base-sha "${{ github.event.pull_request.base.sha }}" --workflow-run-sha "${{ github.event.pull_request.head.sha }}" --status "${{ steps.verify.outputs.verdict || 'blocked' }}" --baseline-status "${{ steps.verify.outputs.baseline_status || 'blocked' }}" --started-at "${{ steps.verify.outputs.started_at || github.event.pull_request.updated_at }}" --finished-at "${{ steps.verify.outputs.finished_at || github.event.pull_request.updated_at }}" --output "${RUNNER_TEMP}/issue-dev-evidence/manifest.json" - name: Upload review evidence if: steps.run.outputs.has_run == 'true' && always() diff --git a/loops/issue-dev-loop/LOOP.md b/loops/issue-dev-loop/LOOP.md index dd91b1e6..41e56af9 100644 --- a/loops/issue-dev-loop/LOOP.md +++ b/loops/issue-dev-loop/LOOP.md @@ -79,11 +79,11 @@ Complete the generated handoff with acceptance criteria, scope, TDD seams, requi Explicitly invoke `$implement`. The orchestrator does not write product code. `$implement` owns TDD at agreed seams, implementation, regular typechecking and targeted tests, the final full suite, `$code-review`, and a local commit. It must not push, create a PR, or merge. Every invocation writes a unique schema-validated result with its invocation ID, timestamps, frozen brief digest, passed checks, and a new commit descending from the prior implementation commit (or the frozen base SHA for the first invocation); record it before PR publication or update. -The recorded implementation commit is the product-code boundary. Later commits may contain only the current run's handoff, sanitized logs, screenshots, and evidence. `record-pr` diffs the implementation commit against the proposed head and rejects every other path, so the orchestrator cannot append unrecorded product changes. Issue runs may not modify the loop runtime, owner channel, workflow, package manifests, verification scripts, or verification configuration. Such control-plane work belongs to a dedicated owner-reviewed evolve/bootstrap PR and becomes executable only after installation from a clean owner-merged `dev`. +The recorded implementation commit is the product-code boundary. Later commits may contain only the current run's handoff, sanitized logs, screenshots, and evidence. `record-pr` diffs the implementation commit against the proposed head and rejects every other path, so the orchestrator cannot append unrecorded product changes. Issue runs may not modify the loop runtime, owner channel, workflow, package manifests, verification scripts, or verification configuration, except for an explicitly allowlisted verifier synchronization whose Git tree entry exactly matches the live owner-merged control SHA. Such control-plane work and every change to that narrow exception belong to a dedicated owner-reviewed evolve/bootstrap PR and become executable only after installation from a clean owner-merged `dev`. ### 5. Verify before publication -Run relevant checks and `pnpm verify`. For UI behavior, capture before/after screenshots under `screen-shots/` at meaningful desktop and mobile viewports and include interaction or accessibility evidence where applicable. Bind `before` to the frozen base and `after` to the latest `$implement` commit; never put the containing commit's not-yet-known hash inside its own files. Commit sanitized screenshots and run metadata to the issue branch. The Draft PR body must render at least one representative before/after pair with Markdown image syntax, using `raw.githubusercontent.com` URLs pinned to the exact recorded head; links to an index or manifest alone are not UI evidence. The low-privilege `pull_request` evidence workflow checks out the exact candidate head, resolves its frozen run base, proves that base remains an ancestor of live `dev`, and separately checks out the immutable owner-merged base without persisted credentials. It installs candidate and baseline dependencies with lifecycle scripts disabled into distinct Docker volumes. Candidate `pnpm verify` and the actual frozen owner-merged baseline `pnpm test` run in separate no-network containers that receive no GitHub token and cannot mount any host checkout. Before accepting the artifact, the installed control plane independently rejects any exact-head change to the workflow or trusted control/verification plane. The manifest binds candidate head, workflow-run SHA, frozen owner-merged base SHA, and the live PR base SHA that selected the workflow. +Run relevant checks and `pnpm verify`. For UI behavior, capture before/after screenshots under `screen-shots/` at meaningful desktop and mobile viewports and include interaction or accessibility evidence where applicable. Bind `before` to the frozen base and `after` to the latest `$implement` commit; never put the containing commit's not-yet-known hash inside its own files. Commit sanitized screenshots and run metadata to the issue branch. The Draft PR body must render at least one representative before/after pair with Markdown image syntax, using `raw.githubusercontent.com` URLs pinned to the exact recorded head; links to an index or manifest alone are not UI evidence. The low-privilege `pull_request` evidence workflow checks out the exact candidate head, resolves its frozen run base, proves that base remains an ancestor of live `dev`, and separately checks out the immutable owner-merged base without persisted credentials. It installs candidate and baseline dependencies with lifecycle scripts disabled into distinct Docker volumes. Candidate `pnpm verify` and the actual frozen owner-merged baseline `pnpm test` run in separate no-network containers that receive no GitHub token and cannot mount any host checkout. The live owner-merged control plane may permit an old active run to synchronize only an explicitly allowlisted verifier file whose Git tree entry is byte-for-byte and mode-for-mode identical to the live PR base; every other candidate change to the workflow or trusted control/verification plane remains forbidden. Before accepting the artifact, the installed control plane independently revalidates that exact synchronization against the manifest's live workflow base SHA. The manifest binds candidate head, workflow-run SHA, frozen owner-merged base SHA, and the live PR base SHA that selected the workflow. ### 6. Create the draft PR diff --git a/loops/issue-dev-loop/SKILL.md b/loops/issue-dev-loop/SKILL.md index 18857613..6d38411d 100644 --- a/loops/issue-dev-loop/SKILL.md +++ b/loops/issue-dev-loop/SKILL.md @@ -7,7 +7,7 @@ Run exactly one bounded issue cycle. Treat [`LOOP.md`](./LOOP.md) as the constit ## Start safely 1. Read `LOOP.md`, `state.md`, and `dependencies.md` completely. -2. Require absolute `ECHO_UI_LOOP_CONTROL_PLANE` and `ECHO_UI_LOOP_TARGET_ROOT` values from the scheduler. Run activation through `"$ECHO_UI_LOOP_CONTROL_PLANE/scripts/with-github-identity" --loop-root "$ECHO_UI_LOOP_TARGET_ROOT" automation -- node "$ECHO_UI_LOOP_CONTROL_PLANE/scripts/loopctl.mjs" validate --activation --loop-root "$ECHO_UI_LOOP_TARGET_ROOT"`. The installed launcher verifies its hash manifest, probes both configured profiles, and attempts full validation first. It may fall back for an older target only after excluding durably finalized runs, matching the worktree to an automation-authored remote active checkpoint, proving the clean event-derived branch and head without index concealment, and proving the issue diff did not modify the protected control or verification plane. A one-use in-memory router capability then checks stable target state, owner channel, JSON history, and a conservatively parsed low-privilege evidence workflow before rechecking the exact clean worktree. Activation, detection, and restore share the same event-derived head resolver. There is no standalone reduced validator, and callers and the public validation API cannot request this mode. +2. Require absolute `ECHO_UI_LOOP_CONTROL_PLANE` and `ECHO_UI_LOOP_TARGET_ROOT` values from the scheduler. Run activation through `"$ECHO_UI_LOOP_CONTROL_PLANE/scripts/with-github-identity" --loop-root "$ECHO_UI_LOOP_TARGET_ROOT" automation -- node "$ECHO_UI_LOOP_CONTROL_PLANE/scripts/loopctl.mjs" validate --activation --loop-root "$ECHO_UI_LOOP_TARGET_ROOT"`. The installed launcher verifies its hash manifest, probes both configured profiles, and attempts full validation first. It may fall back for an older target only after excluding durably finalized runs, matching the worktree to an automation-authored remote active checkpoint, proving the clean event-derived branch and head without index concealment, and proving the issue diff did not modify the protected control or verification plane except for an allowlisted verifier entry exactly matching the installed owner-merged source commit. A one-use in-memory router capability then checks stable target state, owner channel, JSON history, and a conservatively parsed low-privilege evidence workflow before rechecking the exact clean worktree. Activation, detection, and restore share the same event-derived head resolver. There is no standalone reduced validator, and callers and the public validation API cannot request this mode. 3. Read [`references/github-operations.md`](./references/github-operations.md). Run every operational `loopctl`, executor GitHub command, remote Git command, trigger, and reviewer publication through the installed control plane with the explicit target root. Never use the credential-refusing repository launcher, invoke the `.mjs` router directly, install control code from an issue branch, or alter global `gh` or Git credential configuration. 4. Run `loopctl.mjs reconcile` through the automation wrapper to rebuild verified terminal history, pending/completed evolve state, and active runs from the append-only GitHub state journal. It tombstones local terminal-cache rows with no durable counterpart before recomputing metrics. For returned `workType: resume`, fetch the recorded branch through the wrapper, create a clean isolated worktree at the returned exact head, and run `restore-checkpoint --run-id ` inside that worktree. The restore command rejects the wrong branch, a dirty checkout, or any head other than the durable head. Resume it before selecting a new issue. 5. Run `loopctl.mjs evolve-status`. If `evolveDue` is true, start `echo_ui_loop_evolver` with fresh context; do not silently replace it with product work. @@ -54,7 +54,7 @@ The executor must classify every finding as `accepted`, `rejected`, `needs-human ## Verify and notify the owner -Run verification appropriate to the change and require `pnpm verify` before the PR is ready for owner review. Commit sanitized run metadata and relevant `screen-shots` to the issue branch. Wait for the low-privilege `pull_request` evidence workflow: it prepares candidate and frozen-baseline dependencies with lifecycle scripts disabled and runs candidate `pnpm verify` plus the actual frozen owner-merged `pnpm test` in separate no-network Docker volumes with no GitHub token or host-checkout mount. The frozen-base generator binds candidate head, workflow-run SHA, frozen owner-merged base SHA, and live workflow base SHA. Download its artifact and run `loopctl.mjs record-evidence --run-id --manifest --publication-url ` from the exact artifact-head worktree; the installed control plane revalidates the protected diff locally before querying GitHub. +Run verification appropriate to the change and require `pnpm verify` before the PR is ready for owner review. Commit sanitized run metadata and relevant `screen-shots` to the issue branch. An old active run may synchronize an explicitly allowlisted verifier file only when its Git tree entry exactly matches the live owner-merged PR base; do not hand-edit that file or synchronize any other protected path. Wait for the low-privilege `pull_request` evidence workflow: it prepares candidate and frozen-baseline dependencies with lifecycle scripts disabled and runs candidate `pnpm verify` plus the actual frozen owner-merged `pnpm test` in separate no-network Docker volumes with no GitHub token or host-checkout mount. The live owner-merged control plane validates any allowlisted synchronization, while the frozen base remains the product baseline. The generator binds candidate head, workflow-run SHA, frozen owner-merged base SHA, and live workflow base SHA. Download its artifact and run `loopctl.mjs record-evidence --run-id --manifest --publication-url ` from the exact artifact-head worktree; the installed control plane revalidates the protected diff locally before querying GitHub. Before publishing the final review round, write the complete cycle result with an unassigned final `reviewUrl`, then run `loopctl.mjs review-digest --result `. Add the returned marker to the final review body, publish the non-approving review, replace the unassigned URL with GitHub's actual review URL, and confirm `review-digest` is unchanged. After all responses are posted, run `loopctl.mjs record-review --run-id --result --review-url `. The publication digest deliberately canonicalizes GitHub-assigned review URLs while the stored full-file digest still protects the final artifact. Both evidence and review gates must name the current PR head. Keep the PR Draft, emit a blocking `pr_ready_for_review` notification asking `codeacme17` to mark it Ready and review it, then transition from `waiting_for_owner` to `awaiting_owner_review` with the PR URL and exact head SHA. diff --git a/loops/issue-dev-loop/references/github-operations.md b/loops/issue-dev-loop/references/github-operations.md index 98e9aa49..172c32a8 100644 --- a/loops/issue-dev-loop/references/github-operations.md +++ b/loops/issue-dev-loop/references/github-operations.md @@ -10,7 +10,7 @@ Run every executor GitHub command through: `ECHO_UI_LOOP_CONTROL_PLANE` must name the versioned installation created from a clean owner-merged `dev`; `ECHO_UI_LOOP_TARGET_ROOT` names the active worktree's `loops/issue-dev-loop`. Operational `loopctl` and trigger commands must use the scripts inside the installed root and pass `--loop-root "$ECHO_UI_LOOP_TARGET_ROOT"`. The repository launcher intentionally refuses credentials. -For a durable active run whose target predates newer trusted runtime files, the installed activation router attempts full validation first. Only if that fails may it select historical-target compatibility, and only after excluding durably finalized runs, matching an automation-authored remote active checkpoint, proving its event-derived clean exact branch and head with no index concealment flags, and proving the issue diff does not modify the protected control or verification plane. A one-use in-memory router capability then checks stable target state, owner channel, JSON history, and conservatively rejects unrecognized YAML, triggers, or job-level/write permissions before rechecking the worktree. Activation, detection, and restore use the same checkpoint-head resolver. No standalone reduced validator exists, and caller-supplied flags and the public validation API cannot select the reduced mode. +For a durable active run whose target predates newer trusted runtime files, the installed activation router attempts full validation first. Only if that fails may it select historical-target compatibility, and only after excluding durably finalized runs, matching an automation-authored remote active checkpoint, proving its event-derived clean exact branch and head with no index concealment flags, and proving the issue diff does not modify the protected control or verification plane except for an allowlisted verifier entry whose Git tree entry exactly matches the installed owner-merged source commit. A one-use in-memory router capability then checks stable target state, owner channel, JSON history, and conservatively rejects unrecognized YAML, triggers, or job-level/write permissions before rechecking the worktree. Activation, detection, and restore use the same checkpoint-head resolver. No standalone reduced validator exists, and caller-supplied flags and the public validation API cannot select the reduced mode. Run every reviewer publication command through the installed wrapper with role `reviewer`. Before reading either profile, it verifies every installed file, pins absolute Node/Git/`gh` executables, compares the target's security-critical owner-channel values to its trusted copy, removes token environment overrides, runs `gh api user`, and refuses an unexpected or owner identity. For Git, it clears global credential helpers and injects `gh auth git-credential` for the entire trusted child tree. Descendant `git` and `gh` processes pass through a role gate; arbitrary `sh`, `env`, Node scripts, caller PATH shims, and issue-worktree router changes are not authenticated. Never use owner credentials for executor or reviewer actions, never run raw remote `gh`/`git push` commands, and never call `gh auth setup-git`. @@ -44,7 +44,7 @@ The shell launcher removes Node preload hooks before starting the router. The ro ## Evidence artifact -The low-privilege `pull_request` workflow `Issue dev loop evidence` runs only when the branch contains one active issue run. All checkouts disable persisted credentials. It resolves the run's frozen base from the exact candidate, proves that base remains an ancestor of live `dev`, and checks out that immutable owner-merged commit separately from the current control checkout. Container preparation installs the protected candidate and frozen-baseline lockfiles with lifecycle scripts disabled into independent Docker volumes before candidate code runs. Candidate `pnpm verify` and the actual frozen owner-merged baseline `pnpm test` run with `--network none`, no inherited GitHub token, and no mount of any host checkout. The frozen-base generator remains outside those containers and binds the manifest to candidate head, workflow-run SHA, frozen owner-merged base SHA, and the live PR base SHA. The installed control plane later rejects the artifact unless its own NUL-safe, rename-disabled exact-head diff check proves that the PR did not change either side of a loop/control rename, the owner channel, workflow, agent/prompt configuration, deployment-provider configuration, verification scripts, package manifests, lock/workspace files, package hooks, or verification configuration. Exact protected root entries and symlinks are rejected as well as descendants. Wait for that exact-head run to complete, then locate and download the artifact: +The low-privilege `pull_request` workflow `Issue dev loop evidence` runs only when the branch contains one active issue run. All checkouts disable persisted credentials. It resolves the run's frozen base from the exact candidate, proves that base remains an ancestor of live `dev`, and checks out that immutable owner-merged commit separately from the current control checkout. The live owner-merged checkout supplies the validator, history checker, verifier image, and manifest generator; the frozen checkout remains the product baseline. Container preparation installs the protected candidate and frozen-baseline lockfiles with lifecycle scripts disabled into independent Docker volumes before candidate code runs. Candidate `pnpm verify` and the actual frozen owner-merged baseline `pnpm test` run with `--network none`, no inherited GitHub token, and no mount of any host checkout. The generator remains outside those containers and binds the manifest to candidate head, workflow-run SHA, frozen owner-merged base SHA, and the live PR base SHA. The installed control plane later rejects the artifact unless its own NUL-safe, rename-disabled exact-head diff check proves that the PR did not change either side of a loop/control rename, the owner channel, workflow, agent/prompt configuration, deployment-provider configuration, package manifests, lock/workspace files, package hooks, or verification configuration. Verification scripts remain protected except for an explicit allowlist that permits only a regular-file Git tree entry exactly identical in content and mode to the manifest-bound live owner-merged base. Exact protected root entries and symlinks are rejected as well as descendants. Wait for that exact-head run to complete, then locate and download the artifact: ```text "$ECHO_UI_LOOP_CONTROL_PLANE/scripts/with-github-identity" --loop-root "$ECHO_UI_LOOP_TARGET_ROOT" automation -- gh run list --workflow issue-dev-loop-evidence.yml --branch codex/issue- diff --git a/loops/issue-dev-loop/scripts/lib/evidence.mjs b/loops/issue-dev-loop/scripts/lib/evidence.mjs index ab950f90..177b3247 100644 --- a/loops/issue-dev-loop/scripts/lib/evidence.mjs +++ b/loops/issue-dev-loop/scripts/lib/evidence.mjs @@ -70,7 +70,13 @@ async function defaultArtifactManifestLoader({ owner, repo, runId, artifactName } } -async function defaultCandidateControlPlaneVerifier({ loopRoot, runId, baseSha, headSha }) { +async function defaultCandidateControlPlaneVerifier({ + loopRoot, + runId, + baseSha, + headSha, + trustedControlSha, +}) { await execFileAsync( process.execPath, [ @@ -83,6 +89,8 @@ async function defaultCandidateControlPlaneVerifier({ loopRoot, runId, baseSha, baseSha, '--head-sha', headSha, + '--trusted-control-sha', + trustedControlSha, ], { maxBuffer: 4 * 1024 * 1024 }, ) @@ -287,6 +295,7 @@ export async function recordEvidence({ runId: normalizedRunId, baseSha: run.baseSha, headSha, + trustedControlSha: manifest.workflowBaseSha, }) if (manifest.verdict !== 'passed') throw new Error('evidence manifest must have passed verdict') const publishedEvidenceUrl = assertHttpUrl(publicationUrl, 'publicationUrl') diff --git a/loops/issue-dev-loop/scripts/lib/github-identity.mjs b/loops/issue-dev-loop/scripts/lib/github-identity.mjs index f814c1b3..ae6378a5 100644 --- a/loops/issue-dev-loop/scripts/lib/github-identity.mjs +++ b/loops/issue-dev-loop/scripts/lib/github-identity.mjs @@ -1465,6 +1465,7 @@ async function authorizeHistoricalTargetValidation({ realGh, realNode, environment, + trustedControlSha, }) { const localIssue = authorization.issue const repositoryRoot = repositoryRootForLoop(loopRoot) @@ -1542,6 +1543,8 @@ async function authorizeHistoricalTargetValidation({ run.baseSha, '--head-sha', expectedHead, + '--trusted-control-sha', + trustedControlSha, '--durable-issue-number', String(run.issueNumber), '--durable-implementation-commit', @@ -2159,6 +2162,7 @@ export async function runWithGitHubRole({ realGh, realNode, environment: childEnvironment, + trustedControlSha: trustedControlPlane.sourceCommit, }) try { const { validateLoop } = await import('./validation.mjs') diff --git a/loops/issue-dev-loop/scripts/lib/validation.mjs b/loops/issue-dev-loop/scripts/lib/validation.mjs index e5df25e3..138cb014 100644 --- a/loops/issue-dev-loop/scripts/lib/validation.mjs +++ b/loops/issue-dev-loop/scripts/lib/validation.mjs @@ -396,9 +396,17 @@ async function validateLoopMode({ !evidenceWorkflowSource.includes('path: trusted') || (evidenceWorkflowSource.match(/persist-credentials: false/g)?.length ?? 0) < 3 || !evidenceWorkflowSource.includes( - 'trusted/loops/issue-dev-loop/scripts/validate-candidate-control-plane.mjs', + 'control/loops/issue-dev-loop/scripts/validate-candidate-control-plane.mjs', + ) || + !evidenceWorkflowSource.includes( + '--trusted-control-sha "${{ github.event.pull_request.base.sha }}"', + ) || + !evidenceWorkflowSource.includes( + '--file control/loops/issue-dev-loop/scripts/verifier.Dockerfile control/loops/issue-dev-loop/scripts', + ) || + !evidenceWorkflowSource.includes( + 'node control/loops/issue-dev-loop/scripts/generate-evidence.mjs', ) || - !evidenceWorkflowSource.includes('verifier.Dockerfile') || !evidenceWorkflowSource.includes('pnpm install --frozen-lockfile --ignore-scripts') || (evidenceWorkflowSource.match(/docker run --rm --network none/g)?.length ?? 0) < 2 || !evidenceWorkflowSource.includes('src=${GITHUB_WORKSPACE}/trusted,dst=/source,readonly') || diff --git a/loops/issue-dev-loop/scripts/validate-candidate-control-plane.mjs b/loops/issue-dev-loop/scripts/validate-candidate-control-plane.mjs index 4ffca2a5..bea7db60 100644 --- a/loops/issue-dev-loop/scripts/validate-candidate-control-plane.mjs +++ b/loops/issue-dev-loop/scripts/validate-candidate-control-plane.mjs @@ -11,6 +11,10 @@ const repositoryRoot = path.resolve(loopRoot, '..', '..') const runId = assertNonEmpty(args['run-id'], '--run-id') const baseSha = assertNonEmpty(args['base-sha'], '--base-sha') const headSha = assertNonEmpty(args['head-sha'], '--head-sha') +const trustedControlSha = assertNonEmpty( + args['trusted-control-sha'], + '--trusted-control-sha', +) const durableIssueNumber = args['durable-issue-number'] ? Number(args['durable-issue-number']) : null @@ -20,15 +24,21 @@ const durablePrHead = args['durable-pr-head'] for (const [name, sha] of [ ['baseSha', baseSha], ['headSha', headSha], + ['trustedControlSha', trustedControlSha], ]) { if (!/^[0-9a-f]{40}$/i.test(sha)) throw new Error(`${name} must be a full Git SHA`) } -const [checkedOutHead, mergeBase] = await Promise.all([ +const [checkedOutHead, mergeBase, trustedControlMergeBase] = await Promise.all([ execFileAsync('git', ['rev-parse', 'HEAD'], { cwd: repositoryRoot }), execFileAsync('git', ['merge-base', baseSha, headSha], { cwd: repositoryRoot }), + execFileAsync('git', ['merge-base', baseSha, trustedControlSha], { cwd: repositoryRoot }), ]) -if (checkedOutHead.stdout.trim() !== headSha || mergeBase.stdout.trim() !== baseSha) { +if ( + checkedOutHead.stdout.trim() !== headSha || + mergeBase.stdout.trim() !== baseSha || + trustedControlMergeBase.stdout.trim() !== baseSha +) { throw new Error('candidate control-plane validation requires the exact descendant PR head') } @@ -121,10 +131,11 @@ const protectedDeploymentFiles = new Set([ 'render.yaml', 'vercel.json', ]) +const ownerMergedSyncFiles = new Set(['scripts/verify-docs-ui.mjs']) const changedFiles = changed.stdout .split('\0') .filter(Boolean) -const violations = changedFiles.filter((file) => { +const protectedFiles = changedFiles.filter((file) => { if (file.startsWith('loops/issue-dev-loop/')) { return !permittedRunRoots.some((root) => file.startsWith(root)) } @@ -164,6 +175,27 @@ const violations = changedFiles.filter((file) => { /^tsconfig(?:\.[^.]+)*\.json$/.test(basename) ) }) +const violations = [] +for (const file of protectedFiles) { + if (ownerMergedSyncFiles.has(file)) { + const [trustedEntry, candidateEntry] = await Promise.all([ + execFileAsync('git', ['ls-tree', '-z', trustedControlSha, '--', file], { + cwd: repositoryRoot, + }), + execFileAsync('git', ['ls-tree', '-z', headSha, '--', file], { + cwd: repositoryRoot, + }), + ]) + if ( + trustedEntry.stdout.length > 0 && + trustedEntry.stdout === candidateEntry.stdout && + trustedEntry.stdout.startsWith('100644 blob ') + ) { + continue + } + } + violations.push(file) +} for (const file of changedFiles) { if (!permittedRunRoots.some((root) => file.startsWith(root))) continue try { @@ -181,5 +213,12 @@ if (violations.length > 0) { } process.stdout.write( - `${JSON.stringify({ valid: true, runId, baseSha, headSha, changedFiles: changedFiles.length })}\n`, + `${JSON.stringify({ + valid: true, + runId, + baseSha, + headSha, + trustedControlSha, + changedFiles: changedFiles.length, + })}\n`, ) diff --git a/loops/issue-dev-loop/tests/github-identity-routing.test.mjs b/loops/issue-dev-loop/tests/github-identity-routing.test.mjs index b2705551..a9c4223f 100644 --- a/loops/issue-dev-loop/tests/github-identity-routing.test.mjs +++ b/loops/issue-dev-loop/tests/github-identity-routing.test.mjs @@ -522,7 +522,12 @@ export function validateFinalizationHistory() {} ) await writeFile( path.join(trustedLoopRoot, 'scripts', 'validate-candidate-control-plane.mjs'), - `process.stdout.write(JSON.stringify({ valid: true, protectedControlPlane: true }))\n`, + `const args = process.argv.slice(2) +const trustedControlIndex = args.indexOf('--trusted-control-sha') +if (trustedControlIndex < 0 || args[trustedControlIndex + 1] !== '${'a'.repeat(40)}') { + throw new Error('missing installed owner-merged source commit') +} +process.stdout.write(JSON.stringify({ valid: true, protectedControlPlane: true }))\n`, 'utf8', ) diff --git a/loops/issue-dev-loop/tests/runtime.test.mjs b/loops/issue-dev-loop/tests/runtime.test.mjs index 5841f917..ef23de3a 100644 --- a/loops/issue-dev-loop/tests/runtime.test.mjs +++ b/loops/issue-dev-loop/tests/runtime.test.mjs @@ -974,12 +974,18 @@ test('candidate control-plane validation permits run evidence but rejects verifi const runId = 'run-issue-123' await mkdir(path.join(loopRoot, 'logs', 'runs', runId), { recursive: true }) await mkdir(path.join(repository, 'src'), { recursive: true }) + await mkdir(path.join(repository, 'scripts'), { recursive: true }) await mkdir(path.join(repository, '.codex', 'agents'), { recursive: true }) const git = async (...args) => execFileAsync('git', args, { cwd: repository }) await git('init', '--initial-branch=dev') await git('config', 'user.name', 'Loop Test') await git('config', 'user.email', 'loop-test@example.invalid') await writeFile(path.join(repository, 'src', 'feature.js'), 'export const value = 1\n', 'utf8') + await writeFile( + path.join(repository, 'scripts', 'verify-docs-ui.mjs'), + 'export const verifier = "frozen"\n', + 'utf8', + ) await writeFile(path.join(repository, 'package.json'), '{"scripts":{"verify":"true"}}\n', 'utf8') await writeFile( path.join(repository, '.codex', 'agents', 'reviewer.toml'), @@ -990,6 +996,17 @@ test('candidate control-plane validation permits run evidence but rejects verifi await git('commit', '-m', 'base') const baseSha = (await git('rev-parse', 'HEAD')).stdout.trim() + const trustedVerifier = 'export const verifier = "owner-merged"\n' + await writeFile( + path.join(repository, 'scripts', 'verify-docs-ui.mjs'), + trustedVerifier, + 'utf8', + ) + await git('add', 'scripts/verify-docs-ui.mjs') + await git('commit', '-m', 'advance owner verifier') + const trustedControlSha = (await git('rev-parse', 'HEAD')).stdout.trim() + await git('switch', '-c', 'codex/issue-123', baseSha) + await writeFile(path.join(repository, 'src', 'feature.js'), 'export const value = 2\n', 'utf8') await git('add', 'src/feature.js') await git('commit', '-m', 'implementation') @@ -1022,9 +1039,90 @@ test('candidate control-plane validation permits run evidence but rejects verifi baseSha, '--head-sha', permittedHead, + '--trusted-control-sha', + trustedControlSha, ]) assert.equal(JSON.parse(permitted.stdout).valid, true) + await writeFile( + path.join(repository, 'scripts', 'verify-docs-ui.mjs'), + trustedVerifier, + 'utf8', + ) + await git('add', 'scripts/verify-docs-ui.mjs') + await git('commit', '-m', 'synchronize owner verifier') + const synchronizedHead = (await git('rev-parse', 'HEAD')).stdout.trim() + const synchronized = await execFileAsync(process.execPath, [ + validator, + '--loop-root', + loopRoot, + '--run-id', + runId, + '--base-sha', + baseSha, + '--head-sha', + synchronizedHead, + '--trusted-control-sha', + trustedControlSha, + ]) + assert.equal(JSON.parse(synchronized.stdout).trustedControlSha, trustedControlSha) + + await chmod(path.join(repository, 'scripts', 'verify-docs-ui.mjs'), 0o755) + await git('add', 'scripts/verify-docs-ui.mjs') + await git('commit', '-m', 'change synchronized verifier mode') + const modeChangedVerifierHead = (await git('rev-parse', 'HEAD')).stdout.trim() + await assert.rejects( + execFileAsync(process.execPath, [ + validator, + '--loop-root', + loopRoot, + '--run-id', + runId, + '--base-sha', + baseSha, + '--head-sha', + modeChangedVerifierHead, + '--trusted-control-sha', + trustedControlSha, + ]), + /scripts\/verify-docs-ui\.mjs/, + ) + await chmod(path.join(repository, 'scripts', 'verify-docs-ui.mjs'), 0o644) + await git('add', 'scripts/verify-docs-ui.mjs') + await git('commit', '-m', 'restore synchronized verifier mode') + + await writeFile( + path.join(repository, 'scripts', 'verify-docs-ui.mjs'), + 'export const verifier = "candidate-controlled"\n', + 'utf8', + ) + await git('add', 'scripts/verify-docs-ui.mjs') + await git('commit', '-m', 'tamper synchronized verifier') + const tamperedVerifierHead = (await git('rev-parse', 'HEAD')).stdout.trim() + await assert.rejects( + execFileAsync(process.execPath, [ + validator, + '--loop-root', + loopRoot, + '--run-id', + runId, + '--base-sha', + baseSha, + '--head-sha', + tamperedVerifierHead, + '--trusted-control-sha', + trustedControlSha, + ]), + /scripts\/verify-docs-ui\.mjs/, + ) + await writeFile( + path.join(repository, 'scripts', 'verify-docs-ui.mjs'), + trustedVerifier, + 'utf8', + ) + await git('add', 'scripts/verify-docs-ui.mjs') + await git('commit', '-m', 'restore synchronized verifier') + await git('mv', '.codex/agents/reviewer.toml', 'src/reviewer.toml') await git('commit', '-m', 'move protected reviewer configuration') const renamedHead = (await git('rev-parse', 'HEAD')).stdout.trim() @@ -1039,6 +1137,8 @@ test('candidate control-plane validation permits run evidence but rejects verifi baseSha, '--head-sha', renamedHead, + '--trusted-control-sha', + trustedControlSha, ]), /\.codex\/agents\/reviewer\.toml/, ) @@ -1060,6 +1160,8 @@ test('candidate control-plane validation permits run evidence but rejects verifi baseSha, '--head-sha', symlinkHead, + '--trusted-control-sha', + trustedControlSha, ]), /(?:^|\n)\.agents(?:\n|$)/, ) @@ -1086,6 +1188,8 @@ test('candidate control-plane validation permits run evidence but rejects verifi baseSha, '--head-sha', violatingHead, + '--trusted-control-sha', + trustedControlSha, ]), /issue branches cannot modify the trusted control or verification plane:[\s\S]*package\.json/, ) @@ -1120,6 +1224,8 @@ test('candidate control-plane validation permits run evidence but rejects verifi baseSha, '--head-sha', headSha, + '--trusted-control-sha', + trustedControlSha, ]), expected, ) @@ -1154,6 +1260,8 @@ test('durable candidate validation supports pre-implementation and later repair baseSha, '--head-sha', baseSha, + '--trusted-control-sha', + baseSha, '--durable-issue-number', '321', '--durable-implementation-commit', @@ -1182,6 +1290,8 @@ test('durable candidate validation supports pre-implementation and later repair baseSha, '--head-sha', repairCommit, + '--trusted-control-sha', + baseSha, '--durable-issue-number', '321', '--durable-implementation-commit', From 10c62e812f2ae27983f786fa5fdbf2829d1ae058 Mon Sep 17 00:00:00 2001 From: leyoonafr Date: Mon, 27 Jul 2026 01:43:38 +0800 Subject: [PATCH 2/4] add owner-authorized bootstrap publishing --- loops/issue-dev-loop/LOOP.md | 2 + loops/issue-dev-loop/SKILL.md | 4 + .../references/github-operations.md | 17 ++ .../scripts/lib/bootstrap-authorization.mjs | 185 ++++++++++++++ .../scripts/lib/github-identity.mjs | 239 +++++++++++++++++- .../issue-dev-loop/scripts/lib/validation.mjs | 1 + loops/issue-dev-loop/scripts/loopctl.mjs | 17 +- loops/issue-dev-loop/scripts/runtime.mjs | 8 + .../tests/github-identity-routing.test.mjs | 177 +++++++++++++ loops/issue-dev-loop/tests/runtime.test.mjs | 68 +++++ 10 files changed, 712 insertions(+), 6 deletions(-) create mode 100644 loops/issue-dev-loop/scripts/lib/bootstrap-authorization.mjs diff --git a/loops/issue-dev-loop/LOOP.md b/loops/issue-dev-loop/LOOP.md index 41e56af9..a62f182a 100644 --- a/loops/issue-dev-loop/LOOP.md +++ b/loops/issue-dev-loop/LOOP.md @@ -81,6 +81,8 @@ Explicitly invoke `$implement`. The orchestrator does not write product code. `$ The recorded implementation commit is the product-code boundary. Later commits may contain only the current run's handoff, sanitized logs, screenshots, and evidence. `record-pr` diffs the implementation commit against the proposed head and rejects every other path, so the orchestrator cannot append unrecorded product changes. Issue runs may not modify the loop runtime, owner channel, workflow, package manifests, verification scripts, or verification configuration, except for an explicitly allowlisted verifier synchronization whose Git tree entry exactly matches the live owner-merged control SHA. Such control-plane work and every change to that narrow exception belong to a dedicated owner-reviewed evolve/bootstrap PR and become executable only after installation from a clean owner-merged `dev`. +A bootstrap PR is still pushed by the automation identity, never by the owner identity. Before that push, `codeacme17` must publish the exact generated authorization body on the state-journal issue. The authorization binds the configured automation login, repository, `codex/bootstrap-*` branch, current live `dev` SHA, exact local head SHA, purpose, and an expiry of at most 24 hours. The installed router re-fetches that owner comment for every push or Draft-PR creation, requires a clean non-merge descendant of the still-current `origin/dev`, permits only added or modified control-plane paths, and rejects a missing, advanced, or mismatched remote branch. The authorization never permits product paths, Ready, Approve, Merge, force push, or a different commit. + ### 5. Verify before publication Run relevant checks and `pnpm verify`. For UI behavior, capture before/after screenshots under `screen-shots/` at meaningful desktop and mobile viewports and include interaction or accessibility evidence where applicable. Bind `before` to the frozen base and `after` to the latest `$implement` commit; never put the containing commit's not-yet-known hash inside its own files. Commit sanitized screenshots and run metadata to the issue branch. The Draft PR body must render at least one representative before/after pair with Markdown image syntax, using `raw.githubusercontent.com` URLs pinned to the exact recorded head; links to an index or manifest alone are not UI evidence. The low-privilege `pull_request` evidence workflow checks out the exact candidate head, resolves its frozen run base, proves that base remains an ancestor of live `dev`, and separately checks out the immutable owner-merged base without persisted credentials. It installs candidate and baseline dependencies with lifecycle scripts disabled into distinct Docker volumes. Candidate `pnpm verify` and the actual frozen owner-merged baseline `pnpm test` run in separate no-network containers that receive no GitHub token and cannot mount any host checkout. The live owner-merged control plane may permit an old active run to synchronize only an explicitly allowlisted verifier file whose Git tree entry is byte-for-byte and mode-for-mode identical to the live PR base; every other candidate change to the workflow or trusted control/verification plane remains forbidden. Before accepting the artifact, the installed control plane independently revalidates that exact synchronization against the manifest's live workflow base SHA. The manifest binds candidate head, workflow-run SHA, frozen owner-merged base SHA, and the live PR base SHA that selected the workflow. diff --git a/loops/issue-dev-loop/SKILL.md b/loops/issue-dev-loop/SKILL.md index 6d38411d..61e24f6b 100644 --- a/loops/issue-dev-loop/SKILL.md +++ b/loops/issue-dev-loop/SKILL.md @@ -60,6 +60,10 @@ Before publishing the final review round, write the complete cycle result with a The owner is the only actor allowed to mark a Draft PR Ready, approve it, or merge it. Never call non-`--undo` `gh pr ready`, `gh pr merge`, enable auto-merge, push to `main`, push to `dev`, dismiss owner feedback, or bypass branch protections. Before any terminal transition, run `prepare-finalization`. For completion, that command paginates the PR timeline and reviews, requires a Ready transition authored by `codeacme17` strictly after the remotely verified Ready-notification comment with no later redraft, and requires the latest exact-head owner review to be a strictly later `APPROVED` decision before the owner merge. It delivers the informational `pr_completed` GitHub notification with the merge SHA after the remote merge timestamp, waits for the bounded webhook attempt to settle, and binds distinct notification URLs/timestamps into the record. For `failed` or `blocked`, publish a checkpoint after the blocking notification and pause; the terminal record must bind that exact `waiting_for_owner` checkpoint, its digest, current pause, and the matching notification timestamp. Raw executor comments cannot use the reserved `pr_completed` marker. If canonical GitHub delivery fails, no terminal record is created. Only then publish the exact body to the configured state-journal issue and pass its result/comment URL to `observe-owner-merge`; that command revalidates the remote record, appends the local notification/owner audit events, and finalizes. Future workspaces run `reconcile` to revalidate the same notification, pause or Ready, approval, and merge proof before rebuilding local history or suppressing an active checkpoint. +## Publish a control-plane bootstrap + +Keep bootstrap work isolated from active issue/evolve state on a `codex/bootstrap-*` branch based on the exact current `origin/dev`. After the clean, non-merge, control-plane-only head passes `pnpm verify`, run the installed `loopctl.mjs prepare-bootstrap-authorization` with a unique `BST-*` ID, branch, full base/head SHAs, and purpose. Ask `codeacme17` to post the returned body unchanged on the configured state-journal issue. Set `ECHO_UI_LOOP_BOOTSTRAP_AUTHORIZATION_URL` to that owner comment URL, then route the exact `git push origin ` and `gh pr create --repo codeacme17/echo-ui --base dev --head --draft ...` through the installed automation identity. The Draft PR body must contain ``. The router re-fetches the owner comment and rejects expired authorization, identity/repository drift, product paths, deletions, renames, merges, dirty state, stale `dev`, remote ref drift, another head, or another branch. Never use owner credentials to push bootstrap code. + For any pause, do not resume from silence or an arbitrary comment. First require a successfully delivered blocking notification. Then verify the owner's GitHub decision with `loopctl.mjs record-owner-response --run-id --response-url `. A normal comment must include the exact `RESUME ` token printed in the notification; a `CHANGES_REQUESTED` review is itself an explicit decision and must be submitted against the run's current exact head SHA. Only then may `loopctl.mjs transition --run-id --status running` continue. Before any repair work or new push, publish that transition's checkpoint, run the unchanged exact PR through `gh pr ready --undo --repo codeacme17/echo-ui`, observe the same head as Draft with `record-pr`, and publish another checkpoint. `$implement` repair attestations and later PR rebinds are rejected unless this durable redraft happened after the current owner response. ## Stop conditions diff --git a/loops/issue-dev-loop/references/github-operations.md b/loops/issue-dev-loop/references/github-operations.md index 172c32a8..2aef12ee 100644 --- a/loops/issue-dev-loop/references/github-operations.md +++ b/loops/issue-dev-loop/references/github-operations.md @@ -42,6 +42,23 @@ The shell launcher removes Node preload hooks before starting the router. The ro - Notify `codeacme17` only after automated review and verification pass, keep the PR Draft, and ask the owner to mark it Ready before reviewing. - Bind every review to immutable base and head SHAs. +## Owner-authorized bootstrap + +Control-plane bootstrap code is pushed by the configured automation identity, while `codeacme17` remains the authorizing reviewer and merger. From a clean `codex/bootstrap-*` branch based on current `origin/dev`, generate the owner authorization: + +```text +"$ECHO_UI_LOOP_CONTROL_PLANE/scripts/with-github-identity" --loop-root "$ECHO_UI_LOOP_TARGET_ROOT" automation -- node "$ECHO_UI_LOOP_CONTROL_PLANE/scripts/loopctl.mjs" prepare-bootstrap-authorization --loop-root "$ECHO_UI_LOOP_TARGET_ROOT" --authorization-id BST- --branch codex/bootstrap- --base-sha --head-sha --purpose "" +``` + +The owner posts the returned body unchanged on the state-journal issue. Set `ECHO_UI_LOOP_BOOTSTRAP_AUTHORIZATION_URL` to that comment URL. For at most 24 hours, the installed router will re-fetch the comment and allow only: + +```text +"$ECHO_UI_LOOP_CONTROL_PLANE/scripts/with-github-identity" --loop-root "$ECHO_UI_LOOP_TARGET_ROOT" automation -- git push origin codex/bootstrap- +"$ECHO_UI_LOOP_CONTROL_PLANE/scripts/with-github-identity" --loop-root "$ECHO_UI_LOOP_TARGET_ROOT" automation -- gh pr create --repo codeacme17/echo-ui --base dev --head codex/bootstrap- --title "" --body "<body with the exact bootstrap authorization head marker>" --draft +``` + +The exact branch and head must still be a clean, non-merge descendant of the authorization's live `dev` SHA. Only added or modified loop runtime, owner-channel, issue-loop workflow, registered issue-loop agent, or issue-loop adapter paths are allowed. Product paths, deletes, renames, force pushes, stale bases, alternate remote heads, Ready, Approve, and Merge remain prohibited. A changed head requires a new owner comment. + ## Evidence artifact The low-privilege `pull_request` workflow `Issue dev loop evidence` runs only when the branch contains one active issue run. All checkouts disable persisted credentials. It resolves the run's frozen base from the exact candidate, proves that base remains an ancestor of live `dev`, and checks out that immutable owner-merged commit separately from the current control checkout. The live owner-merged checkout supplies the validator, history checker, verifier image, and manifest generator; the frozen checkout remains the product baseline. Container preparation installs the protected candidate and frozen-baseline lockfiles with lifecycle scripts disabled into independent Docker volumes before candidate code runs. Candidate `pnpm verify` and the actual frozen owner-merged baseline `pnpm test` run with `--network none`, no inherited GitHub token, and no mount of any host checkout. The generator remains outside those containers and binds the manifest to candidate head, workflow-run SHA, frozen owner-merged base SHA, and the live PR base SHA. The installed control plane later rejects the artifact unless its own NUL-safe, rename-disabled exact-head diff check proves that the PR did not change either side of a loop/control rename, the owner channel, workflow, agent/prompt configuration, deployment-provider configuration, package manifests, lock/workspace files, package hooks, or verification configuration. Verification scripts remain protected except for an explicit allowlist that permits only a regular-file Git tree entry exactly identical in content and mode to the manifest-bound live owner-merged base. Exact protected root entries and symlinks are rejected as well as descendants. Wait for that exact-head run to complete, then locate and download the artifact: diff --git a/loops/issue-dev-loop/scripts/lib/bootstrap-authorization.mjs b/loops/issue-dev-loop/scripts/lib/bootstrap-authorization.mjs new file mode 100644 index 00000000..848f0304 --- /dev/null +++ b/loops/issue-dev-loop/scripts/lib/bootstrap-authorization.mjs @@ -0,0 +1,185 @@ +import { createHash } from 'node:crypto' +import path from 'node:path' + +import { + DEFAULT_LOOP_ROOT, + assertHttpUrl, + assertNonEmpty, + defaultGitHubApi, + parsePullCommentUrl, + readJson, + sameGitHubLogin, + sameRepository, +} from './common.mjs' + +export const BOOTSTRAP_AUTHORIZATION_ENVIRONMENT_VARIABLE = + 'ECHO_UI_LOOP_BOOTSTRAP_AUTHORIZATION_URL' + +const AUTHORIZATION_LIFETIME_MS = 24 * 60 * 60 * 1000 +const CLOCK_SKEW_MS = 5 * 60 * 1000 +const AUTHORIZATION_ID_PATTERN = /^BST-[A-Z0-9][A-Z0-9-]{5,63}$/ +const BOOTSTRAP_BRANCH_PATTERN = /^codex\/bootstrap-[a-z0-9][a-z0-9._-]{0,63}$/ + +function stateJournalTarget(channel) { + const [owner, repo] = channel.repository.split('/') + return { owner, repo, number: channel.stateIssueNumber } +} + +function validateBootstrapAuthorization(record) { + const normalized = { + schemaVersion: record.schemaVersion, + kind: record.kind, + authorizationId: record.authorizationId, + repository: record.repository, + branch: record.branch, + baseSha: typeof record.baseSha === 'string' ? record.baseSha.toLowerCase() : record.baseSha, + headSha: typeof record.headSha === 'string' ? record.headSha.toLowerCase() : record.headSha, + authorizedActor: record.authorizedActor, + purpose: record.purpose, + expiresAt: record.expiresAt, + } + if ( + normalized.schemaVersion !== 1 || + normalized.kind !== 'control-plane-bootstrap' || + !AUTHORIZATION_ID_PATTERN.test(normalized.authorizationId ?? '') || + !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(normalized.repository ?? '') || + !BOOTSTRAP_BRANCH_PATTERN.test(normalized.branch ?? '') || + !/^[0-9a-f]{40}$/i.test(normalized.baseSha ?? '') || + !/^[0-9a-f]{40}$/i.test(normalized.headSha ?? '') || + normalized.baseSha === normalized.headSha || + !assertNonEmpty(normalized.authorizedActor, 'bootstrap.authorizedActor') || + !assertNonEmpty(normalized.purpose, 'bootstrap.purpose') || + normalized.purpose.length > 240 || + Number.isNaN(Date.parse(normalized.expiresAt)) + ) { + throw new Error('invalid bootstrap authorization') + } + return normalized +} + +export function canonicalBootstrapAuthorization(record) { + return JSON.stringify(validateBootstrapAuthorization(record)) +} + +export function bootstrapAuthorizationDigest(record) { + return createHash('sha256') + .update(canonicalBootstrapAuthorization(record)) + .digest('hex') +} + +export function bootstrapAuthorizationBody(record) { + const normalized = validateBootstrapAuthorization(record) + const serialized = canonicalBootstrapAuthorization(normalized) + const digest = bootstrapAuthorizationDigest(normalized) + return [ + `<!-- issue-dev-loop:bootstrap-authorization:${normalized.authorizationId}:sha256:${digest} -->`, + '```json', + serialized, + '```', + ].join('\n') +} + +function parseBootstrapAuthorizationBody(body) { + const match = body?.match( + /^<!-- issue-dev-loop:bootstrap-authorization:([^:]+):sha256:([0-9a-f]{64}) -->\n```json\n([^\n]+)\n```$/, + ) + if (!match) throw new Error('bootstrap authorization comment has an invalid body') + const record = validateBootstrapAuthorization(JSON.parse(match[3])) + const digest = bootstrapAuthorizationDigest(record) + if ( + record.authorizationId !== match[1] || + digest !== match[2] || + bootstrapAuthorizationBody(record) !== body + ) { + throw new Error('bootstrap authorization comment digest does not match') + } + return { record, digest } +} + +export async function prepareBootstrapAuthorization({ + loopRoot = DEFAULT_LOOP_ROOT, + authorizationId, + branch, + baseSha, + headSha, + purpose, + expiresAt, + now = new Date(), +} = {}) { + const channel = await readJson( + path.resolve(loopRoot, '..', '_shared', 'owner-channel', 'channel.json'), + ) + const record = validateBootstrapAuthorization({ + schemaVersion: 1, + kind: 'control-plane-bootstrap', + authorizationId, + repository: channel.repository, + branch, + baseSha, + headSha, + authorizedActor: channel.automationGitHubLogin, + purpose, + expiresAt: + expiresAt ?? + new Date(now.getTime() + AUTHORIZATION_LIFETIME_MS).toISOString(), + }) + const journal = stateJournalTarget(channel) + return { + record, + digest: bootstrapAuthorizationDigest(record), + body: bootstrapAuthorizationBody(record), + journalIssueUrl: `https://github.com/${channel.repository}/issues/${journal.number}`, + } +} + +export async function verifyBootstrapAuthorizationComment({ + loopRoot = DEFAULT_LOOP_ROOT, + commentUrl, + now = new Date(), + githubApi = defaultGitHubApi, +} = {}) { + const publicationUrl = assertHttpUrl(commentUrl, 'bootstrap.commentUrl') + const target = parsePullCommentUrl(publicationUrl) + const channel = await readJson( + path.resolve(loopRoot, '..', '_shared', 'owner-channel', 'channel.json'), + ) + const journal = stateJournalTarget(channel) + if ( + target?.kind !== 'issue_comment' || + target.surface !== 'issues' || + target.number !== journal.number || + !sameRepository(target, journal) + ) { + throw new Error('bootstrap authorization must be an owner comment on the state journal') + } + const comment = await githubApi( + `repos/${target.owner}/${target.repo}/issues/comments/${target.commentId}`, + ) + if (!sameGitHubLogin(comment.user?.login, channel.ownerGitHubLogin)) { + throw new Error('bootstrap authorization must be authored by the configured owner') + } + const { record, digest } = parseBootstrapAuthorizationBody(comment.body) + const createdAt = Date.parse(comment.created_at) + const expiresAt = Date.parse(record.expiresAt) + if ( + Number.isNaN(createdAt) || + createdAt > now.getTime() + CLOCK_SKEW_MS || + expiresAt <= now.getTime() || + expiresAt > createdAt + AUTHORIZATION_LIFETIME_MS + CLOCK_SKEW_MS + ) { + throw new Error('bootstrap authorization is expired or has an unsafe lifetime') + } + if ( + record.repository.toLowerCase() !== channel.repository.toLowerCase() || + !sameGitHubLogin(record.authorizedActor, channel.automationGitHubLogin) + ) { + throw new Error('bootstrap authorization does not match the configured repository identities') + } + return { + ...record, + publicationUrl, + publicationDigest: digest, + ownerLogin: comment.user.login, + ownerAuthorizedAt: comment.created_at, + } +} diff --git a/loops/issue-dev-loop/scripts/lib/github-identity.mjs b/loops/issue-dev-loop/scripts/lib/github-identity.mjs index ae6378a5..91261b0b 100644 --- a/loops/issue-dev-loop/scripts/lib/github-identity.mjs +++ b/loops/issue-dev-loop/scripts/lib/github-identity.mjs @@ -22,6 +22,10 @@ import { validateCheckpointRecord, verifyLatestDurableCheckpoint, } from './checkpoint-proof.mjs' +import { + BOOTSTRAP_AUTHORIZATION_ENVIRONMENT_VARIABLE, + verifyBootstrapAuthorizationComment, +} from './bootstrap-authorization.mjs' import { verifyPublishedEvolveRequest } from './evolve.mjs' import { loadDurableFinalizationRecords } from './finalization-journal.mjs' import { @@ -290,7 +294,13 @@ function gitSubcommand(args) { } function authorizedPushBranches(authorization) { - return new Set([authorization?.issue?.branch, authorization?.evolve?.branch].filter(Boolean)) + return new Set( + [ + authorization?.issue?.branch, + authorization?.evolve?.branch, + authorization?.bootstrap?.branch, + ].filter(Boolean), + ) } export function assertGitCommandPolicy(role, args, { authorization = null } = {}) { @@ -758,9 +768,16 @@ function pullRequestCreateAllowed(args, commandIndex, authorization, expectedRep if (head === authorization?.issue?.branch && authorization.issue.prNumber === null) { return true } - if (head !== authorization?.evolve?.branch) return false const body = exactlyOne(parsed.values, 'body') - return body?.includes(`<!-- issue-dev-loop:evolve-request:${authorization.evolve.requestId} -->`) + if (head === authorization?.evolve?.branch) { + return body?.includes( + `<!-- issue-dev-loop:evolve-request:${authorization.evolve.requestId} -->`, + ) + } + if (head !== authorization?.bootstrap?.branch) return false + return body?.includes( + `<!-- issue-dev-loop:bootstrap-authorization:${authorization.bootstrap.authorizationId}:head:${authorization.bootstrap.headSha} -->`, + ) } function pullRequestMutationAllowed(kind, args, commandIndex, authorization, expectedRepository) { @@ -1630,7 +1647,7 @@ async function preflightPullRequestWrite({ environment, }) { const intent = pullRequestWriteIntent(role, args, authorization) - if (!intent || authorization.evolve) return + if (!intent || authorization.evolve || authorization.bootstrap) return const runId = authorization.issue?.runId if (!runId) throw new Error('pull request write requires an active durable run') const events = await readEvents(loopRoot, runId) @@ -1914,6 +1931,183 @@ async function preflightIssueBranchPush({ } } +function bootstrapPathAllowed(file) { + return ( + file === '.github/workflows/issue-dev-loop-evidence.yml' || + file === '.codex/config.toml' || + file.startsWith('.codex/agents/echo-ui-') || + file === 'loops/issue-dev-loop' || + file.startsWith('loops/issue-dev-loop/') || + file === 'loops/_shared/owner-channel' || + file.startsWith('loops/_shared/owner-channel/') || + file === '.agents/skills/issue-dev-loop' || + file.startsWith('.agents/skills/issue-dev-loop/') || + file === '.claude/skills/issue-dev-loop' || + file.startsWith('.claude/skills/issue-dev-loop/') || + file === '.cursor/skills/issue-dev-loop' || + file.startsWith('.cursor/skills/issue-dev-loop/') + ) +} + +export async function preflightBootstrapMutation({ + role, + tool, + args, + authorization, + loopRoot, + realGit, + realGh, + environment, +}) { + const bootstrap = authorization.bootstrap + if (!bootstrap) return + const gitPush = tool === 'git' && gitSubcommand(args).name === 'push' + const group = tool === 'gh' ? githubGroup(args) : { name: null, index: -1 } + const command = group.name === 'pr' ? commandAfterGroup(args, group.index) : { name: null } + const pullRequestCreate = command.name === 'create' + if (!gitPush && !pullRequestCreate) return + if (role !== 'automation' || authorization.issue || authorization.evolve) { + throw new Error('bootstrap mutations require one isolated automation authorization') + } + + const repositoryRoot = path.resolve(loopRoot, '..', '..') + const [ + localBranch, + localHead, + localStatus, + localDev, + mergeBase, + mergeCommits, + changedFiles, + forbiddenChanges, + ] = await Promise.all([ + execFileAsync(realGit, ['branch', '--show-current'], { + cwd: repositoryRoot, + env: environment, + }), + execFileAsync(realGit, ['rev-parse', 'HEAD'], { + cwd: repositoryRoot, + env: environment, + }), + execFileAsync(realGit, ['status', '--porcelain=v1', '--untracked-files=all'], { + cwd: repositoryRoot, + env: environment, + maxBuffer: 1024 * 1024, + }), + execFileAsync(realGit, ['rev-parse', 'refs/remotes/origin/dev'], { + cwd: repositoryRoot, + env: environment, + }), + execFileAsync(realGit, ['merge-base', bootstrap.baseSha, bootstrap.headSha], { + cwd: repositoryRoot, + env: environment, + }), + execFileAsync( + realGit, + ['log', '--merges', '--format=%H', `${bootstrap.baseSha}..${bootstrap.headSha}`], + { + cwd: repositoryRoot, + env: environment, + }, + ), + execFileAsync( + realGit, + [ + 'diff', + '--name-only', + '-z', + '--no-renames', + '--diff-filter=ACDMRTUXB', + `${bootstrap.baseSha}...${bootstrap.headSha}`, + ], + { + cwd: repositoryRoot, + env: environment, + maxBuffer: 4 * 1024 * 1024, + }, + ), + execFileAsync( + realGit, + [ + 'diff', + '--name-only', + '-z', + '--no-renames', + '--diff-filter=CDRTUXB', + `${bootstrap.baseSha}...${bootstrap.headSha}`, + ], + { + cwd: repositoryRoot, + env: environment, + maxBuffer: 4 * 1024 * 1024, + }, + ), + ]) + const paths = changedFiles.stdout.split('\0').filter(Boolean) + const changedPathStats = await Promise.all( + paths.map((file) => lstat(path.join(repositoryRoot, file))), + ) + if ( + localBranch.stdout.trim() !== bootstrap.branch || + localHead.stdout.trim() !== bootstrap.headSha || + localDev.stdout.trim() !== bootstrap.baseSha || + mergeBase.stdout.trim() !== bootstrap.baseSha || + localStatus.stdout.trim() || + mergeCommits.stdout.trim() || + paths.length === 0 || + forbiddenChanges.stdout.length > 0 || + paths.some((file) => !bootstrapPathAllowed(file)) || + changedPathStats.some((stats) => !stats.isFile() || stats.isSymbolicLink()) + ) { + throw new Error( + 'bootstrap authorization requires one clean exact-head control-plane-only branch from current origin/dev', + ) + } + + const [owner, repo] = authorization.expectedRepository.split('/') + const githubApi = async (endpoint) => { + const { stdout } = await execFileAsync(realGh, ['api', endpoint], { + env: environment, + maxBuffer: 1024 * 1024, + }) + return JSON.parse(stdout) + } + const [liveDev, matchingRefs] = await Promise.all([ + githubApi(`repos/${owner}/${repo}/git/ref/heads/dev`), + githubApi(`repos/${owner}/${repo}/git/matching-refs/heads/${bootstrap.branch}`), + ]) + const exactRemoteRefs = matchingRefs.filter( + (entry) => entry.ref === `refs/heads/${bootstrap.branch}`, + ) + let remoteCanFastForward = true + const remoteHead = exactRemoteRefs[0]?.object?.sha ?? null + if (gitPush && remoteHead && remoteHead !== bootstrap.headSha) { + try { + await execFileAsync( + realGit, + ['merge-base', '--is-ancestor', remoteHead, bootstrap.headSha], + { + cwd: repositoryRoot, + env: environment, + }, + ) + } catch (error) { + if (error?.code === 1) remoteCanFastForward = false + else throw error + } + } + if ( + liveDev.object?.sha !== bootstrap.baseSha || + exactRemoteRefs.length > 1 || + !remoteCanFastForward || + (pullRequestCreate && + (exactRemoteRefs.length !== 1 || + exactRemoteRefs[0].object?.sha !== bootstrap.headSha)) + ) { + throw new Error('bootstrap authorization no longer matches the live base or remote branch') + } +} + async function preflightEvolveMutation({ role, tool, @@ -2036,7 +2230,32 @@ export async function runWithGitHubRole({ realGh, realNode, }) - const authorization = withRootCommandIntent(await readAuthorizationContext(loopRoot, channel), { + let authorization = await readAuthorizationContext(loopRoot, channel) + const bootstrapAuthorizationUrl = + environment[BOOTSTRAP_AUTHORIZATION_ENVIRONMENT_VARIABLE] + if (bootstrapAuthorizationUrl) { + if (authorization.issue || authorization.evolve) { + throw new Error('bootstrap authorization cannot overlap active issue or evolve work') + } + const githubApi = async (endpoint) => { + const { stdout } = await execFileAsync(realGh, ['api', endpoint], { + env: resolved.routedEnvironment, + maxBuffer: 1024 * 1024, + }) + return JSON.parse(stdout) + } + authorization = { + ...authorization, + bootstrap: await verifyBootstrapAuthorizationComment({ + loopRoot, + commentUrl: bootstrapAuthorizationUrl, + githubApi, + }), + } + } else { + authorization = { ...authorization, bootstrap: null } + } + authorization = withRootCommandIntent(authorization, { tool, args, trustedLoopRoot: trustedControlPlane.loopRoot, @@ -2078,6 +2297,16 @@ export async function runWithGitHubRole({ requiredUntrustedRoots: [repositoryRootForLoop(loopRoot)], }) } + await preflightBootstrapMutation({ + role, + tool, + args, + authorization, + loopRoot, + realGit, + realGh, + environment: resolved.routedEnvironment, + }) await preflightEvolveMutation({ role, tool, diff --git a/loops/issue-dev-loop/scripts/lib/validation.mjs b/loops/issue-dev-loop/scripts/lib/validation.mjs index 138cb014..45fcba83 100644 --- a/loops/issue-dev-loop/scripts/lib/validation.mjs +++ b/loops/issue-dev-loop/scripts/lib/validation.mjs @@ -257,6 +257,7 @@ async function validateLoopMode({ 'scripts/validate-history.mjs', 'scripts/validate-candidate-control-plane.mjs', 'scripts/lib/common.mjs', + 'scripts/lib/bootstrap-authorization.mjs', 'scripts/lib/evidence.mjs', 'scripts/lib/evolve.mjs', 'scripts/lib/finalization-journal.mjs', diff --git a/loops/issue-dev-loop/scripts/loopctl.mjs b/loops/issue-dev-loop/scripts/loopctl.mjs index 9bd74e2f..adec3e9e 100644 --- a/loops/issue-dev-loop/scripts/loopctl.mjs +++ b/loops/issue-dev-loop/scripts/loopctl.mjs @@ -14,6 +14,7 @@ import { observeOwnerMerge, parseArguments, prepareActiveCheckpoint, + prepareBootstrapAuthorization, prepareEvolveRequestPublication, prepareFinalizationRecord, reconcileLoopJournal, @@ -257,6 +258,20 @@ async function main() { }), ) break + case 'prepare-bootstrap-authorization': + output( + await prepareBootstrapAuthorization({ + authorizationId: args['authorization-id'], + branch: args.branch, + baseSha: args['base-sha'], + headSha: args['head-sha'], + purpose: args.purpose, + expiresAt: args['expires-at'], + now: args.now ? new Date(args.now) : undefined, + loopRoot, + }), + ) + break case 'record-evolve-request': output( await recordEvolveRequestPublication({ @@ -278,7 +293,7 @@ async function main() { break default: throw new Error( - 'usage: loopctl.mjs <start|freeze-brief|record-implementation|event|record-pr|record-owner-response|record-evidence|review-digest|record-review|prepare-checkpoint|record-checkpoint|prepare-finalization|record-finalization|reconcile|restore-checkpoint|transition|finalize|observe-owner-merge|notify|detect-work|validate|evolve-status|prepare-evolve-request|record-evolve-request|evolve-complete> [options]', + 'usage: loopctl.mjs <start|freeze-brief|record-implementation|event|record-pr|record-owner-response|record-evidence|review-digest|record-review|prepare-checkpoint|record-checkpoint|prepare-finalization|record-finalization|reconcile|restore-checkpoint|transition|finalize|observe-owner-merge|notify|detect-work|validate|evolve-status|prepare-evolve-request|prepare-bootstrap-authorization|record-evolve-request|evolve-complete> [options]', ) } } diff --git a/loops/issue-dev-loop/scripts/runtime.mjs b/loops/issue-dev-loop/scripts/runtime.mjs index 38096b6e..2cfb7080 100644 --- a/loops/issue-dev-loop/scripts/runtime.mjs +++ b/loops/issue-dev-loop/scripts/runtime.mjs @@ -1,4 +1,12 @@ export { assertAutomationIdentity, DEFAULT_LOOP_ROOT, parseArguments } from './lib/common.mjs' +export { + BOOTSTRAP_AUTHORIZATION_ENVIRONMENT_VARIABLE, + bootstrapAuthorizationBody, + bootstrapAuthorizationDigest, + canonicalBootstrapAuthorization, + prepareBootstrapAuthorization, + verifyBootstrapAuthorizationComment, +} from './lib/bootstrap-authorization.mjs' export { completeEvolve, getEvolveStatus, diff --git a/loops/issue-dev-loop/tests/github-identity-routing.test.mjs b/loops/issue-dev-loop/tests/github-identity-routing.test.mjs index a9c4223f..e6ddae5d 100644 --- a/loops/issue-dev-loop/tests/github-identity-routing.test.mjs +++ b/loops/issue-dev-loop/tests/github-identity-routing.test.mjs @@ -34,6 +34,8 @@ import { } from '../scripts/lib/finalization-proof.mjs' import { assertGitCommandPolicy, + assertGitHubCliPolicy, + preflightBootstrapMutation, resolveExecutable, } from '../scripts/lib/github-identity.mjs' @@ -2332,6 +2334,181 @@ test('authenticated remote Git accepts only exact origin and authorized ref shap } }) +test('bootstrap policy permits only the owner-authorized Ethandasw branch and Draft PR', () => { + const branch = 'codex/bootstrap-trusted-verifier' + const headSha = 'b'.repeat(40) + const authorization = { + expectedRepository: 'codeacme17/echo-ui', + issue: null, + evolve: null, + bootstrap: { + authorizationId: 'BST-20260727-TRUSTED-VERIFIER', + branch, + headSha, + }, + } + assert.doesNotThrow(() => + assertGitCommandPolicy('automation', ['push', 'origin', branch], { authorization }), + ) + assert.throws( + () => + assertGitCommandPolicy('automation', ['push', 'origin', `${branch}-other`], { + authorization, + }), + /only one explicit loop branch/, + ) + const body = `<!-- issue-dev-loop:bootstrap-authorization:${authorization.bootstrap.authorizationId}:head:${headSha} -->` + assert.doesNotThrow(() => + assertGitHubCliPolicy( + 'automation', + [ + 'pr', + 'create', + '--repo', + 'codeacme17/echo-ui', + '--base', + 'dev', + '--head', + branch, + '--title', + 'Bootstrap trusted verifier', + '--body', + body, + '--draft', + ], + { authorization, expectedRepository: 'codeacme17/echo-ui' }, + ), + ) + assert.throws( + () => + assertGitHubCliPolicy( + 'automation', + [ + 'pr', + 'create', + '--repo', + 'codeacme17/echo-ui', + '--base', + 'dev', + '--head', + branch, + '--title', + 'Bootstrap trusted verifier', + '--body', + body, + ], + { authorization, expectedRepository: 'codeacme17/echo-ui' }, + ), + /prohibited for the automation role/, + ) +}) + +test('bootstrap preflight binds a clean control-only commit to live dev and the remote ref', async () => { + const parent = await mkdtemp(path.join(os.tmpdir(), 'echo-ui-bootstrap-preflight-')) + const repository = path.join(parent, 'repository') + const loopRoot = path.join(repository, 'loops', 'issue-dev-loop') + const realGit = await resolveExecutable('git', process.env) + const fakeGh = path.join(parent, 'gh') + await mkdir(loopRoot, { recursive: true }) + await execFileAsync(realGit, ['init', '--initial-branch=dev'], { cwd: repository }) + await execFileAsync(realGit, ['config', 'user.name', 'Fixture'], { cwd: repository }) + await execFileAsync(realGit, ['config', 'user.email', 'fixture@example.com'], { + cwd: repository, + }) + await writeFile(path.join(loopRoot, 'LOOP.md'), 'frozen\n', 'utf8') + await execFileAsync(realGit, ['add', '.'], { cwd: repository }) + await execFileAsync(realGit, ['commit', '-m', 'base'], { cwd: repository }) + const baseSha = (await execFileAsync(realGit, ['rev-parse', 'HEAD'], { cwd: repository })) + .stdout + .trim() + await execFileAsync(realGit, ['update-ref', 'refs/remotes/origin/dev', baseSha], { + cwd: repository, + }) + const branch = 'codex/bootstrap-trusted-verifier' + await execFileAsync(realGit, ['switch', '-c', branch], { cwd: repository }) + await writeFile(path.join(loopRoot, 'LOOP.md'), 'owner-reviewed bootstrap\n', 'utf8') + await execFileAsync(realGit, ['add', '.'], { cwd: repository }) + await execFileAsync(realGit, ['commit', '-m', 'bootstrap'], { cwd: repository }) + const headSha = (await execFileAsync(realGit, ['rev-parse', 'HEAD'], { cwd: repository })) + .stdout + .trim() + await writeFile( + fakeGh, + `#!/usr/bin/env node +const endpoint = process.argv[3] +if (endpoint.endsWith('/git/ref/heads/dev')) { + process.stdout.write(JSON.stringify({ object: { sha: process.env.TEST_BASE_SHA } })) +} else if (endpoint.includes('/git/matching-refs/heads/')) { + const head = process.env.TEST_REMOTE_BOOTSTRAP_HEAD + process.stdout.write(JSON.stringify(head ? [{ ref: 'refs/heads/${branch}', object: { sha: head } }] : [])) +} else { + process.exitCode = 1 +} +`, + 'utf8', + ) + await chmod(fakeGh, 0o755) + const authorization = { + expectedRepository: 'codeacme17/echo-ui', + issue: null, + evolve: null, + bootstrap: { + authorizationId: 'BST-20260727-TRUSTED-VERIFIER', + branch, + baseSha, + headSha, + }, + } + const environment = { + ...process.env, + TEST_BASE_SHA: baseSha, + TEST_REMOTE_BOOTSTRAP_HEAD: '', + } + await preflightBootstrapMutation({ + role: 'automation', + tool: 'git', + args: ['push', 'origin', branch], + authorization, + loopRoot, + realGit, + realGh: fakeGh, + environment, + }) + await preflightBootstrapMutation({ + role: 'automation', + tool: 'gh', + args: ['pr', 'create'], + authorization, + loopRoot, + realGit, + realGh: fakeGh, + environment: { ...environment, TEST_REMOTE_BOOTSTRAP_HEAD: headSha }, + }) + + await writeFile(path.join(repository, 'src.ts'), 'export const product = true\n', 'utf8') + await execFileAsync(realGit, ['add', 'src.ts'], { cwd: repository }) + await execFileAsync(realGit, ['commit', '-m', 'smuggle product code'], { cwd: repository }) + const unsafeHead = ( + await execFileAsync(realGit, ['rev-parse', 'HEAD'], { cwd: repository }) + ).stdout.trim() + await assert.rejects( + preflightBootstrapMutation({ + role: 'automation', + tool: 'git', + args: ['push', 'origin', branch], + authorization: { + ...authorization, + bootstrap: { ...authorization.bootstrap, headSha: unsafeHead }, + }, + loopRoot, + realGit, + realGh: fakeGh, + environment, + }), + /control-plane-only/, + ) +}) + test('automation allows only the exact restore cleanliness Git probes', () => { for (const args of [ ['ls-files', '-v', '-z'], diff --git a/loops/issue-dev-loop/tests/runtime.test.mjs b/loops/issue-dev-loop/tests/runtime.test.mjs index ef23de3a..de9a234c 100644 --- a/loops/issue-dev-loop/tests/runtime.test.mjs +++ b/loops/issue-dev-loop/tests/runtime.test.mjs @@ -34,6 +34,7 @@ import { loadPaginatedGitHubCollection, observeOwnerMerge, prepareActiveCheckpoint, + prepareBootstrapAuthorization, prepareEvolveRequestPublication, prepareFinalizationRecord as runtimePrepareFinalizationRecord, reconcileActiveJournal, @@ -55,6 +56,7 @@ import { startRun, transitionRun as runtimeTransitionRun, validateLoop, + verifyBootstrapAuthorizationComment, } from '../scripts/runtime.mjs' import { observeOwnerApprovedMerge } from '../scripts/lib/owner-gate.mjs' import { @@ -432,6 +434,72 @@ async function createFixture() { return { loopRoot, channelRoot } } +test('owner bootstrap authorization binds Ethandasw to one exact branch and head', async () => { + const { loopRoot } = await createFixture() + const prepared = await prepareBootstrapAuthorization({ + loopRoot, + authorizationId: 'BST-20260727-TRUSTED-VERIFIER', + branch: 'codex/bootstrap-trusted-verifier', + baseSha: 'a'.repeat(40), + headSha: 'b'.repeat(40), + purpose: 'Allow the automation identity to publish the reviewed control-plane bootstrap.', + now: new Date('2026-07-27T01:00:00.000Z'), + }) + const commentUrl = + 'https://github.com/codeacme17/echo-ui/issues/999#issuecomment-777' + const ownerComment = { + user: { login: 'codeacme17' }, + body: prepared.body, + created_at: '2026-07-27T01:00:00.000Z', + } + const verified = await verifyBootstrapAuthorizationComment({ + loopRoot, + commentUrl, + now: new Date('2026-07-27T02:00:00.000Z'), + githubApi: async (endpoint) => { + assert.equal(endpoint, 'repos/codeacme17/echo-ui/issues/comments/777') + return ownerComment + }, + }) + assert.equal(verified.authorizedActor, 'echo-ui-loop[bot]') + assert.equal(verified.branch, 'codex/bootstrap-trusted-verifier') + assert.equal(verified.headSha, 'b'.repeat(40)) + + await assert.rejects( + verifyBootstrapAuthorizationComment({ + loopRoot, + commentUrl, + now: new Date('2026-07-27T02:00:00.000Z'), + githubApi: async () => ({ + ...ownerComment, + user: { login: 'echo-ui-loop[bot]' }, + }), + }), + /configured owner/, + ) + await assert.rejects( + verifyBootstrapAuthorizationComment({ + loopRoot, + commentUrl, + now: new Date('2026-07-28T01:00:01.000Z'), + githubApi: async () => ownerComment, + }), + /expired/, + ) + await assert.rejects( + verifyBootstrapAuthorizationComment({ + loopRoot, + commentUrl, + now: new Date('2026-07-27T02:00:00.000Z'), + githubApi: async () => ({ + ...ownerComment, + body: ownerComment.body.replace(`"headSha":"${'b'.repeat(40)}"`, `"headSha":"${'c'.repeat(40)}"`), + }), + }), + /digest/, + ) +}) + async function startFixtureRun(options) { return startRun({ ...options, From e412fde0191d27f57dd7b4c989912de245b2b70a Mon Sep 17 00:00:00 2001 From: leyoonafr <codeacme17@gmail.com> Date: Mon, 27 Jul 2026 02:23:47 +0800 Subject: [PATCH 3/4] fix bootstrap review findings --- .../scripts/lib/bootstrap-authorization.mjs | 2 +- .../scripts/lib/github-identity.mjs | 99 +++++++++++- .../tests/github-identity-routing.test.mjs | 153 ++++++++++++++++-- loops/issue-dev-loop/tests/runtime.test.mjs | 42 +++++ 4 files changed, 279 insertions(+), 17 deletions(-) diff --git a/loops/issue-dev-loop/scripts/lib/bootstrap-authorization.mjs b/loops/issue-dev-loop/scripts/lib/bootstrap-authorization.mjs index 848f0304..cf557956 100644 --- a/loops/issue-dev-loop/scripts/lib/bootstrap-authorization.mjs +++ b/loops/issue-dev-loop/scripts/lib/bootstrap-authorization.mjs @@ -165,7 +165,7 @@ export async function verifyBootstrapAuthorizationComment({ Number.isNaN(createdAt) || createdAt > now.getTime() + CLOCK_SKEW_MS || expiresAt <= now.getTime() || - expiresAt > createdAt + AUTHORIZATION_LIFETIME_MS + CLOCK_SKEW_MS + expiresAt > createdAt + AUTHORIZATION_LIFETIME_MS ) { throw new Error('bootstrap authorization is expired or has an unsafe lifetime') } diff --git a/loops/issue-dev-loop/scripts/lib/github-identity.mjs b/loops/issue-dev-loop/scripts/lib/github-identity.mjs index 91261b0b..fa9ccc72 100644 --- a/loops/issue-dev-loop/scripts/lib/github-identity.mjs +++ b/loops/issue-dev-loop/scripts/lib/github-identity.mjs @@ -1976,6 +1976,10 @@ export async function preflightBootstrapMutation({ localHead, localStatus, localDev, + gitTopLevel, + gitDirectory, + commonDirectory, + indexState, mergeBase, mergeCommits, changedFiles, @@ -1998,6 +2002,23 @@ export async function preflightBootstrapMutation({ cwd: repositoryRoot, env: environment, }), + execFileAsync(realGit, ['rev-parse', '--show-toplevel'], { + cwd: repositoryRoot, + env: environment, + }), + execFileAsync(realGit, ['rev-parse', '--path-format=absolute', '--git-dir'], { + cwd: repositoryRoot, + env: environment, + }), + execFileAsync(realGit, ['rev-parse', '--path-format=absolute', '--git-common-dir'], { + cwd: repositoryRoot, + env: environment, + }), + execFileAsync(realGit, ['ls-files', '-v', '-z'], { + cwd: repositoryRoot, + env: environment, + maxBuffer: 8 * 1024 * 1024, + }), execFileAsync(realGit, ['merge-base', bootstrap.baseSha, bootstrap.headSha], { cwd: repositoryRoot, env: environment, @@ -2043,26 +2064,98 @@ export async function preflightBootstrapMutation({ }, ), ]) + const [canonicalRepositoryRoot, canonicalGitTopLevel] = await Promise.all([ + realpath(repositoryRoot), + realpath(gitTopLevel.stdout.trim()), + ]) const paths = changedFiles.stdout.split('\0').filter(Boolean) - const changedPathStats = await Promise.all( - paths.map((file) => lstat(path.join(repositoryRoot, file))), + const concealedIndexEntries = indexState.stdout + .split('\0') + .filter(Boolean) + .filter((entry) => !entry.startsWith('H ')) + const tree = await execFileAsync( + realGit, + ['ls-tree', '-z', bootstrap.headSha, '--', ...paths], + { + cwd: repositoryRoot, + env: environment, + maxBuffer: 8 * 1024 * 1024, + }, ) + const treeEntries = new Map() + let malformedTreeEntry = false + for (const rawEntry of tree.stdout.split('\0').filter(Boolean)) { + const separator = rawEntry.indexOf('\t') + const metadata = rawEntry.slice(0, separator).split(' ') + const file = rawEntry.slice(separator + 1) + if ( + separator <= 0 || + metadata.length !== 3 || + !/^[0-9a-f]{40}$/i.test(metadata[2] ?? '') || + !file || + treeEntries.has(file) + ) { + malformedTreeEntry = true + continue + } + treeEntries.set(file, { + mode: metadata[0], + type: metadata[1], + }) + } + const unsafeTreeEntries = + malformedTreeEntry || + treeEntries.size !== paths.length || + paths.some((file) => { + const entry = treeEntries.get(file) + return !entry || entry.type !== 'blob' || !['100644', '100755'].includes(entry.mode) + }) if ( localBranch.stdout.trim() !== bootstrap.branch || localHead.stdout.trim() !== bootstrap.headSha || localDev.stdout.trim() !== bootstrap.baseSha || + canonicalGitTopLevel !== canonicalRepositoryRoot || + gitDirectory.stdout.trim() === commonDirectory.stdout.trim() || + concealedIndexEntries.length > 0 || mergeBase.stdout.trim() !== bootstrap.baseSha || localStatus.stdout.trim() || mergeCommits.stdout.trim() || paths.length === 0 || forbiddenChanges.stdout.length > 0 || paths.some((file) => !bootstrapPathAllowed(file)) || - changedPathStats.some((stats) => !stats.isFile() || stats.isSymbolicLink()) + unsafeTreeEntries ) { throw new Error( 'bootstrap authorization requires one clean exact-head control-plane-only branch from current origin/dev', ) } + try { + await execFileAsync( + realGit, + [ + '-c', + 'core.fileMode=true', + 'diff', + '--quiet', + '--no-ext-diff', + '--no-textconv', + 'HEAD', + '--', + ], + { + cwd: repositoryRoot, + env: environment, + maxBuffer: 1024 * 1024, + }, + ) + } catch (error) { + if (error?.code === 1) { + throw new Error( + 'bootstrap authorization requires tracked filesystem contents and modes to match HEAD', + ) + } + throw error + } const [owner, repo] = authorization.expectedRepository.split('/') const githubApi = async (endpoint) => { diff --git a/loops/issue-dev-loop/tests/github-identity-routing.test.mjs b/loops/issue-dev-loop/tests/github-identity-routing.test.mjs index e6ddae5d..630bf1f0 100644 --- a/loops/issue-dev-loop/tests/github-identity-routing.test.mjs +++ b/loops/issue-dev-loop/tests/github-identity-routing.test.mjs @@ -10,6 +10,8 @@ import { readdir, realpath, rm, + symlink, + unlink, writeFile, } from 'node:fs/promises' import os from 'node:os' @@ -2405,27 +2407,36 @@ test('bootstrap policy permits only the owner-authorized Ethandasw branch and Dr test('bootstrap preflight binds a clean control-only commit to live dev and the remote ref', async () => { const parent = await mkdtemp(path.join(os.tmpdir(), 'echo-ui-bootstrap-preflight-')) - const repository = path.join(parent, 'repository') + const primaryRepository = path.join(parent, 'repository') + const repository = path.join(parent, 'bootstrap-worktree') const loopRoot = path.join(repository, 'loops', 'issue-dev-loop') + const primaryLoopRoot = path.join(primaryRepository, 'loops', 'issue-dev-loop') const realGit = await resolveExecutable('git', process.env) const fakeGh = path.join(parent, 'gh') - await mkdir(loopRoot, { recursive: true }) - await execFileAsync(realGit, ['init', '--initial-branch=dev'], { cwd: repository }) - await execFileAsync(realGit, ['config', 'user.name', 'Fixture'], { cwd: repository }) + await mkdir(primaryLoopRoot, { recursive: true }) + await execFileAsync(realGit, ['init', '--initial-branch=dev'], { + cwd: primaryRepository, + }) + await execFileAsync(realGit, ['config', 'user.name', 'Fixture'], { + cwd: primaryRepository, + }) await execFileAsync(realGit, ['config', 'user.email', 'fixture@example.com'], { - cwd: repository, + cwd: primaryRepository, }) - await writeFile(path.join(loopRoot, 'LOOP.md'), 'frozen\n', 'utf8') - await execFileAsync(realGit, ['add', '.'], { cwd: repository }) - await execFileAsync(realGit, ['commit', '-m', 'base'], { cwd: repository }) - const baseSha = (await execFileAsync(realGit, ['rev-parse', 'HEAD'], { cwd: repository })) - .stdout - .trim() + await writeFile(path.join(primaryLoopRoot, 'LOOP.md'), 'frozen\n', 'utf8') + await execFileAsync(realGit, ['add', '.'], { cwd: primaryRepository }) + await execFileAsync(realGit, ['commit', '-m', 'base'], { cwd: primaryRepository }) + const baseSha = ( + await execFileAsync(realGit, ['rev-parse', 'HEAD'], { cwd: primaryRepository }) + ).stdout.trim() await execFileAsync(realGit, ['update-ref', 'refs/remotes/origin/dev', baseSha], { - cwd: repository, + cwd: primaryRepository, }) const branch = 'codex/bootstrap-trusted-verifier' - await execFileAsync(realGit, ['switch', '-c', branch], { cwd: repository }) + await execFileAsync(realGit, ['branch', branch], { cwd: primaryRepository }) + await execFileAsync(realGit, ['worktree', 'add', repository, branch], { + cwd: primaryRepository, + }) await writeFile(path.join(loopRoot, 'LOOP.md'), 'owner-reviewed bootstrap\n', 'utf8') await execFileAsync(realGit, ['add', '.'], { cwd: repository }) await execFileAsync(realGit, ['commit', '-m', 'bootstrap'], { cwd: repository }) @@ -2485,6 +2496,122 @@ if (endpoint.endsWith('/git/ref/heads/dev')) { environment: { ...environment, TEST_REMOTE_BOOTSTRAP_HEAD: headSha }, }) + const primaryBranch = 'codex/bootstrap-primary-checkout' + await execFileAsync(realGit, ['switch', '-c', primaryBranch], { + cwd: primaryRepository, + }) + await writeFile( + path.join(primaryLoopRoot, 'LOOP.md'), + 'bootstrap from a primary checkout\n', + 'utf8', + ) + await execFileAsync(realGit, ['add', '.'], { cwd: primaryRepository }) + await execFileAsync(realGit, ['commit', '-m', 'primary bootstrap'], { + cwd: primaryRepository, + }) + const primaryHead = ( + await execFileAsync(realGit, ['rev-parse', 'HEAD'], { cwd: primaryRepository }) + ).stdout.trim() + await assert.rejects( + preflightBootstrapMutation({ + role: 'automation', + tool: 'git', + args: ['push', 'origin', primaryBranch], + authorization: { + ...authorization, + bootstrap: { + ...authorization.bootstrap, + branch: primaryBranch, + headSha: primaryHead, + }, + }, + loopRoot: primaryLoopRoot, + realGit, + realGh: fakeGh, + environment, + }), + /clean exact-head control-plane-only/, + ) + + const symlinkPath = path.join(loopRoot, 'scripts', 'concealed-link.mjs') + await mkdir(path.dirname(symlinkPath), { recursive: true }) + await symlink('../LOOP.md', symlinkPath) + await execFileAsync(realGit, ['add', '.'], { cwd: repository }) + await execFileAsync(realGit, ['commit', '-m', 'add concealed symlink'], { + cwd: repository, + }) + const symlinkHead = ( + await execFileAsync(realGit, ['rev-parse', 'HEAD'], { cwd: repository }) + ).stdout.trim() + await unlink(symlinkPath) + await writeFile(symlinkPath, 'export default true\n', 'utf8') + await execFileAsync( + realGit, + ['update-index', '--skip-worktree', 'loops/issue-dev-loop/scripts/concealed-link.mjs'], + { cwd: repository }, + ) + await assert.rejects( + preflightBootstrapMutation({ + role: 'automation', + tool: 'git', + args: ['push', 'origin', branch], + authorization: { + ...authorization, + bootstrap: { ...authorization.bootstrap, headSha: symlinkHead }, + }, + loopRoot, + realGit, + realGh: fakeGh, + environment, + }), + /clean exact-head control-plane-only/, + ) + + await execFileAsync( + realGit, + ['update-index', '--no-skip-worktree', 'loops/issue-dev-loop/scripts/concealed-link.mjs'], + { cwd: repository }, + ) + await unlink(symlinkPath) + await symlink('../LOOP.md', symlinkPath) + const assumeBranch = 'codex/bootstrap-assume-unchanged' + await execFileAsync(realGit, ['switch', '-c', assumeBranch, headSha], { + cwd: repository, + }) + await execFileAsync( + realGit, + ['update-index', '--assume-unchanged', 'loops/issue-dev-loop/LOOP.md'], + { cwd: repository }, + ) + await writeFile(path.join(loopRoot, 'LOOP.md'), 'locally concealed content\n', 'utf8') + await assert.rejects( + preflightBootstrapMutation({ + role: 'automation', + tool: 'git', + args: ['push', 'origin', assumeBranch], + authorization: { + ...authorization, + bootstrap: { + ...authorization.bootstrap, + branch: assumeBranch, + headSha, + }, + }, + loopRoot, + realGit, + realGh: fakeGh, + environment, + }), + /clean exact-head control-plane-only/, + ) + + await execFileAsync( + realGit, + ['update-index', '--no-assume-unchanged', 'loops/issue-dev-loop/LOOP.md'], + { cwd: repository }, + ) + await writeFile(path.join(loopRoot, 'LOOP.md'), 'owner-reviewed bootstrap\n', 'utf8') + await execFileAsync(realGit, ['switch', branch], { cwd: repository }) await writeFile(path.join(repository, 'src.ts'), 'export const product = true\n', 'utf8') await execFileAsync(realGit, ['add', 'src.ts'], { cwd: repository }) await execFileAsync(realGit, ['commit', '-m', 'smuggle product code'], { cwd: repository }) diff --git a/loops/issue-dev-loop/tests/runtime.test.mjs b/loops/issue-dev-loop/tests/runtime.test.mjs index de9a234c..3a855faa 100644 --- a/loops/issue-dev-loop/tests/runtime.test.mjs +++ b/loops/issue-dev-loop/tests/runtime.test.mjs @@ -465,6 +465,48 @@ test('owner bootstrap authorization binds Ethandasw to one exact branch and head assert.equal(verified.branch, 'codex/bootstrap-trusted-verifier') assert.equal(verified.headSha, 'b'.repeat(40)) + const exactLifetime = await prepareBootstrapAuthorization({ + loopRoot, + authorizationId: 'BST-20260727-EXACT-LIFETIME', + branch: 'codex/bootstrap-trusted-verifier', + baseSha: 'a'.repeat(40), + headSha: 'b'.repeat(40), + purpose: 'Exercise the exact authorization lifetime boundary.', + expiresAt: '2026-07-28T01:00:00.000Z', + }) + await assert.doesNotReject( + verifyBootstrapAuthorizationComment({ + loopRoot, + commentUrl, + now: new Date('2026-07-27T02:00:00.000Z'), + githubApi: async () => ({ + ...ownerComment, + body: exactLifetime.body, + }), + }), + ) + const excessiveLifetime = await prepareBootstrapAuthorization({ + loopRoot, + authorizationId: 'BST-20260727-EXCESS-LIFETIME', + branch: 'codex/bootstrap-trusted-verifier', + baseSha: 'a'.repeat(40), + headSha: 'b'.repeat(40), + purpose: 'Reject an authorization one millisecond beyond the maximum lifetime.', + expiresAt: '2026-07-28T01:00:00.001Z', + }) + await assert.rejects( + verifyBootstrapAuthorizationComment({ + loopRoot, + commentUrl, + now: new Date('2026-07-27T02:00:00.000Z'), + githubApi: async () => ({ + ...ownerComment, + body: excessiveLifetime.body, + }), + }), + /unsafe lifetime/, + ) + await assert.rejects( verifyBootstrapAuthorizationComment({ loopRoot, From 66fc518dad9d16f7595210116363021cefdc4781 Mon Sep 17 00:00:00 2001 From: leyoonafr <codeacme17@gmail.com> Date: Mon, 27 Jul 2026 02:52:30 +0800 Subject: [PATCH 4/4] close final bootstrap review findings --- .github/workflows/issue-dev-loop-evidence.yml | 4 +- .../scripts/lib/github-identity.mjs | 49 +++++++++++- .../issue-dev-loop/scripts/lib/validation.mjs | 16 ++++ .../tests/github-identity-routing.test.mjs | 80 +++++++++++++++++++ loops/issue-dev-loop/tests/runtime.test.mjs | 43 ++++++++++ 5 files changed, 187 insertions(+), 5 deletions(-) diff --git a/.github/workflows/issue-dev-loop-evidence.yml b/.github/workflows/issue-dev-loop-evidence.yml index 7948cf4b..1ea4c490 100644 --- a/.github/workflows/issue-dev-loop-evidence.yml +++ b/.github/workflows/issue-dev-loop-evidence.yml @@ -16,9 +16,9 @@ jobs: bootstrap-evidence: if: >- github.event_name == 'pull_request' && - github.head_ref == 'codex/issue-dev-loop' && + startsWith(github.event.pull_request.head.ref, 'codex/bootstrap-') && github.event.pull_request.head.repo.full_name == github.repository && - github.event.pull_request.user.login == 'codeacme17' + github.event.pull_request.user.login == 'Ethandasw' runs-on: ubuntu-latest timeout-minutes: 45 steps: diff --git a/loops/issue-dev-loop/scripts/lib/github-identity.mjs b/loops/issue-dev-loop/scripts/lib/github-identity.mjs index fa9ccc72..3cb5e3cc 100644 --- a/loops/issue-dev-loop/scripts/lib/github-identity.mjs +++ b/loops/issue-dev-loop/scripts/lib/github-identity.mjs @@ -234,7 +234,10 @@ function canonicalRepositoryUrl(repository) { return `https://github.com/${repository}.git` } -export function hardenedGitArguments(args, { expectedRepository = null } = {}) { +export function hardenedGitArguments( + args, + { expectedRepository = null, authorization = null } = {}, +) { const subcommand = gitSubcommand(args) const hardened = [...args] if (['diff', 'show', 'log'].includes(subcommand.name)) { @@ -252,7 +255,11 @@ export function hardenedGitArguments(args, { expectedRepository = null } = {}) { return ['push', lease, repositoryUrl, deleteRef] } const branch = args.at(-1) - return ['push', repositoryUrl, `refs/heads/${branch}:refs/heads/${branch}`] + const bootstrapSource = + branch === authorization?.bootstrap?.branch + ? authorization.bootstrap.headSha + : `refs/heads/${branch}` + return ['push', repositoryUrl, `${bootstrapSource}:refs/heads/${branch}`] } if (subcommand.name === 'fetch') { const branch = args.at(-1) @@ -1031,6 +1038,39 @@ export function assertGitHubCliPolicy( return } + if (authorization?.bootstrap) { + if (group.name === 'issue' && ['list', 'view'].includes(subcommand.name)) return + if ( + group.name === 'pr' && + ['list', 'view', 'checks', 'diff'].includes(subcommand.name) + ) { + return + } + if ( + group.name === 'pr' && + subcommand.name === 'create' && + pullRequestCreateAllowed(args, subcommand.index, authorization, expectedRepository) + ) { + return + } + if (group.name === 'run' && ['list', 'view', 'download'].includes(subcommand.name)) { + return + } + if (group.name !== 'api') reject() + const request = githubApiRequest(args.slice(group.index + 1)) + if (readOnlyIdentityRequest(request)) return + if ( + !request.valid || + request.mutating || + request.endpoint === 'graphql' || + (expectedRepository && + !repositoryInScope(endpointRepository(request.endpoint), expectedRepository)) + ) { + reject() + } + return + } + if (group.name === 'issue') { if (!['list', 'view'].includes(subcommand.name)) reject() return @@ -2459,7 +2499,10 @@ export async function runWithGitHubRole({ } let executionArgs = tool === 'git' - ? hardenedGitArguments(args, { expectedRepository: channel.repository }) + ? hardenedGitArguments(args, { + expectedRepository: channel.repository, + authorization, + }) : [...args] if (activationValidation) { const fullValidationArguments = [ diff --git a/loops/issue-dev-loop/scripts/lib/validation.mjs b/loops/issue-dev-loop/scripts/lib/validation.mjs index 45fcba83..88ce720f 100644 --- a/loops/issue-dev-loop/scripts/lib/validation.mjs +++ b/loops/issue-dev-loop/scripts/lib/validation.mjs @@ -381,11 +381,27 @@ async function validateLoopMode({ const enforcementStep = evidenceWorkflowSource.match( / - name: Enforce verification result\n([\s\S]*?)(?=\n - name:|$)/, )?.[1] + const bootstrapJob = evidenceWorkflowSource.match( + / bootstrap-evidence:\n([\s\S]*?)(?=\n evidence:\n)/, + )?.[1] + const bootstrapVerificationStep = bootstrapJob?.match( + / - name: Run bootstrap verification\n([\s\S]*?)(?=\n - name:|$)/, + )?.[1] if ( (!targetCompatibility && !verificationStep?.includes('pnpm verify')) || (!targetCompatibility && (verificationStep.includes('if:') || !enforcementStep || + !bootstrapJob?.includes( + "startsWith(github.event.pull_request.head.ref, 'codex/bootstrap-')", + ) || + !bootstrapJob.includes( + 'github.event.pull_request.head.repo.full_name == github.repository', + ) || + !bootstrapJob.includes( + `github.event.pull_request.user.login == '${channel.automationGitHubLogin}'`, + ) || + !bootstrapVerificationStep?.includes('run: pnpm verify') || enforcementStep.includes("steps.run.outputs.has_run == 'true'") || !evidenceWorkflowSource.includes( "github.event.pull_request.head.ref != 'codex/issue-dev-loop'", diff --git a/loops/issue-dev-loop/tests/github-identity-routing.test.mjs b/loops/issue-dev-loop/tests/github-identity-routing.test.mjs index 630bf1f0..5b4543a2 100644 --- a/loops/issue-dev-loop/tests/github-identity-routing.test.mjs +++ b/loops/issue-dev-loop/tests/github-identity-routing.test.mjs @@ -37,6 +37,7 @@ import { import { assertGitCommandPolicy, assertGitHubCliPolicy, + hardenedGitArguments, preflightBootstrapMutation, resolveExecutable, } from '../scripts/lib/github-identity.mjs' @@ -2341,6 +2342,7 @@ test('bootstrap policy permits only the owner-authorized Ethandasw branch and Dr const headSha = 'b'.repeat(40) const authorization = { expectedRepository: 'codeacme17/echo-ui', + stateIssueNumber: 105, issue: null, evolve: null, bootstrap: { @@ -2352,6 +2354,17 @@ test('bootstrap policy permits only the owner-authorized Ethandasw branch and Dr assert.doesNotThrow(() => assertGitCommandPolicy('automation', ['push', 'origin', branch], { authorization }), ) + assert.deepEqual( + hardenedGitArguments(['push', 'origin', branch], { + authorization, + expectedRepository: 'codeacme17/echo-ui', + }), + [ + 'push', + 'https://github.com/codeacme17/echo-ui.git', + `${headSha}:refs/heads/${branch}`, + ], + ) assert.throws( () => assertGitCommandPolicy('automation', ['push', 'origin', `${branch}-other`], { @@ -2381,6 +2394,48 @@ test('bootstrap policy permits only the owner-authorized Ethandasw branch and Dr { authorization, expectedRepository: 'codeacme17/echo-ui' }, ), ) + for (const readArguments of [ + ['api', 'repos/codeacme17/echo-ui/pulls/124'], + ['issue', 'view', '105', '--repo', 'codeacme17/echo-ui'], + ['pr', 'view', '124', '--repo', 'codeacme17/echo-ui'], + ['run', 'view', '1234', '--repo', 'codeacme17/echo-ui'], + ]) { + assert.doesNotThrow(() => + assertGitHubCliPolicy('automation', readArguments, { + authorization, + expectedRepository: 'codeacme17/echo-ui', + }), + ) + } + for (const forbiddenArguments of [ + [ + 'api', + '--method', + 'POST', + 'repos/codeacme17/echo-ui/issues/105/comments', + '-f', + 'body=arbitrary', + ], + ['pr', 'comment', '124', '--repo', 'codeacme17/echo-ui', '--body', 'arbitrary'], + [ + 'pr', + 'edit', + '124', + '--repo', + 'codeacme17/echo-ui', + '--title', + 'unauthorized mutation', + ], + ]) { + assert.throws( + () => + assertGitHubCliPolicy('automation', forbiddenArguments, { + authorization, + expectedRepository: 'codeacme17/echo-ui', + }), + /prohibited for the automation role/, + ) + } assert.throws( () => assertGitHubCliPolicy( @@ -2495,6 +2550,31 @@ if (endpoint.endsWith('/git/ref/heads/dev')) { realGh: fakeGh, environment: { ...environment, TEST_REMOTE_BOOTSTRAP_HEAD: headSha }, }) + await execFileAsync( + realGit, + ['update-ref', `refs/heads/${branch}`, baseSha, headSha], + { cwd: repository }, + ) + assert.equal( + (await execFileAsync(realGit, ['rev-parse', branch], { cwd: repository })).stdout.trim(), + baseSha, + ) + assert.deepEqual( + hardenedGitArguments(['push', 'origin', branch], { + authorization, + expectedRepository: authorization.expectedRepository, + }), + [ + 'push', + 'https://github.com/codeacme17/echo-ui.git', + `${headSha}:refs/heads/${branch}`, + ], + ) + await execFileAsync( + realGit, + ['update-ref', `refs/heads/${branch}`, headSha, baseSha], + { cwd: repository }, + ) const primaryBranch = 'codex/bootstrap-primary-checkout' await execFileAsync(realGit, ['switch', '-c', primaryBranch], { diff --git a/loops/issue-dev-loop/tests/runtime.test.mjs b/loops/issue-dev-loop/tests/runtime.test.mjs index 3a855faa..f7a7708d 100644 --- a/loops/issue-dev-loop/tests/runtime.test.mjs +++ b/loops/issue-dev-loop/tests/runtime.test.mjs @@ -5535,6 +5535,27 @@ test('fresh worktrees rebuild pending and completed evolve state from the durabl test('repository loop package satisfies its structural invariants', async () => { const result = await validateLoop({ loopRoot: repositoryLoopRoot }) assert.equal(result.valid, true) + const workflow = await readFile( + path.resolve( + repositoryLoopRoot, + '..', + '..', + '.github', + 'workflows', + 'issue-dev-loop-evidence.yml', + ), + 'utf8', + ) + assert.match( + workflow, + /startsWith\(github\.event\.pull_request\.head\.ref, 'codex\/bootstrap-'\)/, + ) + assert.match( + workflow, + /github\.event\.pull_request\.head\.repo\.full_name == github\.repository/, + ) + assert.match(workflow, /github\.event\.pull_request\.user\.login == 'Ethandasw'/) + assert.match(workflow, /- name: Run bootstrap verification\n run: pnpm verify/) }) test('historical workflow parsing is fail-closed without exposing reduced validation', async () => { @@ -5573,6 +5594,28 @@ test('historical workflow parsing is fail-closed without exposing reduced valida 'workflows', 'issue-dev-loop-evidence.yml', ) + const originalWorkflow = await readFile(workflowPath, 'utf8') + for (const unsafeWorkflow of [ + originalWorkflow.replace( + "startsWith(github.event.pull_request.head.ref, 'codex/bootstrap-')", + "github.event.pull_request.head.ref == 'codex/bootstrap-fixed'", + ), + originalWorkflow.replace( + "github.event.pull_request.user.login == 'Ethandasw'", + "github.event.pull_request.user.login == 'codeacme17'", + ), + originalWorkflow.replace( + '- name: Run bootstrap verification\n run: pnpm verify', + '- name: Run bootstrap verification\n run: pnpm test', + ), + ]) { + await writeFile(workflowPath, unsafeWorkflow, 'utf8') + await assert.rejects( + validateLoop({ loopRoot: historicalLoopRoot }), + /evidence workflow must use a low-privilege isolated PR run/, + ) + } + await writeFile(workflowPath, originalWorkflow, 'utf8') await writeFile( workflowPath, (await readFile(workflowPath, 'utf8')).replace(