From a3be610cfb517c7a9ad58903bc1e4b9a4588cacd Mon Sep 17 00:00:00 2001 From: leyoonafr Date: Sun, 26 Jul 2026 19:49:17 +0800 Subject: [PATCH 01/11] fix(loop): resume historical active targets --- loops/issue-dev-loop/SKILL.md | 2 +- .../references/github-operations.md | 2 + .../scripts/lib/github-identity.mjs | 45 ++-- .../issue-dev-loop/scripts/lib/validation.mjs | 208 +++++++++--------- loops/issue-dev-loop/scripts/loopctl.mjs | 8 +- .../tests/github-identity-routing.test.mjs | 92 +++++--- loops/issue-dev-loop/tests/runtime.test.mjs | 188 +++++++++------- 7 files changed, 311 insertions(+), 234 deletions(-) diff --git a/loops/issue-dev-loop/SKILL.md b/loops/issue-dev-loop/SKILL.md index dcecc252..7e27e7f0 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 and probes both configured profiles before starting validation. +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 and probes both configured profiles before starting validation. When the target contains a durable active run created by an older owner-merged control plane, the launcher alone selects historical-target compatibility validation: installed runtime files remain hash-verified in the trusted bundle, while the target is checked for its stable state files, owner channel, JSON history, and evidence workflow. Callers cannot request this reduced target-only mode directly, and `restore-checkpoint` still requires the durable exact branch, head, and clean worktree. 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. diff --git a/loops/issue-dev-loop/references/github-operations.md b/loops/issue-dev-loop/references/github-operations.md index 15e58eb1..9192bb4c 100644 --- a/loops/issue-dev-loop/references/github-operations.md +++ b/loops/issue-dev-loop/references/github-operations.md @@ -10,6 +10,8 @@ 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, only the installed activation router may select historical-target compatibility validation. It continues to hash-verify the complete installed control plane and both identities, then validates the target's stable state files, owner channel, JSON history, and evidence workflow. A caller-supplied compatibility flag is rejected; the subsequent restore still enforces the journal's exact branch and head on a clean worktree. + 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`. Publish reviewer output only as a non-approving comment review: diff --git a/loops/issue-dev-loop/scripts/lib/github-identity.mjs b/loops/issue-dev-loop/scripts/lib/github-identity.mjs index d10001ab..2e84040c 100644 --- a/loops/issue-dev-loop/scripts/lib/github-identity.mjs +++ b/loops/issue-dev-loop/scripts/lib/github-identity.mjs @@ -240,10 +240,7 @@ export function hardenedGitArguments(args, { expectedRepository = null } = {}) { const rollback = lease?.match( /^--force-with-lease=(refs\/heads\/codex\/issue-[1-9][0-9]*):([0-9a-f]{40})$/, ) - if ( - rollback && - sameArguments(args, ['push', lease, 'origin', `:${rollback[1]}`]) - ) { + if (rollback && sameArguments(args, ['push', lease, 'origin', `:${rollback[1]}`])) { return ['push', lease, repositoryUrl, deleteRef] } const branch = args.at(-1) @@ -783,9 +780,7 @@ function pullRequestMutationAllowed(kind, args, commandIndex, authorization, exp } if (kind === 'ready') { return ( - parsed.values.size <= 1 && - parsed.booleans.size === 1 && - parsed.booleans.get('undo') === true + parsed.values.size <= 1 && parsed.booleans.size === 1 && parsed.booleans.get('undo') === true ) } if (kind === 'comment') { @@ -812,9 +807,7 @@ function reservedAutomationComment(body) { function checkpointPublicationAllowed(body, authorization) { const markers = [ - ...body.matchAll( - //g, - ), + ...body.matchAll(//g), ] if (markers.length !== 1 || markers[0][1] !== authorization?.issue?.runId) { return false @@ -1082,6 +1075,9 @@ function assertRootCommandPolicy({ role, tool, args, loopRoot, trustedLoopRoot, path.resolve(trustedLoopRoot, 'scripts', 'loopctl.mjs'), path.resolve(trustedLoopRoot, 'triggers', 'detect-work.mjs'), ]) + if (args.includes('--target-compatibility')) { + throw new Error('target compatibility validation is reserved to wrapped activation') + } if ( script && allowedScripts.has(script) && @@ -1094,8 +1090,7 @@ function assertRootCommandPolicy({ role, tool, args, loopRoot, trustedLoopRoot, if ( role === 'reviewer' && tool === 'node' && - path.resolve(args[0] ?? '') === - path.resolve(trustedLoopRoot, 'scripts', 'publish-review.mjs') + path.resolve(args[0] ?? '') === path.resolve(trustedLoopRoot, 'scripts', 'publish-review.mjs') ) { parseReviewPublisherArguments(args.slice(1), { authorization, @@ -1301,16 +1296,11 @@ function argumentAfter(args, name) { function withRootCommandIntent(authorization, { tool, args, trustedLoopRoot }) { const script = args[0] ? path.resolve(args[0]) : null const isTrustedLoopctl = - tool === 'node' && - script === path.resolve(trustedLoopRoot, 'scripts', 'loopctl.mjs') + tool === 'node' && script === path.resolve(trustedLoopRoot, 'scripts', 'loopctl.mjs') const withIntent = isTrustedLoopctl ? { ...authorization, rootIntent: args[1] ?? null } : authorization - if ( - !isTrustedLoopctl || - args[1] !== 'start' || - authorization.issue !== null - ) { + if (!isTrustedLoopctl || args[1] !== 'start' || authorization.issue !== null) { return withIntent } const issueNumber = Number(argumentAfter(args, '--issue')) @@ -1478,10 +1468,7 @@ async function preflightPullRequestWrite({ } if (['review', 'inline-review'].includes(intent.kind)) { - if ( - livePullRequest.draft !== true || - !intent.publication - ) { + if (livePullRequest.draft !== true || !intent.publication) { throw new Error('independent review publication requires the recorded Draft PR') } const publishedReviews = await githubPaginatedApi( @@ -1499,9 +1486,7 @@ async function preflightPullRequestWrite({ review.body?.includes(intent.publication.marker), ) ) { - throw new Error( - `adjudication for ${intent.publication.findingId} is already published`, - ) + throw new Error(`adjudication for ${intent.publication.findingId} is already published`) } const findingMarker = `` let findingPublished = false @@ -1935,7 +1920,13 @@ export async function runWithGitHubRole({ ? hardenedGitArguments(args, { expectedRepository: channel.repository }) : [...args] if (activationValidation) { - executionArgs = [args[0], 'validate', '--loop-root', path.resolve(loopRoot)] + executionArgs = [ + args[0], + 'validate', + ...(authorization.issue ? ['--target-compatibility'] : []), + '--loop-root', + path.resolve(loopRoot), + ] } const child = spawnCommand(executable, executionArgs, { env: childEnvironment, diff --git a/loops/issue-dev-loop/scripts/lib/validation.mjs b/loops/issue-dev-loop/scripts/lib/validation.mjs index a4ad0d13..a0793194 100644 --- a/loops/issue-dev-loop/scripts/lib/validation.mjs +++ b/loops/issue-dev-loop/scripts/lib/validation.mjs @@ -25,9 +25,7 @@ export function validateFinalizationHistory(historyLines) { const prior = stateByRunId.get(entry.runId) if (entry.event === 'run_finalization_unverified') { if (prior?.state !== 'finalized') { - throw new Error( - `logs/index.jsonl run is not currently finalized: ${entry.runId}`, - ) + throw new Error(`logs/index.jsonl run is not currently finalized: ${entry.runId}`) } stateByRunId.set(entry.runId, { ...prior, state: 'unverified' }) continue @@ -49,14 +47,21 @@ export function validateFinalizationHistory(historyLines) { export async function validateLoop({ loopRoot = DEFAULT_LOOP_ROOT, activation = false, + targetCompatibility = false, environment = process.env, identityCommand, } = {}) { - const required = [ + const targetRequired = [ 'SKILL.md', 'LOOP.md', 'state.md', 'dependencies.md', + 'evolve/metrics.json', + 'logs/index.jsonl', + 'logs/triggers.jsonl', + 'screen-shots/.gitignore', + ] + const controlPlaneRequired = [ 'agents/openai.yaml', 'agents/echo-ui-pr-reviewer.toml', 'agents/echo-ui-review-adjudicator.toml', @@ -105,10 +110,10 @@ export async function validateLoop({ 'scripts/loopctl.mjs', 'scripts/runtime.mjs', 'triggers/detect-work.mjs', - 'logs/index.jsonl', - 'logs/triggers.jsonl', - 'screen-shots/.gitignore', ] + const required = targetCompatibility + ? targetRequired + : [...targetRequired, ...controlPlaneRequired] const missing = [] for (const relative of required) { if (!(await pathExists(path.join(loopRoot, relative)))) missing.push(relative) @@ -194,6 +199,15 @@ export async function validateLoop({ throw new Error('missing .github/workflows/issue-dev-loop-evidence.yml') } const evidenceWorkflowSource = await readFile(evidenceWorkflow, 'utf8') + if ( + !evidenceWorkflowSource.includes('pull_request:') || + evidenceWorkflowSource.includes('pull_request_target:') || + !evidenceWorkflowSource.includes('permissions:\n contents: read') + ) { + throw new Error( + 'historical target evidence workflow must remain a low-privilege pull_request workflow', + ) + } const verificationStep = evidenceWorkflowSource.match( / - name: Run authoritative verification\n([\s\S]*?)(?=\n - name:)/, )?.[1] @@ -201,107 +215,103 @@ export async function validateLoop({ / - name: Enforce verification result\n([\s\S]*?)(?=\n - name:|$)/, )?.[1] if ( - !verificationStep?.includes('pnpm verify') || - verificationStep.includes('if:') || - !enforcementStep || - enforcementStep.includes("steps.run.outputs.has_run == 'true'") || - !evidenceWorkflowSource.includes('pull_request:') || - evidenceWorkflowSource.includes('pull_request_target:') || - !evidenceWorkflowSource.includes('permissions:\n contents: read') || - !evidenceWorkflowSource.includes( - "github.event.pull_request.head.ref != 'codex/issue-dev-loop'", - ) || - !evidenceWorkflowSource.includes('Check out owner-merged control plane') || - !evidenceWorkflowSource.includes('Check out frozen owner-merged baseline') || - !evidenceWorkflowSource.includes('ref: ${{ github.event.pull_request.base.sha }}') || - !evidenceWorkflowSource.includes('ref: ${{ steps.run.outputs.base_sha }}') || - !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', - ) || - !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') || - !evidenceWorkflowSource.includes('pnpm test') || - !evidenceWorkflowSource.includes( - 'git config --global --add safe.directory /work; pnpm verify', - ) || - !evidenceWorkflowSource.includes( - 'git config --global --add safe.directory /work; pnpm test', - ) || - !evidenceWorkflowSource.includes( - '--trusted-workflow-sha "${{ steps.run.outputs.base_sha }}"', - ) || - !evidenceWorkflowSource.includes( - '--workflow-base-sha "${{ github.event.pull_request.base.sha }}"', - ) || - !evidenceWorkflowSource.includes( - '--workflow-run-sha "${{ github.event.pull_request.head.sha }}"', - ) || - !evidenceWorkflowSource.includes('PR_HEAD_REF: ${{ github.event.pull_request.head.ref }}') || - !evidenceWorkflowSource.includes('--branch "$PR_HEAD_REF"') || - !evidenceWorkflowSource.includes('--baseline-status') + (!targetCompatibility && !verificationStep?.includes('pnpm verify')) || + (!targetCompatibility && + (verificationStep.includes('if:') || + !enforcementStep || + enforcementStep.includes("steps.run.outputs.has_run == 'true'") || + !evidenceWorkflowSource.includes( + "github.event.pull_request.head.ref != 'codex/issue-dev-loop'", + ) || + !evidenceWorkflowSource.includes('Check out owner-merged control plane') || + !evidenceWorkflowSource.includes('Check out frozen owner-merged baseline') || + !evidenceWorkflowSource.includes('ref: ${{ github.event.pull_request.base.sha }}') || + !evidenceWorkflowSource.includes('ref: ${{ steps.run.outputs.base_sha }}') || + !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', + ) || + !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') || + !evidenceWorkflowSource.includes('pnpm test') || + !evidenceWorkflowSource.includes( + 'git config --global --add safe.directory /work; pnpm verify', + ) || + !evidenceWorkflowSource.includes( + 'git config --global --add safe.directory /work; pnpm test', + ) || + !evidenceWorkflowSource.includes( + '--trusted-workflow-sha "${{ steps.run.outputs.base_sha }}"', + ) || + !evidenceWorkflowSource.includes( + '--workflow-base-sha "${{ github.event.pull_request.base.sha }}"', + ) || + !evidenceWorkflowSource.includes( + '--workflow-run-sha "${{ github.event.pull_request.head.sha }}"', + ) || + !evidenceWorkflowSource.includes( + 'PR_HEAD_REF: ${{ github.event.pull_request.head.ref }}', + ) || + !evidenceWorkflowSource.includes('--branch "$PR_HEAD_REF"') || + !evidenceWorkflowSource.includes('--baseline-status'))) ) { throw new Error( 'evidence workflow must use a low-privilege isolated PR run plus owner-merged controls and baseline tests', ) } - const codexConfig = await readFile( - path.resolve(loopRoot, '..', '..', '.codex', 'config.toml'), - 'utf8', - ) - const roleRegistrations = { - echo_ui_pr_reviewer: 'echo-ui-pr-reviewer.toml', - echo_ui_review_adjudicator: 'echo-ui-review-adjudicator.toml', - echo_ui_loop_evolver: 'echo-ui-loop-evolver.toml', - } - for (const [role, roleFile] of Object.entries(roleRegistrations)) { - const registration = `config_file = "../loops/issue-dev-loop/agents/${roleFile}"` - if (!codexConfig.includes(`[agents.${role}]`) || !codexConfig.includes(registration)) { - throw new Error(`Codex role is not registered through config_file: ${role}`) - } - } - for (const roleFile of [ - 'echo-ui-pr-reviewer.toml', - 'echo-ui-review-adjudicator.toml', - ]) { - const roleSource = await readFile( - path.resolve(loopRoot, 'agents', roleFile), + if (!targetCompatibility) { + const codexConfig = await readFile( + path.resolve(loopRoot, '..', '..', '.codex', 'config.toml'), 'utf8', ) - if ( - !roleSource.includes('$ECHO_UI_LOOP_CONTROL_PLANE/scripts/with-github-identity') || - !roleSource.includes('--loop-root') || - !roleSource.includes('$ECHO_UI_LOOP_TARGET_ROOT') || - !roleSource.includes('repository launcher') - ) { - throw new Error(`${roleFile} must publish only through the installed identity launcher`) + const roleRegistrations = { + echo_ui_pr_reviewer: 'echo-ui-pr-reviewer.toml', + echo_ui_review_adjudicator: 'echo-ui-review-adjudicator.toml', + echo_ui_loop_evolver: 'echo-ui-loop-evolver.toml', + } + for (const [role, roleFile] of Object.entries(roleRegistrations)) { + const registration = `config_file = "../loops/issue-dev-loop/agents/${roleFile}"` + if (!codexConfig.includes(`[agents.${role}]`) || !codexConfig.includes(registration)) { + throw new Error(`Codex role is not registered through config_file: ${role}`) + } + } + for (const roleFile of ['echo-ui-pr-reviewer.toml', 'echo-ui-review-adjudicator.toml']) { + const roleSource = await readFile(path.resolve(loopRoot, 'agents', roleFile), 'utf8') + if ( + !roleSource.includes('$ECHO_UI_LOOP_CONTROL_PLANE/scripts/with-github-identity') || + !roleSource.includes('--loop-root') || + !roleSource.includes('$ECHO_UI_LOOP_TARGET_ROOT') || + !roleSource.includes('repository launcher') + ) { + throw new Error(`${roleFile} must publish only through the installed identity launcher`) + } } - } - const contract = await readFile(path.join(loopRoot, 'LOOP.md'), 'utf8') - const skill = await readFile(path.join(loopRoot, 'SKILL.md'), 'utf8') - for (const phrase of [ - 'draft PR targeting `dev`', - 'approve, auto-merge, or merge any PR', - 'Only the remote owner-merge gate', - 'exact reviewed head SHA', - 'No eligible work is a successful no-op', - ]) { - if (!contract.includes(phrase)) throw new Error(`LOOP.md is missing invariant: ${phrase}`) - } - for (const phrase of [ - '$implement', - 'echo_ui_pr_reviewer', - 'echo_ui_loop_evolver', - 'record-pr', - 'record-evidence', - 'pnpm verify', - ]) { - if (!skill.includes(phrase)) { - throw new Error(`SKILL.md is missing runtime dependency: ${phrase}`) + const contract = await readFile(path.join(loopRoot, 'LOOP.md'), 'utf8') + const skill = await readFile(path.join(loopRoot, 'SKILL.md'), 'utf8') + for (const phrase of [ + 'draft PR targeting `dev`', + 'approve, auto-merge, or merge any PR', + 'Only the remote owner-merge gate', + 'exact reviewed head SHA', + 'No eligible work is a successful no-op', + ]) { + if (!contract.includes(phrase)) throw new Error(`LOOP.md is missing invariant: ${phrase}`) + } + for (const phrase of [ + '$implement', + 'echo_ui_pr_reviewer', + 'echo_ui_loop_evolver', + 'record-pr', + 'record-evidence', + 'pnpm verify', + ]) { + if (!skill.includes(phrase)) { + throw new Error(`SKILL.md is missing runtime dependency: ${phrase}`) + } } } diff --git a/loops/issue-dev-loop/scripts/loopctl.mjs b/loops/issue-dev-loop/scripts/loopctl.mjs index ff582069..5edc9def 100644 --- a/loops/issue-dev-loop/scripts/loopctl.mjs +++ b/loops/issue-dev-loop/scripts/loopctl.mjs @@ -236,7 +236,13 @@ async function main() { ) break case 'validate': - output(await validateLoop({ loopRoot, activation: Boolean(args.activation) })) + output( + await validateLoop({ + loopRoot, + activation: Boolean(args.activation), + targetCompatibility: Boolean(args['target-compatibility']), + }), + ) break case 'evolve-status': output(await getEvolveStatus({ loopRoot })) 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 b4e646ed..e1cd3378 100644 --- a/loops/issue-dev-loop/tests/github-identity-routing.test.mjs +++ b/loops/issue-dev-loop/tests/github-identity-routing.test.mjs @@ -127,8 +127,7 @@ async function createFixture({ const implementationResultDigest = createHash('sha256') .update(implementationSource) .digest('hex') - const implementationResultPath = - 'logs/runs/fixture-run/implementation-result.json' + const implementationResultPath = 'logs/runs/fixture-run/implementation-result.json' const run = { schemaVersion: 1, runId: 'fixture-run', @@ -257,11 +256,7 @@ async function createFixture({ }) await Promise.all([mkdir(runRoot, { recursive: true }), mkdir(briefRoot, { recursive: true })]) await writeFile(path.join(runRoot, 'run.json'), `${JSON.stringify(run)}\n`, 'utf8') - await writeFile( - path.join(runRoot, 'implementation-result.json'), - implementationSource, - 'utf8', - ) + await writeFile(path.join(runRoot, 'implementation-result.json'), implementationSource, 'utf8') await writeFile( path.join(runRoot, 'events.jsonl'), `${events.map((event) => JSON.stringify(event)).join('\n')}\n`, @@ -381,6 +376,7 @@ if (commandArguments[0] === 'spawn') { } } else { process.stdout.write(JSON.stringify({ + ...(commandArguments.length > 0 ? { arguments: commandArguments } : {}), config: process.env.GH_CONFIG_DIR, hasGhToken: Boolean(process.env.GH_TOKEN || process.env.GITHUB_TOKEN), gitConfig: [ @@ -564,9 +560,15 @@ ${JSON.stringify(process.execPath)} -e 'process.stdout.write(JSON.stringify({arg gh: await realpath(fakeGh), }, executableDigests: { - node: createHash('sha256').update(await readFile(process.execPath)).digest('hex'), - git: createHash('sha256').update(await readFile(gitExecutable)).digest('hex'), - gh: createHash('sha256').update(await readFile(fakeGh)).digest('hex'), + node: createHash('sha256') + .update(await readFile(process.execPath)) + .digest('hex'), + git: createHash('sha256') + .update(await readFile(gitExecutable)) + .digest('hex'), + gh: createHash('sha256') + .update(await readFile(fakeGh)) + .digest('hex'), }, files: await fixtureManifestFiles(trustedBundleRoot), } @@ -761,11 +763,59 @@ test('wrapped activation validates both profiles without exposing their paths to ], { env: fixture.env }, ) - assert.equal(JSON.parse(stdout).exposesOtherProfiles, false) + const result = JSON.parse(stdout) + assert.equal(result.exposesOtherProfiles, false) + assert.deepEqual(result.arguments, ['validate', '--target-compatibility']) assert.match(await readFile(path.join(fixture.automationProfile, 'probes'), 'utf8'), /probe/) assert.match(await readFile(path.join(fixture.reviewerProfile, 'probes'), 'utf8'), /probe/) }) +test('wrapped activation keeps full validation when there is no active run', async () => { + const fixture = await createFixture({ activeRun: false, recordedPr: false }) + const { stdout } = await execFileAsync( + routerLauncherPath, + [ + '--loop-root', + fixture.loopRoot, + 'automation', + '--', + process.execPath, + fixture.loopctlPath, + 'validate', + '--activation', + '--loop-root', + fixture.loopRoot, + ], + { env: fixture.env }, + ) + const result = JSON.parse(stdout) + assert.equal(result.exposesOtherProfiles, false) + assert.deepEqual(result.arguments, ['validate']) +}) + +test('target compatibility validation cannot be requested outside wrapped activation', async () => { + const fixture = await createFixture() + await assert.rejects( + execFileAsync( + routerLauncherPath, + [ + '--loop-root', + fixture.loopRoot, + 'automation', + '--', + process.execPath, + fixture.loopctlPath, + 'validate', + '--target-compatibility', + '--loop-root', + fixture.loopRoot, + ], + { env: fixture.env }, + ), + /target compatibility validation is reserved to wrapped activation/, + ) +}) + test('reviewer role refuses a profile authenticated as the wrong account', async () => { const fixture = await createFixture() await writeFile(path.join(fixture.reviewerProfile, 'identity'), 'owner-user\n', 'utf8') @@ -911,15 +961,7 @@ test('checkpoint publication is reserved to a semantically attested current run' ] const allowed = await execFileAsync( process.execPath, - [ - routerPath, - '--loop-root', - fixture.loopRoot, - 'automation', - '--', - 'gh', - ...publishArguments, - ], + [routerPath, '--loop-root', fixture.loopRoot, 'automation', '--', 'gh', ...publishArguments], { env: fixture.env }, ) assert.match(allowed.stdout, /issues\/999\/comments/) @@ -1485,15 +1527,7 @@ test('PR and issue mutations reject unsafe shapes and targets', async () => { ['automation', ['pr', 'comment', '106', '--body', 'Missing repository']], [ 'automation', - [ - 'pr', - 'comment', - '106', - '--repo', - 'example/repo', - '--body', - '@owner **pr_completed**', - ], + ['pr', 'comment', '106', '--repo', 'example/repo', '--body', '@owner **pr_completed**'], ], [ 'automation', diff --git a/loops/issue-dev-loop/tests/runtime.test.mjs b/loops/issue-dev-loop/tests/runtime.test.mjs index 48411324..447608c9 100644 --- a/loops/issue-dev-loop/tests/runtime.test.mjs +++ b/loops/issue-dev-loop/tests/runtime.test.mjs @@ -3,6 +3,7 @@ import { execFile } from 'node:child_process' import { createHash } from 'node:crypto' import { chmod, + cp, mkdtemp, mkdir, readFile, @@ -731,10 +732,7 @@ async function writeFixtureFinalization({ ) : null const checkpointEvent = failureStatus - ? (await readFile( - path.join(loopRoot, 'logs', 'runs', runId, 'events.jsonl'), - 'utf8', - )) + ? (await readFile(path.join(loopRoot, 'logs', 'runs', runId, 'events.jsonl'), 'utf8')) .split('\n') .filter(Boolean) .map((line) => JSON.parse(line)) @@ -742,8 +740,7 @@ async function writeFixtureFinalization({ : null const pauseStartedAt = failureStatus ? checkpointRecord.events.findLast( - (event) => - event.type === 'run_status_changed' && event.status === 'waiting_for_owner', + (event) => event.type === 'run_status_changed' && event.status === 'waiting_for_owner', )?.timestamp : null const record = { @@ -1006,11 +1003,7 @@ test('candidate control-plane validation permits run evidence but rejects verifi await git('add', '.') await git('commit', '-m', 'run evidence') const permittedHead = (await git('rev-parse', 'HEAD')).stdout.trim() - const validator = path.join( - repositoryLoopRoot, - 'scripts', - 'validate-candidate-control-plane.mjs', - ) + const validator = path.join(repositoryLoopRoot, 'scripts', 'validate-candidate-control-plane.mjs') const permitted = await execFileAsync(process.execPath, [ validator, '--loop-root', @@ -1100,11 +1093,7 @@ test('candidate control-plane validation permits run evidence but rejects verifi '# Candidate-controlled adapter\n', /\.agents\/skills\/issue-dev-loop\/SKILL\.md/, ], - [ - 'vercel.json', - '{"git":{"deploymentEnabled":{"codex/issue-*":true}}}\n', - /vercel\.json/, - ], + ['vercel.json', '{"git":{"deploymentEnabled":{"codex/issue-*":true}}}\n', /vercel\.json/], ]) { const target = path.join(repository, relativePath) await mkdir(path.dirname(target), { recursive: true }) @@ -1543,17 +1532,18 @@ test('UI draft PR requires embedded before and after screenshots pinned to its e test('evidence workflow marks volume checkouts safe before Git-backed verification', async () => { const workflow = await readFile( - path.resolve(repositoryLoopRoot, '..', '..', '.github', 'workflows', 'issue-dev-loop-evidence.yml'), + path.resolve( + repositoryLoopRoot, + '..', + '..', + '.github', + 'workflows', + 'issue-dev-loop-evidence.yml', + ), 'utf8', ) - assert.match( - workflow, - /git config --global --add safe\.directory \/work; pnpm verify/, - ) - assert.match( - workflow, - /git config --global --add safe\.directory \/work; pnpm test/, - ) + assert.match(workflow, /git config --global --add safe\.directory \/work; pnpm verify/) + assert.match(workflow, /git config --global --add safe\.directory \/work; pnpm test/) }) test('automation identity cannot overlap the repository owner', async () => { @@ -2770,8 +2760,7 @@ test('forged local blocked finalization cannot release an issue claim', async () readyNotifiedAt: null, completionNotifiedAt: null, notificationWebhookStatus: null, - predecessorCheckpointUrl: - 'https://github.com/codeacme17/echo-ui/issues/999#issuecomment-8801', + predecessorCheckpointUrl: 'https://github.com/codeacme17/echo-ui/issues/999#issuecomment-8801', predecessorCheckpointDigest: 'a'.repeat(64), pauseStartedAt: '2030-01-01T00:00:00.000Z', notificationNotifiedAt: '2030-01-01T00:01:00.000Z', @@ -3052,9 +3041,7 @@ test('review gate verifies published findings and classified replies', async () : [ 'PASS', ...(includePriorFinding - ? [ - 'Resolved RVW-1-1-1 and RVW-1-1-2 with published executor responses.', - ] + ? ['Resolved RVW-1-1-1 and RVW-1-1-2 with published executor responses.'] : []), ``, ``, @@ -3621,10 +3608,9 @@ test('canonical GitHub notification persists before a bounded webhook mirror', a ) assert.equal(persisted.delivery.github, 'delivered') assert.match(persisted.delivery.webhook, /^failed: timed out/) - const events = (await readFile( - path.join(loopRoot, 'logs', 'runs', run.runId, 'events.jsonl'), - 'utf8', - )) + const events = ( + await readFile(path.join(loopRoot, 'logs', 'runs', run.runId, 'events.jsonl'), 'utf8') + ) .trim() .split('\n') .map((line) => JSON.parse(line)) @@ -3990,10 +3976,7 @@ test('three matching failures make a fresh evolve session due', async () => { }) assert.deepEqual(tombstoned.durableRunIds, []) await finalizeRun(finalizationOptions) - const restoredHistory = (await readFile( - path.join(loopRoot, 'logs', 'index.jsonl'), - 'utf8', - )) + const restoredHistory = (await readFile(path.join(loopRoot, 'logs', 'index.jsonl'), 'utf8')) .split('\n') .filter(Boolean) .map((line) => JSON.parse(line)) @@ -4027,8 +4010,7 @@ test('three matching failures make a fresh evolve session due', async () => { test('fresh worktrees rebuild finalization history and evolve metrics from GitHub journal', async () => { const { loopRoot } = await createFixture() const durableRunId = '20260722T120000Z-issue-205-journal' - const notificationUrl = - 'https://github.com/codeacme17/echo-ui/issues/205#issuecomment-8802' + const notificationUrl = 'https://github.com/codeacme17/echo-ui/issues/205#issuecomment-8802' const predecessorCheckpointUrl = 'https://github.com/codeacme17/echo-ui/issues/999#issuecomment-8801' const pauseStartedAt = '2026-07-22T12:30:00.000Z' @@ -4223,8 +4205,7 @@ test('fresh worktrees rebuild finalization history and evolve metrics from GitHu payload: { notificationType: 'clarification_required', delivery: { github: 'delivered' }, - deliveryUrl: - 'https://github.com/codeacme17/echo-ui/issues/205#issuecomment-8811', + deliveryUrl: 'https://github.com/codeacme17/echo-ui/issues/205#issuecomment-8811', targetUrl: 'https://github.com/codeacme17/echo-ui/issues/205', }, }, @@ -4239,8 +4220,7 @@ test('fresh worktrees rebuild finalization history and evolve metrics from GitHu ) latestCheckpointRecord.updatedAt = '2026-07-22T13:20:00.000Z' const latestCheckpointDigest = checkpointDigest(latestCheckpointRecord) - const latestCheckpointUrl = - 'https://github.com/codeacme17/echo-ui/issues/999#issuecomment-8810' + const latestCheckpointUrl = 'https://github.com/codeacme17/echo-ui/issues/999#issuecomment-8810' const latestCheckpointBody = [ ``, '```json', @@ -4273,10 +4253,7 @@ test('fresh worktrees rebuild finalization history and evolve metrics from GitHu latestActiveCheckpoints: allActive.activeCheckpoints, }) assert.deepEqual(superseded.durableRunIds, []) - const supersededHistory = await readFile( - path.join(loopRoot, 'logs', 'index.jsonl'), - 'utf8', - ) + const supersededHistory = await readFile(path.join(loopRoot, 'logs', 'index.jsonl'), 'utf8') assert.match(supersededHistory, /run_finalization_unverified/) const supersededMetrics = await getEvolveStatus({ loopRoot }) assert.equal(supersededMetrics.finalizedRuns, 0) @@ -4302,10 +4279,7 @@ test('fresh worktrees rebuild finalization history and evolve metrics from GitHu }) assert.equal(restored.reconciled, 1) assert.deepEqual(restored.durableRunIds, [record.runId]) - const restoredHistory = (await readFile( - path.join(loopRoot, 'logs', 'index.jsonl'), - 'utf8', - )) + const restoredHistory = (await readFile(path.join(loopRoot, 'logs', 'index.jsonl'), 'utf8')) .split('\n') .filter(Boolean) .map((line) => JSON.parse(line)) @@ -4341,10 +4315,7 @@ test('reconciliation excludes local finalization rows without a durable journal })) await writeFile( indexPath, - `${[ - { schemaVersion: 1, event: 'loop_initialized' }, - ...forgedRows, - ] + `${[{ schemaVersion: 1, event: 'loop_initialized' }, ...forgedRows] .map((entry) => JSON.stringify(entry)) .join('\n')}\n`, 'utf8', @@ -4736,8 +4707,7 @@ test('evolve completion rejects an unrelated historical owner-merged PR', async await recordEvolveRequestPublication({ loopRoot, requestId, - commentUrl: - 'https://github.com/codeacme17/echo-ui/issues/999#issuecomment-7000', + commentUrl: 'https://github.com/codeacme17/echo-ui/issues/999#issuecomment-7000', githubApi: async () => ({ user: { login: 'echo-ui-loop[bot]' }, body: preparedRequest.body, @@ -4758,12 +4728,12 @@ test('evolve completion rejects an unrelated historical owner-merged PR', async } if (endpoint.includes('/reviews')) { return [ - { - user: { login: 'codeacme17' }, - state: 'APPROVED', - commit_id: 'a'.repeat(40), - }, - ] + { + user: { login: 'codeacme17' }, + state: 'APPROVED', + commit_id: 'a'.repeat(40), + }, + ] } if (endpoint.includes('/timeline')) { return [ @@ -4792,10 +4762,8 @@ test('evolve completion rejects an unrelated historical owner-merged PR', async test('fresh worktrees rebuild pending and completed evolve state from the durable journal', async () => { const { loopRoot } = await createFixture() const requestId = 'EVL-000010-TEN-FINALIZED-RUNS' - const requestUrl = - 'https://github.com/codeacme17/echo-ui/issues/999#issuecomment-7100' - const completionUrl = - 'https://github.com/codeacme17/echo-ui/issues/999#issuecomment-7101' + const requestUrl = 'https://github.com/codeacme17/echo-ui/issues/999#issuecomment-7100' + const completionUrl = 'https://github.com/codeacme17/echo-ui/issues/999#issuecomment-7101' const prUrl = 'https://github.com/codeacme17/echo-ui/pull/710' const headSha = '7'.repeat(40) const mergeSha = '8'.repeat(40) @@ -4949,8 +4917,7 @@ test('fresh worktrees rebuild pending and completed evolve state from the durabl await writeFile(metricsPath, `${JSON.stringify(initialMetrics)}\n`, 'utf8') await rm(requestPath) - const duplicateRequestUrl = - 'https://github.com/codeacme17/echo-ui/issues/999#issuecomment-7200' + const duplicateRequestUrl = 'https://github.com/codeacme17/echo-ui/issues/999#issuecomment-7200' const duplicateCompletionUrl = 'https://github.com/codeacme17/echo-ui/issues/999#issuecomment-7201' const reconciled = await reconcileEvolveJournal({ @@ -5020,14 +4987,83 @@ test('repository loop package satisfies its structural invariants', async () => assert.equal(result.valid, true) }) +test('historical active-run targets can validate without newer trusted runtime files', async () => { + const parent = await mkdtemp(path.join(os.tmpdir(), 'echo-ui-historical-target-')) + const repositoryRoot = path.join(parent, 'repository') + const historicalLoopRoot = path.join(repositoryRoot, 'loops', 'issue-dev-loop') + try { + await Promise.all([ + cp(repositoryLoopRoot, historicalLoopRoot, { recursive: true }), + cp( + path.resolve(repositoryLoopRoot, '..', '_shared'), + path.join(repositoryRoot, 'loops', '_shared'), + { recursive: true }, + ), + cp( + path.resolve(repositoryLoopRoot, '..', '..', '.codex'), + path.join(repositoryRoot, '.codex'), + { recursive: true }, + ), + mkdir(path.join(repositoryRoot, '.github', 'workflows'), { recursive: true }), + ]) + await cp( + path.resolve( + repositoryLoopRoot, + '..', + '..', + '.github', + 'workflows', + 'issue-dev-loop-evidence.yml', + ), + path.join(repositoryRoot, '.github', 'workflows', 'issue-dev-loop-evidence.yml'), + ) + const workflowPath = path.join( + repositoryRoot, + '.github', + 'workflows', + 'issue-dev-loop-evidence.yml', + ) + await writeFile( + workflowPath, + (await readFile(workflowPath, 'utf8')).replace( + '--baseline-status', + '--historical-baseline-status', + ), + 'utf8', + ) + await Promise.all([ + rm(path.join(historicalLoopRoot, 'scripts', 'lib', 'review-publication.mjs')), + rm(path.join(historicalLoopRoot, 'scripts', 'publish-review.mjs')), + ]) + + await assert.rejects( + validateLoop({ loopRoot: historicalLoopRoot }), + /missing required loop files: .*review-publication\.mjs.*publish-review\.mjs/, + ) + const result = await validateLoop({ + loopRoot: historicalLoopRoot, + targetCompatibility: true, + }) + assert.equal(result.valid, true) + + await rm(path.join(historicalLoopRoot, 'logs', 'index.jsonl')) + await assert.rejects( + validateLoop({ + loopRoot: historicalLoopRoot, + targetCompatibility: true, + }), + /missing required loop files: logs\/index\.jsonl/, + ) + } finally { + await rm(parent, { recursive: true, force: true }) + } +}) + test('repository activation verifies both configured GitHub profiles', async () => { const profileRoot = await mkdtemp(path.join(os.tmpdir(), 'echo-ui-activation-profiles-')) const automationProfile = path.join(profileRoot, 'automation') const reviewerProfile = path.join(profileRoot, 'reviewer') - await Promise.all([ - mkdir(automationProfile), - mkdir(reviewerProfile), - ]) + await Promise.all([mkdir(automationProfile), mkdir(reviewerProfile)]) await Promise.all([chmod(automationProfile, 0o700), chmod(reviewerProfile, 0o700)]) const [canonicalAutomationProfile, canonicalReviewerProfile] = await Promise.all([ realpath(automationProfile), @@ -5036,9 +5072,7 @@ test('repository activation verifies both configured GitHub profiles', async () const environment = { ECHO_UI_LOOP_AUTOMATION_GH_CONFIG_DIR: canonicalAutomationProfile, ECHO_UI_LOOP_REVIEWER_GH_CONFIG_DIR: canonicalReviewerProfile, - ECHO_UI_LOOP_UNTRUSTED_ROOTS: JSON.stringify([ - path.resolve(repositoryLoopRoot, '..', '..'), - ]), + ECHO_UI_LOOP_UNTRUSTED_ROOTS: JSON.stringify([path.resolve(repositoryLoopRoot, '..', '..')]), } const observedProfiles = [] const identityCommand = async (_command, _args, options) => { From c7ba5bd66e11fc9e264b3eadb4cfb43e39673603 Mon Sep 17 00:00:00 2001 From: leyoonafr Date: Sun, 26 Jul 2026 20:16:53 +0800 Subject: [PATCH 02/11] fix(loop): bind historical validation to durable state --- loops/issue-dev-loop/SKILL.md | 2 +- .../references/github-operations.md | 2 +- .../scripts/lib/github-identity.mjs | 137 +++++++++++++++++- .../issue-dev-loop/scripts/lib/validation.mjs | 69 ++++++++- loops/issue-dev-loop/scripts/loopctl.mjs | 4 +- .../scripts/validate-historical-target.mjs | 11 ++ .../tests/github-identity-routing.test.mjs | 128 +++++++++++++++- loops/issue-dev-loop/tests/runtime.test.mjs | 51 ++++++- 8 files changed, 382 insertions(+), 22 deletions(-) create mode 100644 loops/issue-dev-loop/scripts/validate-historical-target.mjs diff --git a/loops/issue-dev-loop/SKILL.md b/loops/issue-dev-loop/SKILL.md index 7e27e7f0..a28ff0bb 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 and probes both configured profiles before starting validation. When the target contains a durable active run created by an older owner-merged control plane, the launcher alone selects historical-target compatibility validation: installed runtime files remain hash-verified in the trusted bundle, while the target is checked for its stable state files, owner channel, JSON history, and evidence workflow. Callers cannot request this reduced target-only mode directly, and `restore-checkpoint` still requires the durable exact branch, head, and clean worktree. +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 matching the local run to an automation-authored remote durable checkpoint, proving the clean exact branch and head, and proving the issue diff did not modify the protected control or verification plane. The installed internal validator then checks stable target state, owner channel, JSON history, and a conservatively parsed low-privilege evidence workflow. Callers and the public validation API cannot request this reduced mode, and `restore-checkpoint` still requires the durable exact branch, head, and clean worktree. 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. diff --git a/loops/issue-dev-loop/references/github-operations.md b/loops/issue-dev-loop/references/github-operations.md index 9192bb4c..05cc6327 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, only the installed activation router may select historical-target compatibility validation. It continues to hash-verify the complete installed control plane and both identities, then validates the target's stable state files, owner channel, JSON history, and evidence workflow. A caller-supplied compatibility flag is rejected; the subsequent restore still enforces the journal's exact branch and head on a clean worktree. +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 an automation-authored remote durable checkpoint exactly matches the local active run, the worktree is on its clean exact branch and head, and the issue diff is proven not to modify the protected control or verification plane. The installed internal validator then checks stable target state, owner channel, JSON history, and conservatively rejects evidence workflows with unrecognized triggers or any job-level/write permission. Caller-supplied flags and the public validation API cannot select the reduced mode; the subsequent restore independently enforces the journal's exact branch and head on a clean worktree. 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`. diff --git a/loops/issue-dev-loop/scripts/lib/github-identity.mjs b/loops/issue-dev-loop/scripts/lib/github-identity.mjs index 2e84040c..b403565f 100644 --- a/loops/issue-dev-loop/scripts/lib/github-identity.mjs +++ b/loops/issue-dev-loop/scripts/lib/github-identity.mjs @@ -14,6 +14,7 @@ import { readJson, sameGitHubLogin, } from './common.mjs' +import { reconcileActiveJournal } from './active-journal.mjs' import { checkpointRecordDigest, parseCheckpointRecord, @@ -1343,6 +1344,97 @@ function activationValidationRequested({ role, tool, args, loopRoot, trustedLoop ) } +async function authorizeHistoricalTargetValidation({ + authorization, + loopRoot, + trustedLoopRoot, + realGit, + realGh, + realNode, + environment, +}) { + const localIssue = authorization.issue + if (!localIssue?.runId) { + throw new Error('historical target validation requires a local active run') + } + const githubApi = async (endpoint) => { + const { stdout } = await execFileAsync(realGh, ['api', endpoint], { + env: environment, + maxBuffer: 1024 * 1024, + }) + return JSON.parse(stdout) + } + const { activeCheckpoints } = await reconcileActiveJournal({ + loopRoot, + githubPaginatedApi: (endpoint) => + paginateGitHubApi(githubApi, endpoint.replace(/[?&]per_page=100$/, '')), + }) + const durable = activeCheckpoints.find( + (checkpoint) => checkpoint.record.run.runId === localIssue.runId, + ) + const run = durable?.record.run + const expectedHead = run?.headSha ?? run?.implementationCommit ?? run?.baseSha + if ( + !run || + run.finishedAt !== null || + run.issueNumber !== localIssue.issueNumber || + run.branch !== localIssue.branch || + run.status !== localIssue.status || + run.implementationCommit !== localIssue.implementationCommit + ) { + throw new Error( + 'historical target validation requires the exact remote durable active checkpoint', + ) + } + + const repositoryRoot = repositoryRootForLoop(loopRoot) + const [branch, head, status] = 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'], { + cwd: repositoryRoot, + env: environment, + maxBuffer: 1024 * 1024, + }), + ]) + if ( + branch.stdout.trim() !== run.branch || + head.stdout.trim() !== expectedHead || + status.stdout.trim() + ) { + throw new Error( + 'historical target validation requires the clean exact durable branch and head', + ) + } + + await execFileAsync( + realNode, + [ + path.resolve(trustedLoopRoot, 'scripts', 'validate-candidate-control-plane.mjs'), + '--loop-root', + path.resolve(loopRoot), + '--run-id', + run.runId, + '--base-sha', + run.baseSha, + '--head-sha', + expectedHead, + ], + { + cwd: repositoryRoot, + env: environment, + maxBuffer: 4 * 1024 * 1024, + }, + ) + return durable +} + function pullRequestWriteIntent(role, args, authorization) { const group = githubGroup(args) if (role === 'reviewer' && group.name === 'api') { @@ -1920,13 +2012,54 @@ export async function runWithGitHubRole({ ? hardenedGitArguments(args, { expectedRepository: channel.repository }) : [...args] if (activationValidation) { - executionArgs = [ + const fullValidationArguments = [ args[0], 'validate', - ...(authorization.issue ? ['--target-compatibility'] : []), '--loop-root', path.resolve(loopRoot), ] + try { + const { stdout } = await execFileAsync(executable, fullValidationArguments, { + env: childEnvironment, + maxBuffer: 4 * 1024 * 1024, + }) + process.stdout.write(stdout) + return 0 + } catch (fullValidationError) { + if (!authorization.issue?.runId) throw fullValidationError + await authorizeHistoricalTargetValidation({ + authorization, + loopRoot, + trustedLoopRoot: trustedControlPlane.loopRoot, + realGit, + realGh, + realNode, + environment: childEnvironment, + }) + try { + const { stdout } = await execFileAsync( + realNode, + [ + path.resolve( + trustedControlPlane.loopRoot, + 'scripts', + 'validate-historical-target.mjs', + ), + '--loop-root', + path.resolve(loopRoot), + ], + { + env: childEnvironment, + maxBuffer: 4 * 1024 * 1024, + }, + ) + process.stdout.write(stdout) + return 0 + } catch (historicalValidationError) { + historicalValidationError.cause = fullValidationError + throw historicalValidationError + } + } } const child = spawnCommand(executable, executionArgs, { env: childEnvironment, diff --git a/loops/issue-dev-loop/scripts/lib/validation.mjs b/loops/issue-dev-loop/scripts/lib/validation.mjs index a0793194..0e572dbe 100644 --- a/loops/issue-dev-loop/scripts/lib/validation.mjs +++ b/loops/issue-dev-loop/scripts/lib/validation.mjs @@ -44,7 +44,55 @@ export function validateFinalizationHistory(historyLines) { } } -export async function validateLoop({ +function activeYamlLines(source) { + return source + .split(/\r?\n/) + .map((line) => line.replace(/\s+$/, '')) + .filter((line) => line.trim() && !line.trimStart().startsWith('#')) +} + +function historicalWorkflowIsLowPrivilege(source) { + const lines = activeYamlLines(source) + const onIndex = lines.findIndex((line) => /^on:\s*(?:#.*)?$/.test(line)) + if (onIndex === -1) return false + const onBlock = lines.slice(onIndex + 1).findIndex((line) => /^\S/.test(line)) + const triggerLines = + onBlock === -1 ? lines.slice(onIndex + 1) : lines.slice(onIndex + 1, onIndex + 1 + onBlock) + if ( + !triggerLines.some((line) => /^ pull_request:\s*(?:#.*)?$/.test(line)) || + lines.some((line) => /^\s*pull_request_target\s*:/.test(line)) + ) { + return false + } + + const permissionIndexes = lines.flatMap((line, index) => + /^permissions\s*:/.test(line) ? [index] : [], + ) + if ( + permissionIndexes.length !== 1 || + !/^permissions:\s*(?:#.*)?$/.test(lines[permissionIndexes[0]]) || + lines.some((line) => /^\s+permissions\s*:/.test(line)) + ) { + return false + } + const permissionIndex = permissionIndexes[0] + const permissionBlockEnd = lines + .slice(permissionIndex + 1) + .findIndex((line) => /^\S/.test(line)) + const permissionLines = + permissionBlockEnd === -1 + ? lines.slice(permissionIndex + 1) + : lines.slice(permissionIndex + 1, permissionIndex + 1 + permissionBlockEnd) + const permissions = new Map() + for (const line of permissionLines) { + const match = line.match(/^ ([a-z-]+):\s*(read|none)\s*(?:#.*)?$/) + if (!match || permissions.has(match[1])) return false + permissions.set(match[1], match[2]) + } + return permissions.get('contents') === 'read' +} + +async function validateLoopMode({ loopRoot = DEFAULT_LOOP_ROOT, activation = false, targetCompatibility = false, @@ -97,6 +145,7 @@ export async function validateLoop({ 'scripts/lib/trusted-control-plane.mjs', 'scripts/github-command-gate.mjs', 'scripts/publish-review.mjs', + 'scripts/validate-historical-target.mjs', 'scripts/identity-bin/gh', 'scripts/identity-bin/git', 'scripts/lib/issue-claim.mjs', @@ -199,11 +248,7 @@ export async function validateLoop({ throw new Error('missing .github/workflows/issue-dev-loop-evidence.yml') } const evidenceWorkflowSource = await readFile(evidenceWorkflow, 'utf8') - if ( - !evidenceWorkflowSource.includes('pull_request:') || - evidenceWorkflowSource.includes('pull_request_target:') || - !evidenceWorkflowSource.includes('permissions:\n contents: read') - ) { + if (!historicalWorkflowIsLowPrivilege(evidenceWorkflowSource)) { throw new Error( 'historical target evidence workflow must remain a low-privilege pull_request workflow', ) @@ -327,3 +372,15 @@ export async function validateLoop({ } return { valid: true, checkedFiles: required.length + jsonFiles.length } } + +export function validateLoop(options = {}) { + return validateLoopMode({ ...options, targetCompatibility: false }) +} + +export function validateHistoricalTarget(options = {}) { + return validateLoopMode({ + ...options, + activation: false, + targetCompatibility: true, + }) +} diff --git a/loops/issue-dev-loop/scripts/loopctl.mjs b/loops/issue-dev-loop/scripts/loopctl.mjs index 5edc9def..9bd74e2f 100644 --- a/loops/issue-dev-loop/scripts/loopctl.mjs +++ b/loops/issue-dev-loop/scripts/loopctl.mjs @@ -236,11 +236,13 @@ async function main() { ) break case 'validate': + if (args['target-compatibility']) { + throw new Error('target compatibility validation is reserved to wrapped activation') + } output( await validateLoop({ loopRoot, activation: Boolean(args.activation), - targetCompatibility: Boolean(args['target-compatibility']), }), ) break diff --git a/loops/issue-dev-loop/scripts/validate-historical-target.mjs b/loops/issue-dev-loop/scripts/validate-historical-target.mjs new file mode 100644 index 00000000..e1a16004 --- /dev/null +++ b/loops/issue-dev-loop/scripts/validate-historical-target.mjs @@ -0,0 +1,11 @@ +#!/usr/bin/env node + +import path from 'node:path' + +import { parseArguments } from './lib/common.mjs' +import { validateHistoricalTarget } from './lib/validation.mjs' + +const args = parseArguments(process.argv.slice(2)) +const loopRoot = args['loop-root'] ? path.resolve(args['loop-root']) : undefined +const result = await validateHistoricalTarget({ loopRoot }) +process.stdout.write(`${JSON.stringify(result, null, 2)}\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 e1cd3378..e93653a8 100644 --- a/loops/issue-dev-loop/tests/github-identity-routing.test.mjs +++ b/loops/issue-dev-loop/tests/github-identity-routing.test.mjs @@ -54,6 +54,8 @@ async function createFixture({ realGit = false, liveDraft = true, ownerFeedback = false, + historicalActivation = false, + remoteCheckpoint = true, } = {}) { const parent = await mkdtemp(path.join(os.tmpdir(), 'echo-ui-identity-routing-')) const loopRoot = path.join(parent, 'issue-dev-loop') @@ -97,6 +99,7 @@ async function createFixture({ ]) await writeFile(path.join(automationProfile, 'identity'), 'executor-user\n', 'utf8') await writeFile(path.join(reviewerProfile, 'identity'), 'reviewer-user\n', 'utf8') + await writeFile(path.join(parent, 'checkpoint-journal.json'), '[]\n', 'utf8') await Promise.all([ chmod(automationProfile, 0o700), chmod(reviewerProfile, 0o700), @@ -276,6 +279,21 @@ async function createFixture({ })}\n`, 'utf8', ) + if (remoteCheckpoint) { + await writeFile( + path.join(parent, 'checkpoint-journal.json'), + `${JSON.stringify([ + { + id: 1, + user: { login: 'executor-user' }, + body: publication.body, + html_url: 'https://github.com/example/repo/issues/999#issuecomment-1', + created_at: '2026-07-23T00:05:00.000Z', + }, + ])}\n`, + 'utf8', + ) + } await writeFile( path.join(parent, 'live-pr.json'), `${JSON.stringify({ @@ -311,7 +329,10 @@ const commandArguments = process.argv.slice(2) const loopRootIndex = commandArguments.indexOf('--loop-root') if (loopRootIndex !== -1) commandArguments.splice(loopRootIndex, 2) -if (commandArguments[0] === 'spawn') { +if (commandArguments[0] === 'validate' && ${JSON.stringify(historicalActivation)}) { + process.stderr.write('missing required loop files: scripts/lib/review-publication.mjs\\n') + process.exitCode = 1 +} else if (commandArguments[0] === 'spawn') { if (!['git', 'gh'].includes(commandArguments[1])) { throw new Error('descendant processes cannot run untrusted executables') } @@ -417,6 +438,16 @@ if (commandArguments[0] === 'spawn') { `, 'utf8', ) + await writeFile( + path.join(trustedLoopRoot, 'scripts', 'validate-historical-target.mjs'), + `process.stdout.write(JSON.stringify({ valid: true, historicalTargetCompatibility: true }))\n`, + 'utf8', + ) + await writeFile( + path.join(trustedLoopRoot, 'scripts', 'validate-candidate-control-plane.mjs'), + `process.stdout.write(JSON.stringify({ valid: true, protectedControlPlane: true }))\n`, + 'utf8', + ) const fakeGh = path.join(binRoot, 'gh') await writeFile( @@ -438,6 +469,14 @@ if [ "$1 $2 $3 $4" != "api user --jq .login" ]; then first_line "$parent_dir/checkpoint-comment.json" exit 0 fi + if [ "$1" = "api" ] && [ "$2" = "repos/example/repo/issues/999/comments?per_page=100&page=1" ]; then + first_line "$parent_dir/checkpoint-journal.json" + exit 0 + fi + if [ "$1" = "api" ] && [ "$2" = "repos/example/repo/issues/999/comments?per_page=100&page=2" ]; then + printf '[]\\n' + exit 0 + fi if [ "$1" = "api" ] && [ "$2" = "repos/example/repo/pulls/106" ]; then first_line "$parent_dir/live-pr.json" exit 0 @@ -745,7 +784,7 @@ test('authenticated routing refuses a replaced pinned executable', async () => { ) }) -test('wrapped activation validates both profiles without exposing their paths to loopctl', async () => { +test('wrapped activation first uses full validation without exposing profile paths', async () => { const fixture = await createFixture() const { stdout } = await execFileAsync( routerLauncherPath, @@ -765,11 +804,94 @@ test('wrapped activation validates both profiles without exposing their paths to ) const result = JSON.parse(stdout) assert.equal(result.exposesOtherProfiles, false) - assert.deepEqual(result.arguments, ['validate', '--target-compatibility']) + assert.deepEqual(result.arguments, ['validate']) assert.match(await readFile(path.join(fixture.automationProfile, 'probes'), 'utf8'), /probe/) assert.match(await readFile(path.join(fixture.reviewerProfile, 'probes'), 'utf8'), /probe/) }) +test('wrapped activation allows an exact durable target ahead of its committed local head cache', async () => { + const fixture = await createFixture({ historicalActivation: true }) + const runPath = path.join(fixture.loopRoot, 'logs', 'runs', 'fixture-run', 'run.json') + const run = JSON.parse(await readFile(runPath, 'utf8')) + await writeFile(runPath, `${JSON.stringify({ ...run, headSha: null })}\n`, 'utf8') + const { stdout } = await execFileAsync( + routerLauncherPath, + [ + '--loop-root', + fixture.loopRoot, + 'automation', + '--', + process.execPath, + fixture.loopctlPath, + 'validate', + '--activation', + '--loop-root', + fixture.loopRoot, + ], + { env: fixture.env }, + ) + assert.deepEqual(JSON.parse(stdout), { + valid: true, + historicalTargetCompatibility: true, + }) +}) + +test('wrapped activation rejects historical validation without a remote durable checkpoint', async () => { + const fixture = await createFixture({ + historicalActivation: true, + remoteCheckpoint: false, + }) + await assert.rejects( + execFileAsync( + routerLauncherPath, + [ + '--loop-root', + fixture.loopRoot, + 'automation', + '--', + process.execPath, + fixture.loopctlPath, + 'validate', + '--activation', + '--loop-root', + fixture.loopRoot, + ], + { env: fixture.env }, + ), + /historical target validation requires the exact remote durable active checkpoint/, + ) +}) + +test('wrapped activation rejects local active state that differs from its durable checkpoint', async () => { + const fixture = await createFixture({ historicalActivation: true }) + const runPath = path.join(fixture.loopRoot, 'logs', 'runs', 'fixture-run', 'run.json') + const run = JSON.parse(await readFile(runPath, 'utf8')) + await writeFile( + runPath, + `${JSON.stringify({ ...run, status: 'waiting_for_owner' })}\n`, + 'utf8', + ) + await assert.rejects( + execFileAsync( + routerLauncherPath, + [ + '--loop-root', + fixture.loopRoot, + 'automation', + '--', + process.execPath, + fixture.loopctlPath, + 'validate', + '--activation', + '--loop-root', + fixture.loopRoot, + ], + { env: fixture.env }, + ), + /historical target validation requires the exact remote durable active checkpoint/, + ) +}) + test('wrapped activation keeps full validation when there is no active run', async () => { const fixture = await createFixture({ activeRun: false, recordedPr: false }) const { stdout } = await execFileAsync( diff --git a/loops/issue-dev-loop/tests/runtime.test.mjs b/loops/issue-dev-loop/tests/runtime.test.mjs index 447608c9..87ab155c 100644 --- a/loops/issue-dev-loop/tests/runtime.test.mjs +++ b/loops/issue-dev-loop/tests/runtime.test.mjs @@ -59,7 +59,10 @@ import { import { observeOwnerApprovedMerge } from '../scripts/lib/owner-gate.mjs' import { assertCredentialProfileIsolation } from '../scripts/lib/github-identity.mjs' import { verifyTerminalExternalProof } from '../scripts/lib/finalization-proof.mjs' -import { validateFinalizationHistory } from '../scripts/lib/validation.mjs' +import { + validateFinalizationHistory, + validateHistoricalTarget, +} from '../scripts/lib/validation.mjs' import { checkpointPublicationBody } from '../scripts/lib/checkpoint-proof.mjs' const bypassCheckpointVerifier = async () => {} @@ -5031,6 +5034,20 @@ test('historical active-run targets can validate without newer trusted runtime f ), 'utf8', ) + await assert.rejects( + execFileAsync( + process.execPath, + [ + path.join(historicalLoopRoot, 'scripts', 'loopctl.mjs'), + 'validate', + '--target-compatibility', + '--loop-root', + historicalLoopRoot, + ], + { cwd: repositoryRoot }, + ), + /target compatibility validation is reserved to wrapped activation/, + ) await Promise.all([ rm(path.join(historicalLoopRoot, 'scripts', 'lib', 'review-publication.mjs')), rm(path.join(historicalLoopRoot, 'scripts', 'publish-review.mjs')), @@ -5040,18 +5057,36 @@ test('historical active-run targets can validate without newer trusted runtime f validateLoop({ loopRoot: historicalLoopRoot }), /missing required loop files: .*review-publication\.mjs.*publish-review\.mjs/, ) - const result = await validateLoop({ - loopRoot: historicalLoopRoot, - targetCompatibility: true, - }) - assert.equal(result.valid, true) - - await rm(path.join(historicalLoopRoot, 'logs', 'index.jsonl')) await assert.rejects( validateLoop({ loopRoot: historicalLoopRoot, targetCompatibility: true, }), + /missing required loop files: .*review-publication\.mjs.*publish-review\.mjs/, + ) + const result = await validateHistoricalTarget({ loopRoot: historicalLoopRoot }) + assert.equal(result.valid, true) + + const historicalWorkflow = await readFile(workflowPath, 'utf8') + await writeFile( + workflowPath, + `${historicalWorkflow} + unsafe: + permissions: write-all + runs-on: ubuntu-latest + steps: [] +`, + 'utf8', + ) + await assert.rejects( + validateHistoricalTarget({ loopRoot: historicalLoopRoot }), + /historical target evidence workflow must remain a low-privilege pull_request workflow/, + ) + await writeFile(workflowPath, historicalWorkflow, 'utf8') + + await rm(path.join(historicalLoopRoot, 'logs', 'index.jsonl')) + await assert.rejects( + validateHistoricalTarget({ loopRoot: historicalLoopRoot }), /missing required loop files: logs\/index\.jsonl/, ) } finally { From 49ec2e91e0177763bb290450257bec696d9756d7 Mon Sep 17 00:00:00 2001 From: leyoonafr Date: Sun, 26 Jul 2026 20:24:18 +0800 Subject: [PATCH 03/11] fix(loop): fail closed on workflow permissions --- .../issue-dev-loop/scripts/lib/validation.mjs | 86 ++++++++++++++----- loops/issue-dev-loop/tests/runtime.test.mjs | 30 +++++-- 2 files changed, 85 insertions(+), 31 deletions(-) diff --git a/loops/issue-dev-loop/scripts/lib/validation.mjs b/loops/issue-dev-loop/scripts/lib/validation.mjs index 0e572dbe..0796eead 100644 --- a/loops/issue-dev-loop/scripts/lib/validation.mjs +++ b/loops/issue-dev-loop/scripts/lib/validation.mjs @@ -51,43 +51,83 @@ function activeYamlLines(source) { .filter((line) => line.trim() && !line.trimStart().startsWith('#')) } +function yamlMapping(line) { + const match = line.match( + /^(\s*)(?:(["'])([^"']+)\2|([A-Za-z_][A-Za-z0-9_-]*))\s*:(.*)$/, + ) + if (!match) return null + return { + indent: match[1].length, + key: match[3] ?? match[4], + quoted: Boolean(match[2]), + value: match[5].replace(/\s+#.*$/, '').trim(), + } +} + function historicalWorkflowIsLowPrivilege(source) { const lines = activeYamlLines(source) - const onIndex = lines.findIndex((line) => /^on:\s*(?:#.*)?$/.test(line)) - if (onIndex === -1) return false - const onBlock = lines.slice(onIndex + 1).findIndex((line) => /^\S/.test(line)) - const triggerLines = - onBlock === -1 ? lines.slice(onIndex + 1) : lines.slice(onIndex + 1, onIndex + 1 + onBlock) + if (lines.some((line) => line.includes('\t') || /^\s*<<\s*:/.test(line))) return false + const mappings = lines.map((line, index) => ({ + index, + mapping: yamlMapping(line), + })) if ( - !triggerLines.some((line) => /^ pull_request:\s*(?:#.*)?$/.test(line)) || - lines.some((line) => /^\s*pull_request_target\s*:/.test(line)) + mappings.some(({ mapping }) => mapping?.quoted) || + mappings.some( + ({ mapping }) => + mapping && + (mapping.key === 'pull_request_target' || + (mapping.key === 'permissions' && mapping.indent > 0)), + ) ) { return false } + const topLevelMappings = mappings.filter(({ mapping }) => mapping?.indent === 0) + const topLevelKeys = topLevelMappings.map(({ mapping }) => mapping.key) + if (new Set(topLevelKeys).size !== topLevelKeys.length) return false - const permissionIndexes = lines.flatMap((line, index) => - /^permissions\s*:/.test(line) ? [index] : [], + const blockLines = ({ index }) => { + const endOffset = lines.slice(index + 1).findIndex((line) => /^\S/.test(line)) + return endOffset === -1 + ? lines.slice(index + 1) + : lines.slice(index + 1, index + 1 + endOffset) + } + + const onEntries = topLevelMappings.filter(({ mapping }) => mapping.key === 'on') + if (onEntries.length !== 1 || onEntries[0].mapping.value) return false + const triggerBlock = blockLines(onEntries[0]) + const triggerBoundaryLines = triggerBlock.filter( + (line) => (line.match(/^\s*/)?.[0].length ?? 0) <= 2, ) + const triggerMappings = triggerBoundaryLines + .map(yamlMapping) + .filter((mapping) => mapping?.indent === 2) if ( - permissionIndexes.length !== 1 || - !/^permissions:\s*(?:#.*)?$/.test(lines[permissionIndexes[0]]) || - lines.some((line) => /^\s+permissions\s*:/.test(line)) + triggerBoundaryLines.length !== 1 || + triggerMappings.length !== 1 || + triggerMappings[0].key !== 'pull_request' || + triggerMappings[0].value ) { return false } - const permissionIndex = permissionIndexes[0] - const permissionBlockEnd = lines - .slice(permissionIndex + 1) - .findIndex((line) => /^\S/.test(line)) - const permissionLines = - permissionBlockEnd === -1 - ? lines.slice(permissionIndex + 1) - : lines.slice(permissionIndex + 1, permissionIndex + 1 + permissionBlockEnd) + + const permissionEntries = topLevelMappings.filter( + ({ mapping }) => mapping.key === 'permissions', + ) + if (permissionEntries.length !== 1 || permissionEntries[0].mapping.value) return false + const permissionLines = blockLines(permissionEntries[0]) const permissions = new Map() for (const line of permissionLines) { - const match = line.match(/^ ([a-z-]+):\s*(read|none)\s*(?:#.*)?$/) - if (!match || permissions.has(match[1])) return false - permissions.set(match[1], match[2]) + const mapping = yamlMapping(line) + if ( + !mapping || + mapping.indent !== 2 || + !['read', 'none'].includes(mapping.value) || + permissions.has(mapping.key) + ) { + return false + } + permissions.set(mapping.key, mapping.value) } return permissions.get('contents') === 'read' } diff --git a/loops/issue-dev-loop/tests/runtime.test.mjs b/loops/issue-dev-loop/tests/runtime.test.mjs index 87ab155c..da78c582 100644 --- a/loops/issue-dev-loop/tests/runtime.test.mjs +++ b/loops/issue-dev-loop/tests/runtime.test.mjs @@ -5068,20 +5068,34 @@ test('historical active-run targets can validate without newer trusted runtime f assert.equal(result.valid, true) const historicalWorkflow = await readFile(workflowPath, 'utf8') - await writeFile( - workflowPath, + const unsafeWorkflows = [ `${historicalWorkflow} unsafe: permissions: write-all runs-on: ubuntu-latest steps: [] `, - 'utf8', - ) - await assert.rejects( - validateHistoricalTarget({ loopRoot: historicalLoopRoot }), - /historical target evidence workflow must remain a low-privilege pull_request workflow/, - ) + `${historicalWorkflow} + unsafe: + "permissions": write-all + runs-on: ubuntu-latest + steps: [] +`, + historicalWorkflow.replace(' pull_request:\n', ' pull_request:\n workflow_dispatch:\n'), + historicalWorkflow.replace(' pull_request:\n', ' "pull_request_target":\n'), + historicalWorkflow.replace( + 'permissions:\n contents: read\n', + 'permissions:\n contents: read\npermissions:\n contents: read\n', + ), + historicalWorkflow.replace('permissions:\n', 'permissions: &shared_permissions\n'), + ] + for (const unsafeWorkflow of unsafeWorkflows) { + await writeFile(workflowPath, unsafeWorkflow, 'utf8') + await assert.rejects( + validateHistoricalTarget({ loopRoot: historicalLoopRoot }), + /historical target evidence workflow must remain a low-privilege pull_request workflow/, + ) + } await writeFile(workflowPath, historicalWorkflow, 'utf8') await rm(path.join(historicalLoopRoot, 'logs', 'index.jsonl')) From aa2090a7609a13251553401436e929a47403d5be Mon Sep 17 00:00:00 2001 From: leyoonafr Date: Sun, 26 Jul 2026 20:31:15 +0800 Subject: [PATCH 04/11] fix(loop): reject flow-style job permissions --- .../issue-dev-loop/scripts/lib/validation.mjs | 26 +++++++++++++++++-- loops/issue-dev-loop/tests/runtime.test.mjs | 4 +++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/loops/issue-dev-loop/scripts/lib/validation.mjs b/loops/issue-dev-loop/scripts/lib/validation.mjs index 0796eead..26c06f34 100644 --- a/loops/issue-dev-loop/scripts/lib/validation.mjs +++ b/loops/issue-dev-loop/scripts/lib/validation.mjs @@ -66,7 +66,16 @@ function yamlMapping(line) { function historicalWorkflowIsLowPrivilege(source) { const lines = activeYamlLines(source) - if (lines.some((line) => line.includes('\t') || /^\s*<<\s*:/.test(line))) return false + if ( + lines.some( + (line) => + line.includes('\t') || + /^\s*<<\s*:/.test(line) || + /^\s*[?:]\s/.test(line), + ) + ) { + return false + } const mappings = lines.map((line, index) => ({ index, mapping: yamlMapping(line), @@ -129,7 +138,20 @@ function historicalWorkflowIsLowPrivilege(source) { } permissions.set(mapping.key, mapping.value) } - return permissions.get('contents') === 'read' + if (permissions.get('contents') !== 'read') return false + + const jobsEntries = topLevelMappings.filter(({ mapping }) => mapping.key === 'jobs') + if (jobsEntries.length !== 1 || jobsEntries[0].mapping.value) return false + const jobDeclarations = blockLines(jobsEntries[0]).filter( + (line) => (line.match(/^\s*/)?.[0].length ?? 0) <= 2, + ) + return ( + jobDeclarations.length > 0 && + jobDeclarations.every((line) => { + const mapping = yamlMapping(line) + return mapping?.indent === 2 && !mapping.value + }) + ) } async function validateLoopMode({ diff --git a/loops/issue-dev-loop/tests/runtime.test.mjs b/loops/issue-dev-loop/tests/runtime.test.mjs index da78c582..03880689 100644 --- a/loops/issue-dev-loop/tests/runtime.test.mjs +++ b/loops/issue-dev-loop/tests/runtime.test.mjs @@ -5088,6 +5088,10 @@ test('historical active-run targets can validate without newer trusted runtime f 'permissions:\n contents: read\npermissions:\n contents: read\n', ), historicalWorkflow.replace('permissions:\n', 'permissions: &shared_permissions\n'), + historicalWorkflow.replace( + 'jobs:\n', + 'jobs:\n unsafe: {permissions: write-all, runs-on: ubuntu-latest, steps: []}\n', + ), ] for (const unsafeWorkflow of unsafeWorkflows) { await writeFile(workflowPath, unsafeWorkflow, 'utf8') From 8f242dda8da791a81179f95ab6ac69362cd79ce6 Mon Sep 17 00:00:00 2001 From: leyoonafr Date: Sun, 26 Jul 2026 20:38:28 +0800 Subject: [PATCH 05/11] fix(loop): reject multiline flow mappings --- .../issue-dev-loop/scripts/lib/validation.mjs | 42 ++++++++++++++++++- loops/issue-dev-loop/tests/runtime.test.mjs | 7 ++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/loops/issue-dev-loop/scripts/lib/validation.mjs b/loops/issue-dev-loop/scripts/lib/validation.mjs index 26c06f34..fa8df4cd 100644 --- a/loops/issue-dev-loop/scripts/lib/validation.mjs +++ b/loops/issue-dev-loop/scripts/lib/validation.mjs @@ -59,11 +59,49 @@ function yamlMapping(line) { return { indent: match[1].length, key: match[3] ?? match[4], + lineIndent: match[1].length, quoted: Boolean(match[2]), value: match[5].replace(/\s+#.*$/, '').trim(), } } +function yamlSequenceMapping(line) { + const match = line.match( + /^(\s*)-\s+(?:(["'])([^"']+)\2|([A-Za-z_][A-Za-z0-9_-]*))\s*:(.*)$/, + ) + if (!match) return null + return { + indent: match[1].length + 2, + key: match[3] ?? match[4], + lineIndent: match[1].length, + quoted: Boolean(match[2]), + value: match[5].replace(/\s+#.*$/, '').trim(), + } +} + +function conservativeYamlBlock(lines) { + let scalarParentIndent = null + for (const line of lines) { + const lineIndent = line.match(/^\s*/)?.[0].length ?? 0 + if (scalarParentIndent !== null && lineIndent > scalarParentIndent) continue + scalarParentIndent = null + + const mapping = yamlMapping(line) ?? yamlSequenceMapping(line) + if ( + !mapping || + mapping.quoted || + /^[{[]/.test(mapping.value) || + /^[!&*]/.test(mapping.value) + ) { + return false + } + if (/^[>|][+-]?(?:[1-9])?$/.test(mapping.value)) { + scalarParentIndent = mapping.lineIndent + } + } + return true +} + function historicalWorkflowIsLowPrivilege(source) { const lines = activeYamlLines(source) if ( @@ -142,7 +180,9 @@ function historicalWorkflowIsLowPrivilege(source) { const jobsEntries = topLevelMappings.filter(({ mapping }) => mapping.key === 'jobs') if (jobsEntries.length !== 1 || jobsEntries[0].mapping.value) return false - const jobDeclarations = blockLines(jobsEntries[0]).filter( + const jobsBlock = blockLines(jobsEntries[0]) + if (!conservativeYamlBlock(jobsBlock)) return false + const jobDeclarations = jobsBlock.filter( (line) => (line.match(/^\s*/)?.[0].length ?? 0) <= 2, ) return ( diff --git a/loops/issue-dev-loop/tests/runtime.test.mjs b/loops/issue-dev-loop/tests/runtime.test.mjs index 03880689..386f1c8b 100644 --- a/loops/issue-dev-loop/tests/runtime.test.mjs +++ b/loops/issue-dev-loop/tests/runtime.test.mjs @@ -5092,6 +5092,13 @@ test('historical active-run targets can validate without newer trusted runtime f 'jobs:\n', 'jobs:\n unsafe: {permissions: write-all, runs-on: ubuntu-latest, steps: []}\n', ), + historicalWorkflow.replace( + 'jobs:\n', + `jobs: + unsafe: + {permissions: write-all, runs-on: ubuntu-latest, steps: []} +`, + ), ] for (const unsafeWorkflow of unsafeWorkflows) { await writeFile(workflowPath, unsafeWorkflow, 'utf8') From 23700822a1dc4db2964f5a53b1b96d4913706c3f Mon Sep 17 00:00:00 2001 From: leyoonafr Date: Sun, 26 Jul 2026 20:39:32 +0800 Subject: [PATCH 06/11] test(loop): cover tagged permission keys --- loops/issue-dev-loop/scripts/lib/validation.mjs | 4 +++- loops/issue-dev-loop/tests/runtime.test.mjs | 12 ++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/loops/issue-dev-loop/scripts/lib/validation.mjs b/loops/issue-dev-loop/scripts/lib/validation.mjs index fa8df4cd..31cece88 100644 --- a/loops/issue-dev-loop/scripts/lib/validation.mjs +++ b/loops/issue-dev-loop/scripts/lib/validation.mjs @@ -90,6 +90,7 @@ function conservativeYamlBlock(lines) { if ( !mapping || mapping.quoted || + mapping.key === 'permissions' || /^[{[]/.test(mapping.value) || /^[!&*]/.test(mapping.value) ) { @@ -109,7 +110,8 @@ function historicalWorkflowIsLowPrivilege(source) { (line) => line.includes('\t') || /^\s*<<\s*:/.test(line) || - /^\s*[?:]\s/.test(line), + /^\s*[?:]\s/.test(line) || + /^(?:---|\.\.\.)\s*(?:#.*)?$/.test(line), ) ) { return false diff --git a/loops/issue-dev-loop/tests/runtime.test.mjs b/loops/issue-dev-loop/tests/runtime.test.mjs index 386f1c8b..dfdd12a7 100644 --- a/loops/issue-dev-loop/tests/runtime.test.mjs +++ b/loops/issue-dev-loop/tests/runtime.test.mjs @@ -5099,6 +5099,18 @@ test('historical active-run targets can validate without newer trusted runtime f {permissions: write-all, runs-on: ubuntu-latest, steps: []} `, ), + historicalWorkflow.replace( + ' runs-on: ubuntu-latest\n', + ' &permission_key permissions: write-all\n runs-on: ubuntu-latest\n', + ), + historicalWorkflow.replace( + ' runs-on: ubuntu-latest\n', + ' !!str permissions: write-all\n runs-on: ubuntu-latest\n', + ), + historicalWorkflow.replace( + ' runs-on: ubuntu-latest\n', + ' - permissions: write-all\n runs-on: ubuntu-latest\n', + ), ] for (const unsafeWorkflow of unsafeWorkflows) { await writeFile(workflowPath, unsafeWorkflow, 'utf8') From feea8f90a5f2ac1e6b646113e956aca7ad665408 Mon Sep 17 00:00:00 2001 From: leyoonafr Date: Sun, 26 Jul 2026 20:55:49 +0800 Subject: [PATCH 07/11] fix(loop): capability-gate historical activation --- loops/issue-dev-loop/SKILL.md | 2 +- .../references/github-operations.md | 2 +- .../scripts/lib/github-identity.mjs | 205 +++++++++++++----- .../issue-dev-loop/scripts/lib/validation.mjs | 44 ++-- .../validate-candidate-control-plane.mjs | 50 ++++- .../scripts/validate-historical-target.mjs | 11 - .../tests/github-identity-routing.test.mjs | 95 +++++++- loops/issue-dev-loop/tests/runtime.test.mjs | 119 ++++++++-- 8 files changed, 416 insertions(+), 112 deletions(-) delete mode 100644 loops/issue-dev-loop/scripts/validate-historical-target.mjs diff --git a/loops/issue-dev-loop/SKILL.md b/loops/issue-dev-loop/SKILL.md index a28ff0bb..b8efdfc3 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 matching the local run to an automation-authored remote durable checkpoint, proving the clean exact branch and head, and proving the issue diff did not modify the protected control or verification plane. The installed internal validator then checks stable target state, owner channel, JSON history, and a conservatively parsed low-privilege evidence workflow. Callers and the public validation API cannot request this reduced mode, and `restore-checkpoint` still requires the durable exact branch, head, and clean worktree. +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 matching the local run to an automation-authored remote durable checkpoint, proving the clean exact 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. There is no standalone reduced validator, and callers and the public validation API cannot request this mode. `restore-checkpoint` still independently requires the durable exact branch, head, and clean worktree. 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. diff --git a/loops/issue-dev-loop/references/github-operations.md b/loops/issue-dev-loop/references/github-operations.md index 05cc6327..afa60239 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 an automation-authored remote durable checkpoint exactly matches the local active run, the worktree is on its clean exact branch and head, and the issue diff is proven not to modify the protected control or verification plane. The installed internal validator then checks stable target state, owner channel, JSON history, and conservatively rejects evidence workflows with unrecognized triggers or any job-level/write permission. Caller-supplied flags and the public validation API cannot select the reduced mode; the subsequent restore independently enforces the journal's exact branch and head on a clean worktree. +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 an automation-authored remote durable checkpoint exactly matches the local active run, the worktree is on its clean exact branch and head with no index concealment flags, and the issue diff is proven not to 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. No standalone reduced validator exists, and caller-supplied flags and the public validation API cannot select the reduced mode; the subsequent restore independently enforces the journal's exact branch and head on a clean worktree. 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`. diff --git a/loops/issue-dev-loop/scripts/lib/github-identity.mjs b/loops/issue-dev-loop/scripts/lib/github-identity.mjs index b403565f..c0bb4c9b 100644 --- a/loops/issue-dev-loop/scripts/lib/github-identity.mjs +++ b/loops/issue-dev-loop/scripts/lib/github-identity.mjs @@ -42,6 +42,7 @@ const roleFields = { environmentVariable: 'reviewerGitHubConfigEnvironmentVariable', }, } +const historicalValidationCapabilities = new WeakSet() const inheritedEnvironmentNames = new Set([ 'CI', @@ -1344,6 +1345,104 @@ function activationValidationRequested({ role, tool, args, loopRoot, trustedLoop ) } +export function consumeHistoricalValidationCapability(capability) { + if ( + (typeof capability !== 'object' && typeof capability !== 'function') || + capability === null || + !historicalValidationCapabilities.delete(capability) + ) { + throw new Error('historical target validation requires an authorized router capability') + } +} + +export function durableCheckpointWorktreeHead(record) { + let expectedHead = record?.run?.baseSha + for (const event of record?.events ?? []) { + if (event.type === 'implementation_completed' && event.status === 'passed') { + expectedHead = event.payload?.commitSha + } + if (event.type === 'pr_published') expectedHead = event.payload?.headSha + } + if (!/^[0-9a-f]{40}$/i.test(expectedHead ?? '')) { + throw new Error('durable active checkpoint has no valid working head') + } + return expectedHead +} + +async function assertCleanExactDurableWorktree({ + realGit, + repositoryRoot, + environment, + run, + expectedHead, +}) { + const [branch, head, status, indexState] = 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'], { + cwd: repositoryRoot, + env: environment, + maxBuffer: 1024 * 1024, + }), + execFileAsync(realGit, ['ls-files', '-v', '-z'], { + cwd: repositoryRoot, + env: environment, + maxBuffer: 8 * 1024 * 1024, + }), + ]) + const concealedIndexEntries = indexState.stdout + .split('\0') + .filter(Boolean) + .filter((entry) => !entry.startsWith('H ')) + if (concealedIndexEntries.length > 0) { + throw new Error( + 'historical target validation rejects index concealment and nonstandard tracked state', + ) + } + if ( + branch.stdout.trim() !== run.branch || + head.stdout.trim() !== expectedHead || + status.stdout.trim() + ) { + throw new Error( + 'historical target validation requires the clean exact durable branch and head', + ) + } + 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( + 'historical target validation requires tracked filesystem contents to match HEAD', + ) + } + throw error + } +} + async function authorizeHistoricalTargetValidation({ authorization, loopRoot, @@ -1354,9 +1453,17 @@ async function authorizeHistoricalTargetValidation({ environment, }) { const localIssue = authorization.issue - if (!localIssue?.runId) { - throw new Error('historical target validation requires a local active run') - } + const repositoryRoot = repositoryRootForLoop(loopRoot) + const [checkedOutBranch, checkedOutHead] = await Promise.all([ + execFileAsync(realGit, ['branch', '--show-current'], { + cwd: repositoryRoot, + env: environment, + }), + execFileAsync(realGit, ['rev-parse', 'HEAD'], { + cwd: repositoryRoot, + env: environment, + }), + ]) const githubApi = async (endpoint) => { const { stdout } = await execFileAsync(realGh, ['api', endpoint], { env: environment, @@ -1369,49 +1476,34 @@ async function authorizeHistoricalTargetValidation({ githubPaginatedApi: (endpoint) => paginateGitHubApi(githubApi, endpoint.replace(/[?&]per_page=100$/, '')), }) - const durable = activeCheckpoints.find( - (checkpoint) => checkpoint.record.run.runId === localIssue.runId, + const durableMatches = activeCheckpoints.filter( + (checkpoint) => + checkpoint.record.run.branch === checkedOutBranch.stdout.trim() && + durableCheckpointWorktreeHead(checkpoint.record) === checkedOutHead.stdout.trim(), ) + const durable = durableMatches.length === 1 ? durableMatches[0] : null const run = durable?.record.run - const expectedHead = run?.headSha ?? run?.implementationCommit ?? run?.baseSha + const expectedHead = durable ? durableCheckpointWorktreeHead(durable.record) : null if ( !run || run.finishedAt !== null || - run.issueNumber !== localIssue.issueNumber || - run.branch !== localIssue.branch || - run.status !== localIssue.status || - run.implementationCommit !== localIssue.implementationCommit + (localIssue && + (run.runId !== localIssue.runId || + run.issueNumber !== localIssue.issueNumber || + run.branch !== localIssue.branch)) ) { throw new Error( 'historical target validation requires the exact remote durable active checkpoint', ) } - const repositoryRoot = repositoryRootForLoop(loopRoot) - const [branch, head, status] = 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'], { - cwd: repositoryRoot, - env: environment, - maxBuffer: 1024 * 1024, - }), - ]) - if ( - branch.stdout.trim() !== run.branch || - head.stdout.trim() !== expectedHead || - status.stdout.trim() - ) { - throw new Error( - 'historical target validation requires the clean exact durable branch and head', - ) - } + await assertCleanExactDurableWorktree({ + realGit, + repositoryRoot, + environment, + run, + expectedHead, + }) await execFileAsync( realNode, @@ -1425,6 +1517,12 @@ async function authorizeHistoricalTargetValidation({ run.baseSha, '--head-sha', expectedHead, + '--durable-issue-number', + String(run.issueNumber), + '--durable-implementation-commit', + run.implementationCommit ?? 'none', + '--durable-pr-head', + run.headSha ?? 'none', ], { cwd: repositoryRoot, @@ -1432,7 +1530,9 @@ async function authorizeHistoricalTargetValidation({ maxBuffer: 4 * 1024 * 1024, }, ) - return durable + const capability = Object.freeze({}) + historicalValidationCapabilities.add(capability) + return { capability, durable, expectedHead, repositoryRoot, run } } function pullRequestWriteIntent(role, args, authorization) { @@ -2026,8 +2126,7 @@ export async function runWithGitHubRole({ process.stdout.write(stdout) return 0 } catch (fullValidationError) { - if (!authorization.issue?.runId) throw fullValidationError - await authorizeHistoricalTargetValidation({ + const historicalAuthorization = await authorizeHistoricalTargetValidation({ authorization, loopRoot, trustedLoopRoot: trustedControlPlane.loopRoot, @@ -2037,23 +2136,19 @@ export async function runWithGitHubRole({ environment: childEnvironment, }) try { - const { stdout } = await execFileAsync( - realNode, - [ - path.resolve( - trustedControlPlane.loopRoot, - 'scripts', - 'validate-historical-target.mjs', - ), - '--loop-root', - path.resolve(loopRoot), - ], - { - env: childEnvironment, - maxBuffer: 4 * 1024 * 1024, - }, - ) - process.stdout.write(stdout) + const { validateLoop } = await import('./validation.mjs') + const result = await validateLoop({ + loopRoot, + historicalCapability: historicalAuthorization.capability, + }) + await assertCleanExactDurableWorktree({ + realGit, + repositoryRoot: historicalAuthorization.repositoryRoot, + environment: childEnvironment, + run: historicalAuthorization.run, + expectedHead: historicalAuthorization.expectedHead, + }) + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`) return 0 } catch (historicalValidationError) { historicalValidationError.cause = fullValidationError diff --git a/loops/issue-dev-loop/scripts/lib/validation.mjs b/loops/issue-dev-loop/scripts/lib/validation.mjs index 31cece88..7847104c 100644 --- a/loops/issue-dev-loop/scripts/lib/validation.mjs +++ b/loops/issue-dev-loop/scripts/lib/validation.mjs @@ -2,7 +2,10 @@ import { readFile, readdir } from 'node:fs/promises' import path from 'node:path' import { DEFAULT_LOOP_ROOT, pathExists, readJson, sameGitHubLogin } from './common.mjs' -import { assertGitHubRoleIdentity } from './github-identity.mjs' +import { + assertGitHubRoleIdentity, + consumeHistoricalValidationCapability, +} from './github-identity.mjs' async function collectFiles(root, output = []) { const entries = await readdir(root, { withFileTypes: true }) @@ -79,7 +82,10 @@ function yamlSequenceMapping(line) { } } -function conservativeYamlBlock(lines) { +function conservativeYamlBlock( + lines, + { rejectFlowValues = true, rejectPermissionKeys = true } = {}, +) { let scalarParentIndent = null for (const line of lines) { const lineIndent = line.match(/^\s*/)?.[0].length ?? 0 @@ -90,8 +96,8 @@ function conservativeYamlBlock(lines) { if ( !mapping || mapping.quoted || - mapping.key === 'permissions' || - /^[{[]/.test(mapping.value) || + (rejectPermissionKeys && mapping.key === 'permissions') || + (rejectFlowValues && /^[{[]/.test(mapping.value)) || /^[!&*]/.test(mapping.value) ) { return false @@ -103,7 +109,7 @@ function conservativeYamlBlock(lines) { return true } -function historicalWorkflowIsLowPrivilege(source) { +export function historicalWorkflowIsLowPrivilege(source) { const lines = activeYamlLines(source) if ( lines.some( @@ -116,6 +122,14 @@ function historicalWorkflowIsLowPrivilege(source) { ) { return false } + if ( + !conservativeYamlBlock(lines, { + rejectFlowValues: false, + rejectPermissionKeys: false, + }) + ) { + return false + } const mappings = lines.map((line, index) => ({ index, mapping: yamlMapping(line), @@ -249,7 +263,6 @@ async function validateLoopMode({ 'scripts/lib/trusted-control-plane.mjs', 'scripts/github-command-gate.mjs', 'scripts/publish-review.mjs', - 'scripts/validate-historical-target.mjs', 'scripts/identity-bin/gh', 'scripts/identity-bin/git', 'scripts/lib/issue-claim.mjs', @@ -478,13 +491,14 @@ async function validateLoopMode({ } export function validateLoop(options = {}) { - return validateLoopMode({ ...options, targetCompatibility: false }) -} - -export function validateHistoricalTarget(options = {}) { - return validateLoopMode({ - ...options, - activation: false, - targetCompatibility: true, - }) + const { historicalCapability, ...validatedOptions } = options + if (historicalCapability) { + consumeHistoricalValidationCapability(historicalCapability) + return validateLoopMode({ + ...validatedOptions, + activation: false, + targetCompatibility: true, + }) + } + return validateLoopMode({ ...validatedOptions, targetCompatibility: false }) } 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 ed1e8586..4ffca2a5 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,11 @@ 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 durableIssueNumber = args['durable-issue-number'] + ? Number(args['durable-issue-number']) + : null +const durableImplementationCommit = args['durable-implementation-commit'] +const durablePrHead = args['durable-pr-head'] for (const [name, sha] of [ ['baseSha', baseSha], @@ -27,25 +32,52 @@ if (checkedOutHead.stdout.trim() !== headSha || mergeBase.stdout.trim() !== base throw new Error('candidate control-plane validation requires the exact descendant PR head') } -const runPath = path.join(loopRoot, 'logs', 'runs', runId, 'run.json') -const runStats = await lstat(runPath) -if (!runStats.isFile() || runStats.isSymbolicLink()) { - throw new Error('candidate run metadata must be a regular file') +const durableMode = durableIssueNumber !== null +let run +if (durableMode) { + if ( + !Number.isInteger(durableIssueNumber) || + durableIssueNumber < 1 || + ![durableImplementationCommit, durablePrHead].every( + (value) => value === 'none' || /^[0-9a-f]{40}$/i.test(value ?? ''), + ) + ) { + throw new Error('durable candidate metadata is invalid') + } + run = { + runId, + issueNumber: durableIssueNumber, + baseSha, + branch: `codex/issue-${durableIssueNumber}`, + finishedAt: null, + implementationCommit: + durableImplementationCommit === 'none' ? null : durableImplementationCommit, + headSha: durablePrHead === 'none' ? null : durablePrHead, + } +} else { + const runPath = path.join(loopRoot, 'logs', 'runs', runId, 'run.json') + const runStats = await lstat(runPath) + if (!runStats.isFile() || runStats.isSymbolicLink()) { + throw new Error('candidate run metadata must be a regular file') + } + run = await readJson(runPath) } -const run = await readJson(runPath) if ( run.runId !== runId || run.baseSha !== baseSha || run.branch !== `codex/issue-${run.issueNumber}` || run.finishedAt !== null || - !/^[0-9a-f]{40}$/i.test(run.implementationCommit ?? '') || + (run.implementationCommit !== null && + !/^[0-9a-f]{40}$/i.test(run.implementationCommit ?? '')) || (run.headSha !== null && !/^[0-9a-f]{40}$/i.test(run.headSha ?? '')) ) { throw new Error('candidate run metadata does not match the protected diff') } -await execFileAsync('git', ['merge-base', '--is-ancestor', run.implementationCommit, headSha], { - cwd: repositoryRoot, -}) +if (run.implementationCommit) { + await execFileAsync('git', ['merge-base', '--is-ancestor', run.implementationCommit, headSha], { + cwd: repositoryRoot, + }) +} if (run.headSha) { await execFileAsync('git', ['merge-base', '--is-ancestor', run.headSha, headSha], { cwd: repositoryRoot, diff --git a/loops/issue-dev-loop/scripts/validate-historical-target.mjs b/loops/issue-dev-loop/scripts/validate-historical-target.mjs deleted file mode 100644 index e1a16004..00000000 --- a/loops/issue-dev-loop/scripts/validate-historical-target.mjs +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/env node - -import path from 'node:path' - -import { parseArguments } from './lib/common.mjs' -import { validateHistoricalTarget } from './lib/validation.mjs' - -const args = parseArguments(process.argv.slice(2)) -const loopRoot = args['loop-root'] ? path.resolve(args['loop-root']) : undefined -const result = await validateHistoricalTarget({ loopRoot }) -process.stdout.write(`${JSON.stringify(result, null, 2)}\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 e93653a8..57a7b9d4 100644 --- a/loops/issue-dev-loop/tests/github-identity-routing.test.mjs +++ b/loops/issue-dev-loop/tests/github-identity-routing.test.mjs @@ -1,7 +1,17 @@ import assert from 'node:assert/strict' import { execFile } from 'node:child_process' import { createHash } from 'node:crypto' -import { chmod, cp, mkdir, mkdtemp, readFile, readdir, realpath, writeFile } from 'node:fs/promises' +import { + chmod, + cp, + mkdir, + mkdtemp, + readFile, + readdir, + realpath, + rm, + writeFile, +} from 'node:fs/promises' import os from 'node:os' import path from 'node:path' import test from 'node:test' @@ -56,6 +66,7 @@ async function createFixture({ ownerFeedback = false, historicalActivation = false, remoteCheckpoint = true, + removeLocalRun = false, } = {}) { const parent = await mkdtemp(path.join(os.tmpdir(), 'echo-ui-identity-routing-')) const loopRoot = path.join(parent, 'issue-dev-loop') @@ -439,8 +450,11 @@ if (commandArguments[0] === 'validate' && ${JSON.stringify(historicalActivation) 'utf8', ) await writeFile( - path.join(trustedLoopRoot, 'scripts', 'validate-historical-target.mjs'), - `process.stdout.write(JSON.stringify({ valid: true, historicalTargetCompatibility: true }))\n`, + path.join(trustedLoopRoot, 'scripts', 'lib', 'validation.mjs'), + `export async function validateLoop() { + return { valid: true, historicalTargetCompatibility: true } +} +`, 'utf8', ) await writeFile( @@ -555,6 +569,14 @@ if [ "$1 $2" = "status --porcelain" ]; then fi exit 0 fi +if [ "$1 $2 $3" = "ls-files -v -z" ]; then + if [ -f ${JSON.stringify(path.join(parent, 'hidden-index-state'))} ]; then + printf "S tracked-file\\0" + else + printf "H tracked-file\\0" + fi + exit 0 +fi if [ "$1" = "merge-base" ]; then exit 0 fi @@ -620,6 +642,12 @@ ${JSON.stringify(process.execPath)} -e 'process.stdout.write(JSON.stringify({arg realpath(automationProfile), realpath(reviewerProfile), ]) + if (removeLocalRun) { + await rm(path.join(loopRoot, 'logs', 'runs', 'fixture-run'), { + recursive: true, + force: true, + }) + } return { loopRoot, @@ -862,13 +890,44 @@ test('wrapped activation rejects historical validation without a remote durable ) }) +test('wrapped activation can select an exact durable checkpoint without local run cache', async () => { + const fixture = await createFixture({ + historicalActivation: true, + removeLocalRun: true, + }) + const { stdout } = await execFileAsync( + routerLauncherPath, + [ + '--loop-root', + fixture.loopRoot, + 'automation', + '--', + process.execPath, + fixture.loopctlPath, + 'validate', + '--activation', + '--loop-root', + fixture.loopRoot, + ], + { env: fixture.env }, + ) + assert.deepEqual(JSON.parse(stdout), { + valid: true, + historicalTargetCompatibility: true, + }) +}) + test('wrapped activation rejects local active state that differs from its durable checkpoint', async () => { const fixture = await createFixture({ historicalActivation: true }) const runPath = path.join(fixture.loopRoot, 'logs', 'runs', 'fixture-run', 'run.json') const run = JSON.parse(await readFile(runPath, 'utf8')) await writeFile( runPath, - `${JSON.stringify({ ...run, status: 'waiting_for_owner' })}\n`, + `${JSON.stringify({ + ...run, + issueNumber: 124, + branch: 'codex/issue-124', + })}\n`, 'utf8', ) await assert.rejects( @@ -892,6 +951,34 @@ test('wrapped activation rejects local active state that differs from its durabl ) }) +test('wrapped activation rejects tracked files hidden by Git index flags', async () => { + const fixture = await createFixture({ historicalActivation: true }) + await writeFile( + path.join(path.dirname(fixture.loopRoot), 'hidden-index-state'), + 'skip-worktree\n', + 'utf8', + ) + await assert.rejects( + execFileAsync( + routerLauncherPath, + [ + '--loop-root', + fixture.loopRoot, + 'automation', + '--', + process.execPath, + fixture.loopctlPath, + 'validate', + '--activation', + '--loop-root', + fixture.loopRoot, + ], + { env: fixture.env }, + ), + /historical target validation rejects index concealment/, + ) +}) + test('wrapped activation keeps full validation when there is no active run', async () => { const fixture = await createFixture({ activeRun: false, recordedPr: false }) const { stdout } = await execFileAsync( diff --git a/loops/issue-dev-loop/tests/runtime.test.mjs b/loops/issue-dev-loop/tests/runtime.test.mjs index dfdd12a7..b91933a5 100644 --- a/loops/issue-dev-loop/tests/runtime.test.mjs +++ b/loops/issue-dev-loop/tests/runtime.test.mjs @@ -57,11 +57,14 @@ import { validateLoop, } from '../scripts/runtime.mjs' import { observeOwnerApprovedMerge } from '../scripts/lib/owner-gate.mjs' -import { assertCredentialProfileIsolation } from '../scripts/lib/github-identity.mjs' +import { + assertCredentialProfileIsolation, + durableCheckpointWorktreeHead, +} from '../scripts/lib/github-identity.mjs' import { verifyTerminalExternalProof } from '../scripts/lib/finalization-proof.mjs' import { + historicalWorkflowIsLowPrivilege, validateFinalizationHistory, - validateHistoricalTarget, } from '../scripts/lib/validation.mjs' import { checkpointPublicationBody } from '../scripts/lib/checkpoint-proof.mjs' @@ -1121,6 +1124,89 @@ test('candidate control-plane validation permits run evidence but rejects verifi } }) +test('durable candidate validation supports pre-implementation and later repair heads', async () => { + const parent = await mkdtemp(path.join(os.tmpdir(), 'echo-ui-durable-candidate-test-')) + const repository = path.join(parent, 'repository') + const loopRoot = path.join(repository, 'loops', 'issue-dev-loop') + const validator = path.join(repositoryLoopRoot, 'scripts', 'validate-candidate-control-plane.mjs') + const runId = 'run-issue-321' + const git = async (...args) => execFileAsync('git', args, { cwd: repository }) + await mkdir(path.join(repository, 'src'), { recursive: true }) + await mkdir(loopRoot, { recursive: true }) + 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 = 0\n', 'utf8') + await git('add', '.') + await git('commit', '-m', 'base') + await git('switch', '-c', 'codex/issue-321') + const baseSha = (await git('rev-parse', 'HEAD')).stdout.trim() + + const preImplementation = await execFileAsync(process.execPath, [ + validator, + '--loop-root', + loopRoot, + '--run-id', + runId, + '--base-sha', + baseSha, + '--head-sha', + baseSha, + '--durable-issue-number', + '321', + '--durable-implementation-commit', + 'none', + '--durable-pr-head', + 'none', + ]) + assert.equal(JSON.parse(preImplementation.stdout).valid, true) + + await writeFile(path.join(repository, 'src', 'feature.js'), 'export const value = 1\n', 'utf8') + await git('add', 'src/feature.js') + await git('commit', '-m', 'first PR head') + const oldPrHead = (await git('rev-parse', 'HEAD')).stdout.trim() + await writeFile(path.join(repository, 'src', 'feature.js'), 'export const value = 2\n', 'utf8') + await git('add', 'src/feature.js') + await git('commit', '-m', 'owner feedback implementation') + const repairCommit = (await git('rev-parse', 'HEAD')).stdout.trim() + + const repair = await execFileAsync(process.execPath, [ + validator, + '--loop-root', + loopRoot, + '--run-id', + runId, + '--base-sha', + baseSha, + '--head-sha', + repairCommit, + '--durable-issue-number', + '321', + '--durable-implementation-commit', + repairCommit, + '--durable-pr-head', + oldPrHead, + ]) + assert.equal(JSON.parse(repair.stdout).valid, true) + assert.equal( + durableCheckpointWorktreeHead({ + run: { baseSha }, + events: [ + { + type: 'pr_published', + payload: { headSha: oldPrHead }, + }, + { + type: 'implementation_completed', + status: 'passed', + payload: { commitSha: repairCommit }, + }, + ], + }), + repairCommit, + ) +}) + test('review publication digest excludes assigned review URLs but binds review content', () => { const review = { schemaVersion: 1, @@ -4990,7 +5076,7 @@ test('repository loop package satisfies its structural invariants', async () => assert.equal(result.valid, true) }) -test('historical active-run targets can validate without newer trusted runtime files', async () => { +test('historical workflow parsing is fail-closed without exposing reduced validation', async () => { const parent = await mkdtemp(path.join(os.tmpdir(), 'echo-ui-historical-target-')) const repositoryRoot = path.join(parent, 'repository') const historicalLoopRoot = path.join(repositoryRoot, 'loops', 'issue-dev-loop') @@ -5064,10 +5150,8 @@ test('historical active-run targets can validate without newer trusted runtime f }), /missing required loop files: .*review-publication\.mjs.*publish-review\.mjs/, ) - const result = await validateHistoricalTarget({ loopRoot: historicalLoopRoot }) - assert.equal(result.valid, true) - const historicalWorkflow = await readFile(workflowPath, 'utf8') + assert.equal(historicalWorkflowIsLowPrivilege(historicalWorkflow), true) const unsafeWorkflows = [ `${historicalWorkflow} unsafe: @@ -5111,20 +5195,23 @@ test('historical active-run targets can validate without newer trusted runtime f ' runs-on: ubuntu-latest\n', ' - permissions: write-all\n runs-on: ubuntu-latest\n', ), + `${historicalWorkflow} +!!str permissions: write-all +`, + `${historicalWorkflow} +@not-yaml +`, ] for (const unsafeWorkflow of unsafeWorkflows) { - await writeFile(workflowPath, unsafeWorkflow, 'utf8') - await assert.rejects( - validateHistoricalTarget({ loopRoot: historicalLoopRoot }), - /historical target evidence workflow must remain a low-privilege pull_request workflow/, - ) + assert.equal(historicalWorkflowIsLowPrivilege(unsafeWorkflow), false) } - await writeFile(workflowPath, historicalWorkflow, 'utf8') - - await rm(path.join(historicalLoopRoot, 'logs', 'index.jsonl')) await assert.rejects( - validateHistoricalTarget({ loopRoot: historicalLoopRoot }), - /missing required loop files: logs\/index\.jsonl/, + async () => + validateLoop({ + loopRoot: historicalLoopRoot, + historicalCapability: {}, + }), + /historical target validation requires an authorized router capability/, ) } finally { await rm(parent, { recursive: true, force: true }) From f1c4a89cee9c088581d8b71c0904f6701bb6d816 Mon Sep 17 00:00:00 2001 From: leyoonafr Date: Sun, 26 Jul 2026 21:06:34 +0800 Subject: [PATCH 08/11] fix(loop): unify durable resume head --- loops/issue-dev-loop/SKILL.md | 2 +- .../references/github-operations.md | 2 +- .../scripts/lib/active-journal.mjs | 3 +- .../scripts/lib/checkpoint-proof.mjs | 14 +++ .../scripts/lib/finalization-journal.mjs | 19 +++- .../scripts/lib/github-identity.mjs | 37 ++++---- loops/issue-dev-loop/scripts/lib/github.mjs | 7 +- .../tests/github-identity-routing.test.mjs | 89 +++++++++++++++++- loops/issue-dev-loop/tests/runtime.test.mjs | 91 ++++++++++++++++++- 9 files changed, 231 insertions(+), 33 deletions(-) diff --git a/loops/issue-dev-loop/SKILL.md b/loops/issue-dev-loop/SKILL.md index b8efdfc3..18857613 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 matching the local run to an automation-authored remote durable checkpoint, proving the clean exact 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. There is no standalone reduced validator, and callers and the public validation API cannot request this mode. `restore-checkpoint` still independently requires the durable exact branch, head, and clean worktree. +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. 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. diff --git a/loops/issue-dev-loop/references/github-operations.md b/loops/issue-dev-loop/references/github-operations.md index afa60239..98e9aa49 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 an automation-authored remote durable checkpoint exactly matches the local active run, the worktree is on its clean exact branch and head with no index concealment flags, and the issue diff is proven not to 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. No standalone reduced validator exists, and caller-supplied flags and the public validation API cannot select the reduced mode; the subsequent restore independently enforces the journal's exact branch and head on a clean worktree. +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. 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`. diff --git a/loops/issue-dev-loop/scripts/lib/active-journal.mjs b/loops/issue-dev-loop/scripts/lib/active-journal.mjs index 92dcca9f..dec93a07 100644 --- a/loops/issue-dev-loop/scripts/lib/active-journal.mjs +++ b/loops/issue-dev-loop/scripts/lib/active-journal.mjs @@ -19,6 +19,7 @@ import { checkpointJournalConfiguration, checkpointPublicationBody, checkpointRecordDigest, + checkpointWorktreeHead, parseCheckpointRecord, validateCheckpointRecord, verifyPublishedCheckpoint, @@ -227,7 +228,7 @@ async function defaultWorkspaceValidator({ loopRoot, record }) { if (branch.stdout.trim() !== record.run.branch) { throw new Error(`restore requires isolated worktree branch ${record.run.branch}`) } - const expectedHead = record.run.headSha ?? record.run.implementationCommit ?? record.run.baseSha + const expectedHead = checkpointWorktreeHead(record) if (head.stdout.trim() !== expectedHead) { throw new Error(`restore requires exact durable head ${expectedHead}`) } diff --git a/loops/issue-dev-loop/scripts/lib/checkpoint-proof.mjs b/loops/issue-dev-loop/scripts/lib/checkpoint-proof.mjs index 49346a6c..c1f4d973 100644 --- a/loops/issue-dev-loop/scripts/lib/checkpoint-proof.mjs +++ b/loops/issue-dev-loop/scripts/lib/checkpoint-proof.mjs @@ -13,6 +13,20 @@ import { const ACTIVE_STATUSES = new Set(['running', 'waiting_for_owner', 'awaiting_owner_review']) +export function checkpointWorktreeHead(record) { + let expectedHead = record?.run?.baseSha + for (const event of record?.events ?? []) { + if (event.type === 'implementation_completed' && event.status === 'passed') { + expectedHead = event.payload?.commitSha + } + if (event.type === 'pr_published') expectedHead = event.payload?.headSha + } + if (!/^[0-9a-f]{40}$/i.test(expectedHead ?? '')) { + throw new Error('durable active checkpoint has no valid working head') + } + return expectedHead +} + export async function checkpointJournalConfiguration(loopRoot) { const channel = await readJson( path.resolve(loopRoot, '..', '_shared', 'owner-channel', 'channel.json'), diff --git a/loops/issue-dev-loop/scripts/lib/finalization-journal.mjs b/loops/issue-dev-loop/scripts/lib/finalization-journal.mjs index ede358ff..1de1a22a 100644 --- a/loops/issue-dev-loop/scripts/lib/finalization-journal.mjs +++ b/loops/issue-dev-loop/scripts/lib/finalization-journal.mjs @@ -320,9 +320,8 @@ export async function recordFinalizationPublication({ return { record, digest, commentUrl } } -export async function reconcileFinalizationJournal({ +export async function loadDurableFinalizationRecords({ loopRoot = DEFAULT_LOOP_ROOT, - now = new Date(), githubPaginatedApi = defaultGitHubPaginatedApi, githubApi = defaultGitHubApi, latestActiveCheckpoints = null, @@ -380,6 +379,22 @@ export async function reconcileFinalizationJournal({ effectiveRecords.sort( (left, right) => Date.parse(left.finishedAt) - Date.parse(right.finishedAt), ) + return effectiveRecords +} + +export async function reconcileFinalizationJournal({ + loopRoot = DEFAULT_LOOP_ROOT, + now = new Date(), + githubPaginatedApi = defaultGitHubPaginatedApi, + githubApi = defaultGitHubApi, + latestActiveCheckpoints = null, +} = {}) { + const effectiveRecords = await loadDurableFinalizationRecords({ + loopRoot, + githubPaginatedApi, + githubApi, + latestActiveCheckpoints, + }) const indexPath = path.join(loopRoot, 'logs', 'index.jsonl') const existing = (await readFile(indexPath, 'utf8')) diff --git a/loops/issue-dev-loop/scripts/lib/github-identity.mjs b/loops/issue-dev-loop/scripts/lib/github-identity.mjs index c0bb4c9b..d1d0be71 100644 --- a/loops/issue-dev-loop/scripts/lib/github-identity.mjs +++ b/loops/issue-dev-loop/scripts/lib/github-identity.mjs @@ -17,11 +17,13 @@ import { import { reconcileActiveJournal } from './active-journal.mjs' import { checkpointRecordDigest, + checkpointWorktreeHead, parseCheckpointRecord, validateCheckpointRecord, verifyLatestDurableCheckpoint, } from './checkpoint-proof.mjs' import { verifyPublishedEvolveRequest } from './evolve.mjs' +import { loadDurableFinalizationRecords } from './finalization-journal.mjs' import { parseReviewPublisherArguments, reviewPublisherSyntheticGitHubArguments, @@ -1355,20 +1357,6 @@ export function consumeHistoricalValidationCapability(capability) { } } -export function durableCheckpointWorktreeHead(record) { - let expectedHead = record?.run?.baseSha - for (const event of record?.events ?? []) { - if (event.type === 'implementation_completed' && event.status === 'passed') { - expectedHead = event.payload?.commitSha - } - if (event.type === 'pr_published') expectedHead = event.payload?.headSha - } - if (!/^[0-9a-f]{40}$/i.test(expectedHead ?? '')) { - throw new Error('durable active checkpoint has no valid working head') - } - return expectedHead -} - async function assertCleanExactDurableWorktree({ realGit, repositoryRoot, @@ -1471,19 +1459,30 @@ async function authorizeHistoricalTargetValidation({ }) return JSON.parse(stdout) } - const { activeCheckpoints } = await reconcileActiveJournal({ + const paginatedApi = (endpoint) => + paginateGitHubApi(githubApi, endpoint.replace(/[?&]per_page=100$/, '')) + const { activeCheckpoints: allActiveCheckpoints } = await reconcileActiveJournal({ loopRoot, - githubPaginatedApi: (endpoint) => - paginateGitHubApi(githubApi, endpoint.replace(/[?&]per_page=100$/, '')), + githubPaginatedApi: paginatedApi, }) + const finalizations = await loadDurableFinalizationRecords({ + loopRoot, + githubPaginatedApi: paginatedApi, + githubApi, + latestActiveCheckpoints: allActiveCheckpoints, + }) + const terminalRunIds = new Set(finalizations.map((record) => record.runId)) + const activeCheckpoints = allActiveCheckpoints.filter( + (checkpoint) => !terminalRunIds.has(checkpoint.record.run.runId), + ) const durableMatches = activeCheckpoints.filter( (checkpoint) => checkpoint.record.run.branch === checkedOutBranch.stdout.trim() && - durableCheckpointWorktreeHead(checkpoint.record) === checkedOutHead.stdout.trim(), + checkpointWorktreeHead(checkpoint.record) === checkedOutHead.stdout.trim(), ) const durable = durableMatches.length === 1 ? durableMatches[0] : null const run = durable?.record.run - const expectedHead = durable ? durableCheckpointWorktreeHead(durable.record) : null + const expectedHead = durable ? checkpointWorktreeHead(durable.record) : null if ( !run || run.finishedAt !== null || diff --git a/loops/issue-dev-loop/scripts/lib/github.mjs b/loops/issue-dev-loop/scripts/lib/github.mjs index bc2de3a0..f2afc6d8 100644 --- a/loops/issue-dev-loop/scripts/lib/github.mjs +++ b/loops/issue-dev-loop/scripts/lib/github.mjs @@ -26,7 +26,7 @@ import { reconcileActiveJournal } from './active-journal.mjs' import { reconcileEvolveJournal } from './evolve.mjs' import { defaultReleaseIssueClaim } from './issue-claim.mjs' import { appendValidatedEvent, finalizeRun, readEvents, readRun } from './run-store.mjs' -import { verifyLatestDurableCheckpoint } from './checkpoint-proof.mjs' +import { checkpointWorktreeHead, verifyLatestDurableCheckpoint } from './checkpoint-proof.mjs' import { validateFinalizationHistory } from './validation.mjs' const PRIORITY = new Map([ @@ -168,10 +168,7 @@ export async function detectWork({ workType: 'resume', runId: resumable.record.run.runId, branch: resumable.record.run.branch, - expectedHeadSha: - resumable.record.run.headSha ?? - resumable.record.run.implementationCommit ?? - resumable.record.run.baseSha, + expectedHeadSha: checkpointWorktreeHead(resumable.record), issue: { number: resumable.record.run.issueNumber, title: resumable.record.run.issueTitle, 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 57a7b9d4..acd43a09 100644 --- a/loops/issue-dev-loop/tests/github-identity-routing.test.mjs +++ b/loops/issue-dev-loop/tests/github-identity-routing.test.mjs @@ -28,6 +28,10 @@ import { prepareEvolveRequestPublication, recordEvolveRequestPublication, } from '../scripts/lib/evolve.mjs' +import { + canonicalFinalizationRecord, + finalizationRecordDigest, +} from '../scripts/lib/finalization-proof.mjs' import { resolveExecutable } from '../scripts/lib/github-identity.mjs' const execFileAsync = promisify(execFile) @@ -67,6 +71,7 @@ async function createFixture({ historicalActivation = false, remoteCheckpoint = true, removeLocalRun = false, + terminalFinalization = false, } = {}) { const parent = await mkdtemp(path.join(os.tmpdir(), 'echo-ui-identity-routing-')) const loopRoot = path.join(parent, 'issue-dev-loop') @@ -305,10 +310,60 @@ async function createFixture({ 'utf8', ) } + if (terminalFinalization) { + const finalization = { + schemaVersion: 1, + runId: run.runId, + issueNumber: run.issueNumber, + status: 'cancelled', + startedAt: run.startedAt, + finishedAt: '2026-07-23T00:06:00.000Z', + prUrl: run.prUrl, + headSha, + mergeSha: null, + failureFingerprint: null, + notificationUrl: null, + readyNotificationUrl: null, + readyNotifiedAt: null, + completionNotifiedAt: null, + notificationWebhookStatus: null, + predecessorCheckpointUrl: null, + predecessorCheckpointDigest: null, + pauseStartedAt: null, + notificationNotifiedAt: null, + } + const finalizationBody = [ + ``, + '```json', + canonicalFinalizationRecord(finalization), + '```', + ].join('\n') + const finalizationComment = { + id: 3, + user: { login: 'executor-user' }, + body: finalizationBody, + html_url: 'https://github.com/example/repo/issues/999#issuecomment-3', + created_at: finalization.finishedAt, + } + const journal = JSON.parse( + await readFile(path.join(parent, 'checkpoint-journal.json'), 'utf8'), + ) + await writeFile( + path.join(parent, 'checkpoint-journal.json'), + `${JSON.stringify([...journal, finalizationComment])}\n`, + 'utf8', + ) + await writeFile( + path.join(parent, 'finalization-comment.json'), + `${JSON.stringify(finalizationComment)}\n`, + 'utf8', + ) + } await writeFile( path.join(parent, 'live-pr.json'), `${JSON.stringify({ - state: 'open', + state: terminalFinalization ? 'closed' : 'open', + merged: false, draft: liveDraft, user: { login: 'executor-user' }, base: { ref: 'dev', repo: { full_name: 'example/repo' } }, @@ -454,6 +509,7 @@ if (commandArguments[0] === 'validate' && ${JSON.stringify(historicalActivation) `export async function validateLoop() { return { valid: true, historicalTargetCompatibility: true } } +export function validateFinalizationHistory() {} `, 'utf8', ) @@ -507,6 +563,10 @@ if [ "$1 $2 $3 $4" != "api user --jq .login" ]; then first_line "$parent_dir/evolve-comment.json" exit 0 fi + if [ "$1" = "api" ] && [ "$2" = "repos/example/repo/issues/comments/3" ]; then + first_line "$parent_dir/finalization-comment.json" + exit 0 + fi if [ "$1 $2" = "pr review" ]; then echo "comment review published" exit 0 @@ -917,6 +977,33 @@ test('wrapped activation can select an exact durable checkpoint without local ru }) }) +test('wrapped activation excludes checkpoints superseded by durable finalization', async () => { + const fixture = await createFixture({ + historicalActivation: true, + removeLocalRun: true, + terminalFinalization: true, + }) + await assert.rejects( + execFileAsync( + routerLauncherPath, + [ + '--loop-root', + fixture.loopRoot, + 'automation', + '--', + process.execPath, + fixture.loopctlPath, + 'validate', + '--activation', + '--loop-root', + fixture.loopRoot, + ], + { env: fixture.env }, + ), + /historical target validation requires the exact remote durable active checkpoint/, + ) +}) + test('wrapped activation rejects local active state that differs from its durable checkpoint', async () => { const fixture = await createFixture({ historicalActivation: true }) const runPath = path.join(fixture.loopRoot, 'logs', 'runs', 'fixture-run', 'run.json') diff --git a/loops/issue-dev-loop/tests/runtime.test.mjs b/loops/issue-dev-loop/tests/runtime.test.mjs index b91933a5..30c4d0ac 100644 --- a/loops/issue-dev-loop/tests/runtime.test.mjs +++ b/loops/issue-dev-loop/tests/runtime.test.mjs @@ -59,14 +59,16 @@ import { import { observeOwnerApprovedMerge } from '../scripts/lib/owner-gate.mjs' import { assertCredentialProfileIsolation, - durableCheckpointWorktreeHead, } from '../scripts/lib/github-identity.mjs' import { verifyTerminalExternalProof } from '../scripts/lib/finalization-proof.mjs' import { historicalWorkflowIsLowPrivilege, validateFinalizationHistory, } from '../scripts/lib/validation.mjs' -import { checkpointPublicationBody } from '../scripts/lib/checkpoint-proof.mjs' +import { + checkpointPublicationBody, + checkpointWorktreeHead, +} from '../scripts/lib/checkpoint-proof.mjs' const bypassCheckpointVerifier = async () => {} const createNotification = (options) => @@ -1189,7 +1191,7 @@ test('durable candidate validation supports pre-implementation and later repair ]) assert.equal(JSON.parse(repair.stdout).valid, true) assert.equal( - durableCheckpointWorktreeHead({ + checkpointWorktreeHead({ run: { baseSha }, events: [ { @@ -4648,6 +4650,89 @@ test('fresh worktrees restore active checkpoints and trigger resumable work', as assert.equal(detected.hasWork, true) assert.equal(detected.workType, 'resume') assert.equal(detected.runId, run.runId) + + const repairRecord = structuredClone(prepared.record) + const oldPrHead = 'a'.repeat(40) + const repairCommit = 'b'.repeat(40) + const repairBrief = 'repair implementation brief\n' + const repairBriefDigest = createHash('sha256').update(repairBrief).digest('hex') + const repairResult = { + schemaVersion: 1, + runId: run.runId, + agent: '$implement', + invocationId: 'repair-invocation', + startedAt: '2026-07-22T12:03:00.000Z', + finishedAt: '2026-07-22T12:04:00.000Z', + briefDigest: repairBriefDigest, + commitSha: repairCommit, + checks: [{ command: 'pnpm verify', status: 'passed' }], + } + const repairResultSource = `${JSON.stringify(repairResult)}\n` + const repairResultDigest = createHash('sha256') + .update(repairResultSource) + .digest('hex') + const repairResultPath = `logs/runs/${run.runId}/repair-result.json` + repairRecord.run.prUrl = 'https://github.com/codeacme17/echo-ui/pull/206' + repairRecord.run.headSha = oldPrHead + repairRecord.run.implementationCommit = repairCommit + repairRecord.run.briefDigest = repairBriefDigest + repairRecord.briefSource = repairBrief + repairRecord.events.push( + { + schemaVersion: 1, + runId: run.runId, + type: 'pr_published', + timestamp: '2026-07-22T12:02:30.000Z', + status: 'draft', + payload: { + prUrl: repairRecord.run.prUrl, + headSha: oldPrHead, + baseBranch: 'dev', + branch: repairRecord.run.branch, + }, + }, + { + schemaVersion: 1, + runId: run.runId, + type: 'implementation_completed', + timestamp: repairResult.finishedAt, + status: 'passed', + payload: { + agent: '$implement', + invocationId: repairResult.invocationId, + startedAt: repairResult.startedAt, + finishedAt: repairResult.finishedAt, + briefDigest: repairBriefDigest, + commitSha: repairCommit, + resultPath: repairResultPath, + resultDigest: repairResultDigest, + }, + }, + ) + repairRecord.artifacts.push({ + path: repairResultPath, + sha256: repairResultDigest, + source: repairResultSource, + }) + repairRecord.updatedAt = repairResult.finishedAt + const repairCheckpoint = { + record: repairRecord, + commentUrl: 'https://github.com/codeacme17/echo-ui/issues/999#issuecomment-9912', + createdAt: '2026-07-22T12:04:30.000Z', + } + const repairDetection = await detectWork({ + loopRoot, + now: new Date('2026-07-22T12:05:00.000Z'), + reconcileJournal: async () => ({ activeCheckpoints: [repairCheckpoint] }), + }) + assert.equal(repairDetection.expectedHeadSha, repairCommit) + await restoreActiveCheckpoint({ + loopRoot, + checkpoint: repairCheckpoint, + workspaceValidator: async ({ record }) => { + assert.equal(checkpointWorktreeHead(record), repairDetection.expectedHeadSha) + }, + }) }) test('checkpoint publication rejects an unattested implementation boundary', async () => { From e7065ea9e0b0a5647037c58aaa81b2507e67ec65 Mon Sep 17 00:00:00 2001 From: leyoonafr Date: Sun, 26 Jul 2026 21:21:40 +0800 Subject: [PATCH 09/11] fix(loop): enforce exact clean resume worktree --- .../scripts/lib/active-journal.mjs | 42 ++++- .../scripts/lib/github-identity.mjs | 4 +- .../tests/github-identity-routing.test.mjs | 9 +- loops/issue-dev-loop/tests/runtime.test.mjs | 156 ++++++++++++++++++ 4 files changed, 204 insertions(+), 7 deletions(-) diff --git a/loops/issue-dev-loop/scripts/lib/active-journal.mjs b/loops/issue-dev-loop/scripts/lib/active-journal.mjs index dec93a07..ea565a22 100644 --- a/loops/issue-dev-loop/scripts/lib/active-journal.mjs +++ b/loops/issue-dev-loop/scripts/lib/active-journal.mjs @@ -211,16 +211,23 @@ export async function reconcileActiveJournal({ async function defaultWorkspaceValidator({ loopRoot, record }) { const repositoryRoot = path.resolve(loopRoot, '..', '..') - const [branch, head, status, gitDirectory, commonDirectory] = await Promise.all([ + const [branch, head, status, gitDirectory, commonDirectory, indexState] = await Promise.all([ execFileAsync('git', ['branch', '--show-current'], { cwd: repositoryRoot }), execFileAsync('git', ['rev-parse', 'HEAD'], { cwd: repositoryRoot }), - execFileAsync('git', ['status', '--porcelain'], { cwd: repositoryRoot }), + execFileAsync('git', ['status', '--porcelain=v1', '--untracked-files=all'], { + cwd: repositoryRoot, + maxBuffer: 1024 * 1024, + }), execFileAsync('git', ['rev-parse', '--path-format=absolute', '--git-dir'], { cwd: repositoryRoot, }), execFileAsync('git', ['rev-parse', '--path-format=absolute', '--git-common-dir'], { cwd: repositoryRoot, }), + execFileAsync('git', ['ls-files', '-v', '-z'], { + cwd: repositoryRoot, + maxBuffer: 8 * 1024 * 1024, + }), ]) if (gitDirectory.stdout.trim() === commonDirectory.stdout.trim()) { throw new Error('restore requires an isolated linked Git worktree') @@ -232,9 +239,40 @@ async function defaultWorkspaceValidator({ loopRoot, record }) { if (head.stdout.trim() !== expectedHead) { throw new Error(`restore requires exact durable head ${expectedHead}`) } + const concealedIndexEntries = indexState.stdout + .split('\0') + .filter(Boolean) + .filter((entry) => !entry.startsWith('H ')) + if (concealedIndexEntries.length > 0) { + throw new Error('restore rejects index concealment and nonstandard tracked state') + } if (status.stdout.trim()) { throw new Error('restore requires a clean isolated worktree') } + try { + await execFileAsync( + 'git', + [ + '-c', + 'core.fileMode=true', + 'diff', + '--quiet', + '--no-ext-diff', + '--no-textconv', + 'HEAD', + '--', + ], + { + cwd: repositoryRoot, + maxBuffer: 1024 * 1024, + }, + ) + } catch (error) { + if (error?.code === 1) { + throw new Error('restore requires tracked filesystem contents to match HEAD') + } + throw error + } } export async function restoreActiveCheckpoint({ diff --git a/loops/issue-dev-loop/scripts/lib/github-identity.mjs b/loops/issue-dev-loop/scripts/lib/github-identity.mjs index d1d0be71..51776d5c 100644 --- a/loops/issue-dev-loop/scripts/lib/github-identity.mjs +++ b/loops/issue-dev-loop/scripts/lib/github-identity.mjs @@ -1373,7 +1373,7 @@ async function assertCleanExactDurableWorktree({ cwd: repositoryRoot, env: environment, }), - execFileAsync(realGit, ['status', '--porcelain'], { + execFileAsync(realGit, ['status', '--porcelain=v1', '--untracked-files=all'], { cwd: repositoryRoot, env: environment, maxBuffer: 1024 * 1024, @@ -1818,7 +1818,7 @@ async function preflightIssueBranchPush({ cwd: repositoryRoot, env: environment, }), - execFileAsync(realGit, ['status', '--porcelain'], { + execFileAsync(realGit, ['status', '--porcelain=v1', '--untracked-files=all'], { cwd: repositoryRoot, env: environment, maxBuffer: 1024 * 1024, 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 acd43a09..5a527b0e 100644 --- a/loops/issue-dev-loop/tests/github-identity-routing.test.mjs +++ b/loops/issue-dev-loop/tests/github-identity-routing.test.mjs @@ -506,7 +506,10 @@ if (commandArguments[0] === 'validate' && ${JSON.stringify(historicalActivation) ) await writeFile( path.join(trustedLoopRoot, 'scripts', 'lib', 'validation.mjs'), - `export async function validateLoop() { + `import { consumeHistoricalValidationCapability } from './github-identity.mjs' + +export async function validateLoop({ historicalCapability } = {}) { + consumeHistoricalValidationCapability(historicalCapability) return { valid: true, historicalTargetCompatibility: true } } export function validateFinalizationHistory() {} @@ -623,7 +626,7 @@ if [ "$1 $2" = "rev-parse HEAD" ]; then echo "${'b'.repeat(40)}" exit 0 fi -if [ "$1 $2" = "status --porcelain" ]; then +if [ "$1 $2 $3" = "status --porcelain=v1 --untracked-files=all" ]; then if [ -f ${JSON.stringify(path.join(parent, 'dirty-git'))} ]; then echo " M src/unsafe.ts" fi @@ -2388,7 +2391,7 @@ if [ "$1 $2" = "rev-parse HEAD" ]; then echo "${'b'.repeat(40)}" exit 0 fi -if [ "$1 $2" = "status --porcelain" ] || [ "$1" = "merge-base" ] || [ "$1 $2" = "diff --name-status" ]; then +if [ "$1 $2 $3" = "status --porcelain=v1 --untracked-files=all" ] || [ "$1" = "merge-base" ] || [ "$1 $2" = "diff --name-status" ]; then exit 0 fi if [ "$1 $2 $3" = "remote get-url origin" ]; then diff --git a/loops/issue-dev-loop/tests/runtime.test.mjs b/loops/issue-dev-loop/tests/runtime.test.mjs index 30c4d0ac..2986bc79 100644 --- a/loops/issue-dev-loop/tests/runtime.test.mjs +++ b/loops/issue-dev-loop/tests/runtime.test.mjs @@ -1209,6 +1209,162 @@ test('durable candidate validation supports pre-implementation and later repair ) }) +test('default checkpoint restore ignores hidden-untracked config and rejects concealed index state', async () => { + const parent = await mkdtemp(path.join(os.tmpdir(), 'echo-ui-restore-cleanliness-test-')) + const repository = path.join(parent, 'repository') + const worktree = path.join(parent, 'worktree') + const loopRoot = path.join(worktree, 'loops', 'issue-dev-loop') + const git = async (cwd, ...args) => execFileAsync('git', args, { cwd }) + try { + await mkdir(path.join(repository, 'loops', 'issue-dev-loop'), { recursive: true }) + await writeFile(path.join(repository, 'tracked.txt'), 'tracked\n', 'utf8') + await writeFile( + path.join(repository, 'loops', 'issue-dev-loop', '.gitkeep'), + '', + 'utf8', + ) + await git(repository, 'init', '--initial-branch=dev') + await git(repository, 'config', 'user.name', 'Loop Test') + await git(repository, 'config', 'user.email', 'loop-test@example.invalid') + await git(repository, 'add', '.') + await git(repository, 'commit', '-m', 'base') + const baseSha = (await git(repository, 'rev-parse', 'HEAD')).stdout.trim() + await git(repository, 'worktree', 'add', '-b', 'codex/issue-444', worktree, baseSha) + + const startedAt = '2026-07-24T00:00:00.000Z' + const record = { + schemaVersion: 1, + kind: 'active-checkpoint', + run: { + schemaVersion: 1, + runId: 'restore-untracked-run', + issueNumber: 444, + issueTitle: 'Restore exact durable worktree', + issueUrl: 'https://github.com/codeacme17/echo-ui/issues/444', + baseBranch: 'dev', + baseSha, + branch: 'codex/issue-444', + status: 'running', + startedAt, + finishedAt: null, + prUrl: null, + headSha: null, + mergeSha: null, + issueSnapshot: { + title: 'Restore exact durable worktree', + body: 'Fixture', + labels: ['codex-ready'], + url: 'https://github.com/codeacme17/echo-ui/issues/444', + capturedAt: startedAt, + }, + briefDigest: null, + uiEvidenceRequired: false, + implementationCommit: null, + }, + briefSource: '', + events: [ + { + schemaVersion: 1, + runId: 'restore-untracked-run', + type: 'loop_started', + timestamp: startedAt, + status: 'running', + payload: { issueNumber: 444, branch: 'codex/issue-444' }, + }, + ], + artifacts: [], + updatedAt: startedAt, + } + const checkpoint = { record, commentUrl: null, createdAt: startedAt } + + const restoredPreImplementation = await restoreActiveCheckpoint({ loopRoot, checkpoint }) + assert.equal(restoredPreImplementation.implementationCommit, null) + for (const directory of ['logs', 'handoffs', 'screen-shots', 'evidence']) { + await rm(path.join(loopRoot, directory), { recursive: true, force: true }) + } + + await git(worktree, 'config', 'status.showUntrackedFiles', 'no') + await writeFile(path.join(worktree, 'hidden-untracked.txt'), 'not clean\n', 'utf8') + const configuredStatus = await git(worktree, 'status', '--porcelain') + assert.equal(configuredStatus.stdout, '') + await assert.rejects( + restoreActiveCheckpoint({ loopRoot, checkpoint }), + /clean isolated worktree/, + ) + + await rm(path.join(worktree, 'hidden-untracked.txt')) + await git(worktree, 'update-index', '--skip-worktree', 'tracked.txt') + await assert.rejects( + restoreActiveCheckpoint({ loopRoot, checkpoint }), + /index concealment/, + ) + + await git(worktree, 'update-index', '--no-skip-worktree', 'tracked.txt') + await writeFile(path.join(worktree, 'tracked.txt'), 'repaired\n', 'utf8') + await git(worktree, 'add', 'tracked.txt') + await git(worktree, 'commit', '-m', 'repair implementation') + const repairCommit = (await git(worktree, 'rev-parse', 'HEAD')).stdout.trim() + const repairBrief = 'Repair the issue and retain the durable resume boundary.\n' + const repairBriefDigest = createHash('sha256').update(repairBrief).digest('hex') + const repairResult = { + schemaVersion: 1, + runId: record.run.runId, + agent: '$implement', + invocationId: 'repair-invocation', + startedAt: '2026-07-24T00:01:00.000Z', + finishedAt: '2026-07-24T00:02:00.000Z', + briefDigest: repairBriefDigest, + commitSha: repairCommit, + checks: [{ command: 'pnpm verify', status: 'passed' }], + } + const repairResultSource = `${JSON.stringify(repairResult)}\n` + const repairResultDigest = createHash('sha256') + .update(repairResultSource) + .digest('hex') + const repairResultPath = `logs/runs/${record.run.runId}/repair-result.json` + const repairFinishedAt = repairResult.finishedAt + const repairRecord = structuredClone(record) + repairRecord.run.implementationCommit = repairCommit + repairRecord.run.briefDigest = repairBriefDigest + repairRecord.briefSource = repairBrief + repairRecord.events.push({ + schemaVersion: 1, + runId: record.run.runId, + type: 'implementation_completed', + timestamp: repairFinishedAt, + status: 'passed', + payload: { + agent: '$implement', + invocationId: repairResult.invocationId, + startedAt: repairResult.startedAt, + finishedAt: repairFinishedAt, + briefDigest: repairBriefDigest, + commitSha: repairCommit, + resultPath: repairResultPath, + resultDigest: repairResultDigest, + }, + }) + repairRecord.artifacts.push({ + path: repairResultPath, + sha256: repairResultDigest, + source: repairResultSource, + }) + repairRecord.updatedAt = repairFinishedAt + + const restoredRepair = await restoreActiveCheckpoint({ + loopRoot, + checkpoint: { + record: repairRecord, + commentUrl: null, + createdAt: repairFinishedAt, + }, + }) + assert.equal(restoredRepair.implementationCommit, repairCommit) + } finally { + await rm(parent, { recursive: true, force: true }) + } +}) + test('review publication digest excludes assigned review URLs but binds review content', () => { const review = { schemaVersion: 1, From 898999f1f4174edc91f8038f02c2ddb946effc2b Mon Sep 17 00:00:00 2001 From: leyoonafr Date: Sun, 26 Jul 2026 21:36:16 +0800 Subject: [PATCH 10/11] fix(loop): reject ambiguous YAML controls --- loops/issue-dev-loop/scripts/lib/validation.mjs | 6 +++++- loops/issue-dev-loop/tests/runtime.test.mjs | 17 +++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/loops/issue-dev-loop/scripts/lib/validation.mjs b/loops/issue-dev-loop/scripts/lib/validation.mjs index 7847104c..e5df25e3 100644 --- a/loops/issue-dev-loop/scripts/lib/validation.mjs +++ b/loops/issue-dev-loop/scripts/lib/validation.mjs @@ -7,6 +7,9 @@ import { consumeHistoricalValidationCapability, } from './github-identity.mjs' +const unsupportedYamlCharacters = + /[\u0000-\u0009\u000b\u000c\u000e-\u001f\u007f-\u009f\u2028\u2029]/u + async function collectFiles(root, output = []) { const entries = await readdir(root, { withFileTypes: true }) for (const entry of entries) { @@ -49,7 +52,7 @@ export function validateFinalizationHistory(historyLines) { function activeYamlLines(source) { return source - .split(/\r?\n/) + .split(/\r\n|[\r\n]/) .map((line) => line.replace(/\s+$/, '')) .filter((line) => line.trim() && !line.trimStart().startsWith('#')) } @@ -110,6 +113,7 @@ function conservativeYamlBlock( } export function historicalWorkflowIsLowPrivilege(source) { + if (unsupportedYamlCharacters.test(source)) return false const lines = activeYamlLines(source) if ( lines.some( diff --git a/loops/issue-dev-loop/tests/runtime.test.mjs b/loops/issue-dev-loop/tests/runtime.test.mjs index 2986bc79..5841f917 100644 --- a/loops/issue-dev-loop/tests/runtime.test.mjs +++ b/loops/issue-dev-loop/tests/runtime.test.mjs @@ -5436,6 +5436,23 @@ test('historical workflow parsing is fail-closed without exposing reduced valida ' runs-on: ubuntu-latest\n', ' - permissions: write-all\n runs-on: ubuntu-latest\n', ), + historicalWorkflow.replace( + ' runs-on: ubuntu-latest\n', + ' runs-on: ubuntu-latest\r permissions: write-all\n', + ), + historicalWorkflow.replace( + ' runs-on: ubuntu-latest\n', + ' runs-on: ubuntu-latest\u0085 permissions: write-all\n', + ), + historicalWorkflow.replace( + ' runs-on: ubuntu-latest\n', + ' runs-on: ubuntu-latest\u2028 permissions: write-all\n', + ), + historicalWorkflow.replace( + ' runs-on: ubuntu-latest\n', + ' runs-on: ubuntu-latest\u2029 permissions: write-all\n', + ), + historicalWorkflow.replace('ubuntu-latest', 'ubuntu-latest\u0000'), `${historicalWorkflow} !!str permissions: write-all `, From 91343574f9ba8155cd9b0ea8e29ac36704eb1c6f Mon Sep 17 00:00:00 2001 From: leyoonafr Date: Sun, 26 Jul 2026 21:47:15 +0800 Subject: [PATCH 11/11] fix(loop): require isolated historical worktree --- .../scripts/lib/github-identity.mjs | 13 ++++++- .../tests/github-identity-routing.test.mjs | 39 +++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/loops/issue-dev-loop/scripts/lib/github-identity.mjs b/loops/issue-dev-loop/scripts/lib/github-identity.mjs index 51776d5c..e3178ceb 100644 --- a/loops/issue-dev-loop/scripts/lib/github-identity.mjs +++ b/loops/issue-dev-loop/scripts/lib/github-identity.mjs @@ -1364,7 +1364,7 @@ async function assertCleanExactDurableWorktree({ run, expectedHead, }) { - const [branch, head, status, indexState] = await Promise.all([ + const [branch, head, status, gitDirectory, commonDirectory, indexState] = await Promise.all([ execFileAsync(realGit, ['branch', '--show-current'], { cwd: repositoryRoot, env: environment, @@ -1378,12 +1378,23 @@ async function assertCleanExactDurableWorktree({ env: environment, maxBuffer: 1024 * 1024, }), + 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, }), ]) + if (gitDirectory.stdout.trim() === commonDirectory.stdout.trim()) { + throw new Error('historical target validation requires an isolated linked Git worktree') + } const concealedIndexEntries = indexState.stdout .split('\0') .filter(Boolean) 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 5a527b0e..bc2158d2 100644 --- a/loops/issue-dev-loop/tests/github-identity-routing.test.mjs +++ b/loops/issue-dev-loop/tests/github-identity-routing.test.mjs @@ -72,6 +72,7 @@ async function createFixture({ remoteCheckpoint = true, removeLocalRun = false, terminalFinalization = false, + isolatedWorktree = true, } = {}) { const parent = await mkdtemp(path.join(os.tmpdir(), 'echo-ui-identity-routing-')) const loopRoot = path.join(parent, 'issue-dev-loop') @@ -626,6 +627,18 @@ if [ "$1 $2" = "rev-parse HEAD" ]; then echo "${'b'.repeat(40)}" exit 0 fi +if [ "$1 $2 $3" = "rev-parse --path-format=absolute --git-dir" ]; then + echo ${JSON.stringify( + isolatedWorktree + ? path.join(parent, '.git', 'worktrees', 'issue-123') + : path.join(parent, '.git'), + )} + exit 0 +fi +if [ "$1 $2 $3" = "rev-parse --path-format=absolute --git-common-dir" ]; then + echo ${JSON.stringify(path.join(parent, '.git'))} + exit 0 +fi if [ "$1 $2 $3" = "status --porcelain=v1 --untracked-files=all" ]; then if [ -f ${JSON.stringify(path.join(parent, 'dirty-git'))} ]; then echo " M src/unsafe.ts" @@ -927,6 +940,32 @@ test('wrapped activation allows an exact durable target ahead of its committed l }) }) +test('wrapped activation rejects an exact durable target in the primary checkout', async () => { + const fixture = await createFixture({ + historicalActivation: true, + isolatedWorktree: false, + }) + await assert.rejects( + execFileAsync( + routerLauncherPath, + [ + '--loop-root', + fixture.loopRoot, + 'automation', + '--', + process.execPath, + fixture.loopctlPath, + 'validate', + '--activation', + '--loop-root', + fixture.loopRoot, + ], + { env: fixture.env }, + ), + /historical target validation requires an isolated linked Git worktree/, + ) +}) + test('wrapped activation rejects historical validation without a remote durable checkpoint', async () => { const fixture = await createFixture({ historicalActivation: true,