Skip to content

Commit 389541f

Browse files
committed
Harden immutable runtime source isolation
1 parent f3720ec commit 389541f

6 files changed

Lines changed: 133 additions & 18 deletions

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

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ const outputPath = process.env.GITHUB_OUTPUT
1313
const MAX_CAPTURE_BYTES = 32768
1414
const MAX_OUTPUT_CHARS = 8192
1515
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)
16+
const PRIVATE_RUNTIME_PATH_FIELDS = new Set(["source", "path", "sourceRoot", "originalSource", "preparedPath", "requestedPath", "source_package_root", "artifacts_path", "runtime_input_path", "task_path", "result_path", "event_stream_path", "materialization_result_path"])
1617
function redact(value) {
1718
if (typeof value === "string") return secretValues.reduce((output, secret) => output.split(secret).join("[REDACTED]"), value)
1819
if (Array.isArray(value)) return value.map(redact)
@@ -95,6 +96,27 @@ function record(value) {
9596
return value && typeof value === "object" && !Array.isArray(value) ? value : {}
9697
}
9798

99+
function isPrivateRuntimePath(value) {
100+
if (!privateRuntimeSourceRoot || typeof value !== "string") return false
101+
const path = resolve(value)
102+
return path === privateRuntimeSourceRoot || path.startsWith(`${privateRuntimeSourceRoot}/`)
103+
}
104+
105+
function omitPrivateRuntimeSourcePaths(value) {
106+
if (Array.isArray(value)) return value.map(omitPrivateRuntimeSourcePaths)
107+
if (!value || typeof value !== "object") return value
108+
return Object.fromEntries(Object.entries(value).flatMap(([key, entry]) => {
109+
if (PRIVATE_RUNTIME_PATH_FIELDS.has(key) && isPrivateRuntimePath(entry)) return []
110+
return [[key, omitPrivateRuntimeSourcePaths(entry)]]
111+
}))
112+
}
113+
114+
function assertNoPrivateRuntimePaths(value) {
115+
if (privateRuntimeSourceRoot && JSON.stringify(value).includes(privateRuntimeSourceRoot)) {
116+
throw new Error("Runtime source paths must never be persisted in workflow results or artifacts.")
117+
}
118+
}
119+
98120
function string(value) {
99121
return typeof value === "string" ? value.trim() : ""
100122
}
@@ -244,6 +266,7 @@ const runtimeSourceInputs = (materializedRuntimeSources?.lowered ?? []).reduce((
244266
return input
245267
}, {})
246268
const sourcePackageRoot = privateRuntimeSourceRoot ? join(privateRuntimeSourceRoot, "prepared-packages") : artifactsPath
269+
const executionInputPath = privateRuntimeSourceRoot ? join(privateRuntimeSourceRoot, "native-agent-task-input.json") : runtimeInputPath
247270

248271
const taskInput = {
249272
schema: "wp-codebox/agent-task-run-request/v1",
@@ -297,20 +320,23 @@ const taskInput = {
297320
},
298321
}
299322

300-
await writeFile(runtimeInputPath, `${JSON.stringify(taskInput, null, 2)}\n`)
323+
await writeFile(executionInputPath, `${JSON.stringify(taskInput, null, 2)}\n`)
301324

302325
let execution = { code: 0, stdout: "", stderr: "", stdout_truncated: false, stderr_truncated: false }
303326
if (request.run_agent && !request.dry_run) {
304-
execution = await command("node", [codeboxCliPath, "agent-task-run", "--input-file", runtimeInputPath, "--result-file", nativeResultPath], workspace, agentEnvironment())
327+
execution = await command("node", [codeboxCliPath, "agent-task-run", "--input-file", executionInputPath, "--result-file", nativeResultPath], workspace, agentEnvironment())
305328
}
306329

307330
// Public package bytes are embedded in the runtime recipe and consumed only by
308331
// the Playground bootstrap before the agent's tools are resolved.
309332

310-
const runtimeResult = request.run_agent && !request.dry_run
333+
const nativeRuntimeResult = request.run_agent && !request.dry_run
311334
? await readNativeResult(nativeResultPath, controlledCodeboxPath, secretValues, redact)
312335
: {}
313336
await rm(nativeResultPath, { force: true })
337+
assertNoPrivateRuntimePaths(nativeRuntimeResult)
338+
const runtimeResult = omitPrivateRuntimeSourcePaths(nativeRuntimeResult)
339+
assertNoPrivateRuntimePaths(runtimeResult)
314340
await cleanupPrivateRuntimeSources()
315341

316342
await redactArtifactFiles(artifactsPath)

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ function normalizeRuntimeArtifactPolicy(value) {
121121
for (const entry of value) {
122122
const artifact = entry && typeof entry === "object" && !Array.isArray(entry) ? entry : {}
123123
const url = normalizeHttpsUrl(artifact.url, "EXTERNAL_PACKAGE_SOURCE_POLICY runtime_artifacts.url")
124-
const sha256 = artifact.sha256 === undefined ? "" : normalizeSha256(artifact.sha256, "EXTERNAL_PACKAGE_SOURCE_POLICY runtime_artifacts.sha256")
124+
const sha256 = normalizeSha256(artifact.sha256, "EXTERNAL_PACKAGE_SOURCE_POLICY runtime_artifacts.sha256")
125125
if (Object.hasOwn(artifacts, url)) throw new Error("EXTERNAL_PACKAGE_SOURCE_POLICY runtime_artifacts must not repeat URLs.")
126126
artifacts[url] = sha256
127127
}
@@ -189,7 +189,7 @@ function normalizeHttpsZipRuntimeSource(source, artifact, policy, label) {
189189
if (Object.keys(artifact).some((key) => !["type", "url", "sha256", "archive_root"].includes(key))) throw new Error(`${label}.source https_zip contains an unsupported field.`)
190190
const expectedDigest = policy.runtime_artifacts?.[url]
191191
if (expectedDigest === undefined) throw new Error("Runtime artifact URL is not authorized.")
192-
if (expectedDigest && expectedDigest !== sha256) throw new Error("Runtime artifact digest does not match its trusted policy.")
192+
if (expectedDigest !== sha256) throw new Error("Runtime artifact digest does not match its trusted policy.")
193193
const metadata = source.metadata && typeof source.metadata === "object" && !Array.isArray(source.metadata) ? source.metadata : {}
194194
return { version: 1, role, source: { type: "https_zip", url, sha256, ...(archive_root ? { archive_root } : {}) }, metadata: normalizeRuntimeMetadata(role, metadata, label) }
195195
}

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

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { constants } from "node:fs"
2-
import { lstat, mkdir, open, readdir, rm, writeFile } from "node:fs/promises"
2+
import { lstat, mkdir, open, readdir, readFile, rm, writeFile } from "node:fs/promises"
33
import { isUtf8 } from "node:buffer"
44
import { join, resolve } from "node:path"
55

@@ -14,17 +14,33 @@ function redact(value) {
1414
return secretValues.reduce((output, secret) => output.split(secret).join("[REDACTED]"), value)
1515
}
1616

17+
const PRIVATE_RUNTIME_PATH_FIELDS = new Set(["source", "path", "sourceRoot", "originalSource", "preparedPath", "requestedPath", "source_package_root", "artifacts_path", "runtime_input_path", "task_path", "result_path", "event_stream_path", "materialization_result_path"])
18+
19+
function isPrivateRuntimePath(value) {
20+
if (!runtimeSourceRoot || typeof value !== "string") return false
21+
const path = resolve(value)
22+
return path === runtimeSourceRoot || path.startsWith(`${runtimeSourceRoot}/`)
23+
}
24+
1725
function omitPrivateRuntimeSourcePaths(value) {
1826
if (Array.isArray(value)) return value.map(omitPrivateRuntimeSourcePaths)
1927
if (!value || typeof value !== "object") return value
2028
return Object.fromEntries(Object.entries(value).flatMap(([key, entry]) => {
21-
if (runtimeSourceRoot && typeof entry === "string" && resolve(entry).startsWith(runtimeSourceRoot) && ["source", "path", "sourceRoot", "originalSource", "preparedPath", "requestedPath"].includes(key)) return []
29+
if (PRIVATE_RUNTIME_PATH_FIELDS.has(key) && isPrivateRuntimePath(entry)) return []
2230
return [[key, omitPrivateRuntimeSourcePaths(entry)]]
2331
}))
2432
}
2533

34+
function sanitizeText(text) {
35+
try {
36+
return `${JSON.stringify(omitPrivateRuntimeSourcePaths(JSON.parse(text)), null, 2)}\n`
37+
} catch {
38+
return text
39+
}
40+
}
41+
2642
async function stageFile(source, destination) {
27-
if (runtimeSourceRoot && resolve(source).startsWith(runtimeSourceRoot)) {
43+
if (isPrivateRuntimePath(source)) {
2844
throw new Error("Runtime source files must never be staged for artifact upload.")
2945
}
3046
const metadata = await lstat(source).catch(() => null)
@@ -37,9 +53,7 @@ async function stageFile(source, destination) {
3753
if (!contents || contents.includes(0) || !isUtf8(contents)) return false
3854
await mkdir(resolve(destination, ".."), { recursive: true })
3955
let text = contents.toString("utf8")
40-
if (source === join(workspace, ".codebox", "native-agent-task-input.json")) {
41-
text = `${JSON.stringify(omitPrivateRuntimeSourcePaths(JSON.parse(text)), null, 2)}\n`
42-
}
56+
text = sanitizeText(text)
4357
if (runtimeSourceRoot && text.includes(runtimeSourceRoot)) throw new Error("Runtime source paths must never be persisted in artifact uploads.")
4458
await writeFile(destination, redact(text))
4559
return true
@@ -56,10 +70,22 @@ async function stageDirectory(source, destination) {
5670
}
5771
}
5872

73+
async function assertNoPrivateRuntimePaths(directory) {
74+
for (const entry of await readdir(directory, { withFileTypes: true })) {
75+
const path = join(directory, entry.name)
76+
if (entry.isDirectory()) await assertNoPrivateRuntimePaths(path)
77+
else if (entry.isFile()) {
78+
const contents = await readFile(path, "utf8")
79+
if (runtimeSourceRoot && contents.includes(runtimeSourceRoot)) throw new Error("Runtime source paths must never be persisted in artifact uploads.")
80+
}
81+
}
82+
}
83+
5984
await rm(uploadPath, { recursive: true, force: true })
6085
await mkdir(uploadPath, { recursive: true })
6186
await stageFile(requestPath, join(uploadPath, ".codebox", "agent-task-request.json"))
6287
for (const path of [".codebox/agent-task-workflow-result.json", ".codebox/native-agent-task-input.json"]) {
6388
await stageFile(join(workspace, path), join(uploadPath, path))
6489
}
6590
await stageDirectory(join(workspace, ".codebox", "agent-task-artifacts"), join(uploadPath, ".codebox", "agent-task-artifacts"))
91+
await assertNoPrivateRuntimePaths(uploadPath)

docs/agent-task-reusable-workflow.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ This release-coherence contract fixes [#1759](https://github.com/Automattic/wp-c
5656
- `external_package_source`: immutable descriptor with `repository`, full commit `revision`, one package-relative `.agent.json` `path`, and `digest`. Packages are supported only from publicly accessible GitHub repositories, fetched from canonical `https://github.com/OWNER/REPOSITORY.git` without credentials. `digest` is exactly `sha256-bytes-v1:<lowercase-sha256>` over the raw file bytes; filenames and JSON content are UTF-8-safe and are not normalized before hashing.
5757
- `EXTERNAL_PACKAGE_SOURCE_POLICY`: required reusable-workflow secret, supplied by the caller's operator-controlled secret configuration. Its strict version 1 JSON shape is `{"version":1,"repositories":{"owner/repository":["agents/example.agent.json"]}}`. Every entry is an exact standalone `.agent.json` path. The policy is validated in runner memory, is never part of task input, and is not uploaded.
5858

59-
`runtime_sources` is an optional versioned JSON array for immutable public runtime dependencies. Git descriptors declare `version: 1`, `role` (`component`, `provider_plugin`, or `bundled_library`), `repository`, a full 40-character `revision`, exact directory `path`, optional `sha256-git-archive-v1` digest, and role metadata. Component and provider descriptors may instead declare `source: {"type":"https_zip","url":"https://...","sha256":"<64 lowercase hex>","archive_root":"plugin-directory"}`. The operator-controlled policy separately authorizes Git sources with `runtime_sources` and exact ZIP URLs with `runtime_artifacts`, for example `{"version":1,"repositories":{},"runtime_artifacts":[{"url":"https://downloads.wordpress.org/plugin/example.zip","sha256":"<64 lowercase hex>"}]}`. ZIP downloads require HTTPS, an exact allowlisted URL, a matching descriptor digest, bounded transfer and extraction sizes, and redirects only to another allowlisted URL on the original host. The runner verifies the digest before extraction; rejects traversal, symlink, special-file, encrypted, ZIP64, oversized, and multi-root archives; stages under a private temp root outside the workspace and artifacts; and removes the staging root after execution. Runtime source descriptors lower into existing component, provider plugin, and runtime overlay inputs; artifacts retain provenance only.
59+
`runtime_sources` is an optional versioned JSON array for immutable public runtime dependencies. Git descriptors declare `version: 1`, `role` (`component`, `provider_plugin`, or `bundled_library`), `repository`, a full 40-character `revision`, exact directory `path`, optional `sha256-git-archive-v1` digest, and role metadata. Component and provider descriptors may instead declare `source: {"type":"https_zip","url":"https://...","sha256":"<64 lowercase hex>","archive_root":"plugin-directory"}`. The operator-controlled policy separately authorizes Git sources with `runtime_sources` and exact ZIP URLs with `runtime_artifacts`, for example `{"version":1,"repositories":{},"runtime_artifacts":[{"url":"https://downloads.wordpress.org/plugin/example.zip","sha256":"<64 lowercase hex>"}]}`. Every exact `runtime_artifacts` URL requires `sha256`; the descriptor digest must exactly equal that trusted policy digest, so callers cannot authorize mutable current bytes. ZIP downloads require HTTPS, an exact allowlisted URL, a matching descriptor digest, bounded transfer and extraction sizes, and redirects only to another allowlisted URL on the original host. The runner verifies the digest before extraction; rejects traversal, symlink, special-file, encrypted, ZIP64, oversized, and multi-root archives; stages under a private temp root outside the workspace and artifacts; removes the staging root after execution; and strips private materialization paths from upload staging before scanning staged files for leaks. Runtime source descriptors lower into existing component, provider plugin, and runtime overlay inputs; artifacts retain provenance only.
6060
- `target_repo`: `OWNER/REPO` target repository.
6161
- `prompt`, `writable_paths`, provider/model, `max_turns`, `time_budget_ms`, callback data, and artifact declarations: native task inputs.
6262
- `runner_workspace`: JSON runner-workspace publication request owned by the package.

0 commit comments

Comments
 (0)