Skip to content

Commit bdde268

Browse files
committed
Report runner workspace integrity changes
1 parent 4126aa3 commit bdde268

3 files changed

Lines changed: 66 additions & 7 deletions

File tree

.github/scripts/run-agent-task/execute-native-agent-task.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -696,7 +696,7 @@ if (execution.code === 0 && runtimeRecord.success === true && verificationPassed
696696
runtimeRecord.metadata = { ...record(runtimeRecord.metadata), runner_workspace_publication: publication }
697697
runtimeRecord.outputs = { ...record(runtimeRecord.outputs), runner_workspace_publication: publication }
698698
} catch (error) {
699-
downstreamFailure = { stage: "publication", message: bounded(error instanceof Error ? error.message : String(error), MAX_WORKFLOW_OUTPUT_BYTES) }
699+
downstreamFailure = { stage: "publication", message: bounded(error instanceof Error ? error.message : String(error), MAX_WORKFLOW_OUTPUT_BYTES), ...(error?.evidence ? { evidence: error.evidence } : {}) }
700700
}
701701
}
702702
if (request.success?.requires_pr === true && workspaceApply.status === "no-op" && !publication) {

packages/runtime-core/src/runner-workspace-apply.ts

Lines changed: 52 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,15 @@ export interface RunnerWorkspaceIntegritySnapshot {
6767
baseline: RunnerWorkspacePublicationFile[]
6868
}
6969

70+
export interface RunnerWorkspaceIntegrityFailureEvidence {
71+
schema: "wp-codebox/runner-workspace-integrity-failure/v1"
72+
added: string[]
73+
modified: string[]
74+
deleted: string[]
75+
total: number
76+
truncated: boolean
77+
}
78+
7079
/**
7180
* Promotes the canonical sandbox patch artifact into the checked-out workspace.
7281
* Artifact references are treated as locators only after containment and digest
@@ -157,7 +166,23 @@ export async function runnerWorkspaceIdentity(root: string): Promise<RunnerWorks
157166
export async function verifyRunnerWorkspaceIntegrity(snapshot: RunnerWorkspaceIntegritySnapshot): Promise<void> {
158167
const current = await snapshotWorkspace(snapshot.workspaceRoot)
159168
if (JSON.stringify(current) !== JSON.stringify(snapshot.files)) {
160-
throw new Error("Runner workspace changed after approval; refusing publication.")
169+
const approved = new Map(snapshot.files.map((file) => [file.path, file]))
170+
const actual = new Map(current.map((file) => [file.path, file]))
171+
const added = [...actual.keys()].filter((path) => !approved.has(path)).sort()
172+
const deleted = [...approved.keys()].filter((path) => !actual.has(path)).sort()
173+
const modified = [...approved.keys()].filter((path) => actual.has(path) && JSON.stringify(approved.get(path)) !== JSON.stringify(actual.get(path))).sort()
174+
const changed = [...added, ...modified, ...deleted]
175+
const limit = 100
176+
const error = new Error(`Runner workspace changed after approval; refusing publication. Changed paths: ${changed.slice(0, 10).join(", ")}`) as Error & { evidence?: RunnerWorkspaceIntegrityFailureEvidence }
177+
error.evidence = {
178+
schema: "wp-codebox/runner-workspace-integrity-failure/v1",
179+
added: added.slice(0, limit),
180+
modified: modified.slice(0, limit),
181+
deleted: deleted.slice(0, limit),
182+
total: changed.length,
183+
truncated: changed.length > limit,
184+
}
185+
throw error
161186
}
162187
}
163188

@@ -235,10 +260,7 @@ function validatePatchPaths(patch: string, changed: RunnerWorkspaceChangedFile[]
235260

236261
async function snapshotWorkspace(root: string): Promise<RunnerWorkspacePublicationFile[]> {
237262
const output: RunnerWorkspacePublicationFile[] = []
238-
const { stdout } = await execFileAsync("git", ["ls-files", "--cached", "--others", "--exclude-standard", "-z"], { cwd: root, maxBuffer: MAX_PATCH_BYTES })
239-
const paths = stdout.split("\0")
240-
.filter((path) => path && path !== ".codebox" && !path.startsWith(".codebox/"))
241-
.sort((left, right) => left.localeCompare(right))
263+
const paths = await workspaceSnapshotPaths(root)
242264
for (const path of paths) {
243265
const absolute = resolve(root, path)
244266
if (!pathIsWithinRoot(absolute, root)) throw new Error(`Runner workspace contains a denied path: ${path}`)
@@ -257,6 +279,31 @@ async function snapshotWorkspace(root: string): Promise<RunnerWorkspacePublicati
257279
return output
258280
}
259281

282+
async function workspaceSnapshotPaths(root: string): Promise<string[]> {
283+
try {
284+
const { stdout } = await execFileAsync("git", ["ls-files", "--cached", "--others", "--exclude-standard", "-z"], { cwd: root, maxBuffer: MAX_PATCH_BYTES })
285+
return stdout.split("\0")
286+
.filter((path) => path && path !== ".codebox" && !path.startsWith(".codebox/"))
287+
.sort((left, right) => left.localeCompare(right))
288+
} catch {
289+
const paths: string[] = []
290+
async function visit(directory: string): Promise<void> {
291+
for (const entry of await readdir(directory, { withFileTypes: true })) {
292+
if ([".git", ".codebox", "node_modules", "vendor"].includes(entry.name)) continue
293+
const absolute = resolve(directory, entry.name)
294+
const path = relative(root, absolute).replaceAll("\\", "/")
295+
if (entry.isDirectory()) {
296+
await visit(absolute)
297+
} else {
298+
paths.push(path)
299+
}
300+
}
301+
}
302+
await visit(root)
303+
return paths.sort((left, right) => left.localeCompare(right))
304+
}
305+
}
306+
260307
function validateAppliedWorkspace(baseline: RunnerWorkspacePublicationFile[], current: RunnerWorkspacePublicationFile[], changed: RunnerWorkspaceChangedFile[]): void {
261308
const before = new Map(baseline.map((file) => [file.path, file]))
262309
const after = new Map(current.map((file) => [file.path, file]))

tests/runner-workspace-apply.test.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,19 @@ const files = [{ path: "/workspace/README.md", relativePath: "README.md", status
108108
const input = await fixture(patch, files)
109109
const result = await applyRunnerWorkspacePatch({ artifactRoot: input.artifacts, artifactRefs: input.refs, workspaceRoot: input.workspace, writablePaths: ["README.md"] })
110110
await writeFile(join(input.workspace, "extra.txt"), "unexpected\n")
111-
await assert.rejects(() => verifyRunnerWorkspaceIntegrity(result.integrity!), /changed after approval/)
111+
await assert.rejects(
112+
() => verifyRunnerWorkspaceIntegrity(result.integrity!),
113+
(error: Error & { evidence?: Record<string, any> }) => {
114+
assert.match(error.message, /Changed paths: extra.txt/)
115+
assert.equal(error.evidence?.schema, "wp-codebox/runner-workspace-integrity-failure/v1")
116+
assert.deepEqual(error.evidence?.added, ["extra.txt"])
117+
assert.deepEqual(error.evidence?.modified, [])
118+
assert.deepEqual(error.evidence?.deleted, [])
119+
assert.equal(error.evidence?.total, 1)
120+
assert.equal(error.evidence?.truncated, false)
121+
return true
122+
},
123+
)
112124
}
113125

114126
{

0 commit comments

Comments
 (0)