Skip to content

Commit feea8f9

Browse files
committed
fix(loop): capability-gate historical activation
1 parent 2370082 commit feea8f9

8 files changed

Lines changed: 416 additions & 112 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, 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.
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 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.
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, 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.
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 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.
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: 150 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ const roleFields = {
4242
environmentVariable: 'reviewerGitHubConfigEnvironmentVariable',
4343
},
4444
}
45+
const historicalValidationCapabilities = new WeakSet()
4546

4647
const inheritedEnvironmentNames = new Set([
4748
'CI',
@@ -1344,6 +1345,104 @@ function activationValidationRequested({ role, tool, args, loopRoot, trustedLoop
13441345
)
13451346
}
13461347

1348+
export function consumeHistoricalValidationCapability(capability) {
1349+
if (
1350+
(typeof capability !== 'object' && typeof capability !== 'function') ||
1351+
capability === null ||
1352+
!historicalValidationCapabilities.delete(capability)
1353+
) {
1354+
throw new Error('historical target validation requires an authorized router capability')
1355+
}
1356+
}
1357+
1358+
export function durableCheckpointWorktreeHead(record) {
1359+
let expectedHead = record?.run?.baseSha
1360+
for (const event of record?.events ?? []) {
1361+
if (event.type === 'implementation_completed' && event.status === 'passed') {
1362+
expectedHead = event.payload?.commitSha
1363+
}
1364+
if (event.type === 'pr_published') expectedHead = event.payload?.headSha
1365+
}
1366+
if (!/^[0-9a-f]{40}$/i.test(expectedHead ?? '')) {
1367+
throw new Error('durable active checkpoint has no valid working head')
1368+
}
1369+
return expectedHead
1370+
}
1371+
1372+
async function assertCleanExactDurableWorktree({
1373+
realGit,
1374+
repositoryRoot,
1375+
environment,
1376+
run,
1377+
expectedHead,
1378+
}) {
1379+
const [branch, head, status, indexState] = await Promise.all([
1380+
execFileAsync(realGit, ['branch', '--show-current'], {
1381+
cwd: repositoryRoot,
1382+
env: environment,
1383+
}),
1384+
execFileAsync(realGit, ['rev-parse', 'HEAD'], {
1385+
cwd: repositoryRoot,
1386+
env: environment,
1387+
}),
1388+
execFileAsync(realGit, ['status', '--porcelain'], {
1389+
cwd: repositoryRoot,
1390+
env: environment,
1391+
maxBuffer: 1024 * 1024,
1392+
}),
1393+
execFileAsync(realGit, ['ls-files', '-v', '-z'], {
1394+
cwd: repositoryRoot,
1395+
env: environment,
1396+
maxBuffer: 8 * 1024 * 1024,
1397+
}),
1398+
])
1399+
const concealedIndexEntries = indexState.stdout
1400+
.split('\0')
1401+
.filter(Boolean)
1402+
.filter((entry) => !entry.startsWith('H '))
1403+
if (concealedIndexEntries.length > 0) {
1404+
throw new Error(
1405+
'historical target validation rejects index concealment and nonstandard tracked state',
1406+
)
1407+
}
1408+
if (
1409+
branch.stdout.trim() !== run.branch ||
1410+
head.stdout.trim() !== expectedHead ||
1411+
status.stdout.trim()
1412+
) {
1413+
throw new Error(
1414+
'historical target validation requires the clean exact durable branch and head',
1415+
)
1416+
}
1417+
try {
1418+
await execFileAsync(
1419+
realGit,
1420+
[
1421+
'-c',
1422+
'core.fileMode=true',
1423+
'diff',
1424+
'--quiet',
1425+
'--no-ext-diff',
1426+
'--no-textconv',
1427+
'HEAD',
1428+
'--',
1429+
],
1430+
{
1431+
cwd: repositoryRoot,
1432+
env: environment,
1433+
maxBuffer: 1024 * 1024,
1434+
},
1435+
)
1436+
} catch (error) {
1437+
if (error?.code === 1) {
1438+
throw new Error(
1439+
'historical target validation requires tracked filesystem contents to match HEAD',
1440+
)
1441+
}
1442+
throw error
1443+
}
1444+
}
1445+
13471446
async function authorizeHistoricalTargetValidation({
13481447
authorization,
13491448
loopRoot,
@@ -1354,9 +1453,17 @@ async function authorizeHistoricalTargetValidation({
13541453
environment,
13551454
}) {
13561455
const localIssue = authorization.issue
1357-
if (!localIssue?.runId) {
1358-
throw new Error('historical target validation requires a local active run')
1359-
}
1456+
const repositoryRoot = repositoryRootForLoop(loopRoot)
1457+
const [checkedOutBranch, checkedOutHead] = await Promise.all([
1458+
execFileAsync(realGit, ['branch', '--show-current'], {
1459+
cwd: repositoryRoot,
1460+
env: environment,
1461+
}),
1462+
execFileAsync(realGit, ['rev-parse', 'HEAD'], {
1463+
cwd: repositoryRoot,
1464+
env: environment,
1465+
}),
1466+
])
13601467
const githubApi = async (endpoint) => {
13611468
const { stdout } = await execFileAsync(realGh, ['api', endpoint], {
13621469
env: environment,
@@ -1369,49 +1476,34 @@ async function authorizeHistoricalTargetValidation({
13691476
githubPaginatedApi: (endpoint) =>
13701477
paginateGitHubApi(githubApi, endpoint.replace(/[?&]per_page=100$/, '')),
13711478
})
1372-
const durable = activeCheckpoints.find(
1373-
(checkpoint) => checkpoint.record.run.runId === localIssue.runId,
1479+
const durableMatches = activeCheckpoints.filter(
1480+
(checkpoint) =>
1481+
checkpoint.record.run.branch === checkedOutBranch.stdout.trim() &&
1482+
durableCheckpointWorktreeHead(checkpoint.record) === checkedOutHead.stdout.trim(),
13741483
)
1484+
const durable = durableMatches.length === 1 ? durableMatches[0] : null
13751485
const run = durable?.record.run
1376-
const expectedHead = run?.headSha ?? run?.implementationCommit ?? run?.baseSha
1486+
const expectedHead = durable ? durableCheckpointWorktreeHead(durable.record) : null
13771487
if (
13781488
!run ||
13791489
run.finishedAt !== null ||
1380-
run.issueNumber !== localIssue.issueNumber ||
1381-
run.branch !== localIssue.branch ||
1382-
run.status !== localIssue.status ||
1383-
run.implementationCommit !== localIssue.implementationCommit
1490+
(localIssue &&
1491+
(run.runId !== localIssue.runId ||
1492+
run.issueNumber !== localIssue.issueNumber ||
1493+
run.branch !== localIssue.branch))
13841494
) {
13851495
throw new Error(
13861496
'historical target validation requires the exact remote durable active checkpoint',
13871497
)
13881498
}
13891499

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-
}
1500+
await assertCleanExactDurableWorktree({
1501+
realGit,
1502+
repositoryRoot,
1503+
environment,
1504+
run,
1505+
expectedHead,
1506+
})
14151507

14161508
await execFileAsync(
14171509
realNode,
@@ -1425,14 +1517,22 @@ async function authorizeHistoricalTargetValidation({
14251517
run.baseSha,
14261518
'--head-sha',
14271519
expectedHead,
1520+
'--durable-issue-number',
1521+
String(run.issueNumber),
1522+
'--durable-implementation-commit',
1523+
run.implementationCommit ?? 'none',
1524+
'--durable-pr-head',
1525+
run.headSha ?? 'none',
14281526
],
14291527
{
14301528
cwd: repositoryRoot,
14311529
env: environment,
14321530
maxBuffer: 4 * 1024 * 1024,
14331531
},
14341532
)
1435-
return durable
1533+
const capability = Object.freeze({})
1534+
historicalValidationCapabilities.add(capability)
1535+
return { capability, durable, expectedHead, repositoryRoot, run }
14361536
}
14371537

14381538
function pullRequestWriteIntent(role, args, authorization) {
@@ -2026,8 +2126,7 @@ export async function runWithGitHubRole({
20262126
process.stdout.write(stdout)
20272127
return 0
20282128
} catch (fullValidationError) {
2029-
if (!authorization.issue?.runId) throw fullValidationError
2030-
await authorizeHistoricalTargetValidation({
2129+
const historicalAuthorization = await authorizeHistoricalTargetValidation({
20312130
authorization,
20322131
loopRoot,
20332132
trustedLoopRoot: trustedControlPlane.loopRoot,
@@ -2037,23 +2136,19 @@ export async function runWithGitHubRole({
20372136
environment: childEnvironment,
20382137
})
20392138
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)
2139+
const { validateLoop } = await import('./validation.mjs')
2140+
const result = await validateLoop({
2141+
loopRoot,
2142+
historicalCapability: historicalAuthorization.capability,
2143+
})
2144+
await assertCleanExactDurableWorktree({
2145+
realGit,
2146+
repositoryRoot: historicalAuthorization.repositoryRoot,
2147+
environment: childEnvironment,
2148+
run: historicalAuthorization.run,
2149+
expectedHead: historicalAuthorization.expectedHead,
2150+
})
2151+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`)
20572152
return 0
20582153
} catch (historicalValidationError) {
20592154
historicalValidationError.cause = fullValidationError

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

Lines changed: 29 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@ import { readFile, readdir } from 'node:fs/promises'
22
import path from 'node:path'
33

44
import { DEFAULT_LOOP_ROOT, pathExists, readJson, sameGitHubLogin } from './common.mjs'
5-
import { assertGitHubRoleIdentity } from './github-identity.mjs'
5+
import {
6+
assertGitHubRoleIdentity,
7+
consumeHistoricalValidationCapability,
8+
} from './github-identity.mjs'
69

710
async function collectFiles(root, output = []) {
811
const entries = await readdir(root, { withFileTypes: true })
@@ -79,7 +82,10 @@ function yamlSequenceMapping(line) {
7982
}
8083
}
8184

82-
function conservativeYamlBlock(lines) {
85+
function conservativeYamlBlock(
86+
lines,
87+
{ rejectFlowValues = true, rejectPermissionKeys = true } = {},
88+
) {
8389
let scalarParentIndent = null
8490
for (const line of lines) {
8591
const lineIndent = line.match(/^\s*/)?.[0].length ?? 0
@@ -90,8 +96,8 @@ function conservativeYamlBlock(lines) {
9096
if (
9197
!mapping ||
9298
mapping.quoted ||
93-
mapping.key === 'permissions' ||
94-
/^[{[]/.test(mapping.value) ||
99+
(rejectPermissionKeys && mapping.key === 'permissions') ||
100+
(rejectFlowValues && /^[{[]/.test(mapping.value)) ||
95101
/^[!&*]/.test(mapping.value)
96102
) {
97103
return false
@@ -103,7 +109,7 @@ function conservativeYamlBlock(lines) {
103109
return true
104110
}
105111

106-
function historicalWorkflowIsLowPrivilege(source) {
112+
export function historicalWorkflowIsLowPrivilege(source) {
107113
const lines = activeYamlLines(source)
108114
if (
109115
lines.some(
@@ -116,6 +122,14 @@ function historicalWorkflowIsLowPrivilege(source) {
116122
) {
117123
return false
118124
}
125+
if (
126+
!conservativeYamlBlock(lines, {
127+
rejectFlowValues: false,
128+
rejectPermissionKeys: false,
129+
})
130+
) {
131+
return false
132+
}
119133
const mappings = lines.map((line, index) => ({
120134
index,
121135
mapping: yamlMapping(line),
@@ -249,7 +263,6 @@ async function validateLoopMode({
249263
'scripts/lib/trusted-control-plane.mjs',
250264
'scripts/github-command-gate.mjs',
251265
'scripts/publish-review.mjs',
252-
'scripts/validate-historical-target.mjs',
253266
'scripts/identity-bin/gh',
254267
'scripts/identity-bin/git',
255268
'scripts/lib/issue-claim.mjs',
@@ -478,13 +491,14 @@ async function validateLoopMode({
478491
}
479492

480493
export function validateLoop(options = {}) {
481-
return validateLoopMode({ ...options, targetCompatibility: false })
482-
}
483-
484-
export function validateHistoricalTarget(options = {}) {
485-
return validateLoopMode({
486-
...options,
487-
activation: false,
488-
targetCompatibility: true,
489-
})
494+
const { historicalCapability, ...validatedOptions } = options
495+
if (historicalCapability) {
496+
consumeHistoricalValidationCapability(historicalCapability)
497+
return validateLoopMode({
498+
...validatedOptions,
499+
activation: false,
500+
targetCompatibility: true,
501+
})
502+
}
503+
return validateLoopMode({ ...validatedOptions, targetCompatibility: false })
490504
}

0 commit comments

Comments
 (0)