Skip to content

Commit f590f35

Browse files
committed
Sanitize private runtime paths before persistence
1 parent b2b0f5e commit f590f35

7 files changed

Lines changed: 147 additions & 73 deletions

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

Lines changed: 17 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { rmSync } from "node:fs"
22
import { appendFile, mkdir, readFile, rm, writeFile } from "node:fs/promises"
3-
import { isAbsolute, join, relative, resolve } from "node:path"
3+
import { join, resolve } from "node:path"
44
import { spawn } from "node:child_process"
55
import { materializeExternalNativePackage, materializeRuntimeSources, normalizeExternalPackageSource, normalizeRuntimeSources, parseExternalPackageSourcePolicy, validateRuntimeSourceModel } from "./materialize-external-native-package.mjs"
66
import { readNativeResult } from "./native-result-file.mjs"
7+
import { assertNoRuntimeSourcePaths, sanitizeRuntimeSourceJson, sanitizeRuntimeSourceText, sanitizeRuntimeSourceValue } from "./runtime-source-sanitizer.mjs"
78

89
const requestPath = process.env.AGENT_TASK_REQUEST_PATH || ".codebox/agent-task-request.json"
910
const workspace = resolve(process.env.AGENT_TASK_WORKSPACE || process.cwd())
@@ -13,7 +14,6 @@ const outputPath = process.env.GITHUB_OUTPUT
1314
const MAX_CAPTURE_BYTES = 32768
1415
const MAX_OUTPUT_CHARS = 8192
1516
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"])
1717
function redact(value) {
1818
if (typeof value === "string") return secretValues.reduce((output, secret) => output.split(secret).join("[REDACTED]"), value)
1919
if (Array.isArray(value)) return value.map(redact)
@@ -22,7 +22,7 @@ function redact(value) {
2222
}
2323

2424
function bounded(value, limit = MAX_CAPTURE_BYTES) {
25-
const safe = redact(value)
25+
const safe = sanitizeRuntimeSourceText(redact(value), privateRuntimeSourceRoot)
2626
if (typeof safe !== "string") return safe
2727
return safe.length > limit ? `${safe.slice(0, limit)}\n[TRUNCATED ${safe.length - limit} characters]` : safe
2828
}
@@ -96,28 +96,6 @@ function record(value) {
9696
return value && typeof value === "object" && !Array.isArray(value) ? value : {}
9797
}
9898

99-
function isPrivateRuntimePath(value) {
100-
if (!privateRuntimeSourceRoot || typeof value !== "string") return false
101-
const path = resolve(value)
102-
const contained = relative(privateRuntimeSourceRoot, path)
103-
return path === privateRuntimeSourceRoot || (contained !== ".." && !contained.startsWith(`..${String.fromCharCode(47)}`) && !isAbsolute(contained))
104-
}
105-
106-
function omitPrivateRuntimeSourcePaths(value) {
107-
if (Array.isArray(value)) return value.map(omitPrivateRuntimeSourcePaths)
108-
if (!value || typeof value !== "object") return value
109-
return Object.fromEntries(Object.entries(value).flatMap(([key, entry]) => {
110-
if (PRIVATE_RUNTIME_PATH_FIELDS.has(key) && isPrivateRuntimePath(entry)) return []
111-
return [[key, omitPrivateRuntimeSourcePaths(entry)]]
112-
}))
113-
}
114-
115-
function assertNoPrivateRuntimePaths(value) {
116-
if (privateRuntimeSourceRoot && JSON.stringify(value).includes(privateRuntimeSourceRoot)) {
117-
throw new Error("Runtime source paths must never be persisted in workflow results or artifacts.")
118-
}
119-
}
120-
12199
function string(value) {
122100
return typeof value === "string" ? value.trim() : ""
123101
}
@@ -200,9 +178,13 @@ async function redactArtifactFiles(directory) {
200178
for (const entry of await readdir(directory, { withFileTypes: true })) {
201179
const path = join(directory, entry.name)
202180
if (entry.isDirectory()) await redactArtifactFiles(path)
203-
if (entry.isFile() && (await stat(path)).size <= 4 * 1024 * 1024) {
181+
if (entry.isFile() && path.endsWith(".json") && (await stat(path)).size <= 4 * 1024 * 1024) {
204182
const contents = await readFile(path, "utf8").catch(() => null)
205-
if (contents !== null) await writeFile(path, bounded(contents, 4 * 1024 * 1024))
183+
if (contents !== null) {
184+
const sanitized = sanitizeRuntimeSourceJson(bounded(contents, 4 * 1024 * 1024), privateRuntimeSourceRootForSanitization)
185+
assertNoRuntimeSourcePaths(sanitized, privateRuntimeSourceRootForSanitization)
186+
await writeFile(path, sanitized)
187+
}
206188
}
207189
}
208190
}
@@ -221,6 +203,7 @@ const resultPath = join(workspace, ".codebox", "agent-task-workflow-result.json"
221203
const controlledCodeboxPath = resolve(requestPath, "..")
222204
const nativeResultPath = join(controlledCodeboxPath, "native-agent-task-result.json")
223205
let privateRuntimeSourceRoot = ""
206+
let privateRuntimeSourceRootForSanitization = ""
224207
let cleaningPrivateRuntimeSources = false
225208
async function cleanupPrivateRuntimeSources() {
226209
if (cleaningPrivateRuntimeSources || !privateRuntimeSourceRoot) return
@@ -263,6 +246,7 @@ const materializedRuntimeSources = request.run_agent && !request.dry_run
263246
? await materializeRuntimeSources(runtimeSources, { policy: externalPackagePolicy, forbiddenRoots: [workspace, artifactsPath] })
264247
: undefined
265248
privateRuntimeSourceRoot = materializedRuntimeSources?.root ?? ""
249+
privateRuntimeSourceRootForSanitization = privateRuntimeSourceRoot
266250
await output("runtime_source_root", privateRuntimeSourceRoot)
267251
const runtimeSourceInputs = (materializedRuntimeSources?.lowered ?? []).reduce((input, lowered) => {
268252
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
340324
? await readNativeResult(nativeResultPath, controlledCodeboxPath, secretValues, redact)
341325
: {}
342326
await rm(nativeResultPath, { force: true })
343-
assertNoPrivateRuntimePaths(nativeRuntimeResult)
344-
const runtimeResult = omitPrivateRuntimeSourcePaths(nativeRuntimeResult)
345-
assertNoPrivateRuntimePaths(runtimeResult)
327+
const runtimeResult = sanitizeRuntimeSourceValue(nativeRuntimeResult, privateRuntimeSourceRootForSanitization)
328+
assertNoRuntimeSourcePaths(runtimeResult, privateRuntimeSourceRootForSanitization)
346329
await cleanupPrivateRuntimeSources()
347-
assertNoPrivateRuntimePaths(runtimeResult)
330+
assertNoRuntimeSourcePaths(runtimeResult, privateRuntimeSourceRootForSanitization)
348331

349332
await redactArtifactFiles(artifactsPath)
350333

@@ -408,7 +391,9 @@ const result = {
408391
...(projectionError ? { projection_error: projectionError } : {}),
409392
}
410393

411-
await writeFile(resultPath, `${JSON.stringify(redact(result), null, 2)}\n`)
394+
const sanitizedResult = sanitizeRuntimeSourceValue(redact(result), privateRuntimeSourceRootForSanitization)
395+
assertNoRuntimeSourcePaths(sanitizedResult, privateRuntimeSourceRootForSanitization)
396+
await writeFile(resultPath, `${JSON.stringify(sanitizedResult, null, 2)}\n`)
412397
await output("job_status", status)
413398
await output("transcript_json", JSON.stringify(agentResult.refs?.transcripts || []))
414399
await output("transcript_summary", `${request.workload?.label || "Run Agent Task"}: ${status}`)

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

Lines changed: 6 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,16 @@ import { constants } from "node:fs"
22
import { lstat, mkdir, open, readdir, readFile, rm, writeFile } from "node:fs/promises"
33
import { isUtf8 } from "node:buffer"
44
import { isAbsolute, join, relative, resolve } from "node:path"
5+
import { assertNoRuntimeSourcePaths, sanitizeRuntimeSourceJson } from "./runtime-source-sanitizer.mjs"
56

67
const MAX_UPLOAD_FILE_BYTES = 4 * 1024 * 1024
78
const workspace = resolve(process.env.AGENT_TASK_WORKSPACE || process.cwd())
89
const uploadPath = resolve(process.env.AGENT_TASK_UPLOAD_PATH || join(workspace, ".codebox", "agent-task-upload"))
910
const requestPath = resolve(process.env.AGENT_TASK_REQUEST_PATH || join(workspace, ".codebox", "agent-task-request.json"))
1011
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)
1112
const runtimeSourceRoot = process.env.WP_CODEBOX_RUNTIME_SOURCE_ROOT ? resolve(process.env.WP_CODEBOX_RUNTIME_SOURCE_ROOT) : ""
13+
const runtimeSourcePrefix = process.env.WP_CODEBOX_RUNTIME_SOURCE_PREFIX ? resolve(process.env.WP_CODEBOX_RUNTIME_SOURCE_PREFIX) : ""
14+
const runtimeSourceRoots = [runtimeSourceRoot, runtimeSourcePrefix].filter(Boolean)
1215
const RUNTIME_SOURCE_TREE = /(^|\/)(prepared-plugins|agents-api|ai-provider-for-openai)(\/|$)/
1316
const RUNTIME_SOURCE_FILE = /^(agents-api\.php|plugin\.php)$/
1417
const RUNTIME_SOURCE_CONTENT = /(?:Plugin Name:|WP_Agents_Registry|OpenAiProvider)/
@@ -17,44 +20,15 @@ function redact(value) {
1720
return secretValues.reduce((output, secret) => output.split(secret).join("[REDACTED]"), value)
1821
}
1922

20-
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"])
21-
2223
function isPrivateRuntimePath(value) {
2324
if (!runtimeSourceRoot || typeof value !== "string") return false
2425
const path = resolve(value)
2526
const contained = relative(runtimeSourceRoot, path)
2627
return path === runtimeSourceRoot || (contained !== ".." && !contained.startsWith(`..${String.fromCharCode(47)}`) && !isAbsolute(contained))
2728
}
2829

29-
function omitPrivateRuntimeSourcePaths(value) {
30-
if (Array.isArray(value)) return value.map(omitPrivateRuntimeSourcePaths)
31-
if (!value || typeof value !== "object") return value
32-
return Object.fromEntries(Object.entries(value).flatMap(([key, entry]) => {
33-
if (PRIVATE_RUNTIME_PATH_FIELDS.has(key) && isPrivateRuntimePath(entry)) return []
34-
if (key === "runtime_sources" && Array.isArray(entry)) return [[key, entry.map(runtimeSourceProvenance)]]
35-
return [[key, omitPrivateRuntimeSourcePaths(entry)]]
36-
}))
37-
}
38-
39-
function runtimeSourceProvenance(source) {
40-
if (!source || typeof source !== "object" || Array.isArray(source)) return source
41-
const descriptor = source
42-
const provenance = { role: descriptor.role }
43-
if (descriptor.source?.type === "https_zip") {
44-
provenance.source = { type: "https_zip", url: descriptor.source.url, sha256: descriptor.source.sha256, ...(descriptor.source.archive_root ? { archive_root: descriptor.source.archive_root } : {}) }
45-
} else {
46-
Object.assign(provenance, ...["repository", "revision", "path", "digest"].flatMap((key) => descriptor[key] ? [{ [key]: descriptor[key] }] : []))
47-
}
48-
if (descriptor.role === "provider_plugin" && Array.isArray(descriptor.metadata?.providers)) provenance.providers = descriptor.metadata.providers
49-
return provenance
50-
}
51-
5230
function sanitizeText(text) {
53-
try {
54-
return `${JSON.stringify(omitPrivateRuntimeSourcePaths(JSON.parse(text)), null, 2)}\n`
55-
} catch {
56-
return text
57-
}
31+
return sanitizeRuntimeSourceJson(text, runtimeSourceRoots)
5832
}
5933

6034
async function stageFile(source, destination) {
@@ -78,7 +52,7 @@ async function stageFile(source, destination) {
7852
throw new Error("Prepared runtime plugin source contents must never be staged for artifact upload.")
7953
}
8054
text = sanitizeText(text)
81-
if (runtimeSourceRoot && text.includes(runtimeSourceRoot)) throw new Error("Runtime source paths must never be persisted in artifact uploads.")
55+
assertNoRuntimeSourcePaths(text, runtimeSourceRoots, "Runtime source paths must never be persisted in artifact uploads.")
8256
await writeFile(destination, redact(text))
8357
return true
8458
}
@@ -100,7 +74,7 @@ async function assertNoPrivateRuntimePaths(directory) {
10074
if (entry.isDirectory()) await assertNoPrivateRuntimePaths(path)
10175
else if (entry.isFile()) {
10276
const contents = await readFile(path, "utf8")
103-
if (runtimeSourceRoot && contents.includes(runtimeSourceRoot)) throw new Error("Runtime source paths must never be persisted in artifact uploads.")
77+
assertNoRuntimeSourcePaths(contents, runtimeSourceRoots, "Runtime source paths must never be persisted in artifact uploads.")
10478
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.")
10579
}
10680
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { resolve } from "node:path"
2+
3+
const PRIVATE_RUNTIME_SOURCE_FIELDS = new Set(["source_package_root"])
4+
export const RUNTIME_SOURCE_PLACEHOLDER = "[runtime-source]"
5+
6+
function runtimeSourceRoots(root) {
7+
return (Array.isArray(root) ? root : [root]).filter((entry) => typeof entry === "string" && entry).map((entry) => resolve(entry)).sort((left, right) => right.length - left.length)
8+
}
9+
10+
function runtimeSourceProvenance(source) {
11+
if (!source || typeof source !== "object" || Array.isArray(source)) return source
12+
const descriptor = source
13+
const provenance = { role: descriptor.role }
14+
if (descriptor.source?.type === "https_zip") {
15+
provenance.source = { type: "https_zip", url: descriptor.source.url, sha256: descriptor.source.sha256, ...(descriptor.source.archive_root ? { archive_root: descriptor.source.archive_root } : {}) }
16+
} else {
17+
Object.assign(provenance, ...["repository", "revision", "path", "digest"].flatMap((key) => descriptor[key] ? [{ [key]: descriptor[key] }] : []))
18+
}
19+
if (descriptor.role === "provider_plugin" && Array.isArray(descriptor.metadata?.providers)) provenance.providers = descriptor.metadata.providers
20+
return provenance
21+
}
22+
23+
export function sanitizeRuntimeSourceText(value, root) {
24+
if (typeof value !== "string") return value
25+
return runtimeSourceRoots(root).reduce((sanitized, sourceRoot) => sanitized.split(sourceRoot).join(RUNTIME_SOURCE_PLACEHOLDER), value)
26+
}
27+
28+
// Runtime results may place paths anywhere, including diagnostics, stack traces,
29+
// command arguments, metadata, and object keys.
30+
export function sanitizeRuntimeSourceValue(value, root) {
31+
if (typeof value === "string") return sanitizeRuntimeSourceText(value, root)
32+
if (Array.isArray(value)) return value.map((entry) => sanitizeRuntimeSourceValue(entry, root))
33+
if (!value || typeof value !== "object") return value
34+
return Object.fromEntries(Object.entries(value).flatMap(([key, entry]) => {
35+
if (PRIVATE_RUNTIME_SOURCE_FIELDS.has(key)) return []
36+
if (key === "runtime_sources" && Array.isArray(entry)) return [[key, entry.map(runtimeSourceProvenance).map((source) => sanitizeRuntimeSourceValue(source, root))]]
37+
return [[sanitizeRuntimeSourceText(key, root), sanitizeRuntimeSourceValue(entry, root)]]
38+
}))
39+
}
40+
41+
export function sanitizeRuntimeSourceJson(text, root) {
42+
try {
43+
return `${JSON.stringify(sanitizeRuntimeSourceValue(JSON.parse(text), root), null, 2)}\n`
44+
} catch {
45+
return sanitizeRuntimeSourceText(text, root)
46+
}
47+
}
48+
49+
export function assertNoRuntimeSourcePaths(value, root, message = "Runtime source paths must never be persisted in workflow results or artifacts.") {
50+
const serialized = JSON.stringify(value)
51+
if (runtimeSourceRoots(root).some((sourceRoot) => serialized.includes(sourceRoot))) throw new Error(message)
52+
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ on:
1111
- "docs/agent-task-reusable-workflow.md"
1212
- "fixtures/agent-task-reusable-workflow-consumer*.yml"
1313
- "fixtures/agent-task-runtime-sources-run-29299109269.json"
14+
- "fixtures/agent-task-runtime-paths-run-29305012941.json"
1415
- "package-lock.json"
1516
- "package.json"
1617
- "tests/agent-task-*.test.ts"
@@ -29,6 +30,7 @@ on:
2930
- "docs/agent-task-reusable-workflow.md"
3031
- "fixtures/agent-task-reusable-workflow-consumer*.yml"
3132
- "fixtures/agent-task-runtime-sources-run-29299109269.json"
33+
- "fixtures/agent-task-runtime-paths-run-29305012941.json"
3234
- "package-lock.json"
3335
- "package.json"
3436
- "tests/agent-task-*.test.ts"

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -283,7 +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: ${{ steps.execute.outputs.runtime_source_root }}
286+
WP_CODEBOX_RUNTIME_SOURCE_PREFIX: ${{ runner.temp }}/wp-codebox-runtime-sources-
287287
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
288288
MODEL_PROVIDER_SECRET_1: ${{ secrets.MODEL_PROVIDER_SECRET_1 }}
289289
MODEL_PROVIDER_SECRET_2: ${{ secrets.MODEL_PROVIDER_SECRET_2 }}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
{
2+
"run_id": "29305012941",
3+
"runtime_root": "/tmp/wp-codebox-runtime-sources-HrTMNi",
4+
"success": {
5+
"schema": "wp-codebox/agent-task-run/v1",
6+
"success": true,
7+
"status": "succeeded",
8+
"agent_task_run_result": {
9+
"schema": "wp-codebox/agent-task-run-result/v1",
10+
"success": true,
11+
"status": "succeeded"
12+
},
13+
"outputs": {
14+
"metadata": {
15+
"source_package_root": "/tmp/wp-codebox-runtime-sources-HrTMNi/prepared-runtime-sources",
16+
"nested": {
17+
"/tmp/wp-codebox-runtime-sources-HrTMNi/key": "/tmp/wp-codebox-runtime-sources-HrTMNi/value"
18+
}
19+
},
20+
"command": {
21+
"args": ["--source=/tmp/wp-codebox-runtime-sources-HrTMNi/source-0", "/tmp/wp-codebox-runtime-sources-HrTMNi/source-1"]
22+
}
23+
}
24+
},
25+
"failure": {
26+
"schema": "wp-codebox/agent-task-run/v1",
27+
"success": false,
28+
"status": "failed",
29+
"agent_task_run_result": {
30+
"schema": "wp-codebox/agent-task-run-result/v1",
31+
"success": false,
32+
"status": "failed"
33+
},
34+
"diagnostics": [{ "message": "Prepared source at /tmp/wp-codebox-runtime-sources-HrTMNi/source-0 failed" }],
35+
"stack": "Error: failed\n at /tmp/wp-codebox-runtime-sources-HrTMNi/source-0/plugin.php:1:1",
36+
"paths": ["/tmp/wp-codebox-runtime-sources-HrTMNi", "/tmp/wp-codebox-runtime-sources-HrTMNi-suffix"]
37+
}
38+
}

0 commit comments

Comments
 (0)