From 71f967d9775af7330b46b757cb8b584cff46ed5a Mon Sep 17 00:00:00 2001 From: kjgbot Date: Tue, 8 Sep 2026 11:04:38 +0200 Subject: [PATCH 01/15] feat(drive-local): pick any backlog item, not the one it was written for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The local drive flow could only ever execute BACKLOG F8b. Its selector hardcoded one file, one old identifier and one new one, and asserted that BACKLOG still contained that exact entry. It proved a relayflow can drive a real change on this checkout with no Cloud admission, no Daytona and no Relaycast workspace — but every later tick needed a human to rewrite the script first. A loop that needs editing between iterations is not a loop. Two changes make it general. Selection now comes from the SDK's backlog picker (gate 3, PR #20) — the same rule the cloud drive uses: first top-level bullet with a bold title, validated for a title, files in scope and a definition of done. Using it rather than a second implementation means the local and cloud loops cannot drift about what "next" means. The script refuses an underspecified package instead of handing an agent something it cannot tell it has finished. Implementation is now an agent step. A deterministic step can only make mechanical changes, and most backlog entries are not mechanical; that limit, not the selector, is what really pinned the old flow to a rename. The agent is told to stay inside the declared scope, to change nothing if the package is already done or its premise is false, and that reporting "already done" is a good tick while inventing an edit to look busy is not. Verified end to end: the flow compiles under the 0.1.0 SDK (5 steps, one of type agent), and `select` run against the real ops/BACKLOG.md picks "`timeoutMs` is enforced LATE, not never", writes the package with its files, definition of done and a pinned HEAD. Two things running it taught me, both now encoded: an agent step cannot declare `timeoutMs` (0.1.0 bounds deterministic steps only), and `selectBacklogEntry` is not re-exported from the SDK index — only `dist/backlog-picker.js` has all four functions. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR --- ops/local-work-package.mjs | 158 ++++++++++++++++++++++++------------- workflows/drive-local.yaml | 89 +++++++++++++++++---- 2 files changed, 173 insertions(+), 74 deletions(-) diff --git a/ops/local-work-package.mjs b/ops/local-work-package.mjs index 00affa790..010bb2b4a 100644 --- a/ops/local-work-package.mjs +++ b/ops/local-work-package.mjs @@ -1,71 +1,115 @@ -// The smallest useful local drive package: a mechanical change from BACKLOG. +// Select one work package from ops/BACKLOG.md for a local drive tick. +// +// This used to hardcode a single item. The constants at the top named one file, +// one old identifier and one new one, and `select` asserted that BACKLOG still +// contained that exact entry. It proved a relayflow could drive a real change on +// this checkout, which was the point at the time, but it could only ever drive +// that one change — every later tick needed a human to rewrite the script first. +// +// Selection now comes from the SDK's backlog picker (gate 3, PR #20), which is +// the same rule the cloud drive uses: the first top-level bullet whose title is +// bold, validated for a title, files in scope, and a definition of done. Using +// it here rather than a second implementation means the local loop and the cloud +// loop cannot drift into disagreeing about what "the next work package" is. +// +// Implementation is no longer this script's job. A mechanical rewrite is the +// only kind of change a deterministic step can make, and most backlog entries +// are not mechanical. The flow now hands the package to an agent step, which is +// what makes the loop general. import assert from 'node:assert/strict'; import { execFileSync } from 'node:child_process'; -import { createHash } from 'node:crypto'; -import { closeSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { createHash, randomUUID } from 'node:crypto'; +import { + closeSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, writeFileSync, +} from 'node:fs'; import { dirname } from 'node:path'; -import { randomUUID } from 'node:crypto'; -const target = 'packages/sdk/src/compile.ts'; const packagePath = '.relayflow/drive-local/package.json'; -const oldName = 'validateKernelRetry'; -const newName = 'validateAuthoringRetryDefaults'; -const hash = text => createHash('sha256').update(text).digest('hex'); -const read = path => readFileSync(path, 'utf8'); -const git = (...args) => execFileSync('git', args, { encoding: 'utf8' }).trim(); +const backlogPath = 'ops/BACKLOG.md'; +// The picker's own module, not the package index: index.js re-exports +// packageFromEntry and validateWorkPackage but NOT selectBacklogEntry or +// renderWorkPackage, so importing the index gets you two of the four. +const sdkEntry = new URL('../packages/sdk/dist/backlog-picker.js', import.meta.url); + +const read = (p) => readFileSync(p, 'utf8'); +const hash = (t) => createHash('sha256').update(t).digest('hex'); +const git = (...a) => execFileSync('git', a, { encoding: 'utf8' }).trim(); // A killed writer leaves the destination wholly old or wholly new. Flush the // replacement before rename and the containing directory before reporting it. function writeAtomically(path, contents) { const temporary = `${path}.${randomUUID()}.tmp`; - try { - const mode = path === target ? statSync(path).mode & 0o777 : 0o600; - writeFileSync(temporary, contents, { flag: 'wx', mode, flush: true }); - renameSync(temporary, path); - const directory = openSync(dirname(path), 'r'); - try { fsyncSync(directory); } finally { closeSync(directory); } - } finally { rmSync(temporary, { force: true }); } + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(temporary, contents, { mode: 0o600 }); + const handle = openSync(temporary, 'r'); + try { fsyncSync(handle); } finally { closeSync(handle); } + renameSync(temporary, path); + const directory = openSync(dirname(path), 'r'); + try { fsyncSync(directory); } finally { closeSync(directory); } } -switch (process.argv[2]) { - case 'select': { - const branch = git('branch', '--show-current'); - assert(branch && branch !== 'main', 'LOCAL_DRIVE_REFUSED: use a work branch'); - assert.equal(git('status', '--porcelain', '--', target), '', 'LOCAL_DRIVE_REFUSED: target has uncommitted edits'); - const entry = read('ops/BACKLOG.md').match(/^- \*\*F8b\*\*[^\n]*(?:\n [^\n]*)*/m)?.[0]; - assert(entry?.includes(oldName), 'BACKLOG_F8B_MISSING: expected the recorded work item'); - const source = read(target); - assert.equal(source.split(oldName).length - 1, 2, 'PACKAGE_ALREADY_APPLIED_OR_CHANGED: expected declaration and call'); - const work = { id: 'F8b', entry, branch, target, before: hash(source), after: hash(source.replaceAll(oldName, newName)) }; - mkdirSync('.relayflow/drive-local', { recursive: true }); - writeAtomically(packagePath, JSON.stringify(work, null, 2) + '\n'); - assert.equal(JSON.parse(read(packagePath)).before, work.before); - console.log(JSON.stringify(work)); - break; - } - case 'apply': { - const work = JSON.parse(read(packagePath)); - assert.equal(git('branch', '--show-current'), work.branch, 'work branch changed'); - const before = read(target); - // A retry after an interrupted write can observe the exact intended end state. - if (hash(before) === work.after) { console.log('PACKAGE_ALREADY_APPLIED: F8b'); break; } - assert.equal(hash(before), work.before, 'TARGET_CHANGED: refusing to overwrite intervening work'); - const after = before.replaceAll(oldName, newName); - assert.notEqual(after, before); - writeAtomically(target, after); - assert.equal(hash(read(target)), work.after, 'MUTATION_NOT_PERSISTED'); - console.log(`PACKAGE_APPLIED: F8b ${work.before} -> ${work.after}`); - console.log(git('diff', '--', target)); - break; - } - case 'report': { - const work = JSON.parse(read(packagePath)); - assert.equal(hash(read(target)), work.after, 'TARGET_CHANGED: expected the applied package'); - const diff = git('diff', '--', target); - assert(diff.includes(`+function ${newName}(`), 'PACKAGE_DIFF_MISSING'); - console.log('PACKAGE_EXECUTED: F8b; delivery requires a branch commit and human-reviewed PR.'); - console.log(diff); - break; +async function loadPicker() { + try { + return await import(sdkEntry.href); + } catch (cause) { + // Say which build is missing rather than surfacing a bare module error. + // The flow builds the SDK before this step; a failure here means that + // step did not run or did not finish. + throw new Error( + `SDK_NOT_BUILT: ${sdkEntry.pathname} is not importable — run the build step first`, + { cause }, + ); } - default: throw new Error('Usage: node ops/local-work-package.mjs '); +} + +async function select() { + const { selectBacklogEntry, packageFromEntry, validateWorkPackage, renderWorkPackage } = + await loadPicker(); + const markdown = read(backlogPath); + const entry = selectBacklogEntry(markdown); + assert(entry, `BACKLOG_EMPTY: no selectable entry in ${backlogPath}`); + + const candidate = packageFromEntry(entry); + const validation = validateWorkPackage(candidate); + // Refuse rather than hand an agent an underspecified package. A tick that + // starts without a definition of done cannot tell whether it finished. + assert( + validation.accepted, + `WORK_PACKAGE_REJECTED: ${validation.accepted ? '' : validation.reason} — ${entry.title}`, + ); + + const pkg = { + selectedAt: new Date().toISOString(), + branch: git('branch', '--show-current'), + head: git('rev-parse', 'HEAD'), + backlogSha256: hash(markdown), + title: validation.work.title, + filesInScope: validation.work.files_in_scope, + definitionOfDone: validation.work.definition_of_done, + brief: renderWorkPackage(entry), + }; + writeAtomically(packagePath, `${JSON.stringify(pkg, null, 2)}\n`); + console.log(`SELECTED ${pkg.title}`); + console.log(` files in scope: ${pkg.filesInScope.join(', ')}`); + console.log(` definition of done: ${pkg.definitionOfDone.length} item(s)`); +} + +function report() { + const pkg = JSON.parse(read(packagePath)); + // The package pins the HEAD it was selected against. Reporting a diff from a + // different commit would describe work this tick did not do. + const head = git('rev-parse', 'HEAD'); + assert.equal(head, pkg.head, `HEAD_MOVED: selected at ${pkg.head}, now ${head}`); + const stat = git('diff', '--stat'); + console.log(`REPORT ${pkg.title}`); + console.log(stat || ' (no working-tree changes)'); + for (const item of pkg.definitionOfDone) console.log(` DoD: ${item}`); +} + +const command = process.argv[2]; +if (command === 'select') await select(); +else if (command === 'report') report(); +else { + console.error('usage: local-work-package.mjs '); + process.exit(2); } diff --git a/workflows/drive-local.yaml b/workflows/drive-local.yaml index 9760c43c2..1faecca31 100644 --- a/workflows/drive-local.yaml +++ b/workflows/drive-local.yaml @@ -1,28 +1,83 @@ -# A bounded, deterministic drive tick: execute backlog F8b on this checkout. +# A local drive tick: pick the next backlog item, do it, verify it, report. +# +# This replaces a flow that could only ever execute BACKLOG F8b. That version +# proved a relayflow can drive a real change on this checkout with no Cloud +# admission, no Daytona and no Relaycast workspace — but its selector hardcoded +# one file and one identifier, so every subsequent tick needed a human to edit +# the script before the loop could run again. A loop that needs editing between +# iterations is not a loop. +# +# Two changes make it general. Selection comes from the SDK's backlog picker +# (gate 3), the same rule the cloud drive uses, so the two cannot drift about +# what "next" means. And implementation is an agent step rather than a +# deterministic rewrite, because a deterministic step can only make mechanical +# changes and most backlog entries are not mechanical. +# # Run: node scripts/run-local-workflow.mjs workflows/drive-local.yaml -# Selection, mutation, existing tests, and the resulting diff are journaled. -# Delivery is a branch/PR by the operator; this flow never merges or switches branches. +# Delivery stays with the operator: this flow never commits, never switches +# branches and never merges. It leaves a working tree and a report. version: '0.1.0' -name: drive-local-f8b -description: Correct the retry validator name to describe the authoring restriction (BACKLOG F8b). +name: drive-local +description: >- + Select the next work package from ops/BACKLOG.md, implement it, verify it + against its own definition of done, and report. Selection, implementation, + verification and reporting are journaled as four steps. steps: - - id: select-package + - id: build-sdk type: deterministic - command: node ops/local-work-package.mjs select - - id: implement-package - type: deterministic - dependsOn: [select-package] - command: node ops/local-work-package.mjs apply - - id: verify-package + timeoutMs: 600000 + # The selector imports the built picker. Building here rather than assuming + # a prior build means a cold checkout runs the same way as a warm one. + command: | + set -eu + npm_config_cache="$PWD/.relayflow/npm-cache" \ + npm --userconfig /tmp/empty-npmrc run build --prefix packages/sdk + + - id: select type: deterministic - dependsOn: [implement-package] + dependsOn: [build-sdk] timeoutMs: 120000 + command: node ops/local-work-package.mjs select + + - id: implement + type: agent + cli: claude + dependsOn: [select] + # No timeoutMs: 0.1.0 bounds deterministic steps only, so an agent step + # cannot declare one. The run-level bound is the operator's. + maxIterations: 1 + instruction: | + Read .relayflow/drive-local/package.json. It holds one work package + selected from ops/BACKLOG.md: a title, the files in scope, and a + definition of done. + + Implement exactly that package on this checkout. Stay inside the files it + names; if the work genuinely requires a file outside that list, say so in + your output rather than widening scope silently. + + Do not commit, do not switch branches, do not merge. The operator delivers + the diff. + + If the package cannot be implemented as written — it is already done, its + premise is false, or it is underspecified — say that plainly and change + nothing. A tick that reports "already done" is a good tick; a tick that + invents an edit to look busy is not. + verification: + type: output_contains + value: "DONE" + + - id: verify + type: deterministic + dependsOn: [implement] + timeoutMs: 900000 + # The suites, not the agent's own account of itself. command: | set -eu - npm_config_cache="$PWD/.relayflow/npm-cache" npm --userconfig /tmp/empty-npmrc run build --prefix packages/sdk cd packages/sdk - node node_modules/vitest/vitest.mjs run tests/spec-parity.test.ts tests/cli.test.ts - - id: report-package + node node_modules/vitest/vitest.mjs run + + - id: report type: deterministic - dependsOn: [verify-package] + dependsOn: [verify] + timeoutMs: 120000 command: node ops/local-work-package.mjs report From 3ad2cbc17fc166e0d5c43eecc686ca7cae369c9f Mon Sep 17 00:00:00 2001 From: kjgbot Date: Tue, 8 Sep 2026 11:07:02 +0200 Subject: [PATCH 02/15] fix(drive-local): skip work this loop cannot bound, do not stall on it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The picker emits ['.'] for files_in_scope when an entry references code but names no path. That is deliberate on its side — its own comment calls it "honest breadth" — and it is a fair description of the entry. It is not usable as scope for an agent: "." is the whole repository, and an agent told its scope is everything has been told nothing. First attempt refused the tick outright when the selected entry was unbounded. That failed closed, which was right, but the current BACKLOG's first selectable entry is unbounded — so the loop would have refused on every run forever. A loop that never runs is not safer than one that runs on bounded work. Selection now walks past entries it cannot bound and reports each skip with its reason. "Next" is still the picker's definition: rather than write a second parser that could disagree with it about what an entry is, the rejected entry's title is cut from the markdown and the picker is asked again. Verified against the real ops/BACKLOG.md: skips the unbounded `timeoutMs` entry and selects "GATES 2 AND 3 ARE BLOCKED ON A MISSING COMPONENT: there is no agent worker" with four concrete files in scope. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR --- ops/local-work-package.mjs | 53 +++++++++++++++++++++++++++++++------- 1 file changed, 43 insertions(+), 10 deletions(-) diff --git a/ops/local-work-package.mjs b/ops/local-work-package.mjs index 010bb2b4a..5ac2ac35f 100644 --- a/ops/local-work-package.mjs +++ b/ops/local-work-package.mjs @@ -65,24 +65,57 @@ async function loadPicker() { async function select() { const { selectBacklogEntry, packageFromEntry, validateWorkPackage, renderWorkPackage } = await loadPicker(); - const markdown = read(backlogPath); - const entry = selectBacklogEntry(markdown); - assert(entry, `BACKLOG_EMPTY: no selectable entry in ${backlogPath}`); + let markdown = read(backlogPath); + const backlogSha256 = hash(markdown); + const skipped = []; + let entry = null; + let validation = null; - const candidate = packageFromEntry(entry); - const validation = validateWorkPackage(candidate); - // Refuse rather than hand an agent an underspecified package. A tick that - // starts without a definition of done cannot tell whether it finished. + // Take the first entry this loop can actually bound. The picker returns one + // entry -- the first top-level bullet with a bold title -- so "next" is found + // by removing the one just rejected and asking it again, rather than writing + // a second parser that could disagree with it about what an entry is. + for (let guard = 0; guard < 50; guard += 1) { + const candidateEntry = selectBacklogEntry(markdown); + if (!candidateEntry) break; + + const candidate = packageFromEntry(candidateEntry); + const result = validateWorkPackage(candidate); + const scope = result.accepted ? result.work.files_in_scope : []; + + // The picker emits ['.'] when an entry references code but names no path. + // That is deliberate on its side -- its comment calls it "honest breadth" -- + // and it is a fair description of the entry. It is not usable as scope for + // an agent: "." is the whole repository, and an agent told its scope is + // everything has been told nothing. Skip rather than widen what an + // unattended tick may touch. + const unbounded = scope.length === 1 && scope[0] === '.'; + if (result.accepted && !unbounded) { + entry = candidateEntry; + validation = result; + break; + } + skipped.push({ + title: candidateEntry.title, + reason: result.accepted ? 'unbounded_scope' : result.reason, + }); + const at = markdown.indexOf(candidateEntry.title); + // Cut past this entry's title so the next exec finds the following bullet. + markdown = at === -1 ? '' : markdown.slice(at + candidateEntry.title.length); + } + + for (const s of skipped) console.log(`SKIPPED [${s.reason}] ${s.title.slice(0, 90)}`); assert( - validation.accepted, - `WORK_PACKAGE_REJECTED: ${validation.accepted ? '' : validation.reason} — ${entry.title}`, + entry && validation?.accepted, + `NO_BOUNDED_WORK: ${skipped.length} entr(y|ies) considered, none named files ` + + `this loop can scope. Add explicit paths to a BACKLOG entry.`, ); const pkg = { selectedAt: new Date().toISOString(), branch: git('branch', '--show-current'), head: git('rev-parse', 'HEAD'), - backlogSha256: hash(markdown), + backlogSha256, title: validation.work.title, filesInScope: validation.work.files_in_scope, definitionOfDone: validation.work.definition_of_done, From 5a1dabb3e895a9a27db57ef6ee3f4f2791b79687 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Tue, 8 Sep 2026 11:10:38 +0200 Subject: [PATCH 03/15] fix(drive-local): a path that no longer exists is not scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backlog entries outlive the tree they were written against. This repo moved `sdk/` to `packages/sdk/`, so an entry naming `sdk/src/protocol.ts` still reads as precise while pointing at nothing. An agent handed four missing files will either invent work or widen scope until it finds something, and the flow's own instruction forbids both. `select` now checks that every declared path exists and skips entries whose scope has rotted, naming the missing files in the skip line. A rotted entry can no longer silently become an agent's instruction. This is deliberately the guard rather than a backlog cleanup. Repairing the entries by hand is a one-time fix that rots again at the next reorg — the sdk/ move already proves that. With the guard in place the skip output IS the worklist, with the exact missing paths named, so the cleanup becomes generated rather than audited. What it reports against the current BACKLOG: 12 entries skipped — 5 unbounded, 5 with no scope at all, 1 with no definition of done, and 1 stale (sdk/tests/live-kernel.test.ts, sdk/src/protocol.ts, sdk/src/journal-client.ts, sdk/src/cli/run.ts). Two of the skipped entries are titled "DONE (PR #45, merged)" and "DONE (PR #42, merged)" and are still sitting in the backlog. It then selects real bounded work: "Regression suite (`regressions/`, dormant)" scoped to regressions/MANIFEST.json, which exists. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR --- ops/local-work-package.mjs | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/ops/local-work-package.mjs b/ops/local-work-package.mjs index 5ac2ac35f..1838ad93b 100644 --- a/ops/local-work-package.mjs +++ b/ops/local-work-package.mjs @@ -20,7 +20,7 @@ import assert from 'node:assert/strict'; import { execFileSync } from 'node:child_process'; import { createHash, randomUUID } from 'node:crypto'; import { - closeSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, writeFileSync, + closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, writeFileSync, } from 'node:fs'; import { dirname } from 'node:path'; @@ -90,14 +90,29 @@ async function select() { // everything has been told nothing. Skip rather than widen what an // unattended tick may touch. const unbounded = scope.length === 1 && scope[0] === '.'; - if (result.accepted && !unbounded) { + + // A path that no longer exists is not scope either. Backlog entries outlive + // the tree they were written against -- this repo moved `sdk/` to + // `packages/sdk/`, so entries naming `sdk/src/protocol.ts` still read as + // precise while pointing at nothing. An agent handed four missing files + // will either invent work or widen scope to find something, and both are + // failures the flow's instruction explicitly forbids. Skipping here means a + // rotted entry can never silently become an agent's instruction, and the + // skip line names the missing paths so the entry can be repaired. + const missing = unbounded ? [] : scope.filter((path) => !existsSync(path)); + + if (result.accepted && !unbounded && missing.length === 0) { entry = candidateEntry; validation = result; break; } skipped.push({ title: candidateEntry.title, - reason: result.accepted ? 'unbounded_scope' : result.reason, + reason: !result.accepted + ? result.reason + : unbounded + ? 'unbounded_scope' + : `stale_scope: ${missing.join(', ')}`, }); const at = markdown.indexOf(candidateEntry.title); // Cut past this entry's title so the next exec finds the following bullet. From 52db46cee7c7d9d577cae92b902f5a99b4fd4d7e Mon Sep 17 00:00:00 2001 From: kjgbot Date: Tue, 8 Sep 2026 11:25:21 +0200 Subject: [PATCH 04/15] =?UTF-8?q?fix(drive-local):=20the=20flow=20could=20?= =?UTF-8?q?never=20run=20=E2=80=94=20pin=20a=20stream,=20drop=20a=20dead?= =?UTF-8?q?=20step?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three review findings, all confirmed by running the launcher rather than reading it. I had only ever compiled this flow, and compiling proves the spec is legal, not that the runner will accept it. LOCAL_AGENT_PINS_REQUIRED. The launcher refuses any agent step that declares no stream — "the kernel refuses workers with no pins" — and the refusal happens before a run is created. So every invocation of this flow failed immediately, and I had described it as safe to run. The agent step now pins a stream. The build-sdk step was dead code. The launcher asserts packages/sdk/dist/cli.js exists during preflight, before it submits anything, so a build step inside the flow can never run on the cold checkout it was meant to serve. Removed, with the prerequisite documented where an operator will see it. The output gate demanded a marker the instruction never requested: verification gates on `DONE` and nothing told the agent to emit it, so a correct implementation would have been recorded as a failure. The instruction now states the contract. Verified by re-running: LOCAL_AGENT_PINS_REQUIRED is gone. Execution then stops on environment rather than on the flow — a built relayflowd, and a working directory short enough for a unix socket path (LOCAL_SOCKET_PATH_TOO_LONG from this scratchpad). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR --- workflows/drive-local.yaml | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/workflows/drive-local.yaml b/workflows/drive-local.yaml index 1faecca31..bd8ba36b3 100644 --- a/workflows/drive-local.yaml +++ b/workflows/drive-local.yaml @@ -23,19 +23,13 @@ description: >- against its own definition of done, and report. Selection, implementation, verification and reporting are journaled as four steps. steps: - - id: build-sdk - type: deterministic - timeoutMs: 600000 - # The selector imports the built picker. Building here rather than assuming - # a prior build means a cold checkout runs the same way as a warm one. - command: | - set -eu - npm_config_cache="$PWD/.relayflow/npm-cache" \ - npm --userconfig /tmp/empty-npmrc run build --prefix packages/sdk - + # No build-sdk step. The launcher asserts `packages/sdk/dist/cli.js` exists + # during preflight, before it submits anything, so a build step inside the + # flow can never run on the cold checkout it was meant to serve. Building the + # SDK is a prerequisite of invoking this flow, not part of it: + # npm --prefix packages/sdk run build - id: select type: deterministic - dependsOn: [build-sdk] timeoutMs: 120000 command: node ops/local-work-package.mjs select @@ -43,6 +37,12 @@ steps: type: agent cli: claude dependsOn: [select] + # Required, not decorative: the launcher refuses an agent step with no pins + # (`LOCAL_AGENT_PINS_REQUIRED: declare a stream; the kernel refuses workers + # with no pins`) and the run is never created. Verified by running it. + surfaces: + streams: + - stream: drive-local # No timeoutMs: 0.1.0 bounds deterministic steps only, so an agent step # cannot declare one. The run-level bound is the operator's. maxIterations: 1 @@ -58,6 +58,10 @@ steps: Do not commit, do not switch branches, do not merge. The operator delivers the diff. + End your final message with the single uppercase word DONE on its own + line. The verification below gates on that exact marker, so a correct + implementation that omits it is recorded as a failure. + If the package cannot be implemented as written — it is already done, its premise is false, or it is underspecified — say that plainly and change nothing. A tick that reports "already done" is a good tick; a tick that From 62420492c3f17f50d404ebfd00aa10a904985754 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Tue, 8 Sep 2026 11:49:32 +0200 Subject: [PATCH 05/15] fix(drive-local): enforce package scope and executable acceptance checks Choose explicit Verify JSON argv declarations in backlog entries instead of translating prose or inferring correctness from the SDK suite. Selection skips packages without executable checks; verification executes every selected check before the regression suite. Add an acceptance assertion to the existing F8b entry. Capture a scope step in the submitted flow that refuses changed verifier code before loading helpers, then checks staged, unstaged, and untracked paths against the selected scope. Reconstruct package metadata from the unchanged backlog and reject tampering, symlinks, and file-to-directory scope widening. Advance skipped entries past their matched bullet line rather than searching for a title mention. Evidence: ops/runtime-evidence/drive-hardening-0908.txt contains literal commands and output for 26 local/launcher tests, 803 SDK passes (3 skipped), the pre-existing obsolete test failure, and the F3 cursor mutation failure/pass. --- ops/BACKLOG.md | 3 + ops/DRIVE-LOCAL.md | 45 ++ ops/drive-local-flow.test.mjs | 51 +++ ops/local-work-package.mjs | 85 +++- ops/local-work-package.test.mjs | 160 +++++-- ops/local-work-test-fixture.mjs | 45 ++ ops/local-work-verification.mjs | 76 +++ ops/runtime-evidence/drive-hardening-0908.txt | 431 ++++++++++++++++++ workflows/drive-local.yaml | 19 +- 9 files changed, 851 insertions(+), 64 deletions(-) create mode 100644 ops/DRIVE-LOCAL.md create mode 100644 ops/drive-local-flow.test.mjs create mode 100644 ops/local-work-test-fixture.mjs create mode 100644 ops/local-work-verification.mjs create mode 100644 ops/runtime-evidence/drive-hardening-0908.txt diff --git a/ops/BACKLOG.md b/ops/BACKLOG.md index 1a285abb1..e70f11b6b 100644 --- a/ops/BACKLOG.md +++ b/ops/BACKLOG.md @@ -238,6 +238,9 @@ complete. Recorded here so they are tracked rather than lost — found by tick message. - **F8b** — `validateKernelRetry` names an authoring rule as if the kernel imposed it — a doc claim the kernel does not make. + Scope: `packages/sdk/src/compile.ts`. Use `validateAuthoringRetryDefaults` + for the authoring validator and its call site; remove the old identifier. + Verify: ["node", "--input-type=module", "-e", "import assert from 'node:assert/strict'; import {readFileSync} from 'node:fs'; const source = readFileSync('packages/sdk/src/compile.ts', 'utf8'); assert(!source.includes('validateKernelRetry')); assert(source.includes('function validateAuthoringRetryDefaults(')); assert(source.includes('validateAuthoringRetryDefaults(step['));"] - **F9** — `probeTrigger` catches every error with no classification, so a broken probe environment is indistinguishable from a bad trigger. - **F10** — small load-bearing boundary details a reader will trip over. diff --git a/ops/DRIVE-LOCAL.md b/ops/DRIVE-LOCAL.md new file mode 100644 index 000000000..ea3a582f9 --- /dev/null +++ b/ops/DRIVE-LOCAL.md @@ -0,0 +1,45 @@ +# Local drive packages + +Build the SDK before launching `workflows/drive-local.yaml`: + +```sh +npm --prefix packages/sdk run build +node scripts/run-local-workflow.mjs workflows/drive-local.yaml +``` + +Run on the branch that should receive the diff, from a clean checkout. The +flow leaves delivery to the operator. It does not commit or merge. + +Selection uses the SDK backlog picker. A locally executable entry must name +existing repository paths in backticks and declare at least one acceptance +command on an indented `Verify:` line. Each command is a JSON argv array, +executed at the repository root with a two-minute bound. For example: + +```text +- **Fix the value** Update `src/value.txt` to contain exactly "fixed". + Verify: ["node", "-e", "require('node:assert/strict').equal(require('node:fs').readFileSync('src/value.txt','utf8'),'fixed')"] +``` + +Multiple `Verify:` lines mean all commands must pass. Commands come from the +backlog before implementation; the agent cannot substitute its own acceptance +checks in package.json. Entries with no executable checks are skipped with +`missing_executable_checks`, alongside the existing unbounded/stale scope +reasons. The old F8b entry now carries a source assertion for its declared +rename. That assertion also allows an already-completed package to pass +without manufacturing another edit. + +The submitted scope step first refuses changes to its helper scripts and +backlog before executing either helper. It then checks the working tree and +index against the selected HEAD, including untracked non-ignored files and +both sides of renames. Scope uses exact paths or directory descendants, never +string-prefix siblings. Backlog, verifier and gate changes fail even when a +package names a containing directory. Symlink changes are refused. Ignored +build/runtime artifacts are excluded from this Git diff boundary; it is not an +OS filesystem sandbox or protection against a process rewriting Git metadata. + +Verification reconstructs the selected work from the unchanged backlog and +compares its scope and commands to package.json. Each package check must pass +before the SDK regression suite runs. An unchanged implementation whose DoD +is unmet fails. HEAD/branch changes fail, and out-of-scope changes produced by +an acceptance command fail too. A failed scope or verification step prevents +the dependent report step from running. diff --git a/ops/drive-local-flow.test.mjs b/ops/drive-local-flow.test.mjs new file mode 100644 index 000000000..2a427a049 --- /dev/null +++ b/ops/drive-local-flow.test.mjs @@ -0,0 +1,51 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { chmodSync, existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import test from 'node:test'; +import { fixture, flow } from './local-work-test-fixture.mjs'; + +const quote = text => "'" + text.replaceAll("'", "'\\''") + "'"; +for (const scenario of ['outside edit', 'verifier edit', 'unchanged package']) { + test(`drive-local journals failure and blocks reporting for ${scenario}`, t => { + const f = fixture(t); + const wrapper = resolve('testdata/preflight/wrapper-session.mjs'); + const cli = join(f.root, '.relayflow/agent.mjs'); + f.put('.relayflow/agent.mjs', `#!/usr/bin/env node +import { receiveWrapperRequest } from ${JSON.stringify(wrapper)}; +import { writeFileSync } from 'node:fs'; +const request = await receiveWrapperRequest(); +if (request) { + ${scenario === 'outside edit' ? `writeFileSync(${JSON.stringify(join(f.root, 'outside.txt'))}, 'changed');` : ''} + ${scenario === 'verifier edit' ? `writeFileSync(${JSON.stringify(join(f.root, 'ops/local-work-package.mjs'))}, 'process.exit(0)');` : ''} + console.log('DONE'); +} +`); + // A scripted worker controls the edit while using the real worker protocol, + // stream pin, submitted flow commands, daemon and journal. + chmodSync(cli, 0o755); + const spec = structuredClone(flow); + for (const step of spec.steps) { + if (step.type === 'agent') step.cli = cli; + else step.command = `cd ${quote(f.root)}\n${step.command}`; + } + const path = join(f.root, '.relayflow/flow.json'); + writeFileSync(path, JSON.stringify(spec)); + const result = spawnSync(process.execPath, ['scripts/run-local-workflow.mjs', path], { + encoding: 'utf8', timeout: 20000, + }); + assert.equal(result.error, undefined, result.stderr); + assert.equal(result.status, 1, result.stderr + result.stdout); + const dataDir = result.stdout.match(/^LOCAL_DATA_DIR=(.+)$/m)?.[1]; + assert(dataDir, result.stderr); + t.after(() => rmSync(dataDir, { recursive: true, force: true })); + const journal = readFileSync(join(dataDir, 'journal.jsonl'), 'utf8').trim().split('\n').map(JSON.parse); + const failedStep = scenario === 'unchanged package' ? 'verify' : 'scope'; + const completion = journal.find(e => e.entry_type === 'step.completed' && e.step_id === failedStep); + assert.equal(completion?.payload.verification.verdict, 'fail', JSON.stringify(journal)); + assert.equal(completion.payload.completionReason, 'retries_exhausted'); + assert.equal(completion.payload.verification.detail, 'exit code was 1'); + assert(!journal.some(e => e.entry_type === 'step.attempt.started' && e.step_id === 'report')); + assert(!existsSync(join(dataDir, 'relayflowd.sock'))); + }); +} diff --git a/ops/local-work-package.mjs b/ops/local-work-package.mjs index 1838ad93b..b297f6c7a 100644 --- a/ops/local-work-package.mjs +++ b/ops/local-work-package.mjs @@ -17,12 +17,13 @@ // are not mechanical. The flow now hands the package to an agent step, which is // what makes the loop general. import assert from 'node:assert/strict'; -import { execFileSync } from 'node:child_process'; +import { execFileSync, spawnSync } from 'node:child_process'; import { createHash, randomUUID } from 'node:crypto'; import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, writeFileSync, } from 'node:fs'; import { dirname } from 'node:path'; +import { checkScope, runChecks, verificationCommands } from './local-work-verification.mjs'; const packagePath = '.relayflow/drive-local/package.json'; const backlogPath = 'ops/BACKLOG.md'; @@ -53,8 +54,7 @@ async function loadPicker() { return await import(sdkEntry.href); } catch (cause) { // Say which build is missing rather than surfacing a bare module error. - // The flow builds the SDK before this step; a failure here means that - // step did not run or did not finish. + // Building the SDK is a launcher prerequisite, not a step in this flow. throw new Error( `SDK_NOT_BUILT: ${sdkEntry.pathname} is not importable — run the build step first`, { cause }, @@ -62,11 +62,9 @@ async function loadPicker() { } } -async function select() { +async function choose(markdown, { pathExists = existsSync, log = true } = {}) { const { selectBacklogEntry, packageFromEntry, validateWorkPackage, renderWorkPackage } = await loadPicker(); - let markdown = read(backlogPath); - const backlogSha256 = hash(markdown); const skipped = []; let entry = null; let validation = null; @@ -75,7 +73,8 @@ async function select() { // entry -- the first top-level bullet with a bold title -- so "next" is found // by removing the one just rejected and asking it again, rather than writing // a second parser that could disagree with it about what an entry is. - for (let guard = 0; guard < 50; guard += 1) { + let commands; + while (markdown.length > 0) { const candidateEntry = selectBacklogEntry(markdown); if (!candidateEntry) break; @@ -99,11 +98,13 @@ async function select() { // failures the flow's instruction explicitly forbids. Skipping here means a // rotted entry can never silently become an agent's instruction, and the // skip line names the missing paths so the entry can be repaired. - const missing = unbounded ? [] : scope.filter((path) => !existsSync(path)); + const missing = unbounded ? [] : scope.filter((path) => !pathExists(path)); + const checks = verificationCommands(candidateEntry.body); - if (result.accepted && !unbounded && missing.length === 0) { + if (result.accepted && !unbounded && missing.length === 0 && checks.length > 0) { entry = candidateEntry; validation = result; + commands = checks; break; } skipped.push({ @@ -112,36 +113,72 @@ async function select() { ? result.reason : unbounded ? 'unbounded_scope' - : `stale_scope: ${missing.join(', ')}`, + : missing.length > 0 + ? `stale_scope: ${missing.join(', ')}` + : 'missing_executable_checks', }); - const at = markdown.indexOf(candidateEntry.title); - // Cut past this entry's title so the next exec finds the following bullet. - markdown = at === -1 ? '' : markdown.slice(at + candidateEntry.title.length); + // Locate the bullet the SDK matched, never a mention of its title in an + // earlier entry's body. Consuming the full line guarantees forward progress. + const lines = markdown.split('\n'); + const at = lines.findIndex(line => line.startsWith(`- **${candidateEntry.title}**`)); + assert(at >= 0, 'BACKLOG_CURSOR_LOST'); + markdown = lines.slice(at + 1).join('\n'); } - for (const s of skipped) console.log(`SKIPPED [${s.reason}] ${s.title.slice(0, 90)}`); + if (log) for (const s of skipped) console.log(`SKIPPED [${s.reason}] ${s.title.slice(0, 90)}`); assert( entry && validation?.accepted, `NO_BOUNDED_WORK: ${skipped.length} entr(y|ies) considered, none named files ` + - `this loop can scope. Add explicit paths to a BACKLOG entry.`, + `this loop can scope and verify. Add explicit paths and Verify: JSON argv to a BACKLOG entry.`, ); - const pkg = { - selectedAt: new Date().toISOString(), - branch: git('branch', '--show-current'), - head: git('rev-parse', 'HEAD'), - backlogSha256, + return { title: validation.work.title, filesInScope: validation.work.files_in_scope, definitionOfDone: validation.work.definition_of_done, + verificationCommands: commands, brief: renderWorkPackage(entry), }; +} + +async function select() { + const markdown = read(backlogPath); + const work = await choose(markdown); + const pkg = { + selectedAt: new Date().toISOString(), + branch: git('branch', '--show-current'), + head: git('rev-parse', 'HEAD'), + backlogSha256: hash(markdown), + ...work, + }; writeAtomically(packagePath, `${JSON.stringify(pkg, null, 2)}\n`); console.log(`SELECTED ${pkg.title}`); console.log(` files in scope: ${pkg.filesInScope.join(', ')}`); console.log(` definition of done: ${pkg.definitionOfDone.length} item(s)`); } +async function verifiedPackage() { + const pkg = JSON.parse(read(packagePath)); + assert.equal(git('rev-parse', 'HEAD'), pkg.head, 'HEAD_MOVED'); + assert.equal(git('branch', '--show-current'), pkg.branch, 'BRANCH_MOVED'); + const markdown = read(backlogPath); + assert.equal(hash(markdown), pkg.backlogSha256, 'BACKLOG_CHANGED'); + // Reconstruct from the unchanged backlog, so editing ignored package.json + // cannot widen scope or replace acceptance commands with `true`. + const selected = await choose(markdown, { + log: false, + // Consult the selected commit so deleting an in-scope file neither shifts + // selection nor resurrects an earlier entry with stale paths. + pathExists: path => spawnSync('git', ['cat-file', '-e', `${pkg.head}:${path.replace(/\/$/, '')}`], + { stdio: 'ignore' }).status === 0, + }); + for (const key of Object.keys(selected)) { + assert.deepEqual(pkg[key], selected[key], `PACKAGE_CHANGED: ${key}`); + } + checkScope(pkg); + return pkg; +} + function report() { const pkg = JSON.parse(read(packagePath)); // The package pins the HEAD it was selected against. Reporting a diff from a @@ -157,7 +194,13 @@ function report() { const command = process.argv[2]; if (command === 'select') await select(); else if (command === 'report') report(); +else if (command === 'scope') await verifiedPackage(); +else if (command === 'verify') { + const pkg = await verifiedPackage(); + runChecks(pkg); + await verifiedPackage(); +} else { - console.error('usage: local-work-package.mjs '); + console.error('usage: local-work-package.mjs '); process.exit(2); } diff --git a/ops/local-work-package.test.mjs b/ops/local-work-package.test.mjs index 3aa83f6d2..498fbcd8b 100644 --- a/ops/local-work-package.test.mjs +++ b/ops/local-work-package.test.mjs @@ -1,53 +1,133 @@ import assert from 'node:assert/strict'; -import { spawnSync, execFileSync } from 'node:child_process'; -import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join, resolve } from 'node:path'; +import { readFileSync, writeFileSync, rmSync, symlinkSync } from 'node:fs'; +import { join } from 'node:path'; import test from 'node:test'; +import { entry, fixture, packagePath, pass, fail } from './local-work-test-fixture.mjs'; -const script = resolve('ops/local-work-package.mjs'); - -test('interrupted package write preserves the original and retry applies once', t => { - const root = mkdtempSync(join(tmpdir(), 'local-package-')); - t.after(() => rmSync(root, { recursive: true, force: true })); - const git = (...args) => execFileSync('git', args, { cwd: root, stdio: 'pipe' }); - git('init', '-q', '-b', 'work'); - mkdirSync(join(root, 'packages/sdk/src'), { recursive: true }); - mkdirSync(join(root, 'ops')); - const target = join(root, 'packages/sdk/src/compile.ts'); - const original = 'function validateKernelRetry() {}\nvalidateKernelRetry();\n'; - writeFileSync(target, original); - writeFileSync(join(root, 'ops/BACKLOG.md'), '- **F8b** — rename `validateKernelRetry`\n'); - git('add', '.'); - git('-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.test', - '-c', 'commit.gpgsign=false', 'commit', '-qm', 'fixture'); - const run = (operation, extra = []) => spawnSync(process.execPath, [...extra, script, operation], - { cwd: root, encoding: 'utf8', timeout: 5000 }); - const selected = run('select'); - assert.equal(selected.status, 0, selected.stderr); - // Intercept the real fs write in a separate process, write a partial prefix, - // then SIGKILL before rename. Never change the package implementation. - const hook = join(root, 'interrupt.cjs'); +const check = ['node', '-e', "require('node:assert/strict').equal(require('node:fs').readFileSync('src/value.txt','utf8'),'fixed')"]; + +test('skip cursor passes a later title mentioned in an earlier body', t => { + const f = fixture(t, '- **First** Update `symbol`; Later is mentioned here.\n' + + '- **Later** Update `anotherSymbol`.\n' + entry()); + const result = f.run('select'); + pass(result); + assert.equal((result.stdout.match(/SKIPPED/g) ?? []).length, 2); + assert.match(result.stdout, /SELECTED Fix value/); + pass(f.scope()); +}); + +test('selection skips missing checks and stale paths with reasons', t => { + const f = fixture(t, entry('No checks', 'src/value.txt', []) + entry('Stale', 'src/missing.txt') + entry()); + const result = f.run('select'); + pass(result); + assert.match(result.stdout, /SKIPPED \[missing_executable_checks\] No checks/); + assert.match(result.stdout, /SKIPPED \[stale_scope: src\/missing.txt\] Stale/); + assert.match(result.stdout, /SELECTED Fix value/); + pass(f.scope()); +}); + +test('a non-SDK package fails unchanged and passes only after its check holds', t => { + const f = fixture(t); + pass(f.run('select')); + fail(f.run('verify'), /PACKAGE_CHECK_FAILED/); + f.put('src/value.txt', 'fixed'); + pass(f.scope()); + pass(f.run('verify')); +}); + +test('multiple acceptance checks all execute and failures propagate', t => { + const f = fixture(t, entry('Two checks', 'src/value.txt', [check, ['node', '-e', 'process.exit(7)']])); + pass(f.run('select')); + f.put('src/value.txt', 'fixed'); + fail(f.run('verify'), /PACKAGE_CHECK_FAILED.*7/); +}); + +for (const kind of ['unstaged', 'staged', 'untracked', 'staged reversal', 'rename', 'deleted']) { + test(`scope refuses an outside ${kind} path`, t => { + const f = fixture(t); + pass(f.run('select')); + if (kind === 'rename') f.git('mv', 'outside.txt', 'src/renamed.txt'); + else if (kind === 'deleted') rmSync(join(f.root, 'outside.txt')); + else { + f.put(kind === 'untracked' ? 'new.txt' : 'outside.txt', 'changed'); + if (kind === 'staged' || kind === 'staged reversal') f.git('add', 'outside.txt'); + if (kind === 'staged reversal') f.put('outside.txt', 'original'); + } + fail(f.scope(), /OUT_OF_SCOPE/); + }); +} + +for (const path of ['ops/local-work-package.mjs', 'ops/local-work-verification.mjs', 'ops/BACKLOG.md']) { + test(`the submitted scope command refuses a changed ${path} before loading it`, t => { + const f = fixture(t); + pass(f.run('select')); + f.put(path, 'process.exit(0);\n'); + fail(f.scope()); + }); +} + +test('editing ignored package metadata cannot widen scope or replace checks', t => { + const f = fixture(t); + pass(f.run('select')); + const original = readFileSync(join(f.root, packagePath), 'utf8'); + for (const change of [{ filesInScope: ['.'] }, { verificationCommands: [['true']] }]) { + f.put(packagePath, JSON.stringify({ ...JSON.parse(original), ...change })); + fail(f.scope(), /PACKAGE_CHANGED/); + } +}); + +test('scope allows directory children but rejects a sibling with the same prefix', t => { + const f = fixture(t, entry('Directory', 'src/')); + pass(f.run('select')); + f.put('src/new.txt', 'new'); + pass(f.scope()); + f.put('src-other/new.txt', 'outside'); + fail(f.scope(), /OUT_OF_SCOPE/); +}); + +test('scope allows in-scope deletions and rejects symlink escapes', t => { + const f = fixture(t); + pass(f.run('select')); + rmSync(join(f.root, 'src/value.txt')); + pass(f.scope()); + symlinkSync('../outside.txt', join(f.root, 'src/value.txt')); + fail(f.scope(), /SYMLINK_SCOPE/); +}); + +test('replacing a scoped file with a directory does not authorize its children', t => { + const f = fixture(t); + pass(f.run('select')); + rmSync(join(f.root, 'src/value.txt')); + f.put('src/value.txt/child.txt', 'outside the declared file'); + fail(f.scope(), /OUT_OF_SCOPE/); +}); + +test('a check that writes outside scope fails verification', t => { + const f = fixture(t, entry('Bad check', 'src/value.txt', + [['node', '-e', "require('node:fs').writeFileSync('outside.txt','changed')"]])); + pass(f.run('select')); + fail(f.run('verify'), /OUT_OF_SCOPE/); +}); + +test('interrupted package write preserves the original and retry replaces it atomically', t => { + const f = fixture(t); + pass(f.run('select')); + const original = readFileSync(join(f.root, packagePath), 'utf8'); + const hook = join(f.root, '.relayflow/interrupt.cjs'); writeFileSync(hook, `const fs = require('node:fs'); const write = fs.writeFileSync; fs.writeFileSync = (path, data, options) => { - if (String(path).includes('compile.ts') && String(path).endsWith('.tmp')) { + if (String(path).includes('package.json') && String(path).endsWith('.tmp')) { write(path, data.slice(0, 9), options); process.kill(process.pid, 'SIGKILL'); } return write(path, data, options); }; -require('node:module').syncBuiltinESMExports(); -`); - const interrupted = run('apply', ['--require', hook]); +require('node:module').syncBuiltinESMExports();\n`); + const interrupted = f.run('select', ['--require', hook]); assert.equal(interrupted.signal, 'SIGKILL', interrupted.stderr); - assert.equal(readFileSync(target, 'utf8'), original); - const applied = run('apply'); - assert.equal(applied.status, 0, applied.stderr); - const expected = original.replaceAll('validateKernelRetry', 'validateAuthoringRetryDefaults'); - assert.equal(readFileSync(target, 'utf8'), expected); - const retry = run('apply'); - assert.equal(retry.status, 0, retry.stderr); - assert.match(retry.stdout, /PACKAGE_ALREADY_APPLIED/); - assert.equal(readFileSync(target, 'utf8'), expected); + assert.equal(readFileSync(join(f.root, packagePath), 'utf8'), original); + pass(f.run('select')); + assert.equal(JSON.parse(readFileSync(join(f.root, packagePath))).title, 'Fix value'); + assert.equal(readFileSync(join(f.root, 'src/value.txt'), 'utf8'), 'broken'); }); diff --git a/ops/local-work-test-fixture.mjs b/ops/local-work-test-fixture.mjs new file mode 100644 index 000000000..592583558 --- /dev/null +++ b/ops/local-work-test-fixture.mjs @@ -0,0 +1,45 @@ +import assert from 'node:assert/strict'; +import { spawnSync, execFileSync } from 'node:child_process'; +import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { load } from '../packages/sdk/node_modules/js-yaml/dist/js-yaml.mjs'; + +export const flow = load(readFileSync('workflows/drive-local.yaml', 'utf8')); +export const packagePath = '.relayflow/drive-local/package.json'; +const check = ['node', '-e', "require('node:assert/strict').equal(require('node:fs').readFileSync('src/value.txt','utf8'),'fixed')"]; +export const entry = (title = 'Fix value', path = 'src/value.txt', commands = [check]) => + `- **${title}** Update \`${path}\` to the required value.\n` + + commands.map(argv => ` Verify: ${JSON.stringify(argv)}\n`).join(''); + +export function fixture(t, backlog = entry()) { + mkdirSync('.relayflow', { recursive: true }); + const root = mkdtempSync(resolve('.relayflow/package-tests-')); + t.after(() => rmSync(root, { recursive: true, force: true })); + const put = (path, content) => { + mkdirSync(join(root, path, '..'), { recursive: true }); + writeFileSync(join(root, path), content); + }; + const git = (...args) => execFileSync('git', args, { cwd: root, stdio: 'pipe' }); + git('init', '-q', '-b', 'work'); + put('.gitignore', '.relayflow/\npackages/sdk/dist/\n'); + put('src/value.txt', 'broken'); + put('outside.txt', 'original'); + put('ops/BACKLOG.md', backlog); + for (const path of ['ops/local-work-package.mjs', 'ops/local-work-verification.mjs', 'workflows/drive-local.yaml']) { + put(path, readFileSync(path)); + } + put('packages/sdk/dist/backlog-picker.js', readFileSync('packages/sdk/dist/backlog-picker.js')); + git('add', '.'); + git('-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.test', + '-c', 'commit.gpgsign=false', 'commit', '-qm', 'fixture'); + const run = (operation, extra = []) => spawnSync(process.execPath, + [...extra, 'ops/local-work-package.mjs', operation], { cwd: root, encoding: 'utf8', timeout: 5000 }); + const scope = () => spawnSync('sh', ['-c', flow.steps.find(s => s.id === 'scope').command], + { cwd: root, encoding: 'utf8', timeout: 5000 }); + return { root, put, git, run, scope }; +} +export const pass = result => assert.equal(result.status, 0, result.stderr + result.stdout); +export const fail = (result, pattern) => { + assert.notEqual(result.status, 0, result.stdout); + if (pattern) assert.match(result.stderr + result.stdout, pattern); +}; diff --git a/ops/local-work-verification.mjs b/ops/local-work-verification.mjs new file mode 100644 index 000000000..adf3bb03b --- /dev/null +++ b/ops/local-work-verification.mjs @@ -0,0 +1,76 @@ +import assert from 'node:assert/strict'; +import { execFileSync, spawnSync } from 'node:child_process'; +import { lstatSync, realpathSync } from 'node:fs'; +import { resolve, sep } from 'node:path'; + +// An explicit argv contract avoids treating prose or arbitrary backticked +// snippets as shell commands. Checks are authored before implementation. +export function verificationCommands(body) { + const lines = body.split('\n').filter(line => /^\s*Verify:/.test(line)); + return lines.map(line => { + let argv; + try { argv = JSON.parse(line.replace(/^\s*Verify:\s*/, '')); } + catch { throw new Error('INVALID_EXECUTABLE_CHECK: Verify must contain a JSON argv array'); } + assert(Array.isArray(argv) && argv.length > 0 && + argv.every(arg => typeof arg === 'string' && !arg.includes('\0')) && argv[0].trim(), + 'INVALID_EXECUTABLE_CHECK: expected nonempty command argv'); + return argv; + }); +} + +const protectedPaths = [ + 'ops/BACKLOG.md', 'ops/local-work-package.mjs', 'ops/local-work-verification.mjs', + 'workflows/drive-local.yaml', 'workflows/gates', 'packages/sdk/src/backlog-picker.ts', +]; +const within = (path, scope) => path === scope || path.startsWith(`${scope}/`); +const gitPaths = (...args) => execFileSync('git', args, { encoding: 'utf8' }).split('\0').filter(Boolean); + +export function checkScope(pkg) { + const root = realpathSync('.'); + const scopes = pkg.filesInScope.map(path => { + const normalized = path.replace(/\/$/, ''); + assert(normalized && !normalized.startsWith('/') && + normalized.split('/').every(part => part && part !== '.' && part !== '..'), + `INVALID_SCOPE: ${path}`); + const kind = execFileSync('git', ['cat-file', '-t', `${pkg.head}:${normalized}`], + { encoding: 'utf8' }).trim(); + return { path: normalized, directory: kind === 'tree' }; + }); + // Check index and working tree separately: a staged edit followed by an + // unstaged reversal must not disappear. --no-renames exposes both endpoints. + const touched = new Set([ + ...gitPaths('diff', '--name-only', '--no-renames', '-z', pkg.head, '--'), + ...gitPaths('diff', '--cached', '--name-only', '--no-renames', '-z', pkg.head, '--'), + ...gitPaths('ls-files', '--others', '--exclude-standard', '-z'), + ]); + for (const path of touched) { + assert(!protectedPaths.some(scope => within(path, scope)) && + scopes.some(scope => path === scope.path || (scope.directory && within(path, scope.path))), + `OUT_OF_SCOPE: ${path}`); + // Reject symlinks, including an ancestor replaced by a symlink, so a + // lexical prefix cannot authorize writing outside the checkout. + let current = root; + for (const part of path.split('/')) { + current = resolve(current, part); + try { + assert(!lstatSync(current).isSymbolicLink(), `SYMLINK_SCOPE: ${path}`); + assert(realpathSync(current).startsWith(`${root}${sep}`), `OUT_OF_SCOPE: ${path}`); + } catch (error) { + if (error.code === 'ENOENT' || error.code === 'ENOTDIR') break; // deletion + throw error; + } + } + } + console.log(`SCOPE_OK: ${touched.size} changed path(s)`); +} + +export function runChecks(pkg) { + assert(pkg.verificationCommands?.length > 0, 'MISSING_EXECUTABLE_CHECKS'); + for (const argv of pkg.verificationCommands) { + console.log(`CHECK ${JSON.stringify(argv)}`); + const result = spawnSync(argv[0], argv.slice(1), { stdio: 'inherit', timeout: 120000 }); + assert(!result.error && result.status === 0, + `PACKAGE_CHECK_FAILED: ${JSON.stringify(argv)} (${result.error?.message ?? result.signal ?? result.status})`); + } + console.log(`PACKAGE_VERIFIED: ${pkg.verificationCommands.length} check(s)`); +} diff --git a/ops/runtime-evidence/drive-hardening-0908.txt b/ops/runtime-evidence/drive-hardening-0908.txt new file mode 100644 index 000000000..b2e7c0f6d --- /dev/null +++ b/ops/runtime-evidence/drive-hardening-0908.txt @@ -0,0 +1,431 @@ +Drive-local hardening — captured commands and output + +The baseline local-package test was already failing: it invoked the removed apply command. The replacement retains interrupted-write coverage for package selection. + +The default npm test invocation encountered a broken mise Cargo shim. The subsequent command selects the installed Rust toolchain without changing repository build scripts. + +F3 mutation procedure replaced only the cursor block with the original title-search implementation, ran the named test, restored the file byte-for-byte in a finally block, and reran the same test. + + +=== baseline.txt === +$ npm --prefix packages/sdk run build + +> @relayflows/sdk@2.0.6 build +> tsc && node scripts/make-cli-executable.mjs + +EXIT_CODE=0 + +$ node ops/local-work-package.mjs select +SKIPPED [unbounded_scope] `timeoutMs` is enforced LATE, not never — a step ran 2.3x past its limit. +SKIPPED [stale_scope: sdk/tests/live-kernel.test.ts, sdk/src/protocol.ts, sdk/src/journal-client.ts, sdk/src/cli/run.ts] GATES 2 AND 3 ARE BLOCKED ON A MISSING COMPONENT: there is no agent worker. +SKIPPED [unbounded_scope] Half the drive runs complete but build nothing. +SKIPPED [missing_scope] DONE (PR #45, merged): refuse an entry with unterminated backticks. +SKIPPED [missing_scope] DONE (PR #42, merged): sharpen what the picker considers actionable. +SKIPPED [unbounded_scope] Close the deterministic-command preflight gap (Codex P1). +SKIPPED [unbounded_scope] Release pipeline (relay pattern, NOT crates.io): +SKIPPED [missing_scope] Persist review transcripts: +SKIPPED [missing_scope] Re-register cloud schedules from current drive.yaml +SKIPPED [missing_definition_of_done] Customer harness is a named design partner +SKIPPED [missing_scope] PR titles from the pr step +SKIPPED [unbounded_scope] The PR-shepherd flow (Garden component, gate 3): +SELECTED Regression suite (`regressions/`, dormant): + files in scope: regressions/MANIFEST.json + definition of done: 1 item(s) +EXIT_CODE=0 + +$ node --test ops/local-work-package.test.mjs +✖ interrupted package write preserves the original and retry applies once (83.60475ms) +ℹ tests 1 +ℹ suites 0 +ℹ pass 0 +ℹ fail 1 +ℹ cancelled 0 +ℹ skipped 0 +ℹ todo 0 +ℹ duration_ms 123.169166 + +✖ failing tests: + +test at ops/local-work-package.test.mjs:10:1 +✖ interrupted package write preserves the original and retry applies once (83.60475ms) + AssertionError [ERR_ASSERTION]: node:internal/modules/run_main:107 + triggerUncaughtException( + ^ + + AssertionError [ERR_ASSERTION]: NO_BOUNDED_WORK: 1 entr(y|ies) considered, none named files this loop can scope. Add explicit paths to a BACKLOG entry. + at select (file:///Users/khaliqgant/AgentWorkforce/flows-runtime-0907-wt/ops/local-work-package.mjs:123:3) + at async file:///Users/khaliqgant/AgentWorkforce/flows-runtime-0907-wt/ops/local-work-package.mjs:158:27 { + generatedMessage: false, + code: 'ERR_ASSERTION', + actual: null, + expected: true, + operator: '==', + diff: 'simple' + } + + Node.js v26.7.0 + + + 1 !== 0 + + at TestContext. (file:///Users/khaliqgant/AgentWorkforce/flows-runtime-0907-wt/ops/local-work-package.test.mjs:27:10) + at Test.runInAsyncScope (node:async_hooks:226:14) + at Test.run (node:internal/test_runner/test:1397:25) + at Test.start (node:internal/test_runner/test:1257:17) + at startSubtestAfterBootstrap (node:internal/test_runner/harness:387:17) { + generatedMessage: false, + code: 'ERR_ASSERTION', + actual: 1, + expected: 0, + operator: 'strictEqual', + diff: 'simple' + } +EXIT_CODE=1 + + +=== cursor-red-green.txt === +F3: restore the title-search cursor only. +$ node --test --test-name-pattern='skip cursor' ops/local-work-package.test.mjs +✖ skip cursor passes a later title mentioned in an earlier body (110.139667ms) +ℹ tests 1 +ℹ suites 0 +ℹ pass 0 +ℹ fail 1 +ℹ cancelled 0 +ℹ skipped 0 +ℹ todo 0 +ℹ duration_ms 154.29075 + +✖ failing tests: + +test at ops/local-work-package.test.mjs:9:1 +✖ skip cursor passes a later title mentioned in an earlier body (110.139667ms) + AssertionError [ERR_ASSERTION]: Expected values to be strictly equal: + + 3 !== 2 + + at TestContext. (file:///Users/khaliqgant/AgentWorkforce/flows-runtime-0907-wt/ops/local-work-package.test.mjs:14:10) + at Test.runInAsyncScope (node:async_hooks:226:14) + at Test.run (node:internal/test_runner/test:1397:25) + at Test.start (node:internal/test_runner/test:1257:17) + at startSubtestAfterBootstrap (node:internal/test_runner/harness:387:17) { + generatedMessage: true, + code: 'ERR_ASSERTION', + actual: 3, + expected: 2, + operator: 'strictEqual', + diff: 'simple' + } +EXIT_CODE=1 + +F3: restore the fix byte-for-byte. +$ node --test --test-name-pattern='skip cursor' ops/local-work-package.test.mjs +✔ skip cursor passes a later title mentioned in an earlier body (193.51275ms) +ℹ tests 1 +ℹ suites 0 +ℹ pass 1 +ℹ fail 0 +ℹ cancelled 0 +ℹ skipped 0 +ℹ todo 0 +ℹ duration_ms 235.98425 +EXIT_CODE=0 + +=== local-tests.txt === +$ node --test ops/local-work-package.test.mjs ops/drive-local-flow.test.mjs scripts/run-local-workflow.test.mjs +✔ drive-local journals failure and blocks reporting for outside edit (771.314958ms) +✔ drive-local journals failure and blocks reporting for verifier edit (602.589416ms) +✔ drive-local journals failure and blocks reporting for unchanged package (676.513334ms) +✔ skip cursor passes a later title mentioned in an earlier body (216.564416ms) +✔ selection skips missing checks and stale paths with reasons (218.617167ms) +✔ a non-SDK package fails unchanged and passes only after its check holds (488.200666ms) +✔ multiple acceptance checks all execute and failures propagate (231.413083ms) +✔ scope refuses an outside unstaged path (205.714125ms) +✔ scope refuses an outside staged path (216.896625ms) +✔ scope refuses an outside untracked path (205.137375ms) +✔ scope refuses an outside staged reversal path (204.578667ms) +✔ scope refuses an outside rename path (199.010584ms) +✔ scope refuses an outside deleted path (193.009708ms) +✔ the submitted scope command refuses a changed ops/local-work-package.mjs before loading it (107.126583ms) +✔ the submitted scope command refuses a changed ops/local-work-verification.mjs before loading it (104.506792ms) +✔ the submitted scope command refuses a changed ops/BACKLOG.md before loading it (108.702042ms) +✔ editing ignored package metadata cannot widen scope or replace checks (234.544833ms) +✔ scope allows directory children but rejects a sibling with the same prefix (293.47625ms) +✔ scope allows in-scope deletions and rejects symlink escapes (290.1025ms) +✔ replacing a scoped file with a directory does not authorize its children (192.775208ms) +✔ a check that writes outside scope fails verification (255.667708ms) +✔ interrupted package write preserves the original and retry replaces it atomically (194.482458ms) +✔ local launcher journals deterministic effects and reads more than one journal page (694.106625ms) +✔ a failed command fails the run and prevents dependent effects (134.531791ms) +✔ the SDK worker completes an agent step through the local journal protocol (614.73825ms) +✔ missing daemon is refused before a data directory or run is created (63.763834ms) +ℹ tests 26 +ℹ suites 0 +ℹ pass 26 +ℹ fail 0 +ℹ cancelled 0 +ℹ skipped 0 +ℹ todo 0 +ℹ duration_ms 4206.092958 +EXIT_CODE=0 + +=== sdk.txt === +$ npm --prefix packages/sdk test + +> @relayflows/sdk@2.0.6 test +> sh scripts/test.sh + + +> @relayflows/sdk@2.0.6 test:prep +> ( cd ../../kernel && sh ../ops/cargo.sh build ) && ( [ ! -d ../../testdata/preflight ] || find ../../testdata/preflight -name '*-cli' -type f -exec chmod +x {} + ) + +mise ERROR cargo is not a valid shim. This likely means you uninstalled a tool and the shim does not point to anything. Run `mise use ` to reinstall the tool. +mise ERROR Run with --verbose or MISE_VERBOSE=1 for more information +EXIT_CODE=1 + +=== sdk-toolchain.txt === +$ env PATH="/Users/khaliqgant/.rustup/toolchains/stable-aarch64-apple-darwin/bin:$PATH" CARGO_TARGET_DIR="$PWD/kernel/target" npm --prefix packages/sdk test + +> @relayflows/sdk@2.0.6 test +> sh scripts/test.sh + + +> @relayflows/sdk@2.0.6 test:prep +> ( cd ../../kernel && sh ../ops/cargo.sh build ) && ( [ ! -d ../../testdata/preflight ] || find ../../testdata/preflight -name '*-cli' -type f -exec chmod +x {} + ) + + Compiling proc-macro2 v1.0.107 + Compiling unicode-ident v1.0.24 + Compiling quote v1.0.47 + Compiling libc v0.2.189 + Compiling stable_deref_trait v1.2.1 + Compiling version_check v0.9.5 + Compiling cfg-if v1.0.4 + Compiling autocfg v1.5.1 + Compiling serde_core v1.0.229 + Compiling getrandom v0.3.4 + Compiling zerocopy v0.8.56 + Compiling smallvec v1.15.2 + Compiling serde v1.0.229 + Compiling litemap v0.8.3 + Compiling writeable v0.6.4 + Compiling num-traits v0.2.19 + Compiling memchr v2.8.3 + Compiling generic-array v0.14.7 + Compiling icu_normalizer_data v2.3.0 + Compiling utf8_iter v1.0.4 + Compiling icu_properties_data v2.3.0 + Compiling zmij v1.0.23 + Compiling typenum v1.20.1 + Compiling parking_lot_core v0.9.12 + Compiling ref-cast v1.0.27 + Compiling syn v3.0.4 + Compiling syn v2.0.119 + Compiling aho-corasick v1.1.5 + Compiling num-integer v0.1.47 + Compiling synstructure v0.13.2 + Compiling num-bigint v0.4.8 + Compiling ahash v0.8.12 + Compiling serde_json v1.0.151 + Compiling shlex v2.0.1 + Compiling find-msvc-tools v0.1.11 + Compiling regex-syntax v0.8.11 + Compiling scopeguard v1.2.0 + Compiling zerofrom-derive v0.1.7 + Compiling yoke-derive v0.8.2 + Compiling lock_api v0.4.14 + Compiling num-rational v0.4.2 + Compiling cc v1.4.4 + Compiling num-iter v0.1.46 + Compiling zerofrom v0.1.8 + Compiling num-complex v0.4.6 + Compiling rand_core v0.9.5 + Compiling ppv-lite86 v0.2.21 + Compiling once_cell v1.21.4 + Compiling itoa v1.0.18 + Compiling pkg-config v0.3.34 + Compiling bit-vec v0.8.0 + Compiling borrow-or-share v0.2.4 + Compiling zerovec-derive v0.11.6 + Compiling displaydoc v0.2.7 + Compiling serde_derive v1.0.229 + Compiling ref-cast-impl v1.0.27 + Compiling regex-automata v0.4.18 + Compiling vcpkg v0.2.15 + Compiling bit-set v0.8.0 + Compiling num v0.4.3 + Compiling rand_chacha v0.9.0 + Compiling parking_lot v0.12.5 + Compiling block-buffer v0.10.4 + Compiling libsqlite3-sys v0.35.0 + Compiling crypto-common v0.1.7 + Compiling utf8parse v0.2.2 + Compiling thiserror v2.0.20 + Compiling foldhash v0.1.5 + Compiling uuid v1.26.0 + Compiling yoke v0.8.3 + Compiling lazy_static v1.5.0 + Compiling vsimd v0.8.0 + Compiling percent-encoding v2.3.2 + Compiling outref v0.5.2 + Compiling fraction v0.15.4 + Compiling hashbrown v0.15.5 + Compiling anstyle-parse v1.0.0 + Compiling uuid-simd v0.8.0 + Compiling digest v0.10.7 + Compiling rand v0.9.5 + Compiling thiserror-impl v2.0.20 + Compiling cpufeatures v0.2.17 + Compiling bytecount v0.6.9 + Compiling base64 v0.22.1 + Compiling num-cmp v0.1.0 + Compiling anstyle v1.0.14 + Compiling zerovec v0.11.8 + Compiling zerotrie v0.2.5 + Compiling colorchoice v1.0.5 + Compiling anstyle-query v1.1.5 + Compiling is_terminal_polyfill v1.70.2 + Compiling hashlink v0.10.0 + Compiling sha2 v0.10.9 + Compiling anstream v1.0.0 + Compiling heck v0.5.0 + Compiling fallible-streaming-iterator v0.1.9 + Compiling strsim v0.11.1 + Compiling clap_lex v1.1.0 + Compiling anyhow v1.0.104 + Compiling fallible-iterator v0.3.0 + Compiling fancy-regex v0.16.2 + Compiling regex v1.13.1 + Compiling bitflags v2.13.1 + Compiling clap_builder v4.6.6 + Compiling clap_derive v4.6.4 + Compiling tinystr v0.8.4 + Compiling icu_locale_core v2.3.0 + Compiling potential_utf v0.1.6 + Compiling icu_collections v2.3.0 + Compiling wait-timeout v0.2.1 + Compiling fluent-uri v0.3.2 + Compiling email_address v0.2.9 + Compiling ulid v1.2.1 + Compiling icu_provider v2.3.1 + Compiling icu_normalizer v2.3.0 + Compiling icu_properties v2.3.0 + Compiling referencing v0.33.0 + Compiling clap v4.6.6 + Compiling idna_adapter v1.2.2 + Compiling idna v1.1.0 + Compiling jsonschema v0.33.0 + Compiling rusqlite v0.37.0 + Compiling relayflowd-core v0.1.0 (/Users/khaliqgant/AgentWorkforce/flows-runtime-0907-wt/kernel/relayflowd-core) + Compiling relayflowd-journal v0.1.0 (/Users/khaliqgant/AgentWorkforce/flows-runtime-0907-wt/kernel/relayflowd-journal) + Compiling relayflowd v0.1.0 (/Users/khaliqgant/AgentWorkforce/flows-runtime-0907-wt/kernel/relayflowd) + Finished `dev` profile [unoptimized + debuginfo] target(s) in 13.67s + +> @relayflows/sdk@2.0.6 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + + +> @relayflows/sdk@2.0.6 build +> tsc && node scripts/make-cli-executable.mjs + + +> @relayflows/sdk@2.0.6 typecheck:tests +> tsc -p tsconfig.tests.json + + + RUN v2.1.9 /Users/khaliqgant/AgentWorkforce/flows-runtime-0907-wt/packages/sdk + +stdout | tests/live-kernel.test.ts +LIVE_KERNEL relayflowd=/Users/khaliqgant/AgentWorkforce/flows-runtime-0907-wt/kernel/target/debug/relayflowd +LIVE_KERNEL flows=/Users/khaliqgant/AgentWorkforce/flows-runtime-0907-wt/packages/sdk/dist/cli.js + + ✓ tests/tick-source.test.ts (33 tests) 13ms + ✓ tests/daemon-lifecycle.test.ts (42 tests) 19ms + ✓ tests/journal-client.test.ts (14 tests) 70ms + ✓ tests/validate.test.ts (68 tests) 16ms + ✓ tests/preflight.test.ts (25 tests) 29ms + ✓ tests/cli-hn-monitor.test.ts (16 tests) 98ms + ✓ tests/gate-contract.test.ts (20 tests) 156ms + ✓ tests/verb-field-lint.test.ts (78 tests) 272ms + ✓ tests/tick-runner.test.ts (22 tests) 524ms + ✓ tests/backlog-picker.test.ts (14 tests) 139ms + ✓ tests/authored-flow-lifecycle-executor.test.ts (27 tests) 508ms + ✓ tests/authored-flow.test.ts (23 tests) 843ms + ✓ authored flow journal executor > refuses manually chained work even when it settles before the body returns 305ms + ✓ authored flow journal executor > retains root operation failures even when a derived rejection handler consumes them 306ms + ✓ tests/work-package-consumer.test.ts (13 tests) 170ms + ✓ tests/backlog-picker-flow.test.ts (6 tests) 466ms + ✓ tests/authored-flow-operation.test.ts (23 tests) 340ms + ✓ tests/spec-parity.test.ts (31 tests) 256ms + ✓ tests/typed-output.test.ts (14 tests) 160ms + ✓ tests/model-selection.test.ts (10 tests) 11ms + ✓ tests/relayflowd-path.test.ts (10 tests) 9ms + ✓ tests/deterministic-llm.test.ts (5 tests) 73ms + ✓ tests/hn-poller.test.ts (6 tests) 3ms + ✓ tests/dir-watcher-poller.test.ts (6 tests) 6ms + ✓ tests/dependency-validation.test.ts (6 tests) 414ms + ✓ tests/direct-input.test.ts (4 tests) 1245ms + ✓ direct .flow.ts input through the built CLI and live runtime > executes inline and file JSON input through relayflowd 637ms + ✓ direct .flow.ts input through the built CLI and live runtime > refuses missing and malformed input before contacting relayflowd 428ms + ✓ tests/work-package-validator.test.ts (7 tests) 4ms + ✓ tests/hello-deterministic.test.ts (5 tests) 12ms + ✓ tests/bin.test.ts (7 tests) 645ms + ✓ tests/parse-json-output.test.ts (7 tests) 1ms + ↓ tests/real-cli-adapters.test.ts (3 tests | 3 skipped) + ✓ tests/cli-adapter.test.ts (3 tests) 4ms + ✓ tests/placement.test.ts (54 tests) 7ms + ✓ tests/memory.test.ts (18 tests) 4ms + ✓ tests/json-schema-bound.test.ts (71 tests) 1702ms + ✓ JSON Schema termination bound > walks a deep schema with an explicit stack rather than recursion 1352ms + ✓ tests/classify-outcome.test.ts (2 tests) 2228ms + ✓ classifyOutcome > gives up and reports when a running run never becomes classifiable 2066ms + ✓ tests/cli.test.ts (63 tests) 4804ms + ✓ flows check CLI > binds a checked relative wrapper to the flow directory for worker execution 1161ms + ✓ flows check CLI > uses the raw Claude adapter model flag instead of accepting auth status as model proof 620ms + ✓ flows check CLI > uses Codex login status and reports a rejected model as unavailable, not unauthenticated 624ms + ✓ flows check CLI > refuses a nonconforming custom wrapper without calling it an authentication failure 316ms + ✓ flows check CLI > accepts an exact allowlisted named-agent model and probes that model 515ms + ✓ flows check CLI > checks the same named-agent contract from declarative JSON 506ms + ✓ tests/daemon-lifecycle-live.test.ts (9 tests) 5488ms + ✓ flows run against a data dir with no daemon (§6 test 7) > cold start spawns exactly one daemon, the run succeeds, and the daemon outlives the CLI 1099ms + ✓ flows run against a data dir with no daemon (§6 test 7) > polls, bounded, for a daemon that holds the lock before it binds 1526ms + ✓ flows run against a data dir with no daemon (§6 test 7) > attaches to a serving daemon that has not published a connection file 539ms + ✓ flows run against a data dir with no daemon (§6 test 7) > a second run attaches to the daemon the first one started, spawning nothing 301ms + ✓ flows run against a data dir with no daemon (§6 test 7) > detects a stale connection file left by a hard kill and starts a fresh daemon 522ms + ✓ concurrent invocations against one empty data dir (§6 test 15) > ends with exactly one daemon owning the socket, and both runs succeed 867ms + ✓ tests/worker-cli.test.ts (13 tests) 25064ms + ✓ custom wrapper execution identity > refuses a wrapper symlink retarget before delivering private values 1106ms + ✓ custom wrapper execution identity > bounds wrapper execution after acknowledgement 881ms + ✓ custom wrapper execution identity > bounds captured wrapper output 489ms + ✓ custom wrapper execution identity > refuses a duplicate execute protocol frame 318ms + ✓ custom wrapper execution bounds are reader-owned > resolves when a conforming wrapper leaks a stdio pipe to a background helper 2063ms + ✓ custom wrapper execution bounds are reader-owned > resolves when the leaked helper inherits stderr only 1906ms + ✓ custom wrapper execution bounds are reader-owned > resolves when a wrapper leaks a stdio pipe and exits before identifying 3258ms + ✓ custom wrapper execution bounds are reader-owned > journals a completionReason at the default bound when a wrapper leaks a stdio pipe 11257ms + ✓ custom wrapper execution bounds are reader-owned > accepts the same over-8KiB payload whether or not it coalesces with the execute token 852ms + ✓ custom wrapper execution bounds are reader-owned > still bounds an un-terminated handshake buffer and names the bound 1526ms + ✓ delivers the journaled memory pack to the real wrapper and excludes its charge from completion usage 830ms +stdout | tests/live-kernel.test.ts > built flows CLI against live relayflowd > hn-monitor analyze-story reaches done through the real Claude analyzer CLI +LIVE_ANALYZER ready: claude -p --model claude-haiku-4-5-20251001 round-trip OK + +stdout | tests/live-kernel.test.ts > built flows CLI against live relayflowd > hn-monitor analyze-story reaches done through the real Claude analyzer CLI +LIVE_ANALYZER analysis: {"reasoning":"This story directly describes an AI agent performing autonomous software development tasks (opening and reviewing pull requests), which is a core application of AI agent automation and closely aligned with autonomous systems in development workflows.","relevance_score":9,"story_title":"Show HN: an agent that opens and reviews its own pull requests [wake-nonce-7f3a91c4]"} + +stdout | tests/live-kernel.test.ts > surface resume after a real daemon kill > resumes a three-step run with each successful completion exactly once +LIVE_KERNEL kill -9 pid=58369 run=01M206M7KTVASPSFP8S8529PP2 while step=two state=Running + + ✓ tests/live-kernel.test.ts (28 tests) 52485ms + ✓ built flows CLI against live relayflowd > runs rung (a), parks rung (b), and keeps JSON report-shaped 1644ms + ✓ built flows CLI against live relayflowd > allows a deterministic run to exceed the bounded request timeout 32411ms + ✓ built flows CLI against live relayflowd > runs an agent CLI end to end through the SDK worker 317ms + ✓ built flows CLI against live relayflowd > can always get a parked run to a late-attaching worker 5557ms + ✓ built flows CLI against live relayflowd > AgentWorker passes a declared model to an identified wrapper as RELAYFLOW_MODEL 673ms + ✓ built flows CLI against live relayflowd > AgentWorker refuses a nonconforming journal-submitted wrapper before exposing RELAYFLOW_MODEL 473ms + ✓ built flows CLI against live relayflowd > AgentWorker executes the raw codex adapter with its real model flag 501ms + ✓ built flows CLI against live relayflowd > hn-monitor analyze-story reaches done through the real Claude analyzer CLI 8661ms + + Test Files 37 passed | 1 skipped (38) + Tests 803 passed | 3 skipped (806) + Start at 11:46:20 + Duration 52.90s (transform 710ms, setup 0ms, collect 3.40s, tests 98.29s, environment 6ms, prepare 1.37s) + +EXIT_CODE=0 diff --git a/workflows/drive-local.yaml b/workflows/drive-local.yaml index bd8ba36b3..460b0a570 100644 --- a/workflows/drive-local.yaml +++ b/workflows/drive-local.yaml @@ -21,7 +21,7 @@ name: drive-local description: >- Select the next work package from ops/BACKLOG.md, implement it, verify it against its own definition of done, and report. Selection, implementation, - verification and reporting are journaled as four steps. + scope enforcement, verification and reporting are journaled as five steps. steps: # No build-sdk step. The launcher asserts `packages/sdk/dist/cli.js` exists # during preflight, before it submits anything, so a build step inside the @@ -70,13 +70,26 @@ steps: type: output_contains value: "DONE" - - id: verify + - id: scope type: deterministic dependsOn: [implement] + timeoutMs: 120000 + # This preamble is captured in the submitted spec. Check the verifier + # BEFORE executing it; an agent cannot bypass scope by editing the script. + command: | + set -eu + git diff --exit-code HEAD -- ops/local-work-package.mjs ops/local-work-verification.mjs ops/BACKLOG.md workflows/drive-local.yaml packages/sdk/src/backlog-picker.ts + git diff --cached --exit-code HEAD -- ops/local-work-package.mjs ops/local-work-verification.mjs ops/BACKLOG.md workflows/drive-local.yaml packages/sdk/src/backlog-picker.ts + node ops/local-work-package.mjs scope + + - id: verify + type: deterministic + dependsOn: [scope] timeoutMs: 900000 - # The suites, not the agent's own account of itself. + # The selected package's checks must pass before the regression suite. command: | set -eu + node ops/local-work-package.mjs verify cd packages/sdk node node_modules/vitest/vitest.mjs run From 4aeb0911ddad6996b6da6389669b292ae9c56055 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Tue, 8 Sep 2026 11:50:08 +0200 Subject: [PATCH 06/15] docs(drive-local): capture real backlog selection and acceptance output --- ops/runtime-evidence/drive-hardening-0908.txt | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/ops/runtime-evidence/drive-hardening-0908.txt b/ops/runtime-evidence/drive-hardening-0908.txt index b2e7c0f6d..c70b293ba 100644 --- a/ops/runtime-evidence/drive-hardening-0908.txt +++ b/ops/runtime-evidence/drive-hardening-0908.txt @@ -429,3 +429,52 @@ LIVE_KERNEL kill -9 pid=58369 run=01M206M7KTVASPSFP8S8529PP2 while step=two stat Duration 52.90s (transform 710ms, setup 0ms, collect 3.40s, tests 98.29s, environment 6ms, prepare 1.37s) EXIT_CODE=0 + +=== Real backlog at implementation commit 6242049 === +$ node ops/local-work-package.mjs select +SKIPPED [unbounded_scope] `timeoutMs` is enforced LATE, not never — a step ran 2.3x past its limit. +SKIPPED [stale_scope: sdk/tests/live-kernel.test.ts, sdk/src/protocol.ts, sdk/src/journal-client.ts, sdk/src/cli/run.ts] GATES 2 AND 3 ARE BLOCKED ON A MISSING COMPONENT: there is no agent worker. +SKIPPED [unbounded_scope] Half the drive runs complete but build nothing. +SKIPPED [missing_scope] DONE (PR #45, merged): refuse an entry with unterminated backticks. +SKIPPED [missing_scope] DONE (PR #42, merged): sharpen what the picker considers actionable. +SKIPPED [unbounded_scope] Close the deterministic-command preflight gap (Codex P1). +SKIPPED [unbounded_scope] Release pipeline (relay pattern, NOT crates.io): +SKIPPED [missing_scope] Persist review transcripts: +SKIPPED [missing_scope] Re-register cloud schedules from current drive.yaml +SKIPPED [missing_definition_of_done] Customer harness is a named design partner +SKIPPED [missing_scope] PR titles from the pr step +SKIPPED [unbounded_scope] The PR-shepherd flow (Garden component, gate 3): +SKIPPED [missing_executable_checks] Regression suite (`regressions/`, dormant): +SKIPPED [unbounded_scope] `f.browser` helper (gate-6 family, plugin-shaped). +SKIPPED [unbounded_scope] Computer use — deferred, behind heavier rails. +SKIPPED [missing_definition_of_done] Upstream issues (2026-08-27): +SKIPPED [unbounded_scope] Cloud sandbox runs die in `sync`: no git remote. +SKIPPED [missing_executable_checks] Documented `steps: []` check/kernel asymmetry (P3, WP-4 review V3). +SKIPPED [unbounded_scope] Scope the `flows check` CLI probe environment (gate 8; WP-7 F6). +SKIPPED [unbounded_scope] F1 +SKIPPED [missing_scope] F2 +SKIPPED [unbounded_scope] F3 +SKIPPED [unbounded_scope] F5 +SKIPPED [unbounded_scope] F6 +SELECTED F8b + files in scope: packages/sdk/src/compile.ts + definition of done: 1 item(s) +EXIT_CODE=0 + +$ node ops/local-work-package.mjs scope +SCOPE_OK: 0 changed path(s) +EXIT_CODE=0 + +$ node ops/local-work-package.mjs verify +SCOPE_OK: 0 changed path(s) +CHECK ["node","--input-type=module","-e","import assert from 'node:assert/strict'; import {readFileSync} from 'node:fs'; const source = readFileSync('packages/sdk/src/compile.ts', 'utf8'); assert(!source.includes('validateKernelRetry')); assert(source.includes('function validateAuthoringRetryDefaults(')); assert(source.includes('validateAuthoringRetryDefaults(step['));"] +PACKAGE_VERIFIED: 1 check(s) +SCOPE_OK: 0 changed path(s) +EXIT_CODE=0 + +$ node ops/local-work-package.mjs report +REPORT F8b + (no working-tree changes) + DoD: F8b +EXIT_CODE=0 + From 9249228e549a591e8309152d840090ed1aee4158 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Wed, 9 Sep 2026 11:07:08 +0200 Subject: [PATCH 07/15] fix(drive-local): restore the work-branch guard dropped in the generalization cubic's P1 on ops/local-work-package.mjs:149 is correct and is a regression. main carries the guard: const branch = git('branch', '--show-current'); assert(branch && branch !== 'main', 'LOCAL_DRIVE_REFUSED: use a work branch'); When select() was generalized it kept RECORDING the branch in the package and stopped asserting it, so the loop would select work while sitting on main and let the agent edit the protected branch. `--show-current` prints nothing on a detached HEAD, which is equally not a work branch and was equally unguarded. The guard now runs before anything is read, and names which case it refused so the operator does not have to guess. Verified: detached HEAD refuses live with "LOCAL_DRIVE_REFUSED: detached HEAD is not a work branch"; the branch condition refuses 'main' and accepts a work branch. Note for the next person testing this: `git checkout main` swaps this script for main's own copy, so a naive "check out main and run it" tests the wrong code and looks like the guard did not fire. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR --- ops/local-work-package.mjs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/ops/local-work-package.mjs b/ops/local-work-package.mjs index b297f6c7a..8d0d30e86 100644 --- a/ops/local-work-package.mjs +++ b/ops/local-work-package.mjs @@ -142,11 +142,22 @@ async function choose(markdown, { pathExists = existsSync, log = true } = {}) { } async function select() { + // Restored guard. The generalization recorded the branch but stopped asserting + // it, so the loop would happily select work while sitting on `main` and let + // the agent edit the protected branch. `--show-current` prints nothing on a + // detached HEAD, which is equally not a work branch. + const branch = git('branch', '--show-current'); + assert( + branch && branch !== 'main', + branch + ? `LOCAL_DRIVE_REFUSED: on '${branch}'; use a work branch, not main` + : 'LOCAL_DRIVE_REFUSED: detached HEAD is not a work branch; check out one', + ); const markdown = read(backlogPath); const work = await choose(markdown); const pkg = { selectedAt: new Date().toISOString(), - branch: git('branch', '--show-current'), + branch, head: git('rev-parse', 'HEAD'), backlogSha256: hash(markdown), ...work, From ed7a147c82ac4c90473fe8d673fdbe1e9eb80196 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Wed, 9 Sep 2026 11:33:11 +0200 Subject: [PATCH 08/15] fix(drive-local): rebuild the SDK before running its suite cubic's P1 on workflows/drive-local.yaml:94 is correct. The verify step ran `vitest` straight after `local-work-package.mjs verify` with no build in between. Tests and probes import `packages/sdk/dist/*` (see ops/probes/pr134-repair-0903/harness.mjs), so when the agent changes SDK TypeScript the suite exercises the PRE-agent artifact and can pass code that was just changed. That is the stale-tree gate failure: a green check against an artifact the change never reached. Now runs the package's own build (`tsc` then `make-cli-executable.mjs`) between verify and the suite. Invoked directly rather than through `npm run build`, matching the existing `node node_modules/vitest/vitest.mjs run` idiom in this same step, because npm hangs on this host: ~/.npmrc is a Dropbox symlink and any bare npm blocks with no output. NOT VERIFIED ON DISK: no local checkout currently has packages/sdk/node_modules, so I could not confirm `node_modules/typescript/bin/tsc` resolves here. It is TypeScript's shipped entry point and mirrors the vitest invocation two lines below it, but the first real loop run is what proves it. If it is wrong the step fails loudly rather than silently testing stale code, which is still an improvement on the current behaviour. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR --- workflows/drive-local.yaml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/workflows/drive-local.yaml b/workflows/drive-local.yaml index 460b0a570..3022e6269 100644 --- a/workflows/drive-local.yaml +++ b/workflows/drive-local.yaml @@ -91,6 +91,12 @@ steps: set -eu node ops/local-work-package.mjs verify cd packages/sdk + # Rebuild before the suite. Tests and probes import packages/sdk/dist/*, + # so running vitest against a stale dist exercises the PRE-agent artifact + # and can pass code the agent just changed. Invoked directly rather than + # via `npm run build` because npm hangs on this host's ~/.npmrc. + node node_modules/typescript/bin/tsc + node scripts/make-cli-executable.mjs node node_modules/vitest/vitest.mjs run - id: report From 5261c2c317cd9f6969ae4260570c164094aabb67 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Wed, 9 Sep 2026 12:17:40 +0200 Subject: [PATCH 09/15] fix(drive-local): run the acceptance gate from an immutable pre-agent snapshot Khaliq approved option A. Addresses #244 H1: "the acceptance gate again trusts code writable by the agent it judges." The hole was specific. The scope guard protects packages/sdk/src/backlog-picker.ts, but local-work-package.mjs imports the BUILT packages/sdk/dist/backlog-picker.js during scope and acceptance. An agent could leave the guarded source untouched, rebuild dist, and the guard would still pass while the gate executed the agent's own code. The rebuild step added earlier in this PR makes dist routinely regenerated, so this was not theoretical. New `gate-snapshot` step runs BEFORE the agent: builds the SDK, copies the picker and both acceptance scripts into .drive-gate/, and records SHA256SUMS. `scope` and `verify` now verify those sums and execute from the snapshot, with DRIVE_GATE_PICKER pinning the snapshotted picker so the acceptance path cannot reach the live dist. The diff guards additionally cover packages/sdk/dist/backlog-picker.js, and .drive-gate/ is gitignored so the snapshot never enters a commit. Ordering is the load-bearing part and it is asserted, not assumed: `implement` now dependsOn [gate-snapshot], not [select]. My first edit missed that and left the agent able to run CONCURRENTLY with the snapshot, which would have voided the whole change while looking correct. Not yet exercised end to end -- the loop needs a real run to prove the snapshot path works. If a copied file or the shasum check is wrong the gate fails loudly rather than silently trusting agent-written code. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR --- .gitignore | 3 +++ ops/local-work-package.mjs | 9 ++++++++- workflows/drive-local.yaml | 36 +++++++++++++++++++++++++++++++----- 3 files changed, 42 insertions(+), 6 deletions(-) diff --git a/.gitignore b/.gitignore index 554689b42..e704692e7 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,6 @@ dist/ .relayflow/ .relayflowd/ .relayflowd-*/ + +# Immutable pre-implementation gate snapshot (drive-local). +.drive-gate/ diff --git a/ops/local-work-package.mjs b/ops/local-work-package.mjs index 8d0d30e86..914f5eb67 100644 --- a/ops/local-work-package.mjs +++ b/ops/local-work-package.mjs @@ -30,7 +30,14 @@ const backlogPath = 'ops/BACKLOG.md'; // The picker's own module, not the package index: index.js re-exports // packageFromEntry and validateWorkPackage but NOT selectBacklogEntry or // renderWorkPackage, so importing the index gets you two of the four. -const sdkEntry = new URL('../packages/sdk/dist/backlog-picker.js', import.meta.url); +// The gate must not execute code the agent it judges can rewrite. The scope +// guard protects packages/sdk/src/backlog-picker.ts, but this imports the BUILT +// dist/backlog-picker.js -- an agent can leave the source untouched, rebuild +// dist, and the guard still passes. DRIVE_GATE_PICKER pins an immutable +// pre-implementation snapshot instead. +const sdkEntry = process.env.DRIVE_GATE_PICKER + ? new URL(`file://${process.env.DRIVE_GATE_PICKER}`) + : new URL('../packages/sdk/dist/backlog-picker.js', import.meta.url); const read = (p) => readFileSync(p, 'utf8'); const hash = (t) => createHash('sha256').update(t).digest('hex'); diff --git a/workflows/drive-local.yaml b/workflows/drive-local.yaml index 3022e6269..5bed7a60c 100644 --- a/workflows/drive-local.yaml +++ b/workflows/drive-local.yaml @@ -33,10 +33,30 @@ steps: timeoutMs: 120000 command: node ops/local-work-package.mjs select + # A: the gate runs from an immutable pre-implementation snapshot. Taken + # BEFORE the agent runs, into a directory the agent has no reason to touch and + # the scope guard refuses changes to. Without this the acceptance path imports + # packages/sdk/dist/backlog-picker.js -- a BUILT artifact the agent can rewrite + # while leaving the guarded source untouched. + - id: gate-snapshot + type: deterministic + dependsOn: [select] + timeoutMs: 300000 + command: | + set -eu + rm -rf .drive-gate && mkdir -p .drive-gate + ( cd packages/sdk && node node_modules/typescript/bin/tsc ) + cp packages/sdk/dist/backlog-picker.js .drive-gate/backlog-picker.js + cp ops/local-work-package.mjs .drive-gate/local-work-package.mjs + cp ops/local-work-verification.mjs .drive-gate/local-work-verification.mjs + shasum -a 256 .drive-gate/* > .drive-gate/SHA256SUMS + echo "GATE_SNAPSHOT_TAKEN" + cat .drive-gate/SHA256SUMS + - id: implement type: agent cli: claude - dependsOn: [select] + dependsOn: [gate-snapshot] # Required, not decorative: the launcher refuses an agent step with no pins # (`LOCAL_AGENT_PINS_REQUIRED: declare a stream; the kernel refuses workers # with no pins`) and the run is never created. Verified by running it. @@ -78,9 +98,13 @@ steps: # BEFORE executing it; an agent cannot bypass scope by editing the script. command: | set -eu - git diff --exit-code HEAD -- ops/local-work-package.mjs ops/local-work-verification.mjs ops/BACKLOG.md workflows/drive-local.yaml packages/sdk/src/backlog-picker.ts - git diff --cached --exit-code HEAD -- ops/local-work-package.mjs ops/local-work-verification.mjs ops/BACKLOG.md workflows/drive-local.yaml packages/sdk/src/backlog-picker.ts - node ops/local-work-package.mjs scope + git diff --exit-code HEAD -- ops/local-work-package.mjs ops/local-work-verification.mjs ops/BACKLOG.md workflows/drive-local.yaml packages/sdk/src/backlog-picker.ts packages/sdk/dist/backlog-picker.js + git diff --cached --exit-code HEAD -- ops/local-work-package.mjs ops/local-work-verification.mjs ops/BACKLOG.md workflows/drive-local.yaml packages/sdk/src/backlog-picker.ts packages/sdk/dist/backlog-picker.js + # The snapshot must be byte-identical to what was taken before the agent + # ran; otherwise the gate is judging with code the agent may have edited. + shasum -a 256 -c .drive-gate/SHA256SUMS + DRIVE_GATE_PICKER="$PWD/.drive-gate/backlog-picker.js" \ + node .drive-gate/local-work-package.mjs scope - id: verify type: deterministic @@ -89,7 +113,9 @@ steps: # The selected package's checks must pass before the regression suite. command: | set -eu - node ops/local-work-package.mjs verify + shasum -a 256 -c .drive-gate/SHA256SUMS + DRIVE_GATE_PICKER="$PWD/.drive-gate/backlog-picker.js" \ + node .drive-gate/local-work-package.mjs verify cd packages/sdk # Rebuild before the suite. Tests and probes import packages/sdk/dist/*, # so running vitest against a stale dist exercises the PRE-agent artifact From e0c5123ec399d41e8f7aa321083539c19294ef1e Mon Sep 17 00:00:00 2001 From: kjgbot Date: Wed, 9 Sep 2026 12:24:54 +0200 Subject: [PATCH 10/15] fix(drive-local): validate descendants of directory scopes for escaping symlinks Khaliq approved option B. Addresses cubic's P1 on ops/local-work-verification.mjs: "A pre-existing symlink inside a declared scope is never inspected when it is unchanged, so a Verify command can write outside the checkout without creating a touched path." The existing symlink walk runs per TOUCHED path, so it only sees paths the agent already modified. A symlink that was there before the run is never touched, never inspected, and a declared directory scope is an authorization to write anywhere beneath it -- including straight through that link. checkScope now walks the descendants of every directory scope before any check runs, and refuses a symlink whose realpath leaves the checkout root. Symlinks that stay inside the root are deliberately allowed: workspace layouts use them legitimately, and the threat here is escape, not indirection. Dangling links are skipped -- a write cannot escape through a link with no target. Verified both directions in a scratch repo with `src` as the declared scope: pre-existing src/escape -> ../../outside SYMLINK_ESCAPES_SCOPE (refused) benign src/inside -> a.txt only SCOPE_OK: 0 changed path(s) so it catches the escape without rejecting internal links. .git is skipped during the walk. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR --- ops/local-work-verification.mjs | 39 ++++++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/ops/local-work-verification.mjs b/ops/local-work-verification.mjs index adf3bb03b..7ea9ff0f0 100644 --- a/ops/local-work-verification.mjs +++ b/ops/local-work-verification.mjs @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { execFileSync, spawnSync } from 'node:child_process'; -import { lstatSync, realpathSync } from 'node:fs'; +import { lstatSync, readdirSync, realpathSync } from 'node:fs'; import { resolve, sep } from 'node:path'; // An explicit argv contract avoids treating prose or arbitrary backticked @@ -36,6 +36,43 @@ export function checkScope(pkg) { { encoding: 'utf8' }).trim(); return { path: normalized, directory: kind === 'tree' }; }); + // A declared DIRECTORY scope is an authorization to write anywhere beneath it, + // so its descendants have to be sound before any check runs -- not just the + // paths that happen to be touched. A pre-existing symlink inside such a scope + // is never "touched", so the per-path walk below never sees it, and a Verify + // command can write straight through it to somewhere outside the checkout. + // Symlinks that stay inside the root are left alone: workspace layouts use + // them legitimately, and the threat is escape, not indirection. + for (const scope of scopes.filter(s => s.directory)) { + const stack = [scope.path]; + while (stack.length) { + const dir = stack.pop(); + let entries; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch (error) { + if (error.code === 'ENOENT' || error.code === 'ENOTDIR') continue; + throw error; + } + for (const entry of entries) { + if (entry.name === '.git') continue; + const child = `${dir}/${entry.name}`; + if (entry.isSymbolicLink()) { + let target; + try { + target = realpathSync(child); + } catch (error) { + if (error.code === 'ENOENT') continue; // dangling: writes cannot escape through it + throw error; + } + assert(target === root || target.startsWith(`${root}${sep}`), + `SYMLINK_ESCAPES_SCOPE: ${child} -> ${target}`); + } else if (entry.isDirectory()) { + stack.push(child); + } + } + } + } // Check index and working tree separately: a staged edit followed by an // unstaged reversal must not disappear. --no-renames exposes both endpoints. const touched = new Set([ From 315cf6f7f11bbe662661db009516dbd7fbdc765f Mon Sep 17 00:00:00 2001 From: kjgbot Date: Wed, 9 Sep 2026 12:42:31 +0200 Subject: [PATCH 11/15] fix(drive-local): give selection and the gate one baseline cubic's P2 on ops/local-work-verification.mjs:35: "When a backlog scope names an existing untracked path, `select` accepts it, but this `git cat-file` lookup aborts `scope` because the path is absent from `pkg.head`." Two different existence rules were in play: select() pathExists = existsSync working tree verifiedPackage() git cat-file -e ${pkg.head}:path committed at HEAD checkScope() git cat-file -t ${pkg.head}:path committed at HEAD So selection blessed a path that exists only on disk, persisted it into the work package, and the gate then refused the very package selection had produced. The loop aborted on its own decision. select() now uses the same committed-at-HEAD rule. An untracked path is refused during selection, with the existing stale_scope reason, instead of passing and detonating two steps later. Committed-at-HEAD is the right rule for both rather than relaxing the gate: scope is a claim about reviewable content, and an untracked path is not yet that. Demonstrated in a scratch repo: ops/tracked.txt existsSync=true inHEAD=true untracked-dir existsSync=true inHEAD=false <- accepted by select, refused by scope Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FtQSAcGDta5VH9xiZFT4sR --- ops/local-work-package.mjs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/ops/local-work-package.mjs b/ops/local-work-package.mjs index 914f5eb67..21453982d 100644 --- a/ops/local-work-package.mjs +++ b/ops/local-work-package.mjs @@ -160,12 +160,22 @@ async function select() { ? `LOCAL_DRIVE_REFUSED: on '${branch}'; use a work branch, not main` : 'LOCAL_DRIVE_REFUSED: detached HEAD is not a work branch; check out one', ); + const head = git('rev-parse', 'HEAD'); const markdown = read(backlogPath); - const work = await choose(markdown); + // Selection and the gate must share one baseline. `existsSync` accepts a path + // that exists only in the working tree, which selection then persists and + // checkScope immediately rejects -- its `git cat-file` lookup consults the + // selected commit, so an untracked path aborts the run on a package selection + // had already blessed. Committed-at-HEAD is the honest rule for both: scope is + // a claim about reviewable content, and an untracked path is not yet that. + const work = await choose(markdown, { + pathExists: path => spawnSync('git', ['cat-file', '-e', `${head}:${path.replace(/\/$/, '')}`], + { stdio: 'ignore' }).status === 0, + }); const pkg = { selectedAt: new Date().toISOString(), branch, - head: git('rev-parse', 'HEAD'), + head, backlogSha256: hash(markdown), ...work, }; From 34349b278d079e8cd9d26ad84948892dad9835e5 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Wed, 9 Sep 2026 12:55:50 +0200 Subject: [PATCH 12/15] fix(drive-local): address report and scope review findings --- ops/DRIVE-LOCAL.md | 15 +- ops/local-work-package.mjs | 5 +- ops/local-work-review.test.mjs | 66 ++++ ops/local-work-test-fixture.mjs | 29 +- ops/local-work-verification.mjs | 4 +- ops/runtime-evidence/drive-hardening-0908.txt | 2 +- .../drive-threads-0909-after.txt | 38 +++ .../drive-threads-0909-baseline.txt | 321 ++++++++++++++++++ .../drive-threads-0909-build.txt | 261 ++++++++++++++ .../drive-threads-0909-decisions.md | 43 +++ .../drive-threads-0909-flow-after.txt | 14 + .../drive-threads-0909-post-suite-before.txt | 37 ++ .../drive-threads-0909-review-before.txt | 81 +++++ .../drive-threads-0909-trust-probe.mjs | 28 ++ .../drive-threads-0909-trust-probe.txt | 13 + workflows/drive-local.yaml | 13 +- 16 files changed, 957 insertions(+), 13 deletions(-) create mode 100644 ops/local-work-review.test.mjs create mode 100644 ops/runtime-evidence/drive-threads-0909-after.txt create mode 100644 ops/runtime-evidence/drive-threads-0909-baseline.txt create mode 100644 ops/runtime-evidence/drive-threads-0909-build.txt create mode 100644 ops/runtime-evidence/drive-threads-0909-decisions.md create mode 100644 ops/runtime-evidence/drive-threads-0909-flow-after.txt create mode 100644 ops/runtime-evidence/drive-threads-0909-post-suite-before.txt create mode 100644 ops/runtime-evidence/drive-threads-0909-review-before.txt create mode 100644 ops/runtime-evidence/drive-threads-0909-trust-probe.mjs create mode 100644 ops/runtime-evidence/drive-threads-0909-trust-probe.txt diff --git a/ops/DRIVE-LOCAL.md b/ops/DRIVE-LOCAL.md index ea3a582f9..c24846e94 100644 --- a/ops/DRIVE-LOCAL.md +++ b/ops/DRIVE-LOCAL.md @@ -11,7 +11,7 @@ Run on the branch that should receive the diff, from a clean checkout. The flow leaves delivery to the operator. It does not commit or merge. Selection uses the SDK backlog picker. A locally executable entry must name -existing repository paths in backticks and declare at least one acceptance +repository paths committed at HEAD in backticks and declare at least one acceptance command on an indented `Verify:` line. Each command is a JSON argv array, executed at the repository root with a two-minute bound. For example: @@ -36,6 +36,8 @@ string-prefix siblings. Backlog, verifier and gate changes fail even when a package names a containing directory. Symlink changes are refused. Ignored build/runtime artifacts are excluded from this Git diff boundary; it is not an OS filesystem sandbox or protection against a process rewriting Git metadata. +Pre-existing unresolved symlinks under directory scopes are refused: creating +a file through a dangling symlink can write outside the checkout. Verification reconstructs the selected work from the unchanged backlog and compares its scope and commands to package.json. Each package check must pass @@ -43,3 +45,14 @@ before the SDK regression suite runs. An unchanged implementation whose DoD is unmet fails. HEAD/branch changes fail, and out-of-scope changes produced by an acceptance command fail too. A failed scope or verification step prevents the dependent report step from running. + +The scope command runs again after the SDK build and suite, before reporting. +Reports include tracked changes against HEAD and non-ignored untracked paths. + +Known trust-boundary blockers remain: ignored package metadata can be repinned +after a commit, and `.drive-gate` scripts and their checksum manifest can be +rewritten together. Neither is an independently owned baseline. Acceptance +commands can also load mutable scripts from implementation scope. See +`runtime-evidence/drive-threads-0909-decisions.md`; this flow is not ready for +unattended use until ownership of that baseline and the acceptance inputs is +settled and enforced. diff --git a/ops/local-work-package.mjs b/ops/local-work-package.mjs index 21453982d..f12a31a43 100644 --- a/ops/local-work-package.mjs +++ b/ops/local-work-package.mjs @@ -213,7 +213,10 @@ function report() { // different commit would describe work this tick did not do. const head = git('rev-parse', 'HEAD'); assert.equal(head, pkg.head, `HEAD_MOVED: selected at ${pkg.head}, now ${head}`); - const stat = git('diff', '--stat'); + const stat = [ + git('diff', 'HEAD', '--stat'), + git('ls-files', '--others', '--exclude-standard'), + ].filter(Boolean).join('\n'); console.log(`REPORT ${pkg.title}`); console.log(stat || ' (no working-tree changes)'); for (const item of pkg.definitionOfDone) console.log(` DoD: ${item}`); diff --git a/ops/local-work-review.test.mjs b/ops/local-work-review.test.mjs new file mode 100644 index 000000000..ce2993ae3 --- /dev/null +++ b/ops/local-work-review.test.mjs @@ -0,0 +1,66 @@ +import assert from 'node:assert/strict'; +import { symlinkSync } from 'node:fs'; +import { join } from 'node:path'; +import test from 'node:test'; +import { entry, fixture, flow, pass, fail } from './local-work-test-fixture.mjs'; + +for (const kind of ['unstaged', 'staged', 'untracked']) { + test(`report includes an allowed ${kind} change`, t => { + const f = fixture(t, entry('Directory', 'src/')); + pass(f.run('select')); + const path = kind === 'untracked' ? 'src/new.txt' : 'src/value.txt'; + f.put(path, 'fixed'); + if (kind === 'staged') f.git('add', path); + pass(f.run('scope')); + const report = f.run('report'); + pass(report); + assert.match(report.stdout, new RegExp(path)); + assert.doesNotMatch(report.stdout, /no working-tree changes/); + }); +} + +for (const kind of ['dangling escape', 'existing internal']) { + test(`directory scope checks a pre-existing ${kind} symlink`, t => { + const f = fixture(t, entry('Directory', 'src/')); + symlinkSync(kind === 'dangling escape' ? '../../../missing-target' : 'value.txt', + join(f.root, 'src/link')); + f.git('add', 'src/link'); + f.git('-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.test', + '-c', 'commit.gpgsign=false', 'commit', '-qm', 'existing link'); + pass(f.run('select')); + const result = f.run('scope'); + if (kind === 'dangling escape') fail(result, /SYMLINK_UNRESOLVED/); + else pass(result); + }); +} + +test('selection skips untracked scope and accepts the next committed scope', t => { + const f = fixture(t, entry('Untracked', 'new/value.txt') + entry()); + f.put('new/value.txt', 'untracked'); + const result = f.run('select'); + pass(result); + assert.match(result.stdout, /SKIPPED \[stale_scope: new\/value.txt\] Untracked/); + assert.match(result.stdout, /SELECTED Fix value/); +}); + +for (const path of ['outside.txt', 'src/value.txt']) { + test(`reporting after SDK suite effects enforces scope for ${path}`, t => { + const f = fixture(t); + pass(f.run('select')); + f.put('src/value.txt', 'fixed'); + pass(f.run('verify')); + // Model a Git-visible effect produced by the SDK suite after package checks. + f.put(path, 'suite effect'); + let previous = 'verify'; + let result; + while (previous !== 'report') { + const next = flow.steps.find(step => step.dependsOn?.includes(previous)); + assert(next, `no path from ${previous} to report`); + result = f.step(next.id); + if (result.status !== 0) break; + previous = next.id; + } + if (path === 'outside.txt') fail(result, /OUT_OF_SCOPE/); + else { pass(result); assert.equal(previous, 'report'); } + }); +} diff --git a/ops/local-work-test-fixture.mjs b/ops/local-work-test-fixture.mjs index 592583558..2088eb188 100644 --- a/ops/local-work-test-fixture.mjs +++ b/ops/local-work-test-fixture.mjs @@ -1,8 +1,12 @@ import assert from 'node:assert/strict'; import { spawnSync, execFileSync } from 'node:child_process'; -import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'node:fs'; +import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync, symlinkSync } from 'node:fs'; import { join, resolve } from 'node:path'; -import { load } from '../packages/sdk/node_modules/js-yaml/dist/js-yaml.mjs'; +import { createRequire } from 'node:module'; + +// Resolve the SDK's declared YAML dependency through its public package entry, +// allowing Node to find either a local install or a hoisted dependency. +const { load } = createRequire(new URL('../packages/sdk/package.json', import.meta.url))('js-yaml'); export const flow = load(readFileSync('workflows/drive-local.yaml', 'utf8')); export const packagePath = '.relayflow/drive-local/package.json'; @@ -21,7 +25,7 @@ export function fixture(t, backlog = entry()) { }; const git = (...args) => execFileSync('git', args, { cwd: root, stdio: 'pipe' }); git('init', '-q', '-b', 'work'); - put('.gitignore', '.relayflow/\npackages/sdk/dist/\n'); + put('.gitignore', '.relayflow/\n.drive-gate/\npackages/sdk/dist/\nnode_modules/\n'); put('src/value.txt', 'broken'); put('outside.txt', 'original'); put('ops/BACKLOG.md', backlog); @@ -29,14 +33,27 @@ export function fixture(t, backlog = entry()) { put(path, readFileSync(path)); } put('packages/sdk/dist/backlog-picker.js', readFileSync('packages/sdk/dist/backlog-picker.js')); + put('packages/sdk/src/backlog-picker.ts', readFileSync('packages/sdk/src/backlog-picker.ts')); + put('packages/sdk/package.json', '{"type":"module"}\n'); + put('packages/sdk/tsconfig.json', JSON.stringify({ + compilerOptions: { target: 'ES2022', module: 'NodeNext', outDir: 'dist', skipLibCheck: true }, + include: ['src/backlog-picker.ts'], + })); + symlinkSync(resolve('packages/sdk/node_modules'), join(root, 'packages/sdk/node_modules')); git('add', '.'); git('-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.test', '-c', 'commit.gpgsign=false', 'commit', '-qm', 'fixture'); - const run = (operation, extra = []) => spawnSync(process.execPath, - [...extra, 'ops/local-work-package.mjs', operation], { cwd: root, encoding: 'utf8', timeout: 5000 }); + const step = id => spawnSync('sh', ['-c', flow.steps.find(s => s.id === id).command], + { cwd: root, encoding: 'utf8', timeout: 10000 }); + const run = (operation, extra = []) => { + const result = spawnSync(process.execPath, + [...extra, 'ops/local-work-package.mjs', operation], { cwd: root, encoding: 'utf8', timeout: 5000 }); + if (operation === 'select' && result.status === 0) pass(step('gate-snapshot')); + return result; + }; const scope = () => spawnSync('sh', ['-c', flow.steps.find(s => s.id === 'scope').command], { cwd: root, encoding: 'utf8', timeout: 5000 }); - return { root, put, git, run, scope }; + return { root, put, git, run, scope, step }; } export const pass = result => assert.equal(result.status, 0, result.stderr + result.stdout); export const fail = (result, pattern) => { diff --git a/ops/local-work-verification.mjs b/ops/local-work-verification.mjs index 7ea9ff0f0..124101091 100644 --- a/ops/local-work-verification.mjs +++ b/ops/local-work-verification.mjs @@ -62,7 +62,9 @@ export function checkScope(pkg) { try { target = realpathSync(child); } catch (error) { - if (error.code === 'ENOENT') continue; // dangling: writes cannot escape through it + // O_CREAT follows dangling links too. Without a resolvable target + // we cannot prove containment, so refuse before running checks. + if (error.code === 'ENOENT') throw new Error(`SYMLINK_UNRESOLVED: ${child}`, { cause: error }); throw error; } assert(target === root || target.startsWith(`${root}${sep}`), diff --git a/ops/runtime-evidence/drive-hardening-0908.txt b/ops/runtime-evidence/drive-hardening-0908.txt index c70b293ba..688ceb594 100644 --- a/ops/runtime-evidence/drive-hardening-0908.txt +++ b/ops/runtime-evidence/drive-hardening-0908.txt @@ -1,6 +1,6 @@ Drive-local hardening — captured commands and output -The baseline local-package test was already failing: it invoked the removed apply command. The replacement retains interrupted-write coverage for package selection. +The baseline local-package test failed during select with NO_BOUNDED_WORK (ops/local-work-package.mjs:123), before any apply command ran. The replacement retains interrupted-write coverage for package selection. The default npm test invocation encountered a broken mise Cargo shim. The subsequent command selects the installed Rust toolchain without changing repository build scripts. diff --git a/ops/runtime-evidence/drive-threads-0909-after.txt b/ops/runtime-evidence/drive-threads-0909-after.txt new file mode 100644 index 000000000..e337c3a7c --- /dev/null +++ b/ops/runtime-evidence/drive-threads-0909-after.txt @@ -0,0 +1,38 @@ +$ node --test ops/local-work-package.test.mjs ops/local-work-review.test.mjs +✔ skip cursor passes a later title mentioned in an earlier body (592.25075ms) +✔ selection skips missing checks and stale paths with reasons (605.41ms) +✔ a non-SDK package fails unchanged and passes only after its check holds (909.826375ms) +✔ multiple acceptance checks all execute and failures propagate (603.32375ms) +✔ scope refuses an outside unstaged path (596.18475ms) +✔ scope refuses an outside staged path (588.12325ms) +✔ scope refuses an outside untracked path (566.958417ms) +✔ scope refuses an outside staged reversal path (573.086334ms) +✔ scope refuses an outside rename path (539.01ms) +✔ scope refuses an outside deleted path (529.127208ms) +✔ the submitted scope command refuses a changed ops/local-work-package.mjs before loading it (440.83575ms) +✔ the submitted scope command refuses a changed ops/local-work-verification.mjs before loading it (416.019333ms) +✔ the submitted scope command refuses a changed ops/BACKLOG.md before loading it (426.724833ms) +✔ editing ignored package metadata cannot widen scope or replace checks (588.573417ms) +✔ scope allows directory children but rejects a sibling with the same prefix (671.063833ms) +✔ scope allows in-scope deletions and rejects symlink escapes (654.396083ms) +✔ replacing a scoped file with a directory does not authorize its children (525.916125ms) +✔ a check that writes outside scope fails verification (580.079417ms) +✔ interrupted package write preserves the original and retry replaces it atomically (856.901417ms) +✔ report includes an allowed unstaged change (604.990041ms) +✔ report includes an allowed staged change (613.164375ms) +✔ report includes an allowed untracked change (600.954375ms) +✔ directory scope checks a pre-existing dangling escape symlink (525.141417ms) +✔ directory scope checks a pre-existing existing internal symlink (578.75775ms) +✔ selection skips untracked scope and accepts the next committed scope (449.340959ms) +✔ reporting after SDK suite effects enforces scope for outside.txt (771.198042ms) +✔ reporting after SDK suite effects enforces scope for src/value.txt (862.321625ms) +ℹ tests 27 +ℹ suites 0 +ℹ pass 27 +ℹ fail 0 +ℹ cancelled 0 +ℹ skipped 0 +ℹ todo 0 +ℹ duration_ms 11308.214375 + +EXIT_CODE=0 diff --git a/ops/runtime-evidence/drive-threads-0909-baseline.txt b/ops/runtime-evidence/drive-threads-0909-baseline.txt new file mode 100644 index 000000000..6185ab0ca --- /dev/null +++ b/ops/runtime-evidence/drive-threads-0909-baseline.txt @@ -0,0 +1,321 @@ +$ node --test ops/local-work-package.test.mjs +✖ skip cursor passes a later title mentioned in an earlier body (189.542625ms) +✖ selection skips missing checks and stale paths with reasons (173.940125ms) +✖ a non-SDK package fails unchanged and passes only after its check holds (282.30425ms) +✔ multiple acceptance checks all execute and failures propagate (273.12475ms) +✖ scope refuses an outside unstaged path (161.20125ms) +✖ scope refuses an outside staged path (163.438917ms) +✖ scope refuses an outside untracked path (153.918833ms) +✖ scope refuses an outside staged reversal path (165.684917ms) +✖ scope refuses an outside rename path (163.418917ms) +✖ scope refuses an outside deleted path (156.003625ms) +✔ the submitted scope command refuses a changed ops/local-work-package.mjs before loading it (139.637792ms) +✔ the submitted scope command refuses a changed ops/local-work-verification.mjs before loading it (137.1565ms) +✔ the submitted scope command refuses a changed ops/BACKLOG.md before loading it (133.5985ms) +✖ editing ignored package metadata cannot widen scope or replace checks (158.204ms) +✖ scope allows directory children but rejects a sibling with the same prefix (153.890833ms) +✖ scope allows in-scope deletions and rejects symlink escapes (151.434708ms) +✖ replacing a scoped file with a directory does not authorize its children (153.157625ms) +✔ a check that writes outside scope fails verification (316.586583ms) +✔ interrupted package write preserves the original and retry replaces it atomically (256.604083ms) +ℹ tests 19 +ℹ suites 0 +ℹ pass 6 +ℹ fail 13 +ℹ cancelled 0 +ℹ skipped 0 +ℹ todo 0 +ℹ duration_ms 3530.785583 + +✖ failing tests: + +test at ops/local-work-package.test.mjs:9:1 +✖ skip cursor passes a later title mentioned in an earlier body (189.542625ms) + AssertionError [ERR_ASSERTION]: shasum: .drive-gate/SHA256SUMS: No such file or directory + + + 2 !== 0 + + at pass (file:///Users/khaliqgant/flows-threads-wt/ops/local-work-test-fixture.mjs:41:38) + at TestContext. (file:///Users/khaliqgant/flows-threads-wt/ops/local-work-package.test.mjs:16:3) + at Test.runInAsyncScope (node:async_hooks:226:14) + at Test.run (node:internal/test_runner/test:1397:25) + at Test.start (node:internal/test_runner/test:1257:17) + at startSubtestAfterBootstrap (node:internal/test_runner/harness:387:17) { + generatedMessage: false, + code: 'ERR_ASSERTION', + actual: 2, + expected: 0, + operator: 'strictEqual', + diff: 'simple' + } + +test at ops/local-work-package.test.mjs:19:1 +✖ selection skips missing checks and stale paths with reasons (173.940125ms) + AssertionError [ERR_ASSERTION]: shasum: .drive-gate/SHA256SUMS: No such file or directory + + + 2 !== 0 + + at pass (file:///Users/khaliqgant/flows-threads-wt/ops/local-work-test-fixture.mjs:41:38) + at TestContext. (file:///Users/khaliqgant/flows-threads-wt/ops/local-work-package.test.mjs:26:3) + at Test.runInAsyncScope (node:async_hooks:226:14) + at Test.run (node:internal/test_runner/test:1397:25) + at Test.processPendingSubtests (node:internal/test_runner/test:969:18) + at Test.postRun (node:internal/test_runner/test:1537:19) + at Test.run (node:internal/test_runner/test:1462:12) + at async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3) { + generatedMessage: false, + code: 'ERR_ASSERTION', + actual: 2, + expected: 0, + operator: 'strictEqual', + diff: 'simple' + } + +test at ops/local-work-package.test.mjs:29:1 +✖ a non-SDK package fails unchanged and passes only after its check holds (282.30425ms) + AssertionError [ERR_ASSERTION]: shasum: .drive-gate/SHA256SUMS: No such file or directory + + + 2 !== 0 + + at pass (file:///Users/khaliqgant/flows-threads-wt/ops/local-work-test-fixture.mjs:41:38) + at TestContext. (file:///Users/khaliqgant/flows-threads-wt/ops/local-work-package.test.mjs:34:3) + at Test.runInAsyncScope (node:async_hooks:226:14) + at Test.run (node:internal/test_runner/test:1397:25) + at Test.processPendingSubtests (node:internal/test_runner/test:969:18) + at Test.postRun (node:internal/test_runner/test:1537:19) + at Test.run (node:internal/test_runner/test:1462:12) + at async Test.processPendingSubtests (node:internal/test_runner/test:969:7) { + generatedMessage: false, + code: 'ERR_ASSERTION', + actual: 2, + expected: 0, + operator: 'strictEqual', + diff: 'simple' + } + +test at ops/local-work-package.test.mjs:46:3 +✖ scope refuses an outside unstaged path (161.20125ms) + AssertionError [ERR_ASSERTION]: The input did not match the regular expression /OUT_OF_SCOPE/. Input: + + 'shasum: .drive-gate/SHA256SUMS: No such file or directory\n' + + at fail (file:///Users/khaliqgant/flows-threads-wt/ops/local-work-test-fixture.mjs:44:23) + at TestContext. (file:///Users/khaliqgant/flows-threads-wt/ops/local-work-package.test.mjs:56:5) + at Test.runInAsyncScope (node:async_hooks:226:14) + at Test.run (node:internal/test_runner/test:1397:25) + at Test.processPendingSubtests (node:internal/test_runner/test:969:18) + at Test.postRun (node:internal/test_runner/test:1537:19) + at Test.run (node:internal/test_runner/test:1462:12) + at async Test.processPendingSubtests (node:internal/test_runner/test:969:7) { + generatedMessage: true, + code: 'ERR_ASSERTION', + actual: 'shasum: .drive-gate/SHA256SUMS: No such file or directory\n', + expected: /OUT_OF_SCOPE/, + operator: 'match', + diff: 'simple' + } + +test at ops/local-work-package.test.mjs:46:3 +✖ scope refuses an outside staged path (163.438917ms) + AssertionError [ERR_ASSERTION]: The input did not match the regular expression /OUT_OF_SCOPE/. Input: + + 'shasum: .drive-gate/SHA256SUMS: No such file or directory\n' + + at fail (file:///Users/khaliqgant/flows-threads-wt/ops/local-work-test-fixture.mjs:44:23) + at TestContext. (file:///Users/khaliqgant/flows-threads-wt/ops/local-work-package.test.mjs:56:5) + at Test.runInAsyncScope (node:async_hooks:226:14) + at Test.run (node:internal/test_runner/test:1397:25) + at Test.processPendingSubtests (node:internal/test_runner/test:969:18) + at Test.postRun (node:internal/test_runner/test:1537:19) + at Test.run (node:internal/test_runner/test:1462:12) + at async Test.processPendingSubtests (node:internal/test_runner/test:969:7) { + generatedMessage: true, + code: 'ERR_ASSERTION', + actual: 'shasum: .drive-gate/SHA256SUMS: No such file or directory\n', + expected: /OUT_OF_SCOPE/, + operator: 'match', + diff: 'simple' + } + +test at ops/local-work-package.test.mjs:46:3 +✖ scope refuses an outside untracked path (153.918833ms) + AssertionError [ERR_ASSERTION]: The input did not match the regular expression /OUT_OF_SCOPE/. Input: + + 'shasum: .drive-gate/SHA256SUMS: No such file or directory\n' + + at fail (file:///Users/khaliqgant/flows-threads-wt/ops/local-work-test-fixture.mjs:44:23) + at TestContext. (file:///Users/khaliqgant/flows-threads-wt/ops/local-work-package.test.mjs:56:5) + at Test.runInAsyncScope (node:async_hooks:226:14) + at Test.run (node:internal/test_runner/test:1397:25) + at Test.processPendingSubtests (node:internal/test_runner/test:969:18) + at Test.postRun (node:internal/test_runner/test:1537:19) + at Test.run (node:internal/test_runner/test:1462:12) + at async Test.processPendingSubtests (node:internal/test_runner/test:969:7) { + generatedMessage: true, + code: 'ERR_ASSERTION', + actual: 'shasum: .drive-gate/SHA256SUMS: No such file or directory\n', + expected: /OUT_OF_SCOPE/, + operator: 'match', + diff: 'simple' + } + +test at ops/local-work-package.test.mjs:46:3 +✖ scope refuses an outside staged reversal path (165.684917ms) + AssertionError [ERR_ASSERTION]: The input did not match the regular expression /OUT_OF_SCOPE/. Input: + + 'shasum: .drive-gate/SHA256SUMS: No such file or directory\n' + + at fail (file:///Users/khaliqgant/flows-threads-wt/ops/local-work-test-fixture.mjs:44:23) + at TestContext. (file:///Users/khaliqgant/flows-threads-wt/ops/local-work-package.test.mjs:56:5) + at Test.runInAsyncScope (node:async_hooks:226:14) + at Test.run (node:internal/test_runner/test:1397:25) + at Test.processPendingSubtests (node:internal/test_runner/test:969:18) + at Test.postRun (node:internal/test_runner/test:1537:19) + at Test.run (node:internal/test_runner/test:1462:12) + at async Test.processPendingSubtests (node:internal/test_runner/test:969:7) { + generatedMessage: true, + code: 'ERR_ASSERTION', + actual: 'shasum: .drive-gate/SHA256SUMS: No such file or directory\n', + expected: /OUT_OF_SCOPE/, + operator: 'match', + diff: 'simple' + } + +test at ops/local-work-package.test.mjs:46:3 +✖ scope refuses an outside rename path (163.418917ms) + AssertionError [ERR_ASSERTION]: The input did not match the regular expression /OUT_OF_SCOPE/. Input: + + 'shasum: .drive-gate/SHA256SUMS: No such file or directory\n' + + at fail (file:///Users/khaliqgant/flows-threads-wt/ops/local-work-test-fixture.mjs:44:23) + at TestContext. (file:///Users/khaliqgant/flows-threads-wt/ops/local-work-package.test.mjs:56:5) + at Test.runInAsyncScope (node:async_hooks:226:14) + at Test.run (node:internal/test_runner/test:1397:25) + at Test.processPendingSubtests (node:internal/test_runner/test:969:18) + at Test.postRun (node:internal/test_runner/test:1537:19) + at Test.run (node:internal/test_runner/test:1462:12) + at async Test.processPendingSubtests (node:internal/test_runner/test:969:7) { + generatedMessage: true, + code: 'ERR_ASSERTION', + actual: 'shasum: .drive-gate/SHA256SUMS: No such file or directory\n', + expected: /OUT_OF_SCOPE/, + operator: 'match', + diff: 'simple' + } + +test at ops/local-work-package.test.mjs:46:3 +✖ scope refuses an outside deleted path (156.003625ms) + AssertionError [ERR_ASSERTION]: The input did not match the regular expression /OUT_OF_SCOPE/. Input: + + 'shasum: .drive-gate/SHA256SUMS: No such file or directory\n' + + at fail (file:///Users/khaliqgant/flows-threads-wt/ops/local-work-test-fixture.mjs:44:23) + at TestContext. (file:///Users/khaliqgant/flows-threads-wt/ops/local-work-package.test.mjs:56:5) + at Test.runInAsyncScope (node:async_hooks:226:14) + at Test.run (node:internal/test_runner/test:1397:25) + at Test.processPendingSubtests (node:internal/test_runner/test:969:18) + at Test.postRun (node:internal/test_runner/test:1537:19) + at Test.run (node:internal/test_runner/test:1462:12) + at async Test.processPendingSubtests (node:internal/test_runner/test:969:7) { + generatedMessage: true, + code: 'ERR_ASSERTION', + actual: 'shasum: .drive-gate/SHA256SUMS: No such file or directory\n', + expected: /OUT_OF_SCOPE/, + operator: 'match', + diff: 'simple' + } + +test at ops/local-work-package.test.mjs:69:1 +✖ editing ignored package metadata cannot widen scope or replace checks (158.204ms) + AssertionError [ERR_ASSERTION]: The input did not match the regular expression /PACKAGE_CHANGED/. Input: + + 'shasum: .drive-gate/SHA256SUMS: No such file or directory\n' + + at fail (file:///Users/khaliqgant/flows-threads-wt/ops/local-work-test-fixture.mjs:44:23) + at TestContext. (file:///Users/khaliqgant/flows-threads-wt/ops/local-work-package.test.mjs:75:5) + at Test.runInAsyncScope (node:async_hooks:226:14) + at Test.run (node:internal/test_runner/test:1397:25) + at Test.processPendingSubtests (node:internal/test_runner/test:969:18) + at Test.postRun (node:internal/test_runner/test:1537:19) + at Test.run (node:internal/test_runner/test:1462:12) + at async Test.processPendingSubtests (node:internal/test_runner/test:969:7) { + generatedMessage: true, + code: 'ERR_ASSERTION', + actual: 'shasum: .drive-gate/SHA256SUMS: No such file or directory\n', + expected: /PACKAGE_CHANGED/, + operator: 'match', + diff: 'simple' + } + +test at ops/local-work-package.test.mjs:79:1 +✖ scope allows directory children but rejects a sibling with the same prefix (153.890833ms) + AssertionError [ERR_ASSERTION]: shasum: .drive-gate/SHA256SUMS: No such file or directory + + + 2 !== 0 + + at pass (file:///Users/khaliqgant/flows-threads-wt/ops/local-work-test-fixture.mjs:41:38) + at TestContext. (file:///Users/khaliqgant/flows-threads-wt/ops/local-work-package.test.mjs:83:3) + at Test.runInAsyncScope (node:async_hooks:226:14) + at Test.run (node:internal/test_runner/test:1397:25) + at Test.processPendingSubtests (node:internal/test_runner/test:969:18) + at Test.postRun (node:internal/test_runner/test:1537:19) + at Test.run (node:internal/test_runner/test:1462:12) + at async Test.processPendingSubtests (node:internal/test_runner/test:969:7) { + generatedMessage: false, + code: 'ERR_ASSERTION', + actual: 2, + expected: 0, + operator: 'strictEqual', + diff: 'simple' + } + +test at ops/local-work-package.test.mjs:88:1 +✖ scope allows in-scope deletions and rejects symlink escapes (151.434708ms) + AssertionError [ERR_ASSERTION]: shasum: .drive-gate/SHA256SUMS: No such file or directory + + + 2 !== 0 + + at pass (file:///Users/khaliqgant/flows-threads-wt/ops/local-work-test-fixture.mjs:41:38) + at TestContext. (file:///Users/khaliqgant/flows-threads-wt/ops/local-work-package.test.mjs:92:3) + at Test.runInAsyncScope (node:async_hooks:226:14) + at Test.run (node:internal/test_runner/test:1397:25) + at Test.processPendingSubtests (node:internal/test_runner/test:969:18) + at Test.postRun (node:internal/test_runner/test:1537:19) + at Test.run (node:internal/test_runner/test:1462:12) + at async Test.processPendingSubtests (node:internal/test_runner/test:969:7) { + generatedMessage: false, + code: 'ERR_ASSERTION', + actual: 2, + expected: 0, + operator: 'strictEqual', + diff: 'simple' + } + +test at ops/local-work-package.test.mjs:97:1 +✖ replacing a scoped file with a directory does not authorize its children (153.157625ms) + AssertionError [ERR_ASSERTION]: The input did not match the regular expression /OUT_OF_SCOPE/. Input: + + 'shasum: .drive-gate/SHA256SUMS: No such file or directory\n' + + at fail (file:///Users/khaliqgant/flows-threads-wt/ops/local-work-test-fixture.mjs:44:23) + at TestContext. (file:///Users/khaliqgant/flows-threads-wt/ops/local-work-package.test.mjs:102:3) + at Test.runInAsyncScope (node:async_hooks:226:14) + at Test.run (node:internal/test_runner/test:1397:25) + at Test.processPendingSubtests (node:internal/test_runner/test:969:18) + at Test.postRun (node:internal/test_runner/test:1537:19) + at Test.run (node:internal/test_runner/test:1462:12) + at async Test.processPendingSubtests (node:internal/test_runner/test:969:7) { + generatedMessage: true, + code: 'ERR_ASSERTION', + actual: 'shasum: .drive-gate/SHA256SUMS: No such file or directory\n', + expected: /OUT_OF_SCOPE/, + operator: 'match', + diff: 'simple' + } + +EXIT_CODE=1 diff --git a/ops/runtime-evidence/drive-threads-0909-build.txt b/ops/runtime-evidence/drive-threads-0909-build.txt new file mode 100644 index 000000000..fb0a423a9 --- /dev/null +++ b/ops/runtime-evidence/drive-threads-0909-build.txt @@ -0,0 +1,261 @@ +$ cd packages/sdk && node node_modules/typescript/bin/tsc && node scripts/make-cli-executable.mjs +EXIT_CODE=0 + +$ cd kernel && sh ../ops/cargo.sh build +mise ERROR cargo is not a valid shim. This likely means you uninstalled a tool and the shim does not point to anything. Run `mise use ` to reinstall the tool. +mise ERROR Run with --verbose or MISE_VERBOSE=1 for more information +EXIT_CODE=1 + +$ cd kernel && PATH=/Users/khaliqgant/.rustup/toolchains/stable-aarch64-apple-darwin/bin:$PATH cargo build + Updating crates.io index + Downloading crates ... + Downloaded wait-timeout v0.2.1 + Downloaded anstyle-query v1.1.5 + Downloaded bit-set v0.8.0 + Downloaded zerofrom v0.1.8 + Downloaded block-buffer v0.10.4 + Downloaded borrow-or-share v0.2.4 + Downloaded anstyle-parse v1.0.0 + Downloaded cfg-if v1.0.4 + Downloaded anstream v1.0.0 + Downloaded anstyle v1.0.14 + Downloaded autocfg v1.5.1 + Downloaded typenum v1.20.1 + Downloaded yoke-derive v0.8.2 + Downloaded utf8parse v0.2.2 + Downloaded uuid-simd v0.8.0 + Downloaded version_check v0.9.5 + Downloaded bit-vec v0.8.0 + Downloaded bytecount v0.6.9 + Downloaded anyhow v1.0.104 + Downloaded ahash v0.8.12 + Downloaded bitflags v2.13.1 + Downloaded cpufeatures v0.2.17 + Downloaded zerofrom-derive v0.1.7 + Downloaded crypto-common v0.1.7 + Downloaded colorchoice v1.0.5 + Downloaded email_address v0.2.9 + Downloaded displaydoc v0.2.7 + Downloaded utf8_iter v1.0.4 + Downloaded clap_lex v1.1.0 + Downloaded fallible-streaming-iterator v0.1.9 + Downloaded tinystr v0.8.4 + Downloaded zerovec-derive v0.11.6 + Downloaded zmij v1.0.23 + Downloaded ulid v1.2.1 + Downloaded vsimd v0.8.0 + Downloaded writeable v0.6.4 + Downloaded idna_adapter v1.2.2 + Downloaded itoa v1.0.18 + Downloaded yoke v0.8.3 + Downloaded is_terminal_polyfill v1.70.2 + Downloaded num-iter v0.1.46 + Downloaded digest v0.10.7 + Downloaded generic-array v0.14.7 + Downloaded stable_deref_trait v1.2.1 + Downloaded unicode-ident v1.0.24 + Downloaded ref-cast v1.0.27 + Downloaded uuid v1.26.0 + Downloaded zerotrie v0.2.5 + Downloaded zerovec v0.11.8 + Downloaded vcpkg v0.2.15 + Downloaded zerocopy v0.8.56 + Downloaded heck v0.5.0 + Downloaded lazy_static v1.5.0 + Downloaded num v0.4.3 + Downloaded num-cmp v0.1.0 + Downloaded num-rational v0.4.2 + Downloaded outref v0.5.2 + Downloaded percent-encoding v2.3.2 + Downloaded pkg-config v0.3.34 + Downloaded potential_utf v0.1.6 + Downloaded rand_chacha v0.9.0 + Downloaded ref-cast-impl v1.0.27 + Downloaded scopeguard v1.2.0 + Downloaded strsim v0.11.1 + Downloaded thiserror v2.0.20 + Downloaded base64 v0.22.1 + Downloaded cc v1.4.4 + Downloaded clap_derive v4.6.4 + Downloaded fallible-iterator v0.3.0 + Downloaded find-msvc-tools v0.1.11 + Downloaded foldhash v0.1.5 + Downloaded hashlink v0.10.0 + Downloaded lock_api v0.4.14 + Downloaded num-complex v0.4.6 + Downloaded num-integer v0.1.47 + Downloaded once_cell v1.21.4 + Downloaded parking_lot_core v0.9.12 + Downloaded ppv-lite86 v0.2.21 + Downloaded quote v1.0.47 + Downloaded rand_core v0.9.5 + Downloaded sha2 v0.10.9 + Downloaded shlex v2.0.1 + Downloaded synstructure v0.13.2 + Downloaded fluent-uri v0.3.2 + Downloaded parking_lot v0.12.5 + Downloaded clap v4.6.6 + Downloaded litemap v0.8.3 + Downloaded thiserror-impl v2.0.20 + Downloaded getrandom v0.3.4 + Downloaded icu_provider v2.3.1 + Downloaded referencing v0.33.0 + Downloaded smallvec v1.15.2 + Downloaded num-traits v0.2.19 + Downloaded icu_normalizer_data v2.3.0 + Downloaded serde_derive v1.0.229 + Downloaded serde_core v1.0.229 + Downloaded proc-macro2 v1.0.107 + Downloaded icu_locale_core v2.3.0 + Downloaded serde v1.0.229 + Downloaded fraction v0.15.4 + Downloaded icu_collections v2.3.0 + Downloaded memchr v2.8.3 + Downloaded num-bigint v0.4.8 + Downloaded fancy-regex v0.16.2 + Downloaded icu_properties v2.3.0 + Downloaded rand v0.9.5 + Downloaded aho-corasick v1.1.5 + Downloaded jsonschema v0.33.0 + Downloaded clap_builder v4.6.6 + Downloaded serde_json v1.0.151 + Downloaded idna v1.1.0 + Downloaded hashbrown v0.15.5 + Downloaded icu_properties_data v2.3.0 + Downloaded regex v1.13.1 + Downloaded rusqlite v0.37.0 + Downloaded syn v2.0.119 + Downloaded syn v3.0.4 + Downloaded regex-syntax v0.8.11 + Downloaded icu_normalizer v2.3.0 + Downloaded regex-automata v0.4.18 + Downloaded libc v0.2.189 + Downloaded libsqlite3-sys v0.35.0 + Compiling proc-macro2 v1.0.107 + Compiling unicode-ident v1.0.24 + Compiling quote v1.0.47 + Compiling libc v0.2.189 + Compiling stable_deref_trait v1.2.1 + Compiling version_check v0.9.5 + Compiling cfg-if v1.0.4 + Compiling autocfg v1.5.1 + Compiling serde_core v1.0.229 + Compiling zerocopy v0.8.56 + Compiling getrandom v0.3.4 + Compiling serde v1.0.229 + Compiling smallvec v1.15.2 + Compiling memchr v2.8.3 + Compiling writeable v0.6.4 + Compiling num-traits v0.2.19 + Compiling litemap v0.8.3 + Compiling generic-array v0.14.7 + Compiling icu_properties_data v2.3.0 + Compiling utf8_iter v1.0.4 + Compiling icu_normalizer_data v2.3.0 + Compiling zmij v1.0.23 + Compiling ref-cast v1.0.27 + Compiling parking_lot_core v0.9.12 + Compiling typenum v1.20.1 + Compiling syn v3.0.4 + Compiling syn v2.0.119 + Compiling num-integer v0.1.47 + Compiling aho-corasick v1.1.5 + Compiling num-bigint v0.4.8 + Compiling ahash v0.8.12 + Compiling serde_json v1.0.151 + Compiling shlex v2.0.1 + Compiling regex-syntax v0.8.11 + Compiling find-msvc-tools v0.1.11 + Compiling synstructure v0.13.2 + Compiling scopeguard v1.2.0 + Compiling lock_api v0.4.14 + Compiling num-rational v0.4.2 + Compiling cc v1.4.4 + Compiling num-iter v0.1.46 + Compiling num-complex v0.4.6 + Compiling ppv-lite86 v0.2.21 + Compiling zerofrom-derive v0.1.7 + Compiling yoke-derive v0.8.2 + Compiling regex-automata v0.4.18 + Compiling rand_core v0.9.5 + Compiling itoa v1.0.18 + Compiling borrow-or-share v0.2.4 + Compiling bit-vec v0.8.0 + Compiling once_cell v1.21.4 + Compiling vcpkg v0.2.15 + Compiling pkg-config v0.3.34 + Compiling bit-set v0.8.0 + Compiling rand_chacha v0.9.0 + Compiling zerofrom v0.1.8 + Compiling num v0.4.3 + Compiling parking_lot v0.12.5 + Compiling crypto-common v0.1.7 + Compiling block-buffer v0.10.4 + Compiling utf8parse v0.2.2 + Compiling vsimd v0.8.0 + Compiling thiserror v2.0.20 + Compiling libsqlite3-sys v0.35.0 + Compiling yoke v0.8.3 + Compiling percent-encoding v2.3.2 + Compiling uuid v1.26.0 + Compiling outref v0.5.2 + Compiling zerovec-derive v0.11.6 + Compiling displaydoc v0.2.7 + Compiling serde_derive v1.0.229 + Compiling ref-cast-impl v1.0.27 + Compiling lazy_static v1.5.0 + Compiling foldhash v0.1.5 + Compiling fraction v0.15.4 + Compiling uuid-simd v0.8.0 + Compiling hashbrown v0.15.5 + Compiling zerotrie v0.2.5 + Compiling thiserror-impl v2.0.20 + Compiling anstyle-parse v1.0.0 + Compiling digest v0.10.7 + Compiling zerovec v0.11.8 + Compiling rand v0.9.5 + Compiling cpufeatures v0.2.17 + Compiling anstyle-query v1.1.5 + Compiling is_terminal_polyfill v1.70.2 + Compiling bytecount v0.6.9 + Compiling colorchoice v1.0.5 + Compiling base64 v0.22.1 + Compiling num-cmp v0.1.0 + Compiling anstyle v1.0.14 + Compiling anstream v1.0.0 + Compiling sha2 v0.10.9 + Compiling hashlink v0.10.0 + Compiling anyhow v1.0.104 + Compiling bitflags v2.13.1 + Compiling fallible-streaming-iterator v0.1.9 + Compiling fallible-iterator v0.3.0 + Compiling heck v0.5.0 + Compiling strsim v0.11.1 + Compiling clap_lex v1.1.0 + Compiling clap_derive v4.6.4 + Compiling clap_builder v4.6.6 + Compiling wait-timeout v0.2.1 + Compiling regex v1.13.1 + Compiling fancy-regex v0.16.2 + Compiling tinystr v0.8.4 + Compiling potential_utf v0.1.6 + Compiling icu_locale_core v2.3.0 + Compiling icu_collections v2.3.0 + Compiling fluent-uri v0.3.2 + Compiling email_address v0.2.9 + Compiling ulid v1.2.1 + Compiling icu_provider v2.3.1 + Compiling icu_properties v2.3.0 + Compiling icu_normalizer v2.3.0 + Compiling referencing v0.33.0 + Compiling clap v4.6.6 + Compiling idna_adapter v1.2.2 + Compiling idna v1.1.0 + Compiling jsonschema v0.33.0 + Compiling rusqlite v0.37.0 + Compiling relayflowd-core v0.1.0 (/Users/khaliqgant/flows-threads-wt/kernel/relayflowd-core) + Compiling relayflowd-journal v0.1.0 (/Users/khaliqgant/flows-threads-wt/kernel/relayflowd-journal) + Compiling relayflowd v0.1.0 (/Users/khaliqgant/flows-threads-wt/kernel/relayflowd) + Finished `dev` profile [unoptimized + debuginfo] target(s) in 15.90s + +EXIT_CODE=0 diff --git a/ops/runtime-evidence/drive-threads-0909-decisions.md b/ops/runtime-evidence/drive-threads-0909-decisions.md new file mode 100644 index 000000000..90dc0e1cf --- /dev/null +++ b/ops/runtime-evidence/drive-threads-0909-decisions.md @@ -0,0 +1,43 @@ +# PR #244: remaining trust-boundary decisions + +The reporting, post-suite scope, dangling-link, fixture dependency, and evidence +narrative fixes do not close the following blockers. No swarm pass is claimed. + +1. **Who owns the selected baseline and gate manifest?** Both the selected HEAD + in ignored package.json and the snapshot's SHA256SUMS live in the agent's + writable checkout. Pinning one writable file with another is insufficient. + The local launcher currently submits a static spec and runs the agent in the + same filesystem. A trusted pre-submission launcher baseline embedded in the + submitted commands, or a separately owned gate execution surface, is needed. + This crosses the current flow/helper scope into launcher ownership. Recommend + the launcher capture the original branch, HEAD, package and gate inputs + before submission, then execute checks from that independently owned state. + The human must choose that ownership boundary before this flow claims it. +2. **How are acceptance-check inputs declared?** Immutable argv alone does not + freeze a script or its imports. A command such as `node src/check.mjs` can + execute an agent-edited judge. Inferring its transitive inputs from arbitrary + argv is not reliable. Choose a declared immutable gate-input contract or an + independently preserved acceptance workspace that tests the changed source. + Do not silently narrow the existing arbitrary-argv authoring contract. + +Reproduce the first two bypasses from the repository root: + +```sh +node ops/runtime-evidence/drive-threads-0909-trust-probe.mjs +``` + +The complete probe and captured output are adjacent files. Both scope commands +exit zero, including after committing an out-of-scope change and repinning the +metadata, and after rewriting the snapshot plus its checksum manifest. This is +failure evidence, not a passing security test. + +The old package tests also lacked the snapshot required by the current flow. +`drive-threads-0909-baseline.txt` captures those failures. The fixture now runs +the submitted snapshot step with the real TypeScript compiler over the actual +picker source before exercising the submitted scope command. + +`drive-threads-0909-review-before.txt` and +`drive-threads-0909-post-suite-before.txt` capture regression failures before +their corresponding fixes. `drive-threads-0909-after.txt` captures the package +and review test run after those fixes. These are before/after reproductions; +no mutation-verification claim is made. diff --git a/ops/runtime-evidence/drive-threads-0909-flow-after.txt b/ops/runtime-evidence/drive-threads-0909-flow-after.txt new file mode 100644 index 000000000..7f53770b9 --- /dev/null +++ b/ops/runtime-evidence/drive-threads-0909-flow-after.txt @@ -0,0 +1,14 @@ +$ node --test ops/drive-local-flow.test.mjs +✔ drive-local journals failure and blocks reporting for outside edit (1786.44325ms) +✔ drive-local journals failure and blocks reporting for verifier edit (946.490334ms) +✔ drive-local journals failure and blocks reporting for unchanged package (1199.776667ms) +ℹ tests 3 +ℹ suites 0 +ℹ pass 3 +ℹ fail 0 +ℹ cancelled 0 +ℹ skipped 0 +ℹ todo 0 +ℹ duration_ms 3977.453917 + +EXIT_CODE=0 diff --git a/ops/runtime-evidence/drive-threads-0909-post-suite-before.txt b/ops/runtime-evidence/drive-threads-0909-post-suite-before.txt new file mode 100644 index 000000000..6a116b78a --- /dev/null +++ b/ops/runtime-evidence/drive-threads-0909-post-suite-before.txt @@ -0,0 +1,37 @@ +$ node --test --test-name-pattern='reporting after SDK' ops/local-work-review.test.mjs +✖ reporting after SDK suite effects enforces scope for outside.txt (1530.28375ms) +✔ reporting after SDK suite effects enforces scope for src/value.txt (710.962875ms) +ℹ tests 2 +ℹ suites 0 +ℹ pass 1 +ℹ fail 1 +ℹ cancelled 0 +ℹ skipped 0 +ℹ todo 0 +ℹ duration_ms 2313.327542 + +✖ failing tests: + +test at ops/local-work-review.test.mjs:47:3 +✖ reporting after SDK suite effects enforces scope for outside.txt (1530.28375ms) + AssertionError [ERR_ASSERTION]: REPORT Fix value + outside.txt | 2 +- + src/value.txt | 2 +- + 2 files changed, 2 insertions(+), 2 deletions(-) + DoD: Fix value + + at fail (file:///Users/khaliqgant/flows-threads-wt/ops/local-work-test-fixture.mjs:60:10) + at TestContext. (file:///Users/khaliqgant/flows-threads-wt/ops/local-work-review.test.mjs:63:33) + at Test.runInAsyncScope (node:async_hooks:226:14) + at Test.run (node:internal/test_runner/test:1397:25) + at Test.start (node:internal/test_runner/test:1257:17) + at startSubtestAfterBootstrap (node:internal/test_runner/harness:387:17) { + generatedMessage: false, + code: 'ERR_ASSERTION', + actual: 0, + expected: 0, + operator: 'notStrictEqual', + diff: 'simple' + } + +EXIT_CODE=1 diff --git a/ops/runtime-evidence/drive-threads-0909-review-before.txt b/ops/runtime-evidence/drive-threads-0909-review-before.txt new file mode 100644 index 000000000..73f9bc603 --- /dev/null +++ b/ops/runtime-evidence/drive-threads-0909-review-before.txt @@ -0,0 +1,81 @@ +$ node --test ops/local-work-review.test.mjs +✔ report includes an allowed unstaged change (273.4115ms) +✖ report includes an allowed staged change (286.932584ms) +✖ report includes an allowed untracked change (268.523542ms) +✖ directory scope checks a pre-existing dangling escape symlink (251.117166ms) +✔ directory scope checks a pre-existing existing internal symlink (242.251375ms) +✔ selection skips untracked scope and accepts the next committed scope (128.28975ms) +ℹ tests 6 +ℹ suites 0 +ℹ pass 3 +ℹ fail 3 +ℹ cancelled 0 +ℹ skipped 0 +ℹ todo 0 +ℹ duration_ms 1496.888125 + +✖ failing tests: + +test at ops/local-work-review.test.mjs:8:3 +✖ report includes an allowed staged change (286.932584ms) + AssertionError [ERR_ASSERTION]: The input did not match the regular expression /src\/value.txt/. Input: + + 'REPORT Directory\n (no working-tree changes)\n DoD: Directory\n' + + at TestContext. (file:///Users/khaliqgant/flows-threads-wt/ops/local-work-review.test.mjs:17:12) + at Test.runInAsyncScope (node:async_hooks:226:14) + at Test.run (node:internal/test_runner/test:1397:25) + at Test.processPendingSubtests (node:internal/test_runner/test:969:18) + at Test.postRun (node:internal/test_runner/test:1537:19) + at Test.run (node:internal/test_runner/test:1462:12) + at async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3) { + generatedMessage: true, + code: 'ERR_ASSERTION', + actual: 'REPORT Directory\n (no working-tree changes)\n DoD: Directory\n', + expected: /src\/value.txt/, + operator: 'match', + diff: 'simple' + } + +test at ops/local-work-review.test.mjs:8:3 +✖ report includes an allowed untracked change (268.523542ms) + AssertionError [ERR_ASSERTION]: The input did not match the regular expression /src\/new.txt/. Input: + + 'REPORT Directory\n (no working-tree changes)\n DoD: Directory\n' + + at TestContext. (file:///Users/khaliqgant/flows-threads-wt/ops/local-work-review.test.mjs:17:12) + at Test.runInAsyncScope (node:async_hooks:226:14) + at Test.run (node:internal/test_runner/test:1397:25) + at Test.processPendingSubtests (node:internal/test_runner/test:969:18) + at Test.postRun (node:internal/test_runner/test:1537:19) + at Test.run (node:internal/test_runner/test:1462:12) + at async Test.processPendingSubtests (node:internal/test_runner/test:969:7) { + generatedMessage: true, + code: 'ERR_ASSERTION', + actual: 'REPORT Directory\n (no working-tree changes)\n DoD: Directory\n', + expected: /src\/new.txt/, + operator: 'match', + diff: 'simple' + } + +test at ops/local-work-review.test.mjs:23:3 +✖ directory scope checks a pre-existing dangling escape symlink (251.117166ms) + AssertionError [ERR_ASSERTION]: SCOPE_OK: 0 changed path(s) + + at fail (file:///Users/khaliqgant/flows-threads-wt/ops/local-work-test-fixture.mjs:43:10) + at TestContext. (file:///Users/khaliqgant/flows-threads-wt/ops/local-work-review.test.mjs:32:37) + at Test.runInAsyncScope (node:async_hooks:226:14) + at Test.run (node:internal/test_runner/test:1397:25) + at Test.processPendingSubtests (node:internal/test_runner/test:969:18) + at Test.postRun (node:internal/test_runner/test:1537:19) + at Test.run (node:internal/test_runner/test:1462:12) + at async Test.processPendingSubtests (node:internal/test_runner/test:969:7) { + generatedMessage: false, + code: 'ERR_ASSERTION', + actual: 0, + expected: 0, + operator: 'notStrictEqual', + diff: 'simple' + } + +EXIT_CODE=1 diff --git a/ops/runtime-evidence/drive-threads-0909-trust-probe.mjs b/ops/runtime-evidence/drive-threads-0909-trust-probe.mjs new file mode 100644 index 000000000..cf834d06d --- /dev/null +++ b/ops/runtime-evidence/drive-threads-0909-trust-probe.mjs @@ -0,0 +1,28 @@ +import { readFileSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import { join } from 'node:path'; +import { entry, fixture, packagePath, pass } from '../local-work-test-fixture.mjs'; +const cleanup = []; +const t = { after: fn => cleanup.push(fn) }; +try { + const f = fixture(t); + pass(f.run('select')); + f.put('outside.txt', 'committed outside scope'); + f.git('add', 'outside.txt'); + f.git('-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.test', '-c', 'commit.gpgsign=false', 'commit', '-qm', 'outside scope'); + const pkg = JSON.parse(readFileSync(join(f.root, packagePath), 'utf8')); + pkg.head = f.git('rev-parse', 'HEAD').toString().trim(); + f.put(packagePath, JSON.stringify(pkg)); + const repinned = f.scope(); + console.log('COMMITTED_OUTSIDE_AND_REPINNED_PACKAGE_SCOPE_EXIT=' + repinned.status); + process.stdout.write(repinned.stdout + repinned.stderr); + + const g = fixture(t); + pass(g.run('select')); + g.put('.drive-gate/local-work-package.mjs', 'process.exit(0);\n'); + pass(spawnSync('sh', ['-c', 'shasum -a 256 .drive-gate/*.mjs .drive-gate/*.js > .drive-gate/SHA256SUMS'], {cwd:g.root, encoding:'utf8'})); + const forged = g.scope(); + console.log('REWRITTEN_SNAPSHOT_AND_CHECKSUM_SCOPE_EXIT=' + forged.status); + process.stdout.write(forged.stdout + forged.stderr); + console.log('IMPLEMENTATION=' + readFileSync(join(g.root, 'src/value.txt'), 'utf8')); +} finally { for (const fn of cleanup) fn(); } diff --git a/ops/runtime-evidence/drive-threads-0909-trust-probe.txt b/ops/runtime-evidence/drive-threads-0909-trust-probe.txt new file mode 100644 index 000000000..067a6cc69 --- /dev/null +++ b/ops/runtime-evidence/drive-threads-0909-trust-probe.txt @@ -0,0 +1,13 @@ +$ node ops/runtime-evidence/drive-threads-0909-trust-probe.mjs +COMMITTED_OUTSIDE_AND_REPINNED_PACKAGE_SCOPE_EXIT=0 +.drive-gate/backlog-picker.js: OK +.drive-gate/local-work-package.mjs: OK +.drive-gate/local-work-verification.mjs: OK +SCOPE_OK: 0 changed path(s) +REWRITTEN_SNAPSHOT_AND_CHECKSUM_SCOPE_EXIT=0 +.drive-gate/local-work-package.mjs: OK +.drive-gate/local-work-verification.mjs: OK +.drive-gate/backlog-picker.js: OK +IMPLEMENTATION=broken + +EXIT_CODE=0 diff --git a/workflows/drive-local.yaml b/workflows/drive-local.yaml index 5bed7a60c..9f0390f32 100644 --- a/workflows/drive-local.yaml +++ b/workflows/drive-local.yaml @@ -21,7 +21,7 @@ name: drive-local description: >- Select the next work package from ops/BACKLOG.md, implement it, verify it against its own definition of done, and report. Selection, implementation, - scope enforcement, verification and reporting are journaled as five steps. + scope enforcement, verification and reporting are journaled steps. steps: # No build-sdk step. The launcher asserts `packages/sdk/dist/cli.js` exists # during preflight, before it submits anything, so a build step inside the @@ -96,7 +96,7 @@ steps: timeoutMs: 120000 # This preamble is captured in the submitted spec. Check the verifier # BEFORE executing it; an agent cannot bypass scope by editing the script. - command: | + command: &scope-check | set -eu git diff --exit-code HEAD -- ops/local-work-package.mjs ops/local-work-verification.mjs ops/BACKLOG.md workflows/drive-local.yaml packages/sdk/src/backlog-picker.ts packages/sdk/dist/backlog-picker.js git diff --cached --exit-code HEAD -- ops/local-work-package.mjs ops/local-work-verification.mjs ops/BACKLOG.md workflows/drive-local.yaml packages/sdk/src/backlog-picker.ts packages/sdk/dist/backlog-picker.js @@ -125,8 +125,15 @@ steps: node scripts/make-cli-executable.mjs node node_modules/vitest/vitest.mjs run - - id: report + - id: final-scope type: deterministic dependsOn: [verify] timeoutMs: 120000 + # Builds and tests can write too; recheck before loading the report helper. + command: *scope-check + + - id: report + type: deterministic + dependsOn: [final-scope] + timeoutMs: 120000 command: node ops/local-work-package.mjs report From 4d88ac2d520a79e8bb65f59935ecca38e22bf7a1 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Wed, 9 Sep 2026 14:00:50 +0200 Subject: [PATCH 13/15] Capture local drive gates and baseline in submitted commands --- ops/DRIVE-LOCAL.md | 38 +++++---- ops/drive-local-flow.test.mjs | 26 +++++-- ops/local-work-gate.mjs | 44 +++++++++++ ops/local-work-gate.test.mjs | 55 +++++++++++++ ops/local-work-package.mjs | 72 +++++++++-------- ops/local-work-test-fixture.mjs | 25 +++--- ops/local-work-verification.mjs | 1 + ...ve-threads-0909-acceptance-input-probe.mjs | 22 ++++++ .../drive-threads-0909-acceptance-input.txt | 10 +++ .../drive-threads-0909-decisions.md | 77 ++++++++++--------- .../drive-threads-0909-owned-build.txt | 6 ++ .../drive-threads-0909-owned-gate-after.txt | 48 ++++++++++++ .../drive-threads-0909-owned-trust-after.txt | 41 ++++++++++ .../drive-threads-0909-trust-probe.mjs | 15 +++- scripts/run-drive-local.mjs | 33 ++++++++ workflows/drive-local.yaml | 46 +++-------- 16 files changed, 417 insertions(+), 142 deletions(-) create mode 100644 ops/local-work-gate.mjs create mode 100644 ops/local-work-gate.test.mjs create mode 100644 ops/runtime-evidence/drive-threads-0909-acceptance-input-probe.mjs create mode 100644 ops/runtime-evidence/drive-threads-0909-acceptance-input.txt create mode 100644 ops/runtime-evidence/drive-threads-0909-owned-build.txt create mode 100644 ops/runtime-evidence/drive-threads-0909-owned-gate-after.txt create mode 100644 ops/runtime-evidence/drive-threads-0909-owned-trust-after.txt create mode 100644 scripts/run-drive-local.mjs diff --git a/ops/DRIVE-LOCAL.md b/ops/DRIVE-LOCAL.md index c24846e94..132fbe919 100644 --- a/ops/DRIVE-LOCAL.md +++ b/ops/DRIVE-LOCAL.md @@ -1,14 +1,17 @@ # Local drive packages -Build the SDK before launching `workflows/drive-local.yaml`: +Launch from a trusted checkout with the SDK dependencies installed: ```sh -npm --prefix packages/sdk run build -node scripts/run-local-workflow.mjs workflows/drive-local.yaml +node scripts/run-drive-local.mjs ``` Run on the branch that should receive the diff, from a clean checkout. The -flow leaves delivery to the operator. It does not commit or merge. +flow leaves delivery to the operator. It does not commit or merge. The wrapper +builds the SDK, captures the original HEAD, branch, backlog hash and gate code, +then submits the prepared commands through the existing local launcher. The +daemon journals those commands before implementation starts. Running the YAML +template directly refuses selection because these captured inputs are missing. Selection uses the SDK backlog picker. A locally executable entry must name repository paths committed at HEAD in backticks and declare at least one acceptance @@ -28,14 +31,15 @@ reasons. The old F8b entry now carries a source assertion for its declared rename. That assertion also allows an already-completed package to pass without manufacturing another edit. -The submitted scope step first refuses changes to its helper scripts and -backlog before executing either helper. It then checks the working tree and +The submitted scope step executes the captured helper code and refuses changes +to its checkout sources and backlog. It checks the working tree and index against the selected HEAD, including untracked non-ignored files and both sides of renames. Scope uses exact paths or directory descendants, never string-prefix siblings. Backlog, verifier and gate changes fail even when a package names a containing directory. Symlink changes are refused. Ignored build/runtime artifacts are excluded from this Git diff boundary; it is not an -OS filesystem sandbox or protection against a process rewriting Git metadata. +OS filesystem sandbox. Moving HEAD or branch fails against the submitted pins, +even if the agent updates the ignored package metadata to match. Pre-existing unresolved symlinks under directory scopes are refused: creating a file through a dangling symlink can write outside the checkout. @@ -49,10 +53,16 @@ the dependent report step from running. The scope command runs again after the SDK build and suite, before reporting. Reports include tracked changes against HEAD and non-ignored untracked paths. -Known trust-boundary blockers remain: ignored package metadata can be repinned -after a commit, and `.drive-gate` scripts and their checksum manifest can be -rewritten together. Neither is an independently owned baseline. Acceptance -commands can also load mutable scripts from implementation scope. See -`runtime-evidence/drive-threads-0909-decisions.md`; this flow is not ready for -unattended use until ownership of that baseline and the acceptance inputs is -settled and enforced. +The execution contract has one owner for each kind of data: + +| Input or policy | Owner and validation | +| --- | --- | +| Gate code, compiled picker, HEAD, branch, backlog hash | Preparing launcher captures bytes into submitted commands; daemon executes its journaled spec. No checkout snapshot or writable checksum is consulted. | +| Package metadata | Private atomic JSON artifact for the agent; scope, verify and report reconstruct its fields from the captured picker and pinned backlog. It cannot redefine the original HEAD. | +| Allowed paths and protected paths | Captured `local-work-verification.mjs`; index and working tree checked separately against the original HEAD, including non-ignored untracked files. | +| Acceptance argv | Parsed from the pinned backlog; every command must succeed, with scope rechecked after execution. | +| Acceptance scripts and dependencies | **Open blocker:** arbitrary argv can load mutable source from implementation scope. | + +The remaining acceptance-input decision is documented in +`runtime-evidence/drive-threads-0909-decisions.md`. This flow is not ready for +unattended use until those inputs have an explicit enforced ownership contract. diff --git a/ops/drive-local-flow.test.mjs b/ops/drive-local-flow.test.mjs index 2a427a049..c39ed7fe5 100644 --- a/ops/drive-local-flow.test.mjs +++ b/ops/drive-local-flow.test.mjs @@ -3,28 +3,44 @@ import { spawnSync } from 'node:child_process'; import { chmodSync, existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { join, resolve } from 'node:path'; import test from 'node:test'; -import { fixture, flow } from './local-work-test-fixture.mjs'; +import { fixture, packagePath } from './local-work-test-fixture.mjs'; const quote = text => "'" + text.replaceAll("'", "'\\''") + "'"; -for (const scenario of ['outside edit', 'verifier edit', 'unchanged package']) { +for (const scenario of ['outside edit', 'verifier edit', 'unchanged package', 'HEAD repin', 'forged snapshot']) { test(`drive-local journals failure and blocks reporting for ${scenario}`, t => { const f = fixture(t); const wrapper = resolve('testdata/preflight/wrapper-session.mjs'); const cli = join(f.root, '.relayflow/agent.mjs'); f.put('.relayflow/agent.mjs', `#!/usr/bin/env node import { receiveWrapperRequest } from ${JSON.stringify(wrapper)}; -import { writeFileSync } from 'node:fs'; +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; const request = await receiveWrapperRequest(); if (request) { ${scenario === 'outside edit' ? `writeFileSync(${JSON.stringify(join(f.root, 'outside.txt'))}, 'changed');` : ''} ${scenario === 'verifier edit' ? `writeFileSync(${JSON.stringify(join(f.root, 'ops/local-work-package.mjs'))}, 'process.exit(0)');` : ''} + ${scenario === 'HEAD repin' ? ` + const git = (...args) => execFileSync('git', args, {cwd: ${JSON.stringify(f.root)}, encoding: 'utf8'}).trim(); + writeFileSync(${JSON.stringify(join(f.root, 'outside.txt'))}, 'changed'); + git('add', 'outside.txt'); + git('-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.test', '-c', 'commit.gpgsign=false', 'commit', '-qm', 'outside'); + const path = ${JSON.stringify(join(f.root, packagePath))}; + const pkg = JSON.parse(readFileSync(path, 'utf8')); + pkg.head = git('rev-parse', 'HEAD'); + writeFileSync(path, JSON.stringify(pkg));` : ''} + ${scenario === 'forged snapshot' ? ` + const directory = ${JSON.stringify(join(f.root, '.drive-gate'))}; + mkdirSync(directory, {recursive: true}); + writeFileSync(directory + '/local-work-package.mjs', 'process.exit(0);'); + const sums = execFileSync('shasum', ['-a', '256', '.drive-gate/local-work-package.mjs'], {cwd: ${JSON.stringify(f.root)}}); + writeFileSync(directory + '/SHA256SUMS', sums);` : ''} console.log('DONE'); } `); // A scripted worker controls the edit while using the real worker protocol, // stream pin, submitted flow commands, daemon and journal. chmodSync(cli, 0o755); - const spec = structuredClone(flow); + const spec = structuredClone(f.preparedFlow); for (const step of spec.steps) { if (step.type === 'agent') step.cli = cli; else step.command = `cd ${quote(f.root)}\n${step.command}`; @@ -40,7 +56,7 @@ if (request) { assert(dataDir, result.stderr); t.after(() => rmSync(dataDir, { recursive: true, force: true })); const journal = readFileSync(join(dataDir, 'journal.jsonl'), 'utf8').trim().split('\n').map(JSON.parse); - const failedStep = scenario === 'unchanged package' ? 'verify' : 'scope'; + const failedStep = ['unchanged package', 'forged snapshot'].includes(scenario) ? 'verify' : 'scope'; const completion = journal.find(e => e.entry_type === 'step.completed' && e.step_id === failedStep); assert.equal(completion?.payload.verification.verdict, 'fail', JSON.stringify(journal)); assert.equal(completion.payload.completionReason, 'retries_exhausted'); diff --git a/ops/local-work-gate.mjs b/ops/local-work-gate.mjs new file mode 100644 index 000000000..6cfc35e5c --- /dev/null +++ b/ops/local-work-gate.mjs @@ -0,0 +1,44 @@ +// Capture the judge in the submitted spec, before the implementation can run. +// The daemon executes these command bytes from its journal, not from a mutable +// checkout copy or a checksum file stored beside that copy. +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +export const shellQuote = value => "'" + value.replaceAll("'", "'\\''") + "'"; +const moduleUrl = source => `data:text/javascript;base64,${Buffer.from(source).toString('base64')}`; + +export function prepareLocalDrive(flow, { root = process.cwd() } = {}) { + assert.equal(flow.name, 'drive-local', 'NOT_LOCAL_DRIVE'); + const read = path => readFileSync(join(root, path), 'utf8'); + const git = (...args) => execFileSync('git', args, { cwd: root, encoding: 'utf8' }).trim(); + const baseline = { + head: git('rev-parse', 'HEAD'), + branch: git('branch', '--show-current'), + backlogSha256: createHash('sha256').update(read('ops/BACKLOG.md')).digest('hex'), + }; + assert(baseline.branch && baseline.branch !== 'main', 'LOCAL_DRIVE_REFUSED: use a work branch'); + const dependency = "'./local-work-verification.mjs'"; + const source = read('ops/local-work-package.mjs'); + assert.equal(source.split(dependency).length, 2, 'GATE_IMPORT_CHANGED'); + const gate = source.replace(dependency, JSON.stringify(moduleUrl(read('ops/local-work-verification.mjs')))); + const picker = moduleUrl(read('packages/sdk/dist/backlog-picker.js')); + const prefix = `DRIVE_GATE_BASELINE=${shellQuote(JSON.stringify(baseline))} ` + + `DRIVE_GATE_PICKER=${shellQuote(picker)} ${shellQuote(process.execPath)} ` + + `--input-type=module --eval ${shellQuote(gate)} local-drive-gate`; + const commands = Object.fromEntries(['select', 'scope', 'verify', 'report'] + .map(operation => [operation, `${prefix} ${operation}`])); + const prepared = structuredClone(flow); + const operations = { select: 'select', 'gate-snapshot': 'scope', scope: 'scope', + verify: 'verify', 'final-scope': 'scope', report: 'report' }; + for (const [id, operation] of Object.entries(operations)) { + const step = prepared.steps.find(candidate => candidate.id === id); + assert(step?.type === 'deterministic', `DRIVE_STEP_CHANGED: ${id}`); + const call = `node ops/local-work-package.mjs ${operation}`; + assert.equal(step.command.split(call).length, 2, `DRIVE_COMMAND_CHANGED: ${id}`); + step.command = step.command.replace(call, commands[operation]); + } + return { flow: prepared, commands, baseline }; +} diff --git a/ops/local-work-gate.test.mjs b/ops/local-work-gate.test.mjs new file mode 100644 index 000000000..411259e80 --- /dev/null +++ b/ops/local-work-gate.test.mjs @@ -0,0 +1,55 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import test from 'node:test'; +import { entry, fixture, packagePath, pass, fail } from './local-work-test-fixture.mjs'; + +test('committing outside scope and repinning metadata cannot replace the submitted HEAD', t => { + const f = fixture(t); + pass(f.run('select')); + f.put('outside.txt', 'committed outside scope'); + f.git('add', 'outside.txt'); + f.git('-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.test', + '-c', 'commit.gpgsign=false', 'commit', '-qm', 'outside scope'); + const pkg = JSON.parse(readFileSync(join(f.root, packagePath), 'utf8')); + pkg.head = f.git('rev-parse', 'HEAD').toString().trim(); + f.put(packagePath, JSON.stringify(pkg)); + for (const operation of ['scope', 'verify', 'report']) fail(f.run(operation), /HEAD_MOVED/); +}); + +test('forged snapshot, checksum and compiled picker cannot replace the submitted judge', t => { + const f = fixture(t); + pass(f.run('select')); + for (const path of ['.drive-gate/local-work-package.mjs', '.drive-gate/local-work-verification.mjs', + '.drive-gate/backlog-picker.js', 'packages/sdk/dist/backlog-picker.js']) { + f.put(path, 'process.exit(0);\n'); + } + pass(spawnSync('sh', ['-c', 'shasum -a 256 .drive-gate/*.mjs .drive-gate/*.js > .drive-gate/SHA256SUMS'], + { cwd: f.root, encoding: 'utf8' })); + pass(f.scope()); + fail(f.run('verify'), /PACKAGE_CHECK_FAILED/); + f.put('src/value.txt', 'fixed'); + pass(f.run('verify')); + pass(f.run('report')); +}); + +test('raw template refuses selection before implementation without captured gate inputs', t => { + const f = fixture(t); + const env = { ...process.env }; + delete env.DRIVE_GATE_PICKER; + delete env.DRIVE_GATE_BASELINE; + fail(spawnSync(process.execPath, ['ops/local-work-package.mjs', 'select'], + { cwd: f.root, encoding: 'utf8', env }), /LOCAL_DRIVE_NOT_PREPARED/); +}); + +for (const title of ['Same title', 'Regex [a].* (b) + $']) { + test(`selection advances past rejected duplicate title: ${title}`, t => { + const f = fixture(t, entry(title, 'missing/value.txt') + entry(title)); + const result = f.run('select'); + pass(result); + assert(result.stdout.includes(`SKIPPED [stale_scope: missing/value.txt] ${title}`)); + const pkg = JSON.parse(readFileSync(join(f.root, packagePath), 'utf8')); + assert.deepEqual(pkg.filesInScope, ['src/value.txt']); + }); +} diff --git a/ops/local-work-package.mjs b/ops/local-work-package.mjs index f12a31a43..d1a53623c 100644 --- a/ops/local-work-package.mjs +++ b/ops/local-work-package.mjs @@ -20,35 +20,37 @@ import assert from 'node:assert/strict'; import { execFileSync, spawnSync } from 'node:child_process'; import { createHash, randomUUID } from 'node:crypto'; import { - closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, writeFileSync, + closeSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, writeFileSync, } from 'node:fs'; import { dirname } from 'node:path'; import { checkScope, runChecks, verificationCommands } from './local-work-verification.mjs'; const packagePath = '.relayflow/drive-local/package.json'; const backlogPath = 'ops/BACKLOG.md'; -// The picker's own module, not the package index: index.js re-exports -// packageFromEntry and validateWorkPackage but NOT selectBacklogEntry or -// renderWorkPackage, so importing the index gets you two of the four. -// The gate must not execute code the agent it judges can rewrite. The scope -// guard protects packages/sdk/src/backlog-picker.ts, but this imports the BUILT -// dist/backlog-picker.js -- an agent can leave the source untouched, rebuild -// dist, and the guard still passes. DRIVE_GATE_PICKER pins an immutable -// pre-implementation snapshot instead. -const sdkEntry = process.env.DRIVE_GATE_PICKER - ? new URL(`file://${process.env.DRIVE_GATE_PICKER}`) - : new URL('../packages/sdk/dist/backlog-picker.js', import.meta.url); +// Both values are embedded by the preparing launcher into the submitted command. +// There is no fallback to an ignored artifact that implementation can replace. +assert(process.env.DRIVE_GATE_PICKER && process.env.DRIVE_GATE_BASELINE, + 'LOCAL_DRIVE_NOT_PREPARED: run node scripts/run-drive-local.mjs'); +const sdkEntry = new URL(process.env.DRIVE_GATE_PICKER); +const baseline = JSON.parse(process.env.DRIVE_GATE_BASELINE); const read = (p) => readFileSync(p, 'utf8'); const hash = (t) => createHash('sha256').update(t).digest('hex'); const git = (...a) => execFileSync('git', a, { encoding: 'utf8' }).trim(); -// A killed writer leaves the destination wholly old or wholly new. Flush the -// replacement before rename and the containing directory before reporting it. +function assertBaseline() { + assert.equal(git('rev-parse', 'HEAD'), baseline.head, 'HEAD_MOVED'); + assert.equal(git('branch', '--show-current'), baseline.branch, 'BRANCH_MOVED'); + assert.equal(hash(read(backlogPath)), baseline.backlogSha256, 'BACKLOG_CHANGED'); +} + +// Only package metadata is written, always private (0600), never executable +// source. Rename prevents partial JSON; file and directory fsync are required +// before success. A directory fsync failure propagates even after rename. function writeAtomically(path, contents) { const temporary = `${path}.${randomUUID()}.tmp`; mkdirSync(dirname(path), { recursive: true }); - writeFileSync(temporary, contents, { mode: 0o600 }); + writeFileSync(temporary, contents, { flag: 'wx', mode: 0o600 }); const handle = openSync(temporary, 'r'); try { fsyncSync(handle); } finally { closeSync(handle); } renameSync(temporary, path); @@ -69,7 +71,7 @@ async function loadPicker() { } } -async function choose(markdown, { pathExists = existsSync, log = true } = {}) { +async function choose(markdown, { pathExists, log = true }) { const { selectBacklogEntry, packageFromEntry, validateWorkPackage, renderWorkPackage } = await loadPicker(); const skipped = []; @@ -149,6 +151,7 @@ async function choose(markdown, { pathExists = existsSync, log = true } = {}) { } async function select() { + assertBaseline(); // Restored guard. The generalization recorded the branch but stopped asserting // it, so the loop would happily select work while sitting on `main` and let // the agent edit the protected branch. `--show-current` prints nothing on a @@ -186,9 +189,10 @@ async function select() { } async function verifiedPackage() { + assertBaseline(); const pkg = JSON.parse(read(packagePath)); - assert.equal(git('rev-parse', 'HEAD'), pkg.head, 'HEAD_MOVED'); - assert.equal(git('branch', '--show-current'), pkg.branch, 'BRANCH_MOVED'); + assert.equal(pkg.head, baseline.head, 'PACKAGE_CHANGED: head'); + assert.equal(pkg.branch, baseline.branch, 'PACKAGE_CHANGED: branch'); const markdown = read(backlogPath); assert.equal(hash(markdown), pkg.backlogSha256, 'BACKLOG_CHANGED'); // Reconstruct from the unchanged backlog, so editing ignored package.json @@ -207,8 +211,8 @@ async function verifiedPackage() { return pkg; } -function report() { - const pkg = JSON.parse(read(packagePath)); +async function report() { + const pkg = await verifiedPackage(); // The package pins the HEAD it was selected against. Reporting a diff from a // different commit would describe work this tick did not do. const head = git('rev-parse', 'HEAD'); @@ -222,16 +226,20 @@ function report() { for (const item of pkg.definitionOfDone) console.log(` DoD: ${item}`); } -const command = process.argv[2]; -if (command === 'select') await select(); -else if (command === 'report') report(); -else if (command === 'scope') await verifiedPackage(); -else if (command === 'verify') { - const pkg = await verifiedPackage(); - runChecks(pkg); - await verifiedPackage(); -} -else { - console.error('usage: local-work-package.mjs '); - process.exit(2); +try { + const command = process.argv[2]; + if (command === 'select') await select(); + else if (command === 'report') await report(); + else if (command === 'scope') await verifiedPackage(); + else if (command === 'verify') { + const pkg = await verifiedPackage(); + runChecks(pkg); + await verifiedPackage(); + } else { + console.error('usage: local-work-package.mjs '); + process.exitCode = 2; + } +} catch (error) { + console.error(error.message); + process.exitCode = 1; } diff --git a/ops/local-work-test-fixture.mjs b/ops/local-work-test-fixture.mjs index 2088eb188..e3c0665f7 100644 --- a/ops/local-work-test-fixture.mjs +++ b/ops/local-work-test-fixture.mjs @@ -1,8 +1,9 @@ import assert from 'node:assert/strict'; import { spawnSync, execFileSync } from 'node:child_process'; -import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync, symlinkSync } from 'node:fs'; +import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'node:fs'; import { join, resolve } from 'node:path'; import { createRequire } from 'node:module'; +import { prepareLocalDrive, shellQuote } from './local-work-gate.mjs'; // Resolve the SDK's declared YAML dependency through its public package entry, // allowing Node to find either a local install or a hoisted dependency. @@ -33,27 +34,21 @@ export function fixture(t, backlog = entry()) { put(path, readFileSync(path)); } put('packages/sdk/dist/backlog-picker.js', readFileSync('packages/sdk/dist/backlog-picker.js')); - put('packages/sdk/src/backlog-picker.ts', readFileSync('packages/sdk/src/backlog-picker.ts')); - put('packages/sdk/package.json', '{"type":"module"}\n'); - put('packages/sdk/tsconfig.json', JSON.stringify({ - compilerOptions: { target: 'ES2022', module: 'NodeNext', outDir: 'dist', skipLibCheck: true }, - include: ['src/backlog-picker.ts'], - })); - symlinkSync(resolve('packages/sdk/node_modules'), join(root, 'packages/sdk/node_modules')); git('add', '.'); git('-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.test', '-c', 'commit.gpgsign=false', 'commit', '-qm', 'fixture'); - const step = id => spawnSync('sh', ['-c', flow.steps.find(s => s.id === id).command], + let captured = prepareLocalDrive(flow, { root }); + const step = id => spawnSync('sh', ['-c', captured.flow.steps.find(s => s.id === id).command], { cwd: root, encoding: 'utf8', timeout: 10000 }); const run = (operation, extra = []) => { - const result = spawnSync(process.execPath, - [...extra, 'ops/local-work-package.mjs', operation], { cwd: root, encoding: 'utf8', timeout: 5000 }); - if (operation === 'select' && result.status === 0) pass(step('gate-snapshot')); + if (operation === 'select') captured = prepareLocalDrive(flow, { root }); + const command = captured.commands[operation].replace('--input-type=module', + `${extra.map(shellQuote).join(' ')} --input-type=module`); + const result = spawnSync('sh', ['-c', command], { cwd: root, encoding: 'utf8', timeout: 5000 }); return result; }; - const scope = () => spawnSync('sh', ['-c', flow.steps.find(s => s.id === 'scope').command], - { cwd: root, encoding: 'utf8', timeout: 5000 }); - return { root, put, git, run, scope, step }; + const scope = () => step('scope'); + return { root, put, git, run, scope, step, get preparedFlow() { return captured.flow; } }; } export const pass = result => assert.equal(result.status, 0, result.stderr + result.stdout); export const fail = (result, pattern) => { diff --git a/ops/local-work-verification.mjs b/ops/local-work-verification.mjs index 124101091..40f6cb9b4 100644 --- a/ops/local-work-verification.mjs +++ b/ops/local-work-verification.mjs @@ -20,6 +20,7 @@ export function verificationCommands(body) { const protectedPaths = [ 'ops/BACKLOG.md', 'ops/local-work-package.mjs', 'ops/local-work-verification.mjs', + 'ops/local-work-gate.mjs', 'scripts/run-drive-local.mjs', 'workflows/drive-local.yaml', 'workflows/gates', 'packages/sdk/src/backlog-picker.ts', ]; const within = (path, scope) => path === scope || path.startsWith(`${scope}/`); diff --git a/ops/runtime-evidence/drive-threads-0909-acceptance-input-probe.mjs b/ops/runtime-evidence/drive-threads-0909-acceptance-input-probe.mjs new file mode 100644 index 000000000..57885f8f1 --- /dev/null +++ b/ops/runtime-evidence/drive-threads-0909-acceptance-input-probe.mjs @@ -0,0 +1,22 @@ +// Open design blocker: argv is pinned, but arbitrary script dependencies are not. +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { entry, fixture, pass, fail } from '../local-work-test-fixture.mjs'; + +const cleanup = []; +try { + const f = fixture({ after: fn => cleanup.push(fn) }, entry('Directory', 'src/', [['node', 'src/check.mjs']])); + f.put('src/check.mjs', "import assert from 'node:assert/strict'; import {readFileSync} from 'node:fs'; assert.equal(readFileSync('src/value.txt','utf8'),'fixed');\n"); + f.git('add', 'src/check.mjs'); + f.git('-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.test', '-c', 'commit.gpgsign=false', 'commit', '-qm', 'acceptance'); + pass(f.run('select')); + const before = f.run('verify'); + fail(before, /PACKAGE_CHECK_FAILED/); + console.log('ORIGINAL_CHECK_WITH_BROKEN_IMPLEMENTATION_EXIT=' + before.status); + f.put('src/check.mjs', 'process.exit(0);\n'); + const forged = f.run('verify'); + pass(forged); + console.log('EDITED_ACCEPTANCE_SCRIPT_EXIT=' + forged.status); + process.stdout.write(forged.stdout + forged.stderr); + console.log('IMPLEMENTATION=' + readFileSync(join(f.root, 'src/value.txt'), 'utf8')); +} finally { for (const fn of cleanup) fn(); } diff --git a/ops/runtime-evidence/drive-threads-0909-acceptance-input.txt b/ops/runtime-evidence/drive-threads-0909-acceptance-input.txt new file mode 100644 index 000000000..b23686593 --- /dev/null +++ b/ops/runtime-evidence/drive-threads-0909-acceptance-input.txt @@ -0,0 +1,10 @@ +$ node ops/runtime-evidence/drive-threads-0909-acceptance-input-probe.mjs +ORIGINAL_CHECK_WITH_BROKEN_IMPLEMENTATION_EXIT=1 +EDITED_ACCEPTANCE_SCRIPT_EXIT=0 +SCOPE_OK: 1 changed path(s) +CHECK ["node","src/check.mjs"] +PACKAGE_VERIFIED: 1 check(s) +SCOPE_OK: 1 changed path(s) +IMPLEMENTATION=broken + +EXIT_CODE=0 diff --git a/ops/runtime-evidence/drive-threads-0909-decisions.md b/ops/runtime-evidence/drive-threads-0909-decisions.md index 90dc0e1cf..b15c03bcc 100644 --- a/ops/runtime-evidence/drive-threads-0909-decisions.md +++ b/ops/runtime-evidence/drive-threads-0909-decisions.md @@ -1,43 +1,46 @@ -# PR #244: remaining trust-boundary decisions +# PR #244: gate ownership and remaining acceptance-input decision -The reporting, post-suite scope, dangling-link, fixture dependency, and evidence -narrative fixes do not close the following blockers. No swarm pass is claimed. +The launcher now captures the gate and baseline in the submitted spec. The +existing daemon journals the command bytes before any implementation worker +runs. HEAD, branch and backlog hash no longer come from agent-writable package +metadata; the judge no longer comes from `.drive-gate` plus a writable checksum. +The kernel and journal protocol are unchanged. The launcher starts from a +trusted checkout; this is not an OS sandbox against arbitrary process or +journal-storage tampering. -1. **Who owns the selected baseline and gate manifest?** Both the selected HEAD - in ignored package.json and the snapshot's SHA256SUMS live in the agent's - writable checkout. Pinning one writable file with another is insufficient. - The local launcher currently submits a static spec and runs the agent in the - same filesystem. A trusted pre-submission launcher baseline embedded in the - submitted commands, or a separately owned gate execution surface, is needed. - This crosses the current flow/helper scope into launcher ownership. Recommend - the launcher capture the original branch, HEAD, package and gate inputs - before submission, then execute checks from that independently owned state. - The human must choose that ownership boundary before this flow claims it. -2. **How are acceptance-check inputs declared?** Immutable argv alone does not - freeze a script or its imports. A command such as `node src/check.mjs` can - execute an agent-edited judge. Inferring its transitive inputs from arbitrary - argv is not reliable. Choose a declared immutable gate-input contract or an - independently preserved acceptance workspace that tests the changed source. - Do not silently narrow the existing arbitrary-argv authoring contract. +The contract and its owners are summarized in `../DRIVE-LOCAL.md`. +`local-work-verification.mjs` owns the protected-path policy once; YAML no longer +duplicates it. Test fixtures use the same preparing function as the launcher and +no longer symlink host node_modules or run a separate snapshot compiler. -Reproduce the first two bypasses from the repository root: +**Remaining decision: how are acceptance-check inputs declared?** Immutable +argv alone does not freeze a script or its imports. A command such as +`node src/check.mjs` can execute an agent-edited judge. Inferring its transitive +inputs from arbitrary argv is not reliable. Choose a declared immutable +acceptance-input contract or an independently preserved acceptance workspace +that tests the changed source. Do not silently narrow the existing arbitrary +argv authoring contract. The flow remains blocked for unattended use; no swarm +pass is claimed. -```sh -node ops/runtime-evidence/drive-threads-0909-trust-probe.mjs -``` +`drive-threads-0909-acceptance-input-probe.mjs` reproduces this remaining bypass: +an unchanged broken value fails its original check, but replacing an allowed +check script with a no-op makes verification exit zero. The adjacent capture +records the literal command and output. A zero probe exit means the known +bypass was reproduced, not that this boundary is safe. -The complete probe and captured output are adjacent files. Both scope commands -exit zero, including after committing an out-of-scope change and repinning the -metadata, and after rewriting the snapshot plus its checksum manifest. This is -failure evidence, not a passing security test. +## Captured evidence lifecycle -The old package tests also lacked the snapshot required by the current flow. -`drive-threads-0909-baseline.txt` captures those failures. The fixture now runs -the submitted snapshot step with the real TypeScript compiler over the actual -picker source before exercising the submitted scope command. - -`drive-threads-0909-review-before.txt` and -`drive-threads-0909-post-suite-before.txt` capture regression failures before -their corresponding fixes. `drive-threads-0909-after.txt` captures the package -and review test run after those fixes. These are before/after reproductions; -no mutation-verification claim is made. +- At commit `34349b2`, `drive-threads-0909-trust-probe.mjs` produced the adjacent + `drive-threads-0909-trust-probe.txt`: repinning HEAD and rewriting a snapshot + plus manifest were both accepted. That file is historical failure evidence. + To reproduce those exact bytes, use that commit's probe and fixture together. +- The updated probe now asserts HEAD repinning is refused and a forged legacy + snapshot cannot bless broken implementation. It then fixes the value and + verifies the good case. `drive-threads-0909-owned-trust-after.txt` captures it. +- `drive-threads-0909-owned-gate-after.txt` captures the combined package, review, + gate and real-daemon journal tests. The daemon cases assert failed completion + reasons and that reporting never starts after a bypass attempt. +- Earlier `baseline`, `review-before`, `post-suite-before` and `after` captures + retain their historical meaning. The old fixture compiled a snapshot, which + is superseded by the preparing launcher. These are before/after reproductions; + none is labeled mutation verification. diff --git a/ops/runtime-evidence/drive-threads-0909-owned-build.txt b/ops/runtime-evidence/drive-threads-0909-owned-build.txt new file mode 100644 index 000000000..4dc625bd6 --- /dev/null +++ b/ops/runtime-evidence/drive-threads-0909-owned-build.txt @@ -0,0 +1,6 @@ +$ node --check scripts/run-drive-local.mjs +EXIT_CODE=0 +$ node --check ops/local-work-gate.mjs +EXIT_CODE=0 +$ cd packages/sdk && node node_modules/typescript/bin/tsc +EXIT_CODE=0 diff --git a/ops/runtime-evidence/drive-threads-0909-owned-gate-after.txt b/ops/runtime-evidence/drive-threads-0909-owned-gate-after.txt new file mode 100644 index 000000000..268e3c747 --- /dev/null +++ b/ops/runtime-evidence/drive-threads-0909-owned-gate-after.txt @@ -0,0 +1,48 @@ +$ node --test ops/local-work-package.test.mjs ops/local-work-review.test.mjs ops/local-work-gate.test.mjs ops/drive-local-flow.test.mjs +✔ drive-local journals failure and blocks reporting for outside edit (1089.814667ms) +✔ drive-local journals failure and blocks reporting for verifier edit (901.611166ms) +✔ drive-local journals failure and blocks reporting for unchanged package (1054.339291ms) +✔ drive-local journals failure and blocks reporting for HEAD repin (841.583833ms) +✔ drive-local journals failure and blocks reporting for forged snapshot (986.059959ms) +✔ committing outside scope and repinning metadata cannot replace the submitted HEAD (365.7425ms) +✔ forged snapshot, checksum and compiled picker cannot replace the submitted judge (811.5855ms) +✔ raw template refuses selection before implementation without captured gate inputs (94.041792ms) +✔ selection advances past rejected duplicate title: Same title (185.584792ms) +✔ selection advances past rejected duplicate title: Regex [a].* (b) + $ (204.468ms) +✔ skip cursor passes a later title mentioned in an earlier body (301.222167ms) +✔ selection skips missing checks and stale paths with reasons (322.633041ms) +✔ a non-SDK package fails unchanged and passes only after its check holds (624.09425ms) +✔ multiple acceptance checks all execute and failures propagate (350.9095ms) +✔ scope refuses an outside unstaged path (279.713667ms) +✔ scope refuses an outside staged path (266.095375ms) +✔ scope refuses an outside untracked path (238.358541ms) +✔ scope refuses an outside staged reversal path (256.264875ms) +✔ scope refuses an outside rename path (263.418417ms) +✔ scope refuses an outside deleted path (294.105541ms) +✔ the submitted scope command refuses a changed ops/local-work-package.mjs before loading it (236.873958ms) +✔ the submitted scope command refuses a changed ops/local-work-verification.mjs before loading it (233.794208ms) +✔ the submitted scope command refuses a changed ops/BACKLOG.md before loading it (196.681375ms) +✔ editing ignored package metadata cannot widen scope or replace checks (274.704167ms) +✔ scope allows directory children but rejects a sibling with the same prefix (331.91425ms) +✔ scope allows in-scope deletions and rejects symlink escapes (327.241416ms) +✔ replacing a scoped file with a directory does not authorize its children (238.158333ms) +✔ a check that writes outside scope fails verification (308.1535ms) +✔ interrupted package write preserves the original and retry replaces it atomically (323.611125ms) +✔ report includes an allowed unstaged change (422.349958ms) +✔ report includes an allowed staged change (452.419666ms) +✔ report includes an allowed untracked change (408.382209ms) +✔ directory scope checks a pre-existing dangling escape symlink (293.552958ms) +✔ directory scope checks a pre-existing existing internal symlink (294.07025ms) +✔ selection skips untracked scope and accepts the next committed scope (168.807167ms) +✔ reporting after SDK suite effects enforces scope for outside.txt (417.239459ms) +✔ reporting after SDK suite effects enforces scope for src/value.txt (579.685042ms) +ℹ tests 37 +ℹ suites 0 +ℹ pass 37 +ℹ fail 0 +ℹ cancelled 0 +ℹ skipped 0 +ℹ todo 0 +ℹ duration_ms 5726.671 + +EXIT_CODE=0 diff --git a/ops/runtime-evidence/drive-threads-0909-owned-trust-after.txt b/ops/runtime-evidence/drive-threads-0909-owned-trust-after.txt new file mode 100644 index 000000000..2ee9ac921 --- /dev/null +++ b/ops/runtime-evidence/drive-threads-0909-owned-trust-after.txt @@ -0,0 +1,41 @@ +$ node ops/runtime-evidence/drive-threads-0909-trust-probe.mjs +COMMITTED_OUTSIDE_AND_REPINNED_PACKAGE_SCOPE_EXIT=1 +HEAD_MOVED ++ actual - expected + ++ '706ca9e0bcf4182e1c5e40b0646de396f8f55b86' +- '281522931b9fe9fe8fbbde78ca27ce7da0018ff7' + +REWRITTEN_SNAPSHOT_AND_CHECKSUM_VERIFY_EXIT=1 +SCOPE_OK: 0 changed path(s) +CHECK ["node","-e","require('node:assert/strict').equal(require('node:fs').readFileSync('src/value.txt','utf8'),'fixed')"] +node:internal/assert/utils:146 + throw error; + ^ + +AssertionError [ERR_ASSERTION]: Expected values to be strictly equal: + +'broken' !== 'fixed' + + at [eval]:1:31 + at runScriptInThisContext (node:internal/vm:219:10) + at node:internal/process/execution:483:12 + at [eval]-wrapper:6:24 + at runScriptInContext (node:internal/process/execution:481:60) + at evalFunction (node:internal/process/execution:315:30) + at evalTypeScript (node:internal/process/execution:327:3) + at node:internal/main/eval_string:71:3 { + generatedMessage: true, + code: 'ERR_ASSERTION', + actual: 'broken', + expected: 'fixed', + operator: 'strictEqual', + diff: 'simple' +} + +Node.js v26.7.0 +PACKAGE_CHECK_FAILED: ["node","-e","require('node:assert/strict').equal(require('node:fs').readFileSync('src/value.txt','utf8'),'fixed')"] (1) +IMPLEMENTATION=broken +REPAIRED_IMPLEMENTATION_VERIFY_EXIT=0 + +EXIT_CODE=0 diff --git a/ops/runtime-evidence/drive-threads-0909-trust-probe.mjs b/ops/runtime-evidence/drive-threads-0909-trust-probe.mjs index cf834d06d..e5a69d72a 100644 --- a/ops/runtime-evidence/drive-threads-0909-trust-probe.mjs +++ b/ops/runtime-evidence/drive-threads-0909-trust-probe.mjs @@ -1,7 +1,7 @@ import { readFileSync } from 'node:fs'; import { spawnSync } from 'node:child_process'; import { join } from 'node:path'; -import { entry, fixture, packagePath, pass } from '../local-work-test-fixture.mjs'; +import { fixture, packagePath, pass, fail } from '../local-work-test-fixture.mjs'; const cleanup = []; const t = { after: fn => cleanup.push(fn) }; try { @@ -16,13 +16,20 @@ try { const repinned = f.scope(); console.log('COMMITTED_OUTSIDE_AND_REPINNED_PACKAGE_SCOPE_EXIT=' + repinned.status); process.stdout.write(repinned.stdout + repinned.stderr); + fail(repinned, /HEAD_MOVED/); const g = fixture(t); pass(g.run('select')); - g.put('.drive-gate/local-work-package.mjs', 'process.exit(0);\n'); + for (const path of ['.drive-gate/local-work-package.mjs', '.drive-gate/local-work-verification.mjs', + '.drive-gate/backlog-picker.js']) g.put(path, 'process.exit(0);\n'); pass(spawnSync('sh', ['-c', 'shasum -a 256 .drive-gate/*.mjs .drive-gate/*.js > .drive-gate/SHA256SUMS'], {cwd:g.root, encoding:'utf8'})); - const forged = g.scope(); - console.log('REWRITTEN_SNAPSHOT_AND_CHECKSUM_SCOPE_EXIT=' + forged.status); + const forged = g.run('verify'); + console.log('REWRITTEN_SNAPSHOT_AND_CHECKSUM_VERIFY_EXIT=' + forged.status); process.stdout.write(forged.stdout + forged.stderr); + fail(forged, /PACKAGE_CHECK_FAILED/); console.log('IMPLEMENTATION=' + readFileSync(join(g.root, 'src/value.txt'), 'utf8')); + g.put('src/value.txt', 'fixed'); + const repaired = g.run('verify'); + pass(repaired); + console.log('REPAIRED_IMPLEMENTATION_VERIFY_EXIT=' + repaired.status); } finally { for (const fn of cleanup) fn(); } diff --git a/scripts/run-drive-local.mjs b/scripts/run-drive-local.mjs new file mode 100644 index 000000000..6c2aa2a22 --- /dev/null +++ b/scripts/run-drive-local.mjs @@ -0,0 +1,33 @@ +#!/usr/bin/env node +// Prepare the local drive's trusted commands before submitting its journaled run. +import { spawnSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { prepareLocalDrive } from '../ops/local-work-gate.mjs'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +process.chdir(root); +try { + if (process.argv.length !== 2) throw new Error('Usage: node scripts/run-drive-local.mjs'); + const built = spawnSync(process.execPath, ['node_modules/typescript/bin/tsc'], { + cwd: join(root, 'packages/sdk'), stdio: 'inherit', + }); + if (built.error || built.status !== 0) throw new Error('LOCAL_DRIVE_SDK_BUILD_FAILED'); + const { load } = createRequire(join(root, 'packages/sdk/package.json'))('js-yaml'); + const authored = load(readFileSync('workflows/drive-local.yaml', 'utf8')); + const prepared = prepareLocalDrive(authored, { root }); + mkdirSync('.relayflow', { recursive: true }); + const directory = mkdtempSync(resolve('.relayflow/drive-submission-')); + const path = join(directory, 'flow.json'); + writeFileSync(path, JSON.stringify(prepared.flow), { mode: 0o600 }); + // Once runStart journals the spec, rewriting this input file cannot change + // commands already owned by the daemon. + const run = spawnSync(process.execPath, ['scripts/run-local-workflow.mjs', path], { stdio: 'inherit' }); + if (run.error) throw run.error; + process.exitCode = run.status ?? 1; +} catch (error) { + console.error(error.message); + process.exitCode = 1; +} diff --git a/workflows/drive-local.yaml b/workflows/drive-local.yaml index 9f0390f32..a5a6467c1 100644 --- a/workflows/drive-local.yaml +++ b/workflows/drive-local.yaml @@ -13,7 +13,9 @@ # deterministic rewrite, because a deterministic step can only make mechanical # changes and most backlog entries are not mechanical. # -# Run: node scripts/run-local-workflow.mjs workflows/drive-local.yaml +# Run: node scripts/run-drive-local.mjs +# The preparing launcher embeds gate code and baseline pins into the submitted +# spec. Running this template directly fails before any implementation step. # Delivery stays with the operator: this flow never commits, never switches # branches and never merges. It leaves a working tree and a report. version: '0.1.0' @@ -23,35 +25,19 @@ description: >- against its own definition of done, and report. Selection, implementation, scope enforcement, verification and reporting are journaled steps. steps: - # No build-sdk step. The launcher asserts `packages/sdk/dist/cli.js` exists - # during preflight, before it submits anything, so a build step inside the - # flow can never run on the cold checkout it was meant to serve. Building the - # SDK is a prerequisite of invoking this flow, not part of it: - # npm --prefix packages/sdk run build + # The preparing launcher builds the SDK before capture and submission. - id: select type: deterministic timeoutMs: 120000 command: node ops/local-work-package.mjs select - # A: the gate runs from an immutable pre-implementation snapshot. Taken - # BEFORE the agent runs, into a directory the agent has no reason to touch and - # the scope guard refuses changes to. Without this the acceptance path imports - # packages/sdk/dist/backlog-picker.js -- a BUILT artifact the agent can rewrite - # while leaving the guarded source untouched. + # Gate code is already captured in the submitted spec. Before implementation, + # validate the selected package against that baseline and the current tree. - id: gate-snapshot type: deterministic dependsOn: [select] timeoutMs: 300000 - command: | - set -eu - rm -rf .drive-gate && mkdir -p .drive-gate - ( cd packages/sdk && node node_modules/typescript/bin/tsc ) - cp packages/sdk/dist/backlog-picker.js .drive-gate/backlog-picker.js - cp ops/local-work-package.mjs .drive-gate/local-work-package.mjs - cp ops/local-work-verification.mjs .drive-gate/local-work-verification.mjs - shasum -a 256 .drive-gate/* > .drive-gate/SHA256SUMS - echo "GATE_SNAPSHOT_TAKEN" - cat .drive-gate/SHA256SUMS + command: &scope-check node ops/local-work-package.mjs scope - id: implement type: agent @@ -94,17 +80,9 @@ steps: type: deterministic dependsOn: [implement] timeoutMs: 120000 - # This preamble is captured in the submitted spec. Check the verifier - # BEFORE executing it; an agent cannot bypass scope by editing the script. - command: &scope-check | - set -eu - git diff --exit-code HEAD -- ops/local-work-package.mjs ops/local-work-verification.mjs ops/BACKLOG.md workflows/drive-local.yaml packages/sdk/src/backlog-picker.ts packages/sdk/dist/backlog-picker.js - git diff --cached --exit-code HEAD -- ops/local-work-package.mjs ops/local-work-verification.mjs ops/BACKLOG.md workflows/drive-local.yaml packages/sdk/src/backlog-picker.ts packages/sdk/dist/backlog-picker.js - # The snapshot must be byte-identical to what was taken before the agent - # ran; otherwise the gate is judging with code the agent may have edited. - shasum -a 256 -c .drive-gate/SHA256SUMS - DRIVE_GATE_PICKER="$PWD/.drive-gate/backlog-picker.js" \ - node .drive-gate/local-work-package.mjs scope + # The launcher substitutes the captured command. protectedPaths has one + # owner in that captured verifier; no mutable pre-load helper is executed. + command: *scope-check - id: verify type: deterministic @@ -113,9 +91,7 @@ steps: # The selected package's checks must pass before the regression suite. command: | set -eu - shasum -a 256 -c .drive-gate/SHA256SUMS - DRIVE_GATE_PICKER="$PWD/.drive-gate/backlog-picker.js" \ - node .drive-gate/local-work-package.mjs verify + node ops/local-work-package.mjs verify cd packages/sdk # Rebuild before the suite. Tests and probes import packages/sdk/dist/*, # so running vitest against a stale dist exercises the PRE-agent artifact From c3345ed4107681de8cf86583c740c50772311276 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Wed, 9 Sep 2026 14:04:09 +0200 Subject: [PATCH 14/15] Clarify historical drive snapshot and launcher documentation --- .gitignore | 2 +- ops/RUNTIME-STATUS.md | 4 ++++ workflows/drive-local.yaml | 1 + 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index e704692e7..d249b39fd 100644 --- a/.gitignore +++ b/.gitignore @@ -18,5 +18,5 @@ dist/ .relayflowd/ .relayflowd-*/ -# Immutable pre-implementation gate snapshot (drive-local). +# Legacy drive-local snapshots; the preparing launcher no longer executes them. .drive-gate/ diff --git a/ops/RUNTIME-STATUS.md b/ops/RUNTIME-STATUS.md index f78af0474..4b3256933 100644 --- a/ops/RUNTIME-STATUS.md +++ b/ops/RUNTIME-STATUS.md @@ -147,6 +147,10 @@ Its runtime behavior was not tested or inferred from a successful v2 check. ## Work package execution and delivery Scaffold commit: `89f2f1d31f262420c2c5f613efa3f50c70153bfa`. +The command and receipts below describe that historical scaffold. The current +local drive uses `node scripts/run-drive-local.mjs` to capture gate inputs before +submission; see [DRIVE-LOCAL.md](DRIVE-LOCAL.md) for its remaining acceptance-input +blocker. From a clean work branch at that commit, after the setup above: ```sh diff --git a/workflows/drive-local.yaml b/workflows/drive-local.yaml index a5a6467c1..a5a52e8d9 100644 --- a/workflows/drive-local.yaml +++ b/workflows/drive-local.yaml @@ -33,6 +33,7 @@ steps: # Gate code is already captured in the submitted spec. Before implementation, # validate the selected package against that baseline and the current tree. + # Retain the existing step ID for journal readers; no snapshot is written here. - id: gate-snapshot type: deterministic dependsOn: [select] From f3fde5dcf05708caa704a51a8dedcf70329e6749 Mon Sep 17 00:00:00 2001 From: kjgbot Date: Wed, 9 Sep 2026 14:31:52 +0200 Subject: [PATCH 15/15] Load local drive gate inputs from a pinned Git commit --- ops/DRIVE-LOCAL.md | 35 +++++++-- ops/local-work-gate.mjs | 73 ++++++++++--------- ops/local-work-gate.test.mjs | 2 +- ops/local-work-git-input.test.mjs | 72 ++++++++++++++++++ ops/local-work-package.mjs | 6 +- ops/local-work-snapshot.mjs | 35 +++++++++ ops/local-work-test-fixture.mjs | 18 +++-- ops/local-work-verification.mjs | 2 +- .../drive-threads-0909-decisions.md | 43 +++++++---- .../drive-threads-0909-git-input-after.txt | 53 ++++++++++++++ .../drive-threads-0909-git-input-before.txt | 55 ++++++++++++++ scripts/run-drive-local.mjs | 9 ++- workflows/drive-local.yaml | 25 ++++--- 13 files changed, 349 insertions(+), 79 deletions(-) create mode 100644 ops/local-work-git-input.test.mjs create mode 100644 ops/local-work-snapshot.mjs create mode 100644 ops/runtime-evidence/drive-threads-0909-git-input-after.txt create mode 100644 ops/runtime-evidence/drive-threads-0909-git-input-before.txt diff --git a/ops/DRIVE-LOCAL.md b/ops/DRIVE-LOCAL.md index 132fbe919..48ff2967c 100644 --- a/ops/DRIVE-LOCAL.md +++ b/ops/DRIVE-LOCAL.md @@ -8,10 +8,23 @@ node scripts/run-drive-local.mjs Run on the branch that should receive the diff, from a clean checkout. The flow leaves delivery to the operator. It does not commit or merge. The wrapper -builds the SDK, captures the original HEAD, branch, backlog hash and gate code, -then submits the prepared commands through the existing local launcher. The -daemon journals those commands before implementation starts. Running the YAML -template directly refuses selection because these captured inputs are missing. +builds the SDK for the local launcher, pins the original HEAD and branch, and +submits commands through the existing local launcher. The daemon journals those +pins before implementation starts. Running the YAML template directly refuses +the snapshot step because its pinned inputs are missing. + +`gate-snapshot` runs first. It extracts the package helper, verifier and SDK +picker source with `git --no-replace-objects show :`, then compiles +that picker in a temporary directory outside the checkout. Its own extraction +script also comes from that Git ref. Neither working-tree helper files nor +ignored SDK dist supply gate inputs. The installed TypeScript compiler is a +trusted toolchain dependency; no compiler package is copied into the temp dir. + +Selection records the same ref in the work package as `head`. Subsequent checks +compare that field to the submitted pin, so changing package metadata cannot +repin it after a commit. The ref supplies the gate-input integrity claim; there +is no SHA256SUMS file beside the scripts. The launcher removes the temporary +directory when its run returns. Selection uses the SDK backlog picker. A locally executable entry must name repository paths committed at HEAD in backticks and declare at least one acceptance @@ -31,7 +44,7 @@ reasons. The old F8b entry now carries a source assertion for its declared rename. That assertion also allows an already-completed package to pass without manufacturing another edit. -The submitted scope step executes the captured helper code and refuses changes +The submitted scope step executes the helpers extracted from Git and refuses changes to its checkout sources and backlog. It checks the working tree and index against the selected HEAD, including untracked non-ignored files and both sides of renames. Scope uses exact paths or directory descendants, never @@ -57,12 +70,18 @@ The execution contract has one owner for each kind of data: | Input or policy | Owner and validation | | --- | --- | -| Gate code, compiled picker, HEAD, branch, backlog hash | Preparing launcher captures bytes into submitted commands; daemon executes its journaled spec. No checkout snapshot or writable checksum is consulted. | -| Package metadata | Private atomic JSON artifact for the agent; scope, verify and report reconstruct its fields from the captured picker and pinned backlog. It cannot redefine the original HEAD. | -| Allowed paths and protected paths | Captured `local-work-verification.mjs`; index and working tree checked separately against the original HEAD, including non-ignored untracked files. | +| Gate inputs | Package helper, verifier, extraction script and picker source come from the pinned Git commit, with replacement objects disabled. The picker is built during `gate-snapshot` before selection or implementation. | +| HEAD, branch, backlog hash | Preparing launcher pins the original commit/branch and the hash of its committed backlog in submitted commands. The package records the commit as `head`; no adjacent ref or checksum file is authority. | +| Package metadata | Private atomic JSON artifact for the agent; scope, verify and report reconstruct its fields from the picker built from the pinned ref and the pinned backlog. It cannot redefine the original HEAD. | +| Allowed paths and protected paths | `local-work-verification.mjs` extracted from the pinned ref; index and working tree checked separately against the original HEAD, including non-ignored untracked files. | | Acceptance argv | Parsed from the pinned backlog; every command must succeed, with scope rechecked after execution. | | Acceptance scripts and dependencies | **Open blocker:** arbitrary argv can load mutable source from implementation scope. | +**A same-user agent can still write to the temporary execution directory.** +This meets the narrower bar that gate inputs come from a pinned Git ref rather +than files implementation edits. It does not make extracted runtime files +immutable, isolate processes, or prevent arbitrary Git-storage tampering. + The remaining acceptance-input decision is documented in `runtime-evidence/drive-threads-0909-decisions.md`. This flow is not ready for unattended use until those inputs have an explicit enforced ownership contract. diff --git a/ops/local-work-gate.mjs b/ops/local-work-gate.mjs index 6cfc35e5c..8400df52f 100644 --- a/ops/local-work-gate.mjs +++ b/ops/local-work-gate.mjs @@ -1,44 +1,49 @@ -// Capture the judge in the submitted spec, before the implementation can run. -// The daemon executes these command bytes from its journal, not from a mutable -// checkout copy or a checksum file stored beside that copy. +// Pin the Git ref before submission; gate-snapshot extracts only its Git objects. +// The temporary execution directory is not a same-user filesystem sandbox. import assert from 'node:assert/strict'; import { execFileSync } from 'node:child_process'; import { createHash } from 'node:crypto'; -import { readFileSync } from 'node:fs'; -import { join } from 'node:path'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { tmpdir } from 'node:os'; +import { fileURLToPath, pathToFileURL } from 'node:url'; export const shellQuote = value => "'" + value.replaceAll("'", "'\\''") + "'"; -const moduleUrl = source => `data:text/javascript;base64,${Buffer.from(source).toString('base64')}`; - -export function prepareLocalDrive(flow, { root = process.cwd() } = {}) { +export function prepareLocalDrive(flow, { root = process.cwd(), + compiler = fileURLToPath(new URL('../packages/sdk/node_modules/typescript/bin/tsc', import.meta.url)) } = {}) { assert.equal(flow.name, 'drive-local', 'NOT_LOCAL_DRIVE'); - const read = path => readFileSync(join(root, path), 'utf8'); - const git = (...args) => execFileSync('git', args, { cwd: root, encoding: 'utf8' }).trim(); + const git = (...args) => execFileSync('git', ['--no-replace-objects', ...args], { cwd: root, encoding: 'utf8' }); + const head = git('rev-parse', 'HEAD').trim(); const baseline = { - head: git('rev-parse', 'HEAD'), - branch: git('branch', '--show-current'), - backlogSha256: createHash('sha256').update(read('ops/BACKLOG.md')).digest('hex'), + head, + branch: git('branch', '--show-current').trim(), + backlogSha256: createHash('sha256').update(git('show', `${head}:ops/BACKLOG.md`)).digest('hex'), }; assert(baseline.branch && baseline.branch !== 'main', 'LOCAL_DRIVE_REFUSED: use a work branch'); - const dependency = "'./local-work-verification.mjs'"; - const source = read('ops/local-work-package.mjs'); - assert.equal(source.split(dependency).length, 2, 'GATE_IMPORT_CHANGED'); - const gate = source.replace(dependency, JSON.stringify(moduleUrl(read('ops/local-work-verification.mjs')))); - const picker = moduleUrl(read('packages/sdk/dist/backlog-picker.js')); - const prefix = `DRIVE_GATE_BASELINE=${shellQuote(JSON.stringify(baseline))} ` + - `DRIVE_GATE_PICKER=${shellQuote(picker)} ${shellQuote(process.execPath)} ` + - `--input-type=module --eval ${shellQuote(gate)} local-drive-gate`; - const commands = Object.fromEntries(['select', 'scope', 'verify', 'report'] - .map(operation => [operation, `${prefix} ${operation}`])); - const prepared = structuredClone(flow); - const operations = { select: 'select', 'gate-snapshot': 'scope', scope: 'scope', - verify: 'verify', 'final-scope': 'scope', report: 'report' }; - for (const [id, operation] of Object.entries(operations)) { - const step = prepared.steps.find(candidate => candidate.id === id); - assert(step?.type === 'deterministic', `DRIVE_STEP_CHANGED: ${id}`); - const call = `node ops/local-work-package.mjs ${operation}`; - assert.equal(step.command.split(call).length, 2, `DRIVE_COMMAND_CHANGED: ${id}`); - step.command = step.command.replace(call, commands[operation]); - } - return { flow: prepared, commands, baseline }; + const snapshot = git('show', `${head}:ops/local-work-snapshot.mjs`); + const directory = mkdtempSync(join(tmpdir(), 'drive-gate-')); + const dispose = () => rmSync(directory, { recursive: true, force: true }); + try { + const picker = pathToFileURL(join(directory, 'dist/backlog-picker.js')).href; + const prefix = `GIT_NO_REPLACE_OBJECTS=1 DRIVE_GATE_BASELINE=${shellQuote(JSON.stringify(baseline))} ` + + `DRIVE_GATE_PICKER=${shellQuote(picker)} ${shellQuote(process.execPath)} ` + + shellQuote(join(directory, 'local-work-package.mjs')); + const commands = Object.fromEntries(['select', 'scope', 'verify', 'report'] + .map(operation => [operation, `${prefix} ${operation}`])); + const prepared = structuredClone(flow); + const snapshotStep = prepared.steps.find(step => step.id === 'gate-snapshot'); + assert.equal(snapshotStep?.command, 'node ops/local-work-snapshot.mjs', 'DRIVE_SNAPSHOT_CHANGED'); + snapshotStep.command = `${shellQuote(process.execPath)} --input-type=module --eval ${shellQuote(snapshot)} ` + + `local-drive-snapshot ${shellQuote(head)} ${shellQuote(directory)} ${shellQuote(resolve(compiler))}`; + const operations = { select: 'select', 'initial-scope': 'scope', scope: 'scope', + verify: 'verify', 'final-scope': 'scope', report: 'report' }; + for (const [id, operation] of Object.entries(operations)) { + const step = prepared.steps.find(candidate => candidate.id === id); + assert(step?.type === 'deterministic', `DRIVE_STEP_CHANGED: ${id}`); + const call = `node ops/local-work-package.mjs ${operation}`; + assert.equal(step.command.split(call).length, 2, `DRIVE_COMMAND_CHANGED: ${id}`); + step.command = step.command.replace(call, commands[operation]); + } + return { flow: prepared, commands, baseline, directory, dispose }; + } catch (error) { dispose(); throw error; } } diff --git a/ops/local-work-gate.test.mjs b/ops/local-work-gate.test.mjs index 411259e80..f2a06805f 100644 --- a/ops/local-work-gate.test.mjs +++ b/ops/local-work-gate.test.mjs @@ -18,7 +18,7 @@ test('committing outside scope and repinning metadata cannot replace the submitt for (const operation of ['scope', 'verify', 'report']) fail(f.run(operation), /HEAD_MOVED/); }); -test('forged snapshot, checksum and compiled picker cannot replace the submitted judge', t => { +test('forged legacy checkout snapshot and compiled picker cannot replace the Git-extracted judge', t => { const f = fixture(t); pass(f.run('select')); for (const path of ['.drive-gate/local-work-package.mjs', '.drive-gate/local-work-verification.mjs', diff --git a/ops/local-work-git-input.test.mjs b/ops/local-work-git-input.test.mjs new file mode 100644 index 000000000..ad14c834c --- /dev/null +++ b/ops/local-work-git-input.test.mjs @@ -0,0 +1,72 @@ +import assert from 'node:assert/strict'; +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import test from 'node:test'; +import { fixture, packagePath, pass, fail } from './local-work-test-fixture.mjs'; + +test('preparation reads the committed gate instead of a working-tree replacement', t => { + const f = fixture(t); + const source = f.git('show', 'HEAD:ops/local-work-package.mjs').toString(); + f.put('ops/local-work-package.mjs', source.replace('const packagePath', 'process.exit(0);\nconst packagePath')); + const selected = f.run('select'); + pass(selected); + assert.match(selected.stdout, /SELECTED Fix value/); + assert.equal(readFileSync(join(f.gateDirectory, 'local-work-package.mjs'), 'utf8'), source); + assert.equal(JSON.parse(readFileSync(join(f.root, packagePath), 'utf8')).head, + f.git('rev-parse', 'HEAD').toString().trim()); + fail(f.run('verify'), /OUT_OF_SCOPE/); + f.put('ops/local-work-package.mjs', f.git('show', 'HEAD:ops/local-work-package.mjs')); + f.put('src/value.txt', 'fixed'); + pass(f.run('verify')); +}); + +test('gate-snapshot builds committed picker source outside the checkout', t => { + const f = fixture(t); + const original = f.git('show', 'HEAD:packages/sdk/src/backlog-picker.ts'); + f.put('packages/sdk/src/backlog-picker.ts', 'this is not valid TypeScript'); + const selected = f.run('select'); + pass(selected); + assert.match(selected.stdout, /SELECTED Fix value/); + assert(!f.gateDirectory.startsWith(f.root)); + assert.deepEqual(readFileSync(join(f.gateDirectory, 'backlog-picker.ts')), original); + assert(!existsSync(join(f.gateDirectory, 'SHA256SUMS'))); + fail(f.scope(), /OUT_OF_SCOPE/); + f.put('packages/sdk/src/backlog-picker.ts', original); + f.put('src/value.txt', 'fixed'); + pass(f.run('verify')); +}); + +test('a committed picker compile failure prevents package selection', t => { + const f = fixture(t); + f.put('packages/sdk/src/backlog-picker.ts', 'this is not valid TypeScript'); + f.git('add', 'packages/sdk/src/backlog-picker.ts'); + f.git('-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.test', + '-c', 'commit.gpgsign=false', 'commit', '-qm', 'broken picker source'); + fail(f.run('select'), /error TS/); + assert(!existsSync(join(f.root, packagePath))); +}); + +test('an ignored compiled picker cannot become the input to selection', t => { + const f = fixture(t); + f.put('packages/sdk/dist/backlog-picker.js', 'process.exit(0);\n'); + const selected = f.run('select'); + pass(selected); + assert.match(selected.stdout, /SELECTED Fix value/); + fail(f.run('verify'), /PACKAGE_CHECK_FAILED/); + f.put('src/value.txt', 'fixed'); + pass(f.run('verify')); +}); + +test('Git replacement objects cannot substitute gate source at the pinned ref', t => { + const f = fixture(t); + const original = f.git('rev-parse', 'HEAD:ops/local-work-package.mjs').toString().trim(); + f.put('.relayflow/replacement.mjs', 'process.exit(0);\n'); + const replacement = f.git('hash-object', '-w', '.relayflow/replacement.mjs').toString().trim(); + f.git('replace', original, replacement); + const selected = f.run('select'); + pass(selected); + assert.match(selected.stdout, /SELECTED Fix value/); + fail(f.run('verify'), /PACKAGE_CHECK_FAILED/); + f.put('src/value.txt', 'fixed'); + pass(f.run('verify')); +}); diff --git a/ops/local-work-package.mjs b/ops/local-work-package.mjs index d1a53623c..ecc3eeef1 100644 --- a/ops/local-work-package.mjs +++ b/ops/local-work-package.mjs @@ -62,10 +62,10 @@ async function loadPicker() { try { return await import(sdkEntry.href); } catch (cause) { - // Say which build is missing rather than surfacing a bare module error. - // Building the SDK is a launcher prerequisite, not a step in this flow. + // This is the picker built from Git by gate-snapshot, not SDK dist in the + // implementation checkout. Missing artifacts must never trigger a fallback. throw new Error( - `SDK_NOT_BUILT: ${sdkEntry.pathname} is not importable — run the build step first`, + `GATE_PICKER_UNAVAILABLE: ${sdkEntry.pathname}; rerun through scripts/run-drive-local.mjs`, { cause }, ); } diff --git a/ops/local-work-snapshot.mjs b/ops/local-work-snapshot.mjs new file mode 100644 index 000000000..ed528128f --- /dev/null +++ b/ops/local-work-snapshot.mjs @@ -0,0 +1,35 @@ +// Executed as gate-snapshot before selection and implementation. Source inputs +// come from a commit, never the checkout. The ref is the integrity claim; there +// is no checksum manifest. A same-user process can still alter these temp files. +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { readdirSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; + +try { + const [head, directory, compiler, ...extra] = process.argv.slice(2); + assert(head && directory && compiler && !extra.length, 'LOCAL_DRIVE_NOT_PREPARED: run node scripts/run-drive-local.mjs'); + assert(/^[a-f0-9]{40}$/.test(head), 'INVALID_GATE_REF'); + const git = (...args) => execFileSync('git', ['--no-replace-objects', ...args]); + assert.equal(git('rev-parse', 'HEAD').toString().trim(), head, 'HEAD_MOVED'); + assert.equal(readdirSync(directory).length, 0, 'GATE_DIRECTORY_NOT_EMPTY'); + for (const [source, destination] of [ + ['ops/local-work-package.mjs', 'local-work-package.mjs'], + ['ops/local-work-verification.mjs', 'local-work-verification.mjs'], + ['packages/sdk/src/backlog-picker.ts', 'backlog-picker.ts'], + ]) { + writeFileSync(join(directory, destination), git('show', `${head}:${source}`), { flag: 'wx', mode: 0o600 }); + } + writeFileSync(join(directory, 'package.json'), '{"type":"module"}\n', { flag: 'wx', mode: 0o600 }); + writeFileSync(join(directory, 'tsconfig.json'), JSON.stringify({ + compilerOptions: { target: 'ES2022', module: 'NodeNext', outDir: 'dist', strict: true, types: [], skipLibCheck: true }, + files: ['backlog-picker.ts'], + }), { flag: 'wx', mode: 0o600 }); + // The installed TypeScript compiler is part of the trusted local toolchain. + // Its inputs are the extracted source and this explicit build configuration. + execFileSync(process.execPath, [compiler, '--project', join(directory, 'tsconfig.json')], { stdio: 'inherit' }); + console.log(`GATE_FROM_GIT: ${head}`); +} catch (error) { + console.error(error.message); + process.exitCode = 1; +} diff --git a/ops/local-work-test-fixture.mjs b/ops/local-work-test-fixture.mjs index e3c0665f7..80689a97f 100644 --- a/ops/local-work-test-fixture.mjs +++ b/ops/local-work-test-fixture.mjs @@ -30,7 +30,8 @@ export function fixture(t, backlog = entry()) { put('src/value.txt', 'broken'); put('outside.txt', 'original'); put('ops/BACKLOG.md', backlog); - for (const path of ['ops/local-work-package.mjs', 'ops/local-work-verification.mjs', 'workflows/drive-local.yaml']) { + for (const path of ['ops/local-work-package.mjs', 'ops/local-work-verification.mjs', + 'ops/local-work-snapshot.mjs', 'packages/sdk/src/backlog-picker.ts', 'workflows/drive-local.yaml']) { put(path, readFileSync(path)); } put('packages/sdk/dist/backlog-picker.js', readFileSync('packages/sdk/dist/backlog-picker.js')); @@ -38,17 +39,24 @@ export function fixture(t, backlog = entry()) { git('-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.test', '-c', 'commit.gpgsign=false', 'commit', '-qm', 'fixture'); let captured = prepareLocalDrive(flow, { root }); + t.after(() => captured.dispose()); const step = id => spawnSync('sh', ['-c', captured.flow.steps.find(s => s.id === id).command], { cwd: root, encoding: 'utf8', timeout: 10000 }); const run = (operation, extra = []) => { - if (operation === 'select') captured = prepareLocalDrive(flow, { root }); - const command = captured.commands[operation].replace('--input-type=module', - `${extra.map(shellQuote).join(' ')} --input-type=module`); + if (operation === 'select') { + captured.dispose(); + captured = prepareLocalDrive(flow, { root }); + const snapshot = step('gate-snapshot'); + if (snapshot.status !== 0) return snapshot; + } + const command = captured.commands[operation].replace(`${shellQuote(process.execPath)} `, + `${shellQuote(process.execPath)} ${extra.map(shellQuote).join(' ')} `); const result = spawnSync('sh', ['-c', command], { cwd: root, encoding: 'utf8', timeout: 5000 }); return result; }; const scope = () => step('scope'); - return { root, put, git, run, scope, step, get preparedFlow() { return captured.flow; } }; + return { root, put, git, run, scope, step, get preparedFlow() { return captured.flow; }, + get gateDirectory() { return captured.directory; } }; } export const pass = result => assert.equal(result.status, 0, result.stderr + result.stdout); export const fail = (result, pattern) => { diff --git a/ops/local-work-verification.mjs b/ops/local-work-verification.mjs index 40f6cb9b4..e22544ffd 100644 --- a/ops/local-work-verification.mjs +++ b/ops/local-work-verification.mjs @@ -20,7 +20,7 @@ export function verificationCommands(body) { const protectedPaths = [ 'ops/BACKLOG.md', 'ops/local-work-package.mjs', 'ops/local-work-verification.mjs', - 'ops/local-work-gate.mjs', 'scripts/run-drive-local.mjs', + 'ops/local-work-gate.mjs', 'ops/local-work-snapshot.mjs', 'scripts/run-drive-local.mjs', 'workflows/drive-local.yaml', 'workflows/gates', 'packages/sdk/src/backlog-picker.ts', ]; const within = (path, scope) => path === scope || path.startsWith(`${scope}/`); diff --git a/ops/runtime-evidence/drive-threads-0909-decisions.md b/ops/runtime-evidence/drive-threads-0909-decisions.md index b15c03bcc..8cfb2462b 100644 --- a/ops/runtime-evidence/drive-threads-0909-decisions.md +++ b/ops/runtime-evidence/drive-threads-0909-decisions.md @@ -1,17 +1,32 @@ -# PR #244: gate ownership and remaining acceptance-input decision - -The launcher now captures the gate and baseline in the submitted spec. The -existing daemon journals the command bytes before any implementation worker -runs. HEAD, branch and backlog hash no longer come from agent-writable package -metadata; the judge no longer comes from `.drive-gate` plus a writable checksum. -The kernel and journal protocol are unchanged. The launcher starts from a -trusted checkout; this is not an OS sandbox against arbitrary process or -journal-storage tampering. - -The contract and its owners are summarized in `../DRIVE-LOCAL.md`. -`local-work-verification.mjs` owns the protected-path policy once; YAML no longer -duplicates it. Test fixtures use the same preparing function as the launcher and -no longer symlink host node_modules or run a separate snapshot compiler. +# PR #244: pinned Git gate inputs and remaining acceptance-input decision + +The user clarified the H1 design: copying working-tree bytes does not create a +trust boundary. The earlier command embedding at `4d88ac2` is superseded because +it also took its helper and compiled-picker inputs from the working tree. + +The launcher now pins HEAD before submission. `gate-snapshot` extracts the +package helper, verifier and picker TypeScript source from that commit with +`git --no-replace-objects show`, and builds the picker in a temporary directory +outside the checkout before selection and implementation. The snapshot script +itself is loaded from the same ref. The package records it as `head`; checks +compare it to the submitted pin so repinning metadata cannot hide a moved HEAD. +No SHA256SUMS or adjacent ref file is used as authority. + +The installed TypeScript compiler and local launcher remain trusted tools. A +same-user agent can still write to the extracted temp files. The claim is about +the origin of gate inputs, not same-user filesystem isolation or runtime-file +immutability. The kernel and journal protocol are unchanged. + +The contract is summarized in `../DRIVE-LOCAL.md`. The fixture uses the same +preparer and actual snapshot step, commits source into its disposable Git repo, +and invokes the installed compiler without symlinking host node_modules. + +`drive-threads-0909-git-input-before.txt` captures two failures before this fix: +a substituted working-tree helper or ignored compiled picker returned success +without selecting any package. The current regression tests also assert that +extracted bytes match Git, dirty TypeScript is ignored as input, committed +invalid TypeScript fails the snapshot build, and fixed implementation passes. +The combined captured run is `drive-threads-0909-git-input-after.txt`. **Remaining decision: how are acceptance-check inputs declared?** Immutable argv alone does not freeze a script or its imports. A command such as diff --git a/ops/runtime-evidence/drive-threads-0909-git-input-after.txt b/ops/runtime-evidence/drive-threads-0909-git-input-after.txt new file mode 100644 index 000000000..ebab4b647 --- /dev/null +++ b/ops/runtime-evidence/drive-threads-0909-git-input-after.txt @@ -0,0 +1,53 @@ +$ node --test ops/local-work-package.test.mjs ops/local-work-review.test.mjs ops/local-work-gate.test.mjs ops/local-work-git-input.test.mjs ops/drive-local-flow.test.mjs +✔ drive-local journals failure and blocks reporting for outside edit (1569.386209ms) +✔ drive-local journals failure and blocks reporting for verifier edit (1338.876042ms) +✔ drive-local journals failure and blocks reporting for unchanged package (1419.740917ms) +✔ drive-local journals failure and blocks reporting for HEAD repin (1237.099416ms) +✔ drive-local journals failure and blocks reporting for forged snapshot (1282.077542ms) +✔ committing outside scope and repinning metadata cannot replace the submitted HEAD (933.017166ms) +✔ forged legacy checkout snapshot and compiled picker cannot replace the Git-extracted judge (1304.608875ms) +✔ raw template refuses selection before implementation without captured gate inputs (174.595834ms) +✔ selection advances past rejected duplicate title: Same title (617.767041ms) +✔ selection advances past rejected duplicate title: Regex [a].* (b) + $ (600.579708ms) +✔ preparation reads the committed gate instead of a working-tree replacement (1081.631542ms) +✔ gate-snapshot builds committed picker source outside the checkout (999.972542ms) +✔ a committed picker compile failure prevents package selection (513.214333ms) +✔ an ignored compiled picker cannot become the input to selection (1016.511833ms) +✔ Git replacement objects cannot substitute gate source at the pinned ref (970.567792ms) +✔ skip cursor passes a later title mentioned in an earlier body (817.737334ms) +✔ selection skips missing checks and stale paths with reasons (804.1355ms) +✔ a non-SDK package fails unchanged and passes only after its check holds (1051.719084ms) +✔ multiple acceptance checks all execute and failures propagate (829.5705ms) +✔ scope refuses an outside unstaged path (669.861834ms) +✔ scope refuses an outside staged path (631.429708ms) +✔ scope refuses an outside untracked path (594.261792ms) +✔ scope refuses an outside staged reversal path (568.371417ms) +✔ scope refuses an outside rename path (569.997916ms) +✔ scope refuses an outside deleted path (519.848917ms) +✔ the submitted scope command refuses a changed ops/local-work-package.mjs before loading it (501.358125ms) +✔ the submitted scope command refuses a changed ops/local-work-verification.mjs before loading it (495.480167ms) +✔ the submitted scope command refuses a changed ops/BACKLOG.md before loading it (462.9035ms) +✔ editing ignored package metadata cannot widen scope or replace checks (524.0125ms) +✔ scope allows directory children but rejects a sibling with the same prefix (584.357666ms) +✔ scope allows in-scope deletions and rejects symlink escapes (606.571792ms) +✔ replacing a scoped file with a directory does not authorize its children (505.246208ms) +✔ a check that writes outside scope fails verification (586.051958ms) +✔ interrupted package write preserves the original and retry replaces it atomically (1099.643667ms) +✔ report includes an allowed unstaged change (976.478625ms) +✔ report includes an allowed staged change (911.101833ms) +✔ report includes an allowed untracked change (891.555583ms) +✔ directory scope checks a pre-existing dangling escape symlink (770.519ms) +✔ directory scope checks a pre-existing existing internal symlink (706.921209ms) +✔ selection skips untracked scope and accepts the next committed scope (510.050208ms) +✔ reporting after SDK suite effects enforces scope for outside.txt (781.408584ms) +✔ reporting after SDK suite effects enforces scope for src/value.txt (895.763ms) +ℹ tests 42 +ℹ suites 0 +ℹ pass 42 +ℹ fail 0 +ℹ cancelled 0 +ℹ skipped 0 +ℹ todo 0 +ℹ duration_ms 12484.159542 + +EXIT_CODE=0 diff --git a/ops/runtime-evidence/drive-threads-0909-git-input-before.txt b/ops/runtime-evidence/drive-threads-0909-git-input-before.txt new file mode 100644 index 000000000..276f418bb --- /dev/null +++ b/ops/runtime-evidence/drive-threads-0909-git-input-before.txt @@ -0,0 +1,55 @@ +$ node --test ops/local-work-git-input.test.mjs +✖ preparation reads the committed gate instead of a working-tree replacement (109.660583ms) +✖ an ignored compiled picker cannot become the input to selection (134.15875ms) +ℹ tests 2 +ℹ suites 0 +ℹ pass 0 +ℹ fail 2 +ℹ cancelled 0 +ℹ skipped 0 +ℹ todo 0 +ℹ duration_ms 288.575875 + +✖ failing tests: + +test at ops/local-work-git-input.test.mjs:7:1 +✖ preparation reads the committed gate instead of a working-tree replacement (109.660583ms) + AssertionError [ERR_ASSERTION]: The input did not match the regular expression /SELECTED Fix value/. Input: + + '' + + at TestContext. (file:///Users/khaliqgant/flows-threads-wt/ops/local-work-git-input.test.mjs:13:10) + at Test.runInAsyncScope (node:async_hooks:226:14) + at Test.run (node:internal/test_runner/test:1397:25) + at Test.start (node:internal/test_runner/test:1257:17) + at startSubtestAfterBootstrap (node:internal/test_runner/harness:387:17) { + generatedMessage: true, + code: 'ERR_ASSERTION', + actual: '', + expected: /SELECTED Fix value/, + operator: 'match', + diff: 'simple' + } + +test at ops/local-work-git-input.test.mjs:22:1 +✖ an ignored compiled picker cannot become the input to selection (134.15875ms) + AssertionError [ERR_ASSERTION]: The input did not match the regular expression /SELECTED Fix value/. Input: + + '' + + at TestContext. (file:///Users/khaliqgant/flows-threads-wt/ops/local-work-git-input.test.mjs:27:10) + at Test.runInAsyncScope (node:async_hooks:226:14) + at Test.run (node:internal/test_runner/test:1397:25) + at Test.processPendingSubtests (node:internal/test_runner/test:969:18) + at Test.postRun (node:internal/test_runner/test:1537:19) + at Test.run (node:internal/test_runner/test:1462:12) + at async startSubtestAfterBootstrap (node:internal/test_runner/harness:387:3) { + generatedMessage: true, + code: 'ERR_ASSERTION', + actual: '', + expected: /SELECTED Fix value/, + operator: 'match', + diff: 'simple' + } + +EXIT_CODE=1 diff --git a/scripts/run-drive-local.mjs b/scripts/run-drive-local.mjs index 6c2aa2a22..043af6e47 100644 --- a/scripts/run-drive-local.mjs +++ b/scripts/run-drive-local.mjs @@ -9,6 +9,7 @@ import { prepareLocalDrive } from '../ops/local-work-gate.mjs'; const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); process.chdir(root); +let prepared; try { if (process.argv.length !== 2) throw new Error('Usage: node scripts/run-drive-local.mjs'); const built = spawnSync(process.execPath, ['node_modules/typescript/bin/tsc'], { @@ -17,17 +18,19 @@ try { if (built.error || built.status !== 0) throw new Error('LOCAL_DRIVE_SDK_BUILD_FAILED'); const { load } = createRequire(join(root, 'packages/sdk/package.json'))('js-yaml'); const authored = load(readFileSync('workflows/drive-local.yaml', 'utf8')); - const prepared = prepareLocalDrive(authored, { root }); + prepared = prepareLocalDrive(authored, { root }); mkdirSync('.relayflow', { recursive: true }); const directory = mkdtempSync(resolve('.relayflow/drive-submission-')); const path = join(directory, 'flow.json'); writeFileSync(path, JSON.stringify(prepared.flow), { mode: 0o600 }); - // Once runStart journals the spec, rewriting this input file cannot change - // commands already owned by the daemon. + // The submitted commands carry the original ref and gate directory. Rewriting + // the input JSON cannot change commands already journaled by the daemon. const run = spawnSync(process.execPath, ['scripts/run-local-workflow.mjs', path], { stdio: 'inherit' }); if (run.error) throw run.error; process.exitCode = run.status ?? 1; } catch (error) { console.error(error.message); process.exitCode = 1; +} finally { + prepared?.dispose(); } diff --git a/workflows/drive-local.yaml b/workflows/drive-local.yaml index a5a52e8d9..8e3a80922 100644 --- a/workflows/drive-local.yaml +++ b/workflows/drive-local.yaml @@ -14,8 +14,9 @@ # changes and most backlog entries are not mechanical. # # Run: node scripts/run-drive-local.mjs -# The preparing launcher embeds gate code and baseline pins into the submitted -# spec. Running this template directly fails before any implementation step. +# The preparing launcher pins the Git ref in submitted commands. gate-snapshot +# extracts gate inputs from that ref and builds the picker outside the checkout. +# Running this template directly fails before any implementation step. # Delivery stays with the operator: this flow never commits, never switches # branches and never merges. It leaves a working tree and a report. version: '0.1.0' @@ -26,24 +27,28 @@ description: >- scope enforcement, verification and reporting are journaled steps. steps: # The preparing launcher builds the SDK before capture and submission. + - id: gate-snapshot + type: deterministic + timeoutMs: 300000 + command: node ops/local-work-snapshot.mjs + + # Selection uses the picker built from Git and records that ref as pkg.head. - id: select type: deterministic + dependsOn: [gate-snapshot] timeoutMs: 120000 command: node ops/local-work-package.mjs select - # Gate code is already captured in the submitted spec. Before implementation, - # validate the selected package against that baseline and the current tree. - # Retain the existing step ID for journal readers; no snapshot is written here. - - id: gate-snapshot + - id: initial-scope type: deterministic dependsOn: [select] - timeoutMs: 300000 + timeoutMs: 120000 command: &scope-check node ops/local-work-package.mjs scope - id: implement type: agent cli: claude - dependsOn: [gate-snapshot] + dependsOn: [initial-scope] # Required, not decorative: the launcher refuses an agent step with no pins # (`LOCAL_AGENT_PINS_REQUIRED: declare a stream; the kernel refuses workers # with no pins`) and the run is never created. Verified by running it. @@ -81,8 +86,8 @@ steps: type: deterministic dependsOn: [implement] timeoutMs: 120000 - # The launcher substitutes the captured command. protectedPaths has one - # owner in that captured verifier; no mutable pre-load helper is executed. + # The launcher substitutes the command for the Git-extracted helper. + # protectedPaths has one owner in the verifier extracted from the same ref. command: *scope-check - id: verify