From f590f35a14ebc7aa5ca56ffdcf1264329a41bcd0 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Tue, 14 Jul 2026 00:18:04 -0400 Subject: [PATCH] Sanitize private runtime paths before persistence --- .../execute-native-agent-task.mjs | 49 ++++++----------- .../prepare-agent-task-upload.mjs | 38 +++----------- .../runtime-source-sanitizer.mjs | 52 +++++++++++++++++++ .github/workflows/agent-task-contracts.yml | 2 + .github/workflows/run-agent-task.yml | 2 +- ...nt-task-runtime-paths-run-29305012941.json | 38 ++++++++++++++ tests/runtime-sources-materialization.test.ts | 39 +++++++++++--- 7 files changed, 147 insertions(+), 73 deletions(-) create mode 100644 .github/scripts/run-agent-task/runtime-source-sanitizer.mjs create mode 100644 fixtures/agent-task-runtime-paths-run-29305012941.json 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 e280624dd..d5b5db1e3 100644 --- a/.github/scripts/run-agent-task/execute-native-agent-task.mjs +++ b/.github/scripts/run-agent-task/execute-native-agent-task.mjs @@ -1,9 +1,10 @@ import { rmSync } from "node:fs" import { appendFile, mkdir, readFile, rm, writeFile } from "node:fs/promises" -import { isAbsolute, join, relative, resolve } from "node:path" +import { join, resolve } from "node:path" import { spawn } from "node:child_process" import { materializeExternalNativePackage, materializeRuntimeSources, normalizeExternalPackageSource, normalizeRuntimeSources, parseExternalPackageSourcePolicy, validateRuntimeSourceModel } from "./materialize-external-native-package.mjs" import { readNativeResult } from "./native-result-file.mjs" +import { assertNoRuntimeSourcePaths, sanitizeRuntimeSourceJson, sanitizeRuntimeSourceText, sanitizeRuntimeSourceValue } from "./runtime-source-sanitizer.mjs" const requestPath = process.env.AGENT_TASK_REQUEST_PATH || ".codebox/agent-task-request.json" const workspace = resolve(process.env.AGENT_TASK_WORKSPACE || process.cwd()) @@ -13,7 +14,6 @@ const outputPath = process.env.GITHUB_OUTPUT const MAX_CAPTURE_BYTES = 32768 const MAX_OUTPUT_CHARS = 8192 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) -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"]) function redact(value) { if (typeof value === "string") return secretValues.reduce((output, secret) => output.split(secret).join("[REDACTED]"), value) if (Array.isArray(value)) return value.map(redact) @@ -22,7 +22,7 @@ function redact(value) { } function bounded(value, limit = MAX_CAPTURE_BYTES) { - const safe = redact(value) + const safe = sanitizeRuntimeSourceText(redact(value), privateRuntimeSourceRoot) if (typeof safe !== "string") return safe return safe.length > limit ? `${safe.slice(0, limit)}\n[TRUNCATED ${safe.length - limit} characters]` : safe } @@ -96,28 +96,6 @@ function record(value) { return value && typeof value === "object" && !Array.isArray(value) ? value : {} } -function isPrivateRuntimePath(value) { - if (!privateRuntimeSourceRoot || typeof value !== "string") return false - const path = resolve(value) - const contained = relative(privateRuntimeSourceRoot, path) - return path === privateRuntimeSourceRoot || (contained !== ".." && !contained.startsWith(`..${String.fromCharCode(47)}`) && !isAbsolute(contained)) -} - -function omitPrivateRuntimeSourcePaths(value) { - if (Array.isArray(value)) return value.map(omitPrivateRuntimeSourcePaths) - if (!value || typeof value !== "object") return value - return Object.fromEntries(Object.entries(value).flatMap(([key, entry]) => { - if (PRIVATE_RUNTIME_PATH_FIELDS.has(key) && isPrivateRuntimePath(entry)) return [] - return [[key, omitPrivateRuntimeSourcePaths(entry)]] - })) -} - -function assertNoPrivateRuntimePaths(value) { - if (privateRuntimeSourceRoot && JSON.stringify(value).includes(privateRuntimeSourceRoot)) { - throw new Error("Runtime source paths must never be persisted in workflow results or artifacts.") - } -} - function string(value) { return typeof value === "string" ? value.trim() : "" } @@ -200,9 +178,13 @@ async function redactArtifactFiles(directory) { for (const entry of await readdir(directory, { withFileTypes: true })) { const path = join(directory, entry.name) if (entry.isDirectory()) await redactArtifactFiles(path) - if (entry.isFile() && (await stat(path)).size <= 4 * 1024 * 1024) { + if (entry.isFile() && path.endsWith(".json") && (await stat(path)).size <= 4 * 1024 * 1024) { const contents = await readFile(path, "utf8").catch(() => null) - if (contents !== null) await writeFile(path, bounded(contents, 4 * 1024 * 1024)) + if (contents !== null) { + const sanitized = sanitizeRuntimeSourceJson(bounded(contents, 4 * 1024 * 1024), privateRuntimeSourceRootForSanitization) + assertNoRuntimeSourcePaths(sanitized, privateRuntimeSourceRootForSanitization) + await writeFile(path, sanitized) + } } } } @@ -221,6 +203,7 @@ const resultPath = join(workspace, ".codebox", "agent-task-workflow-result.json" const controlledCodeboxPath = resolve(requestPath, "..") const nativeResultPath = join(controlledCodeboxPath, "native-agent-task-result.json") let privateRuntimeSourceRoot = "" +let privateRuntimeSourceRootForSanitization = "" let cleaningPrivateRuntimeSources = false async function cleanupPrivateRuntimeSources() { if (cleaningPrivateRuntimeSources || !privateRuntimeSourceRoot) return @@ -263,6 +246,7 @@ const materializedRuntimeSources = request.run_agent && !request.dry_run ? await materializeRuntimeSources(runtimeSources, { policy: externalPackagePolicy, forbiddenRoots: [workspace, artifactsPath] }) : undefined privateRuntimeSourceRoot = materializedRuntimeSources?.root ?? "" +privateRuntimeSourceRootForSanitization = privateRuntimeSourceRoot await output("runtime_source_root", privateRuntimeSourceRoot) const runtimeSourceInputs = (materializedRuntimeSources?.lowered ?? []).reduce((input, lowered) => { for (const [key, entries] of Object.entries(lowered)) input[key] = [...(input[key] ?? []), ...entries] @@ -340,11 +324,10 @@ const nativeRuntimeResult = request.run_agent && !request.dry_run ? await readNativeResult(nativeResultPath, controlledCodeboxPath, secretValues, redact) : {} await rm(nativeResultPath, { force: true }) -assertNoPrivateRuntimePaths(nativeRuntimeResult) -const runtimeResult = omitPrivateRuntimeSourcePaths(nativeRuntimeResult) -assertNoPrivateRuntimePaths(runtimeResult) +const runtimeResult = sanitizeRuntimeSourceValue(nativeRuntimeResult, privateRuntimeSourceRootForSanitization) +assertNoRuntimeSourcePaths(runtimeResult, privateRuntimeSourceRootForSanitization) await cleanupPrivateRuntimeSources() -assertNoPrivateRuntimePaths(runtimeResult) +assertNoRuntimeSourcePaths(runtimeResult, privateRuntimeSourceRootForSanitization) await redactArtifactFiles(artifactsPath) @@ -408,7 +391,9 @@ const result = { ...(projectionError ? { projection_error: projectionError } : {}), } -await writeFile(resultPath, `${JSON.stringify(redact(result), null, 2)}\n`) +const sanitizedResult = sanitizeRuntimeSourceValue(redact(result), privateRuntimeSourceRootForSanitization) +assertNoRuntimeSourcePaths(sanitizedResult, privateRuntimeSourceRootForSanitization) +await writeFile(resultPath, `${JSON.stringify(sanitizedResult, null, 2)}\n`) await output("job_status", status) await output("transcript_json", JSON.stringify(agentResult.refs?.transcripts || [])) await output("transcript_summary", `${request.workload?.label || "Run Agent Task"}: ${status}`) diff --git a/.github/scripts/run-agent-task/prepare-agent-task-upload.mjs b/.github/scripts/run-agent-task/prepare-agent-task-upload.mjs index d1cfb514f..069cce35f 100644 --- a/.github/scripts/run-agent-task/prepare-agent-task-upload.mjs +++ b/.github/scripts/run-agent-task/prepare-agent-task-upload.mjs @@ -2,6 +2,7 @@ import { constants } from "node:fs" import { lstat, mkdir, open, readdir, readFile, rm, writeFile } from "node:fs/promises" import { isUtf8 } from "node:buffer" import { isAbsolute, join, relative, resolve } from "node:path" +import { assertNoRuntimeSourcePaths, sanitizeRuntimeSourceJson } from "./runtime-source-sanitizer.mjs" const MAX_UPLOAD_FILE_BYTES = 4 * 1024 * 1024 const workspace = resolve(process.env.AGENT_TASK_WORKSPACE || process.cwd()) @@ -9,6 +10,8 @@ const uploadPath = resolve(process.env.AGENT_TASK_UPLOAD_PATH || join(workspace, const requestPath = resolve(process.env.AGENT_TASK_REQUEST_PATH || join(workspace, ".codebox", "agent-task-request.json")) 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) const runtimeSourceRoot = process.env.WP_CODEBOX_RUNTIME_SOURCE_ROOT ? resolve(process.env.WP_CODEBOX_RUNTIME_SOURCE_ROOT) : "" +const runtimeSourcePrefix = process.env.WP_CODEBOX_RUNTIME_SOURCE_PREFIX ? resolve(process.env.WP_CODEBOX_RUNTIME_SOURCE_PREFIX) : "" +const runtimeSourceRoots = [runtimeSourceRoot, runtimeSourcePrefix].filter(Boolean) const RUNTIME_SOURCE_TREE = /(^|\/)(prepared-plugins|agents-api|ai-provider-for-openai)(\/|$)/ const RUNTIME_SOURCE_FILE = /^(agents-api\.php|plugin\.php)$/ const RUNTIME_SOURCE_CONTENT = /(?:Plugin Name:|WP_Agents_Registry|OpenAiProvider)/ @@ -17,8 +20,6 @@ function redact(value) { return secretValues.reduce((output, secret) => output.split(secret).join("[REDACTED]"), value) } -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"]) - function isPrivateRuntimePath(value) { if (!runtimeSourceRoot || typeof value !== "string") return false const path = resolve(value) @@ -26,35 +27,8 @@ function isPrivateRuntimePath(value) { return path === runtimeSourceRoot || (contained !== ".." && !contained.startsWith(`..${String.fromCharCode(47)}`) && !isAbsolute(contained)) } -function omitPrivateRuntimeSourcePaths(value) { - if (Array.isArray(value)) return value.map(omitPrivateRuntimeSourcePaths) - if (!value || typeof value !== "object") return value - return Object.fromEntries(Object.entries(value).flatMap(([key, entry]) => { - if (PRIVATE_RUNTIME_PATH_FIELDS.has(key) && isPrivateRuntimePath(entry)) return [] - if (key === "runtime_sources" && Array.isArray(entry)) return [[key, entry.map(runtimeSourceProvenance)]] - return [[key, omitPrivateRuntimeSourcePaths(entry)]] - })) -} - -function runtimeSourceProvenance(source) { - if (!source || typeof source !== "object" || Array.isArray(source)) return source - const descriptor = source - const provenance = { role: descriptor.role } - if (descriptor.source?.type === "https_zip") { - provenance.source = { type: "https_zip", url: descriptor.source.url, sha256: descriptor.source.sha256, ...(descriptor.source.archive_root ? { archive_root: descriptor.source.archive_root } : {}) } - } else { - Object.assign(provenance, ...["repository", "revision", "path", "digest"].flatMap((key) => descriptor[key] ? [{ [key]: descriptor[key] }] : [])) - } - if (descriptor.role === "provider_plugin" && Array.isArray(descriptor.metadata?.providers)) provenance.providers = descriptor.metadata.providers - return provenance -} - function sanitizeText(text) { - try { - return `${JSON.stringify(omitPrivateRuntimeSourcePaths(JSON.parse(text)), null, 2)}\n` - } catch { - return text - } + return sanitizeRuntimeSourceJson(text, runtimeSourceRoots) } async function stageFile(source, destination) { @@ -78,7 +52,7 @@ async function stageFile(source, destination) { throw new Error("Prepared runtime plugin source contents must never be staged for artifact upload.") } text = sanitizeText(text) - if (runtimeSourceRoot && text.includes(runtimeSourceRoot)) throw new Error("Runtime source paths must never be persisted in artifact uploads.") + assertNoRuntimeSourcePaths(text, runtimeSourceRoots, "Runtime source paths must never be persisted in artifact uploads.") await writeFile(destination, redact(text)) return true } @@ -100,7 +74,7 @@ async function assertNoPrivateRuntimePaths(directory) { if (entry.isDirectory()) await assertNoPrivateRuntimePaths(path) else if (entry.isFile()) { const contents = await readFile(path, "utf8") - if (runtimeSourceRoot && contents.includes(runtimeSourceRoot)) throw new Error("Runtime source paths must never be persisted in artifact uploads.") + assertNoRuntimeSourcePaths(contents, runtimeSourceRoots, "Runtime source paths must never be persisted in artifact uploads.") if (RUNTIME_SOURCE_TREE.test(path) || RUNTIME_SOURCE_FILE.test(entry.name) || RUNTIME_SOURCE_CONTENT.test(contents)) throw new Error("Prepared runtime plugin sources must never be persisted in artifact uploads.") } } diff --git a/.github/scripts/run-agent-task/runtime-source-sanitizer.mjs b/.github/scripts/run-agent-task/runtime-source-sanitizer.mjs new file mode 100644 index 000000000..26c74ddfd --- /dev/null +++ b/.github/scripts/run-agent-task/runtime-source-sanitizer.mjs @@ -0,0 +1,52 @@ +import { resolve } from "node:path" + +const PRIVATE_RUNTIME_SOURCE_FIELDS = new Set(["source_package_root"]) +export const RUNTIME_SOURCE_PLACEHOLDER = "[runtime-source]" + +function runtimeSourceRoots(root) { + return (Array.isArray(root) ? root : [root]).filter((entry) => typeof entry === "string" && entry).map((entry) => resolve(entry)).sort((left, right) => right.length - left.length) +} + +function runtimeSourceProvenance(source) { + if (!source || typeof source !== "object" || Array.isArray(source)) return source + const descriptor = source + const provenance = { role: descriptor.role } + if (descriptor.source?.type === "https_zip") { + provenance.source = { type: "https_zip", url: descriptor.source.url, sha256: descriptor.source.sha256, ...(descriptor.source.archive_root ? { archive_root: descriptor.source.archive_root } : {}) } + } else { + Object.assign(provenance, ...["repository", "revision", "path", "digest"].flatMap((key) => descriptor[key] ? [{ [key]: descriptor[key] }] : [])) + } + if (descriptor.role === "provider_plugin" && Array.isArray(descriptor.metadata?.providers)) provenance.providers = descriptor.metadata.providers + return provenance +} + +export function sanitizeRuntimeSourceText(value, root) { + if (typeof value !== "string") return value + return runtimeSourceRoots(root).reduce((sanitized, sourceRoot) => sanitized.split(sourceRoot).join(RUNTIME_SOURCE_PLACEHOLDER), value) +} + +// Runtime results may place paths anywhere, including diagnostics, stack traces, +// command arguments, metadata, and object keys. +export function sanitizeRuntimeSourceValue(value, root) { + if (typeof value === "string") return sanitizeRuntimeSourceText(value, root) + if (Array.isArray(value)) return value.map((entry) => sanitizeRuntimeSourceValue(entry, root)) + if (!value || typeof value !== "object") return value + return Object.fromEntries(Object.entries(value).flatMap(([key, entry]) => { + if (PRIVATE_RUNTIME_SOURCE_FIELDS.has(key)) return [] + if (key === "runtime_sources" && Array.isArray(entry)) return [[key, entry.map(runtimeSourceProvenance).map((source) => sanitizeRuntimeSourceValue(source, root))]] + return [[sanitizeRuntimeSourceText(key, root), sanitizeRuntimeSourceValue(entry, root)]] + })) +} + +export function sanitizeRuntimeSourceJson(text, root) { + try { + return `${JSON.stringify(sanitizeRuntimeSourceValue(JSON.parse(text), root), null, 2)}\n` + } catch { + return sanitizeRuntimeSourceText(text, root) + } +} + +export function assertNoRuntimeSourcePaths(value, root, message = "Runtime source paths must never be persisted in workflow results or artifacts.") { + const serialized = JSON.stringify(value) + if (runtimeSourceRoots(root).some((sourceRoot) => serialized.includes(sourceRoot))) throw new Error(message) +} diff --git a/.github/workflows/agent-task-contracts.yml b/.github/workflows/agent-task-contracts.yml index f67f1adf6..ff1df5df4 100644 --- a/.github/workflows/agent-task-contracts.yml +++ b/.github/workflows/agent-task-contracts.yml @@ -11,6 +11,7 @@ on: - "docs/agent-task-reusable-workflow.md" - "fixtures/agent-task-reusable-workflow-consumer*.yml" - "fixtures/agent-task-runtime-sources-run-29299109269.json" + - "fixtures/agent-task-runtime-paths-run-29305012941.json" - "package-lock.json" - "package.json" - "tests/agent-task-*.test.ts" @@ -29,6 +30,7 @@ on: - "docs/agent-task-reusable-workflow.md" - "fixtures/agent-task-reusable-workflow-consumer*.yml" - "fixtures/agent-task-runtime-sources-run-29299109269.json" + - "fixtures/agent-task-runtime-paths-run-29305012941.json" - "package-lock.json" - "package.json" - "tests/agent-task-*.test.ts" diff --git a/.github/workflows/run-agent-task.yml b/.github/workflows/run-agent-task.yml index 93c57054c..de22853a3 100644 --- a/.github/workflows/run-agent-task.yml +++ b/.github/workflows/run-agent-task.yml @@ -283,7 +283,7 @@ jobs: AGENT_TASK_WORKSPACE: ${{ github.workspace }}/workspace AGENT_TASK_REQUEST_PATH: ${{ github.workspace }}/.codebox/agent-task-request.json AGENT_TASK_UPLOAD_PATH: ${{ github.workspace }}/workspace/.codebox/agent-task-upload - WP_CODEBOX_RUNTIME_SOURCE_ROOT: ${{ steps.execute.outputs.runtime_source_root }} + WP_CODEBOX_RUNTIME_SOURCE_PREFIX: ${{ runner.temp }}/wp-codebox-runtime-sources- OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} MODEL_PROVIDER_SECRET_1: ${{ secrets.MODEL_PROVIDER_SECRET_1 }} MODEL_PROVIDER_SECRET_2: ${{ secrets.MODEL_PROVIDER_SECRET_2 }} diff --git a/fixtures/agent-task-runtime-paths-run-29305012941.json b/fixtures/agent-task-runtime-paths-run-29305012941.json new file mode 100644 index 000000000..63503da0e --- /dev/null +++ b/fixtures/agent-task-runtime-paths-run-29305012941.json @@ -0,0 +1,38 @@ +{ + "run_id": "29305012941", + "runtime_root": "/tmp/wp-codebox-runtime-sources-HrTMNi", + "success": { + "schema": "wp-codebox/agent-task-run/v1", + "success": true, + "status": "succeeded", + "agent_task_run_result": { + "schema": "wp-codebox/agent-task-run-result/v1", + "success": true, + "status": "succeeded" + }, + "outputs": { + "metadata": { + "source_package_root": "/tmp/wp-codebox-runtime-sources-HrTMNi/prepared-runtime-sources", + "nested": { + "/tmp/wp-codebox-runtime-sources-HrTMNi/key": "/tmp/wp-codebox-runtime-sources-HrTMNi/value" + } + }, + "command": { + "args": ["--source=/tmp/wp-codebox-runtime-sources-HrTMNi/source-0", "/tmp/wp-codebox-runtime-sources-HrTMNi/source-1"] + } + } + }, + "failure": { + "schema": "wp-codebox/agent-task-run/v1", + "success": false, + "status": "failed", + "agent_task_run_result": { + "schema": "wp-codebox/agent-task-run-result/v1", + "success": false, + "status": "failed" + }, + "diagnostics": [{ "message": "Prepared source at /tmp/wp-codebox-runtime-sources-HrTMNi/source-0 failed" }], + "stack": "Error: failed\n at /tmp/wp-codebox-runtime-sources-HrTMNi/source-0/plugin.php:1:1", + "paths": ["/tmp/wp-codebox-runtime-sources-HrTMNi", "/tmp/wp-codebox-runtime-sources-HrTMNi-suffix"] + } +} diff --git a/tests/runtime-sources-materialization.test.ts b/tests/runtime-sources-materialization.test.ts index d1b06064d..804c023c9 100644 --- a/tests/runtime-sources-materialization.test.ts +++ b/tests/runtime-sources-materialization.test.ts @@ -7,11 +7,24 @@ import { inspectZipArchive, materializeRuntimeSources, normalizeRuntimeSource, n import { withTempDir } from "../scripts/test-kit.js" import { buildAgentTaskRecipe } from "../packages/runtime-core/src/agent-task-recipe.js" import { normalizeTaskInput } from "../packages/runtime-core/src/task-input.js" +import { assertNoRuntimeSourcePaths, sanitizeRuntimeSourceValue } from "../.github/scripts/run-agent-task/runtime-source-sanitizer.mjs" const execFileAsync = promisify(execFile) const hostedRegression = JSON.parse(await readFile(new URL("../fixtures/agent-task-runtime-sources-run-29299109269.json", import.meta.url), "utf8")) assert.equal(hostedRegression.run_id, "29299109269") assert.deepEqual(hostedRegression.runtime_sources.map((source: { role: string }) => source.role), ["component", "provider_plugin", "bundled_library"]) +const hostedPathRegression = JSON.parse(await readFile(new URL("../fixtures/agent-task-runtime-paths-run-29305012941.json", import.meta.url), "utf8")) +for (const result of [hostedPathRegression.success, hostedPathRegression.failure]) { + const sanitized = sanitizeRuntimeSourceValue(result, hostedPathRegression.runtime_root) + assertNoRuntimeSourcePaths(sanitized, hostedPathRegression.runtime_root) + assert.equal(JSON.stringify(sanitized).includes(hostedPathRegression.runtime_root), false) + assert.equal(JSON.stringify(sanitized).includes("source_package_root"), false) + assert.match(JSON.stringify(sanitized), /\[runtime-source\]/) +} +const sanitizedFailure = sanitizeRuntimeSourceValue(hostedPathRegression.failure, hostedPathRegression.runtime_root) +assert.equal(sanitizedFailure.status, "failed") +assert.equal(sanitizedFailure.success, false) +assert.match(sanitizedFailure.diagnostics[0].message, /\[runtime-source\]/) await withTempDir("wp-codebox-runtime-sources-", async (repository) => { for (const path of ["components/runtime", "providers/example", "libraries/client"]) await mkdir(join(repository, path), { recursive: true }) @@ -129,8 +142,10 @@ await withTempDir("wp-codebox-runtime-source-upload-", async (directory) => { assert.match(stagedInput, /"runtime_source"/) const stagedResult = await readFile(join(upload, ".codebox", "agent-task-workflow-result.json"), "utf8") assert.doesNotMatch(stagedResult, /private-runtime-source/) - await writeFile(join(artifacts, "leak.json"), privateRoot) - 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/) + await writeFile(join(artifacts, "leak.json"), `runtime log ${privateRoot}/source.php`) + await execFileAsync(process.execPath, [script.pathname], { env: { ...process.env, AGENT_TASK_WORKSPACE: workspace, AGENT_TASK_UPLOAD_PATH: upload, WP_CODEBOX_RUNTIME_SOURCE_ROOT: privateRoot } }) + assert.doesNotMatch(await readFile(join(upload, ".codebox", "agent-task-artifacts", "leak.json"), "utf8"), /private-runtime-source/) + assert.match(await readFile(join(upload, ".codebox", "agent-task-artifacts", "leak.json"), "utf8"), /\[runtime-source\]/) await rm(join(artifacts, "leak.json")) await mkdir(join(artifacts, "prepared-plugins", "agents-api"), { recursive: true }) await writeFile(join(artifacts, "prepared-plugins", "agents-api", "agents-api.php"), " { await rm(join(artifacts, "prepared-plugins"), { recursive: true, force: true }) await mkdir(suffixedPrivateRoot, { recursive: true }) await writeFile(join(artifacts, "suffixed-root-leak.json"), suffixedPrivateRoot) - 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: suffixedPrivateRoot } }), /Runtime source paths must never be persisted/) + await execFileAsync(process.execPath, [script.pathname], { env: { ...process.env, AGENT_TASK_WORKSPACE: workspace, AGENT_TASK_UPLOAD_PATH: upload, WP_CODEBOX_RUNTIME_SOURCE_ROOT: suffixedPrivateRoot } }) + assert.doesNotMatch(await readFile(join(upload, ".codebox", "agent-task-artifacts", "suffixed-root-leak.json"), "utf8"), /actual-mkdtemp-suffix/) }) await withTempDir("wp-codebox-runtime-sources-workflow-", async (directory) => { @@ -165,7 +181,7 @@ await withTempDir("wp-codebox-runtime-sources-workflow-", async (directory) => { await writeFile(join(tools, "git"), `#!${process.execPath}\nimport { spawnSync } from "node:child_process"\nconst args = process.argv.slice(2).map((value) => value.startsWith("https://github.com/") ? ${JSON.stringify(repository)} : value)\nconst result = spawnSync(${JSON.stringify(gitPath)}, args, { stdio: "inherit" })\nprocess.exit(result.status ?? 1)\n`) await chmod(join(tools, "git"), 0o755) const capturedInput = join(directory, "captured-native-input.json") - await writeFile(join(directory, "fake-cli.mjs"), `import { readFile, writeFile } from "node:fs/promises"\nconst input = process.argv[process.argv.indexOf("--input-file") + 1]\nconst result = process.argv[process.argv.indexOf("--result-file") + 1]\nawait writeFile(${JSON.stringify(capturedInput)}, await readFile(input))\nawait writeFile(result, JSON.stringify({ schema: "wp-codebox/agent-task-run/v1", success: true, status: "succeeded", agent_task_run_result: { schema: "wp-codebox/agent-task-run-result/v1", success: true, status: "succeeded" }, outputs: {} }))\n`) + await writeFile(join(directory, "fake-cli.mjs"), `import { readFile, writeFile } from "node:fs/promises"\nconst input = process.argv[process.argv.indexOf("--input-file") + 1]\nconst result = process.argv[process.argv.indexOf("--result-file") + 1]\nconst taskInput = JSON.parse(await readFile(input))\nconst runtimeRoot = taskInput.source_package_root.replace(/\\/prepared-runtime-sources$/, "")\nconst nativeResult = JSON.parse(${JSON.stringify(JSON.stringify(hostedPathRegression.success))}.replaceAll(${JSON.stringify(hostedPathRegression.runtime_root)}, runtimeRoot))\nawait writeFile(${JSON.stringify(capturedInput)}, JSON.stringify(taskInput))\nawait writeFile(result, JSON.stringify(nativeResult))\n`) const request = { workload: { id: "runtime-sources-workflow", label: "Runtime sources workflow" }, access: { caller_repo: "example/target", allowed_repos: ["example/target"], access_token_repos: ["example/target"] }, @@ -189,6 +205,11 @@ await withTempDir("wp-codebox-runtime-sources-workflow-", async (directory) => { const environment = { ...process.env, PATH: `${tools}:${process.env.PATH}`, TMPDIR: temp, GITHUB_OUTPUT: githubOutput, AGENT_TASK_WORKSPACE: workspace, AGENT_TASK_REQUEST_PATH: join(codebox, "agent-task-request.json"), WP_CODEBOX_CLI_PATH: join(directory, "fake-cli.mjs"), GITHUB_TOKEN: "test-token", EXTERNAL_PACKAGE_SOURCE_POLICY: JSON.stringify({ version: 1, repositories: { "example/source": ["fixture.agent.json"] }, runtime_sources: { "example/source": ["plugin"] } }) } const executorPath = new URL("../.github/scripts/run-agent-task/execute-native-agent-task.mjs", import.meta.url) await execFileAsync(process.execPath, [executorPath.pathname], { env: environment }) + const workflowResult = await readFile(join(codebox, "agent-task-workflow-result.json"), "utf8") + const exactRuntimeRoot = JSON.parse(await readFile(capturedInput, "utf8")).source_package_root.replace(/\/prepared-runtime-sources$/, "") + assert.doesNotMatch(workflowResult, new RegExp(exactRuntimeRoot.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))) + assert.doesNotMatch(workflowResult, /source_package_root/) + assert.match(workflowResult, /\[runtime-source\]/) const nativeInput = JSON.parse(await readFile(capturedInput, "utf8")) assert.deepEqual(nativeInput.task_input.runtime_task.input.input.provider, "openai") assert.deepEqual(nativeInput.task_input.runtime_task.input.input.model, "gpt-5.5") @@ -197,10 +218,11 @@ await withTempDir("wp-codebox-runtime-sources-workflow-", async (directory) => { await assert.rejects(access(join(codebox, "native-agent-task-input.json")), /ENOENT/, "runtime source native input must remain private") const uploaderPath = new URL("../.github/scripts/run-agent-task/prepare-agent-task-upload.mjs", import.meta.url) const output = await readFile(githubOutput, "utf8") - const exactPrivateRuntimeRoot = output.match(/runtime_source_root<<__WP_CODEBOX_OUTPUT__\n(.+)\n__WP_CODEBOX_OUTPUT__/s)?.[1] - assert.ok(exactPrivateRuntimeRoot?.startsWith(join(temp, "wp-codebox-runtime-sources-")), "executor must expose the exact mkdtemp root only as a step output") + const exactPrivateRuntimeRoot = exactRuntimeRoot + assert.match(output, /runtime_source_root<<__WP_CODEBOX_OUTPUT__\n\[runtime-source\]\n__WP_CODEBOX_OUTPUT__/, "executor must sanitize the private root in step output") await writeFile(join(codebox, "agent-task-artifacts", "exact-root-leak.json"), exactPrivateRuntimeRoot) - await assert.rejects(execFileAsync(process.execPath, [uploaderPath.pathname], { env: { ...environment, AGENT_TASK_UPLOAD_PATH: upload, WP_CODEBOX_RUNTIME_SOURCE_ROOT: exactPrivateRuntimeRoot } }), /Runtime source paths must never be persisted/) + await execFileAsync(process.execPath, [uploaderPath.pathname], { env: { ...environment, AGENT_TASK_UPLOAD_PATH: upload, WP_CODEBOX_RUNTIME_SOURCE_ROOT: exactPrivateRuntimeRoot } }) + assert.doesNotMatch(await readFile(join(upload, ".codebox", "agent-task-artifacts", "exact-root-leak.json"), "utf8"), new RegExp(exactPrivateRuntimeRoot.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))) await rm(join(codebox, "agent-task-artifacts", "exact-root-leak.json")) await execFileAsync(process.execPath, [uploaderPath.pathname], { env: { ...environment, AGENT_TASK_UPLOAD_PATH: upload, WP_CODEBOX_RUNTIME_SOURCE_ROOT: exactPrivateRuntimeRoot } }) const privateRuntimePrefix = exactPrivateRuntimeRoot @@ -218,7 +240,8 @@ assert.match(executor, /for \(const signal of \["SIGINT", "SIGTERM", "SIGHUP"\]\ assert.match(executor, /cleanupPrivateRuntimeSources\(\)\.finally\(\(\) => process\.exit\(128\)\)/) assert.match(executor, /const executionInputPath = privateRuntimeSourceRoot \? join\(privateRuntimeSourceRoot, "native-agent-task-input\.json"\) : runtimeInputPath/) assert.match(executor, /const privatePreparationRoot = privateRuntimeSourceRoot \? join\(privateRuntimeSourceRoot, "prepared-runtime-sources"\) : ""/) -assert.match(executor, /assertNoPrivateRuntimePaths\(nativeRuntimeResult\)/) +assert.match(executor, /sanitizeRuntimeSourceValue\(nativeRuntimeResult, privateRuntimeSourceRootForSanitization\)/) +assert.match(executor, /assertNoRuntimeSourcePaths\(sanitizedResult, privateRuntimeSourceRootForSanitization\)/) assert.match(executor, /await output\("runtime_source_root", privateRuntimeSourceRoot\)/) console.log("runtime sources materialization ok")