Skip to content

Commit a12368d

Browse files
committed
Validate runner workspace patch base identity
1 parent d146341 commit a12368d

7 files changed

Lines changed: 229 additions & 10 deletions

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -590,9 +590,9 @@ if (execution.code === 0 && runtimeResult.success === true && request.runner_wor
590590
const publicCore = await import(pathToFileURL(join(codeboxRoot, "packages/runtime-core/dist/public.js")).href)
591591
const refs = publicCore.normalizePublicArtifactRefDTOs(runtimeResult)
592592
.filter((ref) => ref.kind === "codebox-patch" || ref.kind === "codebox-changed-files")
593-
workspaceApply = await runnerWorkspaceCore.applyRunnerWorkspacePatch({ artifactRoot: artifactsPath, artifactRefs: refs, workspaceRoot: workspace, writablePaths })
593+
workspaceApply = await runnerWorkspaceCore.applyRunnerWorkspacePatch({ artifactRoot: artifactsPath, artifactRefs: refs, workspaceRoot: workspace, writablePaths, seedIdentity: runnerWorkspaceSeedSnapshot?.provenance.identity })
594594
} catch (error) {
595-
downstreamFailure = { stage: "apply", message: bounded(error instanceof Error ? error.message : String(error), MAX_OUTPUT_CHARS) }
595+
downstreamFailure = { stage: "apply", message: bounded(error instanceof Error ? error.message : String(error), MAX_OUTPUT_CHARS), ...(error?.evidence ? { evidence: error.evidence } : {}) }
596596
}
597597
}
598598
runtimeResult = sanitizeRuntimeSourceValue(runtimeResult, runtimeSourceOutputRoots)

.github/scripts/run-agent-task/prepare-agent-task-upload.mjs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,22 @@ function projectWorkflowResult(value) {
223223
}).filter(([, entry]) => entry !== undefined)))
224224
}
225225

226+
async function stageApplyFailureEvidence(result) {
227+
const evidence = record(record(result.failure).evidence)
228+
const files = [
229+
["patch", record(evidence.patch)],
230+
["changed-files", record(evidence.changed_files)],
231+
]
232+
for (const [name, descriptor] of files) {
233+
const artifactPath = safeRelativeArtifactPath(descriptor.artifact_path)
234+
if (!artifactPath) continue
235+
const source = resolve(artifactsPath, artifactPath)
236+
if (relative(artifactsPath, source).startsWith("..")) continue
237+
const destination = join(uploadPath, ".codebox", "agent-task-artifacts", "apply-failure", name === "patch" ? "rejected.patch" : "changed-files.json")
238+
await stageTextFile(source, destination, { allowTargetCode: true })
239+
}
240+
}
241+
226242
function safeTargetPath(value) {
227243
const path = safeRelativeArtifactPath(value)
228244
return path ? `workspace/${path}` : undefined
@@ -469,6 +485,7 @@ await mkdir(uploadPath, { recursive: true })
469485
await stageTextFile(requestPath, join(uploadPath, ".codebox", "agent-task-request.json"))
470486
await stageTextFile(resultSource, join(uploadPath, ".codebox", "agent-task-workflow-result.json"), { projectWorkflowResult: true })
471487
await stageTextFile(join(workspace, ".codebox", "native-agent-task-input.json"), join(uploadPath, ".codebox", "native-agent-task-input.json"), { compactNativeInput: true })
488+
await stageApplyFailureEvidence(result)
472489
for (const path of declaredPaths) {
473490
const source = resolve(artifactsPath, path)
474491
if (relative(artifactsPath, source).startsWith("..") || sourceCategory(path, source)) {

.github/scripts/run-agent-task/runner-workspace-seed-snapshot.mjs

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { createHash } from "node:crypto"
22
import { chmod, copyFile, lstat, mkdir, mkdtemp, readdir, readFile, rm } from "node:fs/promises"
33
import { tmpdir } from "node:os"
44
import { join, relative, resolve } from "node:path"
5+
import { execFile } from "node:child_process"
6+
import { promisify } from "node:util"
57

68
// Snapshot policy is intentionally allow-by-exception: disposable build trees and
79
// credential-bearing files never enter the agent-readable workspace. `.env.example`
@@ -10,6 +12,7 @@ export const RUNNER_WORKSPACE_SEED_EXCLUDES = [".git/**", ".codebox/**", "node_m
1012
const EXCLUDED_ROOT_NAMES = new Set(RUNNER_WORKSPACE_SEED_EXCLUDES.filter((pattern) => pattern.endsWith("/**")).map((pattern) => pattern.slice(0, -3)))
1113
const MAX_FILES = 10_000
1214
const MAX_BYTES = 256 * 1024 * 1024
15+
const execFileAsync = promisify(execFile)
1316

1417
function snapshotError(message) {
1518
const error = new Error(message)
@@ -80,11 +83,17 @@ export async function createRunnerWorkspaceSeedSnapshot(source) {
8083
const sourceStat = await lstat(sourceRoot)
8184
if (!sourceStat.isDirectory() || sourceStat.isSymbolicLink()) throw snapshotError("Runner workspace seed source must be a real directory.")
8285
await copyTree(sourceRoot, root)
86+
const contentDigest = digest.digest("hex")
87+
const head = await gitHead(sourceRoot)
8388
return {
8489
source: root,
8590
provenance: {
8691
schema: "wp-codebox/runner-workspace-seed-snapshot/v1",
87-
digest: { sha256: digest.digest("hex") },
92+
digest: { sha256: contentDigest },
93+
identity: {
94+
content_digest: { algorithm: "sha256", value: contentDigest },
95+
...(head ? { git: { head } } : {}),
96+
},
8897
files: fileCount,
8998
bytes: byteCount,
9099
excludes: RUNNER_WORKSPACE_SEED_EXCLUDES,
@@ -99,3 +108,13 @@ export async function createRunnerWorkspaceSeedSnapshot(source) {
99108
throw error
100109
}
101110
}
111+
112+
async function gitHead(source) {
113+
try {
114+
const { stdout } = await execFileAsync("git", ["rev-parse", "HEAD"], { cwd: source })
115+
const head = stdout.trim()
116+
return /^[a-f0-9]{40}$/i.test(head) ? head : ""
117+
} catch {
118+
return ""
119+
}
120+
}

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

Lines changed: 85 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,22 @@ export interface RunnerWorkspaceApplyRequest {
2828
artifactRefs: RunnerWorkspaceArtifactRef[]
2929
workspaceRoot: string
3030
writablePaths: string[]
31+
seedIdentity?: RunnerWorkspaceSeedIdentity
3132
verify?: () => Promise<void>
3233
}
3334

35+
export interface RunnerWorkspaceSeedIdentity {
36+
content_digest: { algorithm: "sha256"; value: string }
37+
git?: { head: string }
38+
}
39+
40+
export interface RunnerWorkspaceApplyFailureEvidence {
41+
expected_identity?: RunnerWorkspaceSeedIdentity
42+
actual_identity: RunnerWorkspaceSeedIdentity
43+
patch: { artifact_path: string; sha256: string }
44+
changed_files: { artifact_path: string; sha256: string }
45+
}
46+
3447
export interface RunnerWorkspaceApplyResult {
3548
schema: "wp-codebox/runner-workspace-apply-result/v1"
3649
status: "applied" | "no-op"
@@ -72,15 +85,27 @@ export async function applyRunnerWorkspacePatch(request: RunnerWorkspaceApplyReq
7285

7386
const changed = parseChangedFiles(changedRaw)
7487
validateChangedFiles(changed, request.writablePaths)
88+
const failureEvidence = runnerWorkspaceFailureEvidence(request.seedIdentity, await runnerWorkspaceIdentity(workspaceRoot), artifactRoot, patchPath, patch, changedPath, changedRaw)
89+
if (request.seedIdentity) {
90+
if (!sameIdentity(request.seedIdentity, failureEvidence.actual_identity)) {
91+
throw applyFailure("Runner workspace seed identity does not match the host workspace; refusing to apply patch.", {
92+
...failureEvidence,
93+
})
94+
}
95+
}
7596
if (changed.length === 0) {
7697
if (patch.trim()) throw new Error("Canonical patch is non-empty but changed-files declares no changes.")
7798
return { schema: "wp-codebox/runner-workspace-apply-result/v1", status: "no-op", changedFiles: [] }
7899
}
79100
if (!patch.trim()) throw new Error("Canonical changed-files declares changes but patch is empty.")
80101
validatePatchPaths(patch, changed)
81102

82-
await execGit(workspaceRoot, ["apply", "--check", "--whitespace=error", "--", patchPath])
83-
await execGit(workspaceRoot, ["apply", "--whitespace=error", "--", patchPath])
103+
try {
104+
await execGit(workspaceRoot, ["apply", "--check", "--whitespace=error", "--", patchPath])
105+
await execGit(workspaceRoot, ["apply", "--whitespace=error", "--", patchPath])
106+
} catch (error) {
107+
throw applyFailure(error instanceof Error ? error.message : String(error), failureEvidence)
108+
}
84109
const files = await snapshotWorkspace(workspaceRoot)
85110
validateAppliedWorkspace(baseline, files, changed)
86111
if (request.verify) await request.verify()
@@ -98,6 +123,36 @@ export async function applyRunnerWorkspacePatch(request: RunnerWorkspaceApplyReq
98123
}
99124
}
100125

126+
export async function runnerWorkspaceIdentity(root: string): Promise<RunnerWorkspaceSeedIdentity> {
127+
const digest = createHash("sha256")
128+
async function visit(directory: string): Promise<void> {
129+
for (const entry of (await readdir(directory, { withFileTypes: true })).sort((left, right) => left.name.localeCompare(right.name))) {
130+
if ([".git", ".codebox", "node_modules", "vendor", "dist", "build", "coverage", ".cache"].includes(entry.name)) continue
131+
const absolute = resolve(directory, entry.name)
132+
const path = relative(root, absolute).replaceAll("\\", "/")
133+
const stat = await lstat(absolute)
134+
if (excludedSeedFile(path)) continue
135+
if (stat.isSymbolicLink() || (!stat.isDirectory() && !stat.isFile())) throw new Error(`Runner workspace contains an unsupported path type: ${path}`)
136+
if (stat.isDirectory()) {
137+
digest.update(`directory\0${path}\n`)
138+
await visit(absolute)
139+
continue
140+
}
141+
const bytes = await readFile(absolute)
142+
digest.update(`file\0${path}\0${(stat.mode & 0o111 ? 0o755 : 0o644).toString(8)}\0${bytes.length}\n`)
143+
digest.update(bytes)
144+
}
145+
}
146+
await visit(root)
147+
const identity: RunnerWorkspaceSeedIdentity = { content_digest: { algorithm: "sha256", value: digest.digest("hex") } }
148+
try {
149+
const { stdout } = await execFileAsync("git", ["rev-parse", "HEAD"], { cwd: root, maxBuffer: 1024 })
150+
const head = stdout.trim()
151+
if (/^[a-f0-9]{40}$/i.test(head)) identity.git = { head }
152+
} catch { /* A non-git workspace still has a content identity. */ }
153+
return identity
154+
}
155+
101156
/** Ensures checks did not mutate approved output or introduce unrelated files. */
102157
export async function verifyRunnerWorkspaceIntegrity(snapshot: RunnerWorkspaceIntegritySnapshot): Promise<void> {
103158
const current = await snapshotWorkspace(snapshot.workspaceRoot)
@@ -217,6 +272,34 @@ function validateAppliedWorkspace(baseline: RunnerWorkspacePublicationFile[], cu
217272
}
218273
}
219274

275+
function sameIdentity(expected: RunnerWorkspaceSeedIdentity, actual: RunnerWorkspaceSeedIdentity): boolean {
276+
return expected.content_digest.algorithm === "sha256"
277+
&& expected.content_digest.value === actual.content_digest.value
278+
&& (!expected.git?.head || expected.git.head === actual.git?.head)
279+
}
280+
281+
function runnerWorkspaceFailureEvidence(expected: RunnerWorkspaceSeedIdentity | undefined, actual: RunnerWorkspaceSeedIdentity, artifactRoot: string, patchPath: string, patch: string, changedPath: string, changedRaw: string): RunnerWorkspaceApplyFailureEvidence {
282+
return {
283+
...(expected ? { expected_identity: expected } : {}),
284+
actual_identity: actual,
285+
patch: { artifact_path: relative(artifactRoot, patchPath).replaceAll("\\", "/"), sha256: createHash("sha256").update(patch).digest("hex") },
286+
changed_files: { artifact_path: relative(artifactRoot, changedPath).replaceAll("\\", "/"), sha256: createHash("sha256").update(changedRaw).digest("hex") },
287+
}
288+
}
289+
290+
function excludedSeedFile(path: string): boolean {
291+
const name = path.split("/").at(-1)?.toLowerCase() ?? ""
292+
return (name === ".env" || (name.startsWith(".env.") && name !== ".env.example"))
293+
|| [".npmrc", ".yarnrc.yml", ".pypirc", ".netrc", "auth.json", "credentials", "credentials.json", "secrets.json", "token.json", "id_rsa", "id_ed25519", "id_ecdsa", "id_dsa"].includes(name)
294+
|| /\.(?:pem|key|p12|pfx)$/i.test(name)
295+
}
296+
297+
function applyFailure(message: string, evidence: RunnerWorkspaceApplyFailureEvidence): Error {
298+
const error = new Error(message) as Error & { evidence?: RunnerWorkspaceApplyFailureEvidence }
299+
error.evidence = evidence
300+
return error
301+
}
302+
220303
async function execGit(cwd: string, args: string[]): Promise<void> {
221304
try {
222305
await execFileAsync("git", args, { cwd, maxBuffer: MAX_PATCH_BYTES })

tests/execute-native-agent-task-lifecycle.test.mjs

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,13 @@ import { chmod, mkdtemp, mkdir, readFile, writeFile } from "node:fs/promises"
33
import { tmpdir } from "node:os"
44
import { join, resolve } from "node:path"
55
import { spawn } from "node:child_process"
6+
import { execFile } from "node:child_process"
7+
import { promisify } from "node:util"
68

79
const root = resolve(".")
810
const executor = join(root, ".github/scripts/run-agent-task/execute-native-agent-task.mjs")
11+
const uploader = join(root, ".github/scripts/run-agent-task/prepare-agent-task-upload.mjs")
12+
const execFileAsync = promisify(execFile)
913

1014
async function run(mode = "success") {
1115
const temp = await mkdtemp(join(tmpdir(), "wp-codebox-native-lifecycle-"))
@@ -61,19 +65,22 @@ async function run(mode = "success") {
6165

6266
const fakeCli = join(temp, "fake-cli.mjs")
6367
const noOp = mode.startsWith("no-op")
68+
const mismatch = mode === "mismatch"
69+
const applyReject = mode === "apply-reject"
6470
await writeFile(fakeCli, `
6571
import { mkdir, readFile, writeFile } from "node:fs/promises"
6672
import { join } from "node:path"
6773
const output = process.argv[process.argv.indexOf("--result-file") + 1]
6874
const input = JSON.parse(await readFile(process.argv[process.argv.indexOf("--input-file") + 1], "utf8"))
6975
const root = join(process.cwd(), ".codebox", "agent-task-artifacts", "files")
7076
await mkdir(root, { recursive: true })
71-
const patch = ${noOp} ? "" : "--- a/README.md\\n+++ b/README.md\\n@@ -1 +1 @@\\n-before\\n+after\\n"
77+
const patch = ${JSON.stringify(noOp ? "" : applyReject ? "--- a/README.md\n+++ b/README.md\n@@ -1 +1 @@\n-not-before\n+after\n" : "--- a/README.md\n+++ b/README.md\n@@ -1 +1 @@\n-before\n+after\n")}
7278
await writeFile(join(root, "patch.diff"), patch)
7379
await writeFile(join(root, "changed-files.json"), JSON.stringify({
7480
schema: "wp-codebox/changed-files/v1",
7581
files: ${noOp} ? [] : [{ path: "/workspace/README.md", relativePath: "README.md", status: "modified", beforeMode: "100644", afterMode: "100644" }],
7682
}))
83+
${mismatch ? "await writeFile(join(process.cwd(), \"README.md\"), \"diverged\\n\")" : ""}
7784
await writeFile(join(process.cwd(), ".codebox", "order"), "runtime\\n")
7885
const summary = {
7986
schema: "wp-codebox/agent-task-run-result/v1",
@@ -190,4 +197,44 @@ const noOpMaintenance = await run("no-op-maintenance")
190197
assert.equal(noOpMaintenance.code, 0)
191198
assert(!noOpMaintenance.order.includes("publish"))
192199

200+
const mismatch = await run("mismatch")
201+
assert.equal(mismatch.code, 1)
202+
assert.equal(await readFile(join(mismatch.workspace, "README.md"), "utf8"), "diverged\n", "base mismatch fails before the rejected patch can mutate the host workspace")
203+
assert.equal(mismatch.result.failure.stage, "apply")
204+
assert.match(mismatch.result.failure.message, /seed identity does not match/)
205+
assert.notEqual(mismatch.result.failure.evidence.expected_identity.content_digest.value, mismatch.result.failure.evidence.actual_identity.content_digest.value)
206+
assert.equal(mismatch.result.failure.evidence.patch.artifact_path, "files/patch.diff")
207+
assert.equal(mismatch.result.failure.evidence.changed_files.artifact_path, "files/changed-files.json")
208+
await execFileAsync(process.execPath, [uploader], {
209+
cwd: mismatch.workspace,
210+
env: {
211+
...process.env,
212+
AGENT_TASK_WORKSPACE: mismatch.workspace,
213+
AGENT_TASK_REQUEST_PATH: join(mismatch.workspace, ".codebox", "agent-task-request.json"),
214+
AGENT_TASK_UPLOAD_PATH: join(mismatch.workspace, ".codebox", "agent-task-upload"),
215+
},
216+
})
217+
assert.equal(await readFile(join(mismatch.workspace, ".codebox", "agent-task-upload", ".codebox", "agent-task-artifacts", "apply-failure", "rejected.patch"), "utf8"), "--- a/README.md\n+++ b/README.md\n@@ -1 +1 @@\n-before\n+after\n")
218+
assert.match(await readFile(join(mismatch.workspace, ".codebox", "agent-task-upload", ".codebox", "agent-task-artifacts", "apply-failure", "changed-files.json"), "utf8"), /README\.md/)
219+
220+
const applyReject = await run("apply-reject")
221+
assert.equal(applyReject.code, 1)
222+
assert.equal(await readFile(join(applyReject.workspace, "README.md"), "utf8"), "before\n", "a rejected patch does not partially mutate the matching host workspace")
223+
assert.equal(applyReject.result.failure.stage, "apply")
224+
assert.match(applyReject.result.failure.message, /Host git apply failed/)
225+
assert.deepEqual(applyReject.result.failure.evidence.expected_identity, applyReject.result.failure.evidence.actual_identity, "the failure records the matching seed and host identities")
226+
assert.equal(applyReject.result.failure.evidence.patch.artifact_path, "files/patch.diff")
227+
assert.equal(applyReject.result.failure.evidence.changed_files.artifact_path, "files/changed-files.json")
228+
await execFileAsync(process.execPath, [uploader], {
229+
cwd: applyReject.workspace,
230+
env: {
231+
...process.env,
232+
AGENT_TASK_WORKSPACE: applyReject.workspace,
233+
AGENT_TASK_REQUEST_PATH: join(applyReject.workspace, ".codebox", "agent-task-request.json"),
234+
AGENT_TASK_UPLOAD_PATH: join(applyReject.workspace, ".codebox", "agent-task-upload"),
235+
},
236+
})
237+
assert.match(await readFile(join(applyReject.workspace, ".codebox", "agent-task-upload", ".codebox", "agent-task-artifacts", "apply-failure", "rejected.patch"), "utf8"), /-not-before/)
238+
assert.match(await readFile(join(applyReject.workspace, ".codebox", "agent-task-upload", ".codebox", "agent-task-artifacts", "apply-failure", "changed-files.json"), "utf8"), /README\.md/)
239+
193240
console.log("native agent task lifecycle ok")

tests/execute-native-agent-task-playground-e2e.test.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,11 @@ try {
3131
await writeFile(join(workspace, "id_ed25519"), "PRIVATE_KEY_SENTINEL\n")
3232
await writeFile(join(workspace, ".codebox", "private.txt"), "PRIVATE_CODEBOX_SENTINEL\n")
3333
await writeFile(join(workspace, "node_modules", "private.txt"), "PRIVATE_NODE_MODULES_SENTINEL\n")
34+
await execFileAsync("git", ["init", "--quiet"], { cwd: workspace })
35+
await execFileAsync("git", ["config", "user.email", "tests@example.invalid"], { cwd: workspace })
36+
await execFileAsync("git", ["config", "user.name", "Tests"], { cwd: workspace })
37+
await execFileAsync("git", ["add", "."], { cwd: workspace })
38+
await execFileAsync("git", ["commit", "--quiet", "-m", "seed"], { cwd: workspace })
3439
const packageBytes = Buffer.from(`${JSON.stringify({ schema_version: 1, bundle_slug: "fixture-agent", agent: { agent_slug: "fixture-agent", agent_name: "Fixture Agent", description: "Playground imported-agent fixture.", agent_config: { instructions: "Read and update README.", enabled_tools: ["workspace_read", "workspace_edit"], modes: ["chat"] } } })}\n`)
3540
await writeFile(packagePath, packageBytes)
3641
await mkdir(interceptor, { recursive: true })
@@ -68,10 +73,12 @@ add_filter( 'pre_http_request', static function( $preempt, $args, $url ) {
6873
assert.deepEqual(input.task_input.workspaces.map((entry: { target: string, mode: string }) => ({ target: entry.target, mode: entry.mode })), [{ target: "/workspace", mode: "readwrite" }])
6974
assert.notEqual(seededWorkspace.seed.source, workspace)
7075
const seedProvenance = input.task_input.runtime_task.input.metadata.runner_workspace_seed
71-
assert.match(seedProvenance.digest.sha256, /^[a-f0-9]{64}$/)
76+
assert.match(seedProvenance.digest.sha256, /^[a-f0-9]{64}$/)
77+
assert.match(seedProvenance.identity.content_digest.value, /^[a-f0-9]{64}$/)
78+
assert.match(seedProvenance.identity.git.head, /^[a-f0-9]{40}$/)
7279
assert.equal(seedProvenance.files, 2, "only README and the explicit .env.example template are copied")
73-
assert.equal(seedProvenance.excluded.files, 6)
74-
assert.deepEqual(seedProvenance.excluded.categories, [{ category: "credentials", count: 2 }, { category: "environment", count: 1 }, { category: "generated-tree", count: 2 }, { category: "private-key", count: 1 }])
80+
assert.equal(seedProvenance.excluded.files, 7)
81+
assert.deepEqual(seedProvenance.excluded.categories, [{ category: "credentials", count: 2 }, { category: "environment", count: 1 }, { category: "generated-tree", count: 3 }, { category: "private-key", count: 1 }])
7582
assert.equal(execution.code, 0, `${execution.stderr ?? ""}\n${JSON.stringify(result)}`)
7683
assert.equal(result.success, true)
7784
assert.equal(await readFile(join(workspace, "README.md"), "utf8"), "after\n")

0 commit comments

Comments
 (0)