Skip to content

Commit c9c36d7

Browse files
committed
Harden runtime source isolation
1 parent c63e600 commit c9c36d7

6 files changed

Lines changed: 76 additions & 12 deletions

File tree

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

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,19 @@ const runtimeInputPath = join(workspace, ".codebox", "native-agent-task-input.js
196196
const resultPath = join(workspace, ".codebox", "agent-task-workflow-result.json")
197197
const controlledCodeboxPath = resolve(requestPath, "..")
198198
const nativeResultPath = join(controlledCodeboxPath, "native-agent-task-result.json")
199+
let privateRuntimeSourceRoot = ""
200+
let cleaningPrivateRuntimeSources = false
201+
async function cleanupPrivateRuntimeSources() {
202+
if (cleaningPrivateRuntimeSources || !privateRuntimeSourceRoot) return
203+
cleaningPrivateRuntimeSources = true
204+
const root = privateRuntimeSourceRoot
205+
privateRuntimeSourceRoot = ""
206+
await rm(root, { recursive: true, force: true })
207+
}
208+
process.once("exit", () => { if (privateRuntimeSourceRoot) rmSync(privateRuntimeSourceRoot, { recursive: true, force: true }) })
209+
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
210+
process.once(signal, () => { cleanupPrivateRuntimeSources().finally(() => process.exit(128)) })
211+
}
199212
const runnerWorkspaceTools = [
200213
"workspace-read", "workspace-ls", "workspace-grep", "workspace-write", "workspace-edit", "workspace-apply-patch",
201214
"workspace-git-status", "workspace-git-diff", "workspace-git-add", "workspace-git-commit", "workspace-git-push",
@@ -223,9 +236,9 @@ const materializedPackage = request.run_agent && !request.dry_run
223236
? await materializeExternalNativePackage(externalPackageSource, { policy: externalPackagePolicy })
224237
: undefined
225238
const materializedRuntimeSources = request.run_agent && !request.dry_run
226-
? await materializeRuntimeSources(runtimeSources, { policy: externalPackagePolicy })
239+
? await materializeRuntimeSources(runtimeSources, { policy: externalPackagePolicy, forbiddenRoots: [workspace, artifactsPath] })
227240
: undefined
228-
process.once("exit", () => { if (materializedRuntimeSources?.root) rmSync(materializedRuntimeSources.root, { recursive: true, force: true }) })
241+
privateRuntimeSourceRoot = materializedRuntimeSources?.root ?? ""
229242
const runtimeSourceInputs = (materializedRuntimeSources?.lowered ?? []).reduce((input, lowered) => {
230243
for (const [key, entries] of Object.entries(lowered)) input[key] = [...(input[key] ?? []), ...entries]
231244
return input
@@ -276,7 +289,7 @@ const taskInput = {
276289
artifact_declarations: request.artifacts?.declarations || [],
277290
required_artifacts: request.artifacts?.expected || [],
278291
output_projections: [],
279-
metadata: { workload: request.workload, ...(materializedPackage ? { imported_agent: materializedPackage.identity } : {}), runtime_sources: materializedRuntimeSources?.descriptors.map(({ metadata, ...provenance }) => ({ ...provenance, metadata })) ?? [] },
292+
metadata: { workload: request.workload, ...(materializedPackage ? { imported_agent: materializedPackage.identity } : {}), runtime_sources: materializedRuntimeSources?.descriptors ?? [] },
280293
},
281294
},
282295
},
@@ -296,7 +309,7 @@ const runtimeResult = request.run_agent && !request.dry_run
296309
? await readNativeResult(nativeResultPath, controlledCodeboxPath, secretValues, redact)
297310
: {}
298311
await rm(nativeResultPath, { force: true })
299-
await rm(materializedRuntimeSources?.root ?? "", { recursive: true, force: true })
312+
await cleanupPrivateRuntimeSources()
300313

301314
await redactArtifactFiles(artifactsPath)
302315

.github/scripts/run-agent-task/materialize-external-native-package.mjs

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { createHash } from "node:crypto"
22
import { mkdtemp, rm, mkdir, writeFile } from "node:fs/promises"
33
import { tmpdir } from "node:os"
4-
import { join } from "node:path"
4+
import { isAbsolute, join, relative, resolve } from "node:path"
55
import { spawn } from "node:child_process"
66

77
const REPOSITORY = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/
@@ -178,6 +178,7 @@ export async function materializeRuntimeSources(sources, options = {}) {
178178
const descriptors = normalizeRuntimeSources(sources, options.policy)
179179
const root = await mkdtemp(join(options.tempRoot ?? tmpdir(), "wp-codebox-runtime-sources-"))
180180
try {
181+
assertPrivateRuntimeRoot(root, options.forbiddenRoots)
181182
const lowered = []
182183
for (const [index, descriptor] of descriptors.entries()) {
183184
const checkout = join(root, `source-${index}`)
@@ -189,25 +190,42 @@ export async function materializeRuntimeSources(sources, options = {}) {
189190
await run("git", ["-c", "credential.helper=", "-c", "http.extraHeader=", "fetch", "--depth=1", "origin", descriptor.revision], { cwd: checkout, env: environment })
190191
const commit = (await run("git", ["rev-parse", "FETCH_HEAD^{commit}"], { cwd: checkout, env: environment })).toString("utf8").trim().toLowerCase()
191192
if (commit !== descriptor.revision) throw new Error("Runtime source revision did not resolve to the requested immutable commit.")
192-
const objectType = (await run("git", ["cat-file", "-t", `${descriptor.revision}:${descriptor.path}`], { cwd: checkout, env: environment })).toString("utf8").trim()
193+
const sourceObject = descriptor.path === "." ? `${descriptor.revision}^{tree}` : `${descriptor.revision}:${descriptor.path}`
194+
const objectType = (await run("git", ["cat-file", "-t", sourceObject], { cwd: checkout, env: environment })).toString("utf8").trim()
193195
if (objectType !== "tree") throw new Error("Runtime source path must identify a directory.")
194-
const entries = (await run("git", ["ls-tree", "-r", "-z", descriptor.revision, "--", descriptor.path], { cwd: checkout, env: environment })).toString("utf8").split("\0").filter(Boolean)
196+
const entries = (await run("git", descriptor.path === "." ? ["ls-tree", "-r", "-z", descriptor.revision] : ["ls-tree", "-r", "-z", descriptor.revision, "--", descriptor.path], { cwd: checkout, env: environment })).toString("utf8").split("\0").filter(Boolean)
195197
if (entries.length === 0 || entries.some((entry) => !/^100(?:644|755)\s+blob\s+[0-9a-f]{40}\t/.test(entry))) throw new Error("Runtime source must contain only regular files; symlinks and special files are rejected.")
196-
const archive = await run("git", ["archive", "--format=tar", descriptor.revision, descriptor.path], { cwd: checkout, env: environment })
198+
const archive = await run("git", descriptor.path === "." ? ["archive", "--format=tar", descriptor.revision] : ["archive", "--format=tar", descriptor.revision, descriptor.path], { cwd: checkout, env: environment })
197199
if (descriptor.digest && sha256GitArchiveV1(archive) !== descriptor.digest) throw new Error("Runtime source archive digest does not match the trusted descriptor.")
198200
await mkdir(source, { recursive: true })
199201
const archivePath = join(checkout, "source.tar")
200202
await writeFile(archivePath, archive)
201203
await run("tar", ["-xf", archivePath, "-C", source], { cwd: checkout, env: environment })
202-
const materializedPath = join(source, descriptor.path)
204+
const materializedPath = descriptor.path === "." ? source : join(source, descriptor.path)
203205
lowered.push(lowerRuntimeSource(descriptor, materializedPath))
204206
}
205-
return { root, descriptors, lowered }
207+
return { root, descriptors: descriptors.map(runtimeSourceProvenance), lowered }
206208
} catch (error) { await rm(root, { recursive: true, force: true }); throw error }
207209
}
208210

211+
export function runtimeSourceProvenance(descriptor) {
212+
return { role: descriptor.role, repository: descriptor.repository, revision: descriptor.revision, path: descriptor.path, ...(descriptor.digest ? { digest: descriptor.digest } : {}) }
213+
}
214+
215+
export function assertPrivateRuntimeRoot(root, forbiddenRoots = []) {
216+
const privateRoot = resolve(root)
217+
for (const forbiddenRoot of forbiddenRoots) {
218+
if (!forbiddenRoot) continue
219+
const boundary = resolve(forbiddenRoot)
220+
const path = relative(boundary, privateRoot)
221+
if (privateRoot === boundary || (!path.startsWith(`..${String.fromCharCode(47)}`) && path !== ".." && !isAbsolute(path))) {
222+
throw new Error("Runtime sources must be materialized outside target workspaces and artifacts.")
223+
}
224+
}
225+
}
226+
209227
export function lowerRuntimeSource(descriptor, source) {
210-
const provenance = { role: descriptor.role, repository: descriptor.repository, revision: descriptor.revision, path: descriptor.path, ...(descriptor.digest ? { digest: descriptor.digest } : {}) }
228+
const provenance = runtimeSourceProvenance(descriptor)
211229
if (descriptor.role === "component") return { component_contracts: [{ path: source, ...descriptor.metadata, metadata: { runtime_source: provenance } }] }
212230
if (descriptor.role === "provider_plugin") return { provider_plugin_paths: [source], provider_plugins: [{ source, ...descriptor.metadata, metadata: { runtime_source: provenance } }] }
213231
return { runtime_overlays: [{ kind: "bundled-library", source, ...descriptor.metadata, metadata: { runtime_source: provenance } }] }

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,16 @@ const workspace = resolve(process.env.AGENT_TASK_WORKSPACE || process.cwd())
88
const uploadPath = resolve(process.env.AGENT_TASK_UPLOAD_PATH || join(workspace, ".codebox", "agent-task-upload"))
99
const requestPath = resolve(process.env.AGENT_TASK_REQUEST_PATH || join(workspace, ".codebox", "agent-task-request.json"))
1010
const secretValues = ["OPENAI_API_KEY", "MODEL_PROVIDER_SECRET_1", "MODEL_PROVIDER_SECRET_2", "MODEL_PROVIDER_SECRET_3", "MODEL_PROVIDER_SECRET_4", "MODEL_PROVIDER_SECRET_5", "GITHUB_TOKEN", "GH_TOKEN", "ACCESS_TOKEN", "EXTERNAL_PACKAGE_SOURCE_POLICY"].map((name) => process.env[name]).filter(Boolean)
11+
const runtimeSourceRoot = process.env.WP_CODEBOX_RUNTIME_SOURCE_ROOT ? resolve(process.env.WP_CODEBOX_RUNTIME_SOURCE_ROOT) : ""
1112

1213
function redact(value) {
1314
return secretValues.reduce((output, secret) => output.split(secret).join("[REDACTED]"), value)
1415
}
1516

1617
async function stageFile(source, destination) {
18+
if (runtimeSourceRoot && resolve(source).startsWith(runtimeSourceRoot)) {
19+
throw new Error("Runtime source files must never be staged for artifact upload.")
20+
}
1721
const metadata = await lstat(source).catch(() => null)
1822
if (!metadata?.isFile() || metadata.size > MAX_UPLOAD_FILE_BYTES) return false
1923
const handle = await open(source, constants.O_RDONLY | constants.O_NOFOLLOW).catch(() => null)
@@ -23,7 +27,9 @@ async function stageFile(source, destination) {
2327
await handle.close()
2428
if (!contents || contents.includes(0) || !isUtf8(contents)) return false
2529
await mkdir(resolve(destination, ".."), { recursive: true })
26-
await writeFile(destination, redact(contents.toString("utf8")))
30+
const text = contents.toString("utf8")
31+
if (runtimeSourceRoot && text.includes(runtimeSourceRoot)) throw new Error("Runtime source paths must never be persisted in artifact uploads.")
32+
await writeFile(destination, redact(text))
2733
return true
2834
}
2935

.github/workflows/agent-task-contracts.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ on:
1010
- "contracts/run-agent-task-reusable-workflow-interface.v1.json"
1111
- "docs/agent-task-reusable-workflow.md"
1212
- "fixtures/agent-task-reusable-workflow-consumer*.yml"
13+
- "fixtures/agent-task-runtime-sources-run-29299109269.json"
1314
- "package-lock.json"
1415
- "package.json"
1516
- "tests/agent-task-*.test.ts"
@@ -26,6 +27,7 @@ on:
2627
- "contracts/run-agent-task-reusable-workflow-interface.v1.json"
2728
- "docs/agent-task-reusable-workflow.md"
2829
- "fixtures/agent-task-reusable-workflow-consumer*.yml"
30+
- "fixtures/agent-task-runtime-sources-run-29299109269.json"
2931
- "package-lock.json"
3032
- "package.json"
3133
- "tests/agent-task-*.test.ts"

.github/workflows/run-agent-task.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,7 @@ jobs:
283283
AGENT_TASK_WORKSPACE: ${{ github.workspace }}/workspace
284284
AGENT_TASK_REQUEST_PATH: ${{ github.workspace }}/.codebox/agent-task-request.json
285285
AGENT_TASK_UPLOAD_PATH: ${{ github.workspace }}/workspace/.codebox/agent-task-upload
286+
WP_CODEBOX_RUNTIME_SOURCE_ROOT: ${{ runner.temp }}/wp-codebox-runtime-sources-
286287
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
287288
MODEL_PROVIDER_SECRET_1: ${{ secrets.MODEL_PROVIDER_SECRET_1 }}
288289
MODEL_PROVIDER_SECRET_2: ${{ secrets.MODEL_PROVIDER_SECRET_2 }}

tests/runtime-sources-materialization.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ await withTempDir("wp-codebox-runtime-sources-", async (repository) => {
3535
assert.equal(materialized.lowered[1].provider_plugins[0].slug, "example-provider")
3636
assert.equal(materialized.lowered[2].runtime_overlays[0].strategy, "scoped-bundle")
3737
assert.ok(relative(repository, materialized.root).startsWith(".."), "sources are outside the target workspace")
38+
assert.deepEqual(Object.keys(materialized.descriptors[0]).sort(), ["path", "repository", "revision", "role"])
39+
await mkdir(join(repository, "artifacts"), { recursive: true })
40+
await assert.rejects(materializeRuntimeSources(sources, { policy, remotes: { "example/runtime": repository }, tempRoot: join(repository, "artifacts"), forbiddenRoots: [repository] }), /outside target workspaces and artifacts/)
3841
const loweredInput = materialized.lowered.reduce((input, lowered) => {
3942
for (const [key, entries] of Object.entries(lowered)) (input as Record<string, unknown[]>)[key] = [...((input as Record<string, unknown[]>)[key] ?? []), ...(entries as unknown[])]
4043
return input
@@ -56,4 +59,25 @@ await withTempDir("wp-codebox-runtime-sources-", async (repository) => {
5659
await rm(materialized.root, { recursive: true, force: true })
5760
})
5861

62+
await withTempDir("wp-codebox-runtime-source-upload-", async (directory) => {
63+
const workspace = join(directory, "workspace")
64+
const artifacts = join(workspace, ".codebox", "agent-task-artifacts")
65+
const upload = join(workspace, ".codebox", "agent-task-upload")
66+
const privateRoot = join(directory, "private-runtime-source")
67+
await mkdir(artifacts, { recursive: true })
68+
await mkdir(privateRoot, { recursive: true })
69+
await writeFile(join(privateRoot, "source.php"), "<?php // private runtime source\n")
70+
await writeFile(join(artifacts, "safe.json"), JSON.stringify({ provenance: { role: "component", repository: "example/runtime", revision: "a".repeat(40), path: "plugin" } }))
71+
const script = new URL("../.github/scripts/run-agent-task/prepare-agent-task-upload.mjs", import.meta.url)
72+
await execFileAsync(process.execPath, [script.pathname], { env: { ...process.env, AGENT_TASK_WORKSPACE: workspace, AGENT_TASK_UPLOAD_PATH: upload, WP_CODEBOX_RUNTIME_SOURCE_ROOT: privateRoot } })
73+
const staged = await readFile(join(upload, ".codebox", "agent-task-artifacts", "safe.json"), "utf8")
74+
assert.doesNotMatch(staged, /private-runtime-source|private runtime source/)
75+
await writeFile(join(artifacts, "leak.json"), privateRoot)
76+
await assert.rejects(execFileAsync(process.execPath, [script.pathname], { env: { ...process.env, AGENT_TASK_WORKSPACE: workspace, AGENT_TASK_UPLOAD_PATH: upload, WP_CODEBOX_RUNTIME_SOURCE_ROOT: privateRoot } }), /Runtime source paths must never be persisted/)
77+
})
78+
79+
const executor = await readFile(new URL("../.github/scripts/run-agent-task/execute-native-agent-task.mjs", import.meta.url), "utf8")
80+
assert.match(executor, /for \(const signal of \["SIGINT", "SIGTERM", "SIGHUP"\]\)/)
81+
assert.match(executor, /cleanupPrivateRuntimeSources\(\)\.finally\(\(\) => process\.exit\(128\)\)/)
82+
5983
console.log("runtime sources materialization ok")

0 commit comments

Comments
 (0)