Skip to content

Commit c7ba5bd

Browse files
committed
fix(loop): bind historical validation to durable state
1 parent a3be610 commit c7ba5bd

8 files changed

Lines changed: 382 additions & 22 deletions

File tree

loops/issue-dev-loop/SKILL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ Run exactly one bounded issue cycle. Treat [`LOOP.md`](./LOOP.md) as the constit
77
## Start safely
88

99
1. Read `LOOP.md`, `state.md`, and `dependencies.md` completely.
10-
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.
10+
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.
1111
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.
1212
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 <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.
1313
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.

loops/issue-dev-loop/references/github-operations.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ Run every executor GitHub command through:
1010

1111
`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.
1212

13-
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.
13+
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.
1414

1515
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`.
1616

loops/issue-dev-loop/scripts/lib/github-identity.mjs

Lines changed: 135 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
readJson,
1515
sameGitHubLogin,
1616
} from './common.mjs'
17+
import { reconcileActiveJournal } from './active-journal.mjs'
1718
import {
1819
checkpointRecordDigest,
1920
parseCheckpointRecord,
@@ -1343,6 +1344,97 @@ function activationValidationRequested({ role, tool, args, loopRoot, trustedLoop
13431344
)
13441345
}
13451346

1347+
async function authorizeHistoricalTargetValidation({
1348+
authorization,
1349+
loopRoot,
1350+
trustedLoopRoot,
1351+
realGit,
1352+
realGh,
1353+
realNode,
1354+
environment,
1355+
}) {
1356+
const localIssue = authorization.issue
1357+
if (!localIssue?.runId) {
1358+
throw new Error('historical target validation requires a local active run')
1359+
}
1360+
const githubApi = async (endpoint) => {
1361+
const { stdout } = await execFileAsync(realGh, ['api', endpoint], {
1362+
env: environment,
1363+
maxBuffer: 1024 * 1024,
1364+
})
1365+
return JSON.parse(stdout)
1366+
}
1367+
const { activeCheckpoints } = await reconcileActiveJournal({
1368+
loopRoot,
1369+
githubPaginatedApi: (endpoint) =>
1370+
paginateGitHubApi(githubApi, endpoint.replace(/[?&]per_page=100$/, '')),
1371+
})
1372+
const durable = activeCheckpoints.find(
1373+
(checkpoint) => checkpoint.record.run.runId === localIssue.runId,
1374+
)
1375+
const run = durable?.record.run
1376+
const expectedHead = run?.headSha ?? run?.implementationCommit ?? run?.baseSha
1377+
if (
1378+
!run ||
1379+
run.finishedAt !== null ||
1380+
run.issueNumber !== localIssue.issueNumber ||
1381+
run.branch !== localIssue.branch ||
1382+
run.status !== localIssue.status ||
1383+
run.implementationCommit !== localIssue.implementationCommit
1384+
) {
1385+
throw new Error(
1386+
'historical target validation requires the exact remote durable active checkpoint',
1387+
)
1388+
}
1389+
1390+
const repositoryRoot = repositoryRootForLoop(loopRoot)
1391+
const [branch, head, status] = await Promise.all([
1392+
execFileAsync(realGit, ['branch', '--show-current'], {
1393+
cwd: repositoryRoot,
1394+
env: environment,
1395+
}),
1396+
execFileAsync(realGit, ['rev-parse', 'HEAD'], {
1397+
cwd: repositoryRoot,
1398+
env: environment,
1399+
}),
1400+
execFileAsync(realGit, ['status', '--porcelain'], {
1401+
cwd: repositoryRoot,
1402+
env: environment,
1403+
maxBuffer: 1024 * 1024,
1404+
}),
1405+
])
1406+
if (
1407+
branch.stdout.trim() !== run.branch ||
1408+
head.stdout.trim() !== expectedHead ||
1409+
status.stdout.trim()
1410+
) {
1411+
throw new Error(
1412+
'historical target validation requires the clean exact durable branch and head',
1413+
)
1414+
}
1415+
1416+
await execFileAsync(
1417+
realNode,
1418+
[
1419+
path.resolve(trustedLoopRoot, 'scripts', 'validate-candidate-control-plane.mjs'),
1420+
'--loop-root',
1421+
path.resolve(loopRoot),
1422+
'--run-id',
1423+
run.runId,
1424+
'--base-sha',
1425+
run.baseSha,
1426+
'--head-sha',
1427+
expectedHead,
1428+
],
1429+
{
1430+
cwd: repositoryRoot,
1431+
env: environment,
1432+
maxBuffer: 4 * 1024 * 1024,
1433+
},
1434+
)
1435+
return durable
1436+
}
1437+
13461438
function pullRequestWriteIntent(role, args, authorization) {
13471439
const group = githubGroup(args)
13481440
if (role === 'reviewer' && group.name === 'api') {
@@ -1920,13 +2012,54 @@ export async function runWithGitHubRole({
19202012
? hardenedGitArguments(args, { expectedRepository: channel.repository })
19212013
: [...args]
19222014
if (activationValidation) {
1923-
executionArgs = [
2015+
const fullValidationArguments = [
19242016
args[0],
19252017
'validate',
1926-
...(authorization.issue ? ['--target-compatibility'] : []),
19272018
'--loop-root',
19282019
path.resolve(loopRoot),
19292020
]
2021+
try {
2022+
const { stdout } = await execFileAsync(executable, fullValidationArguments, {
2023+
env: childEnvironment,
2024+
maxBuffer: 4 * 1024 * 1024,
2025+
})
2026+
process.stdout.write(stdout)
2027+
return 0
2028+
} catch (fullValidationError) {
2029+
if (!authorization.issue?.runId) throw fullValidationError
2030+
await authorizeHistoricalTargetValidation({
2031+
authorization,
2032+
loopRoot,
2033+
trustedLoopRoot: trustedControlPlane.loopRoot,
2034+
realGit,
2035+
realGh,
2036+
realNode,
2037+
environment: childEnvironment,
2038+
})
2039+
try {
2040+
const { stdout } = await execFileAsync(
2041+
realNode,
2042+
[
2043+
path.resolve(
2044+
trustedControlPlane.loopRoot,
2045+
'scripts',
2046+
'validate-historical-target.mjs',
2047+
),
2048+
'--loop-root',
2049+
path.resolve(loopRoot),
2050+
],
2051+
{
2052+
env: childEnvironment,
2053+
maxBuffer: 4 * 1024 * 1024,
2054+
},
2055+
)
2056+
process.stdout.write(stdout)
2057+
return 0
2058+
} catch (historicalValidationError) {
2059+
historicalValidationError.cause = fullValidationError
2060+
throw historicalValidationError
2061+
}
2062+
}
19302063
}
19312064
const child = spawnCommand(executable, executionArgs, {
19322065
env: childEnvironment,

loops/issue-dev-loop/scripts/lib/validation.mjs

Lines changed: 63 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,55 @@ export function validateFinalizationHistory(historyLines) {
4444
}
4545
}
4646

47-
export async function validateLoop({
47+
function activeYamlLines(source) {
48+
return source
49+
.split(/\r?\n/)
50+
.map((line) => line.replace(/\s+$/, ''))
51+
.filter((line) => line.trim() && !line.trimStart().startsWith('#'))
52+
}
53+
54+
function historicalWorkflowIsLowPrivilege(source) {
55+
const lines = activeYamlLines(source)
56+
const onIndex = lines.findIndex((line) => /^on:\s*(?:#.*)?$/.test(line))
57+
if (onIndex === -1) return false
58+
const onBlock = lines.slice(onIndex + 1).findIndex((line) => /^\S/.test(line))
59+
const triggerLines =
60+
onBlock === -1 ? lines.slice(onIndex + 1) : lines.slice(onIndex + 1, onIndex + 1 + onBlock)
61+
if (
62+
!triggerLines.some((line) => /^ pull_request:\s*(?:#.*)?$/.test(line)) ||
63+
lines.some((line) => /^\s*pull_request_target\s*:/.test(line))
64+
) {
65+
return false
66+
}
67+
68+
const permissionIndexes = lines.flatMap((line, index) =>
69+
/^permissions\s*:/.test(line) ? [index] : [],
70+
)
71+
if (
72+
permissionIndexes.length !== 1 ||
73+
!/^permissions:\s*(?:#.*)?$/.test(lines[permissionIndexes[0]]) ||
74+
lines.some((line) => /^\s+permissions\s*:/.test(line))
75+
) {
76+
return false
77+
}
78+
const permissionIndex = permissionIndexes[0]
79+
const permissionBlockEnd = lines
80+
.slice(permissionIndex + 1)
81+
.findIndex((line) => /^\S/.test(line))
82+
const permissionLines =
83+
permissionBlockEnd === -1
84+
? lines.slice(permissionIndex + 1)
85+
: lines.slice(permissionIndex + 1, permissionIndex + 1 + permissionBlockEnd)
86+
const permissions = new Map()
87+
for (const line of permissionLines) {
88+
const match = line.match(/^ ([a-z-]+):\s*(read|none)\s*(?:#.*)?$/)
89+
if (!match || permissions.has(match[1])) return false
90+
permissions.set(match[1], match[2])
91+
}
92+
return permissions.get('contents') === 'read'
93+
}
94+
95+
async function validateLoopMode({
4896
loopRoot = DEFAULT_LOOP_ROOT,
4997
activation = false,
5098
targetCompatibility = false,
@@ -97,6 +145,7 @@ export async function validateLoop({
97145
'scripts/lib/trusted-control-plane.mjs',
98146
'scripts/github-command-gate.mjs',
99147
'scripts/publish-review.mjs',
148+
'scripts/validate-historical-target.mjs',
100149
'scripts/identity-bin/gh',
101150
'scripts/identity-bin/git',
102151
'scripts/lib/issue-claim.mjs',
@@ -199,11 +248,7 @@ export async function validateLoop({
199248
throw new Error('missing .github/workflows/issue-dev-loop-evidence.yml')
200249
}
201250
const evidenceWorkflowSource = await readFile(evidenceWorkflow, 'utf8')
202-
if (
203-
!evidenceWorkflowSource.includes('pull_request:') ||
204-
evidenceWorkflowSource.includes('pull_request_target:') ||
205-
!evidenceWorkflowSource.includes('permissions:\n contents: read')
206-
) {
251+
if (!historicalWorkflowIsLowPrivilege(evidenceWorkflowSource)) {
207252
throw new Error(
208253
'historical target evidence workflow must remain a low-privilege pull_request workflow',
209254
)
@@ -327,3 +372,15 @@ export async function validateLoop({
327372
}
328373
return { valid: true, checkedFiles: required.length + jsonFiles.length }
329374
}
375+
376+
export function validateLoop(options = {}) {
377+
return validateLoopMode({ ...options, targetCompatibility: false })
378+
}
379+
380+
export function validateHistoricalTarget(options = {}) {
381+
return validateLoopMode({
382+
...options,
383+
activation: false,
384+
targetCompatibility: true,
385+
})
386+
}

loops/issue-dev-loop/scripts/loopctl.mjs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -236,11 +236,13 @@ async function main() {
236236
)
237237
break
238238
case 'validate':
239+
if (args['target-compatibility']) {
240+
throw new Error('target compatibility validation is reserved to wrapped activation')
241+
}
239242
output(
240243
await validateLoop({
241244
loopRoot,
242245
activation: Boolean(args.activation),
243-
targetCompatibility: Boolean(args['target-compatibility']),
244246
}),
245247
)
246248
break
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
#!/usr/bin/env node
2+
3+
import path from 'node:path'
4+
5+
import { parseArguments } from './lib/common.mjs'
6+
import { validateHistoricalTarget } from './lib/validation.mjs'
7+
8+
const args = parseArguments(process.argv.slice(2))
9+
const loopRoot = args['loop-root'] ? path.resolve(args['loop-root']) : undefined
10+
const result = await validateHistoricalTarget({ loopRoot })
11+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`)

0 commit comments

Comments
 (0)