From bdde268c1aabcd558a188fe9f6bbdddb84751166 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 18 Jul 2026 07:27:08 -0400 Subject: [PATCH] Report runner workspace integrity changes --- .../execute-native-agent-task.mjs | 2 +- .../src/runner-workspace-apply.ts | 57 +++++++++++++++++-- tests/runner-workspace-apply.test.ts | 14 ++++- 3 files changed, 66 insertions(+), 7 deletions(-) diff --git a/.github/scripts/run-agent-task/execute-native-agent-task.mjs b/.github/scripts/run-agent-task/execute-native-agent-task.mjs index 162f7748..e1269522 100644 --- a/.github/scripts/run-agent-task/execute-native-agent-task.mjs +++ b/.github/scripts/run-agent-task/execute-native-agent-task.mjs @@ -696,7 +696,7 @@ if (execution.code === 0 && runtimeRecord.success === true && verificationPassed runtimeRecord.metadata = { ...record(runtimeRecord.metadata), runner_workspace_publication: publication } runtimeRecord.outputs = { ...record(runtimeRecord.outputs), runner_workspace_publication: publication } } catch (error) { - downstreamFailure = { stage: "publication", message: bounded(error instanceof Error ? error.message : String(error), MAX_WORKFLOW_OUTPUT_BYTES) } + downstreamFailure = { stage: "publication", message: bounded(error instanceof Error ? error.message : String(error), MAX_WORKFLOW_OUTPUT_BYTES), ...(error?.evidence ? { evidence: error.evidence } : {}) } } } if (request.success?.requires_pr === true && workspaceApply.status === "no-op" && !publication) { diff --git a/packages/runtime-core/src/runner-workspace-apply.ts b/packages/runtime-core/src/runner-workspace-apply.ts index d5045a2a..74356a7d 100644 --- a/packages/runtime-core/src/runner-workspace-apply.ts +++ b/packages/runtime-core/src/runner-workspace-apply.ts @@ -67,6 +67,15 @@ export interface RunnerWorkspaceIntegritySnapshot { baseline: RunnerWorkspacePublicationFile[] } +export interface RunnerWorkspaceIntegrityFailureEvidence { + schema: "wp-codebox/runner-workspace-integrity-failure/v1" + added: string[] + modified: string[] + deleted: string[] + total: number + truncated: boolean +} + /** * Promotes the canonical sandbox patch artifact into the checked-out workspace. * Artifact references are treated as locators only after containment and digest @@ -157,7 +166,23 @@ export async function runnerWorkspaceIdentity(root: string): Promise { const current = await snapshotWorkspace(snapshot.workspaceRoot) if (JSON.stringify(current) !== JSON.stringify(snapshot.files)) { - throw new Error("Runner workspace changed after approval; refusing publication.") + const approved = new Map(snapshot.files.map((file) => [file.path, file])) + const actual = new Map(current.map((file) => [file.path, file])) + const added = [...actual.keys()].filter((path) => !approved.has(path)).sort() + const deleted = [...approved.keys()].filter((path) => !actual.has(path)).sort() + const modified = [...approved.keys()].filter((path) => actual.has(path) && JSON.stringify(approved.get(path)) !== JSON.stringify(actual.get(path))).sort() + const changed = [...added, ...modified, ...deleted] + const limit = 100 + const error = new Error(`Runner workspace changed after approval; refusing publication. Changed paths: ${changed.slice(0, 10).join(", ")}`) as Error & { evidence?: RunnerWorkspaceIntegrityFailureEvidence } + error.evidence = { + schema: "wp-codebox/runner-workspace-integrity-failure/v1", + added: added.slice(0, limit), + modified: modified.slice(0, limit), + deleted: deleted.slice(0, limit), + total: changed.length, + truncated: changed.length > limit, + } + throw error } } @@ -235,10 +260,7 @@ function validatePatchPaths(patch: string, changed: RunnerWorkspaceChangedFile[] async function snapshotWorkspace(root: string): Promise { const output: RunnerWorkspacePublicationFile[] = [] - const { stdout } = await execFileAsync("git", ["ls-files", "--cached", "--others", "--exclude-standard", "-z"], { cwd: root, maxBuffer: MAX_PATCH_BYTES }) - const paths = stdout.split("\0") - .filter((path) => path && path !== ".codebox" && !path.startsWith(".codebox/")) - .sort((left, right) => left.localeCompare(right)) + const paths = await workspaceSnapshotPaths(root) for (const path of paths) { const absolute = resolve(root, path) if (!pathIsWithinRoot(absolute, root)) throw new Error(`Runner workspace contains a denied path: ${path}`) @@ -257,6 +279,31 @@ async function snapshotWorkspace(root: string): Promise { + try { + const { stdout } = await execFileAsync("git", ["ls-files", "--cached", "--others", "--exclude-standard", "-z"], { cwd: root, maxBuffer: MAX_PATCH_BYTES }) + return stdout.split("\0") + .filter((path) => path && path !== ".codebox" && !path.startsWith(".codebox/")) + .sort((left, right) => left.localeCompare(right)) + } catch { + const paths: string[] = [] + async function visit(directory: string): Promise { + for (const entry of await readdir(directory, { withFileTypes: true })) { + if ([".git", ".codebox", "node_modules", "vendor"].includes(entry.name)) continue + const absolute = resolve(directory, entry.name) + const path = relative(root, absolute).replaceAll("\\", "/") + if (entry.isDirectory()) { + await visit(absolute) + } else { + paths.push(path) + } + } + } + await visit(root) + return paths.sort((left, right) => left.localeCompare(right)) + } +} + function validateAppliedWorkspace(baseline: RunnerWorkspacePublicationFile[], current: RunnerWorkspacePublicationFile[], changed: RunnerWorkspaceChangedFile[]): void { const before = new Map(baseline.map((file) => [file.path, file])) const after = new Map(current.map((file) => [file.path, file])) diff --git a/tests/runner-workspace-apply.test.ts b/tests/runner-workspace-apply.test.ts index c17ad6c3..a260a918 100644 --- a/tests/runner-workspace-apply.test.ts +++ b/tests/runner-workspace-apply.test.ts @@ -108,7 +108,19 @@ const files = [{ path: "/workspace/README.md", relativePath: "README.md", status const input = await fixture(patch, files) const result = await applyRunnerWorkspacePatch({ artifactRoot: input.artifacts, artifactRefs: input.refs, workspaceRoot: input.workspace, writablePaths: ["README.md"] }) await writeFile(join(input.workspace, "extra.txt"), "unexpected\n") - await assert.rejects(() => verifyRunnerWorkspaceIntegrity(result.integrity!), /changed after approval/) + await assert.rejects( + () => verifyRunnerWorkspaceIntegrity(result.integrity!), + (error: Error & { evidence?: Record }) => { + assert.match(error.message, /Changed paths: extra.txt/) + assert.equal(error.evidence?.schema, "wp-codebox/runner-workspace-integrity-failure/v1") + assert.deepEqual(error.evidence?.added, ["extra.txt"]) + assert.deepEqual(error.evidence?.modified, []) + assert.deepEqual(error.evidence?.deleted, []) + assert.equal(error.evidence?.total, 1) + assert.equal(error.evidence?.truncated, false) + return true + }, + ) } {