Skip to content

Commit 6e295a0

Browse files
authored
Allowlist native agent-task uploads (#1776)
* Allowlist native agent-task uploads * Normalize native agent task failures
1 parent 559cee5 commit 6e295a0

6 files changed

Lines changed: 307 additions & 62 deletions

File tree

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

Lines changed: 63 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { rmSync } from "node:fs"
22
import { appendFile, mkdir, readFile, rm, writeFile } from "node:fs/promises"
3-
import { join, resolve } from "node:path"
3+
import { join, relative, 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"
@@ -14,6 +14,8 @@ const outputPath = process.env.GITHUB_OUTPUT
1414
const MAX_CAPTURE_BYTES = 32768
1515
const MAX_OUTPUT_CHARS = 8192
1616
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)
17+
let privateRuntimeSourceRoot = ""
18+
let privateRuntimeSourceRootForSanitization = ""
1719
function redact(value) {
1820
if (typeof value === "string") return secretValues.reduce((output, secret) => output.split(secret).join("[REDACTED]"), value)
1921
if (Array.isArray(value)) return value.map(redact)
@@ -173,6 +175,45 @@ function projections(value, runtimeResult) {
173175
return output
174176
}
175177

178+
function workflowPath(path) {
179+
const relativePath = relative(workspace, resolve(path))
180+
return relativePath && !relativePath.startsWith("..") ? relativePath.replaceAll("\\", "/") : ".codebox/agent-task-workflow-result.json"
181+
}
182+
183+
function failureClassification(error) {
184+
const message = error instanceof Error ? error.message : String(error)
185+
const code = typeof error?.code === "string" && error.code ? error.code : ""
186+
if (code.includes(".policy")) return { code, classification: "policy" }
187+
if (code && !/materializ|fetch|download|archive|entrypoint|git failed|spawn git/i.test(message)) return { code, classification: "native-agent-task" }
188+
if (/policy|authorized|allowlisted|allowed_repos|ACCESS_TOKEN/i.test(message)) return { code: "wp-codebox.agent-task.policy", classification: "policy" }
189+
if (/materializ|fetch|download|archive|entrypoint|git failed|spawn git/i.test(message)) return { code: "wp-codebox.agent-task.materialization", classification: "materialization" }
190+
if (/approval|publication|pull request/i.test(message)) return { code: "wp-codebox.agent-task.approval", classification: "approval" }
191+
if (/projection/i.test(message)) return { code: "wp-codebox.agent-task.output-projection", classification: "output-projection" }
192+
return { code: "wp-codebox.agent-task.execution", classification: "execution" }
193+
}
194+
195+
async function writeNormalizedFailure(error, request = {}) {
196+
const resultPath = join(workspace, ".codebox", "agent-task-workflow-result.json")
197+
const failure = failureClassification(error)
198+
const message = bounded(error instanceof Error ? error.message : String(error), MAX_OUTPUT_CHARS)
199+
const accessError = failure.classification === "policy" && /allowed_repos|ACCESS_TOKEN|GitHub token|Caller repository|authorized/i.test(message)
200+
const result = {
201+
schema: "wp-codebox/agent-task-workflow-result/v1",
202+
run_id: `${record(request).workload?.id || "agent-task"}-${process.env.GITHUB_RUN_ID || "local"}`.replace(/[^A-Za-z0-9._-]+/g, "-"),
203+
status: "failed",
204+
success: false,
205+
request_path: workflowPath(requestPath),
206+
failure: { ...failure, message },
207+
...(accessError ? { access: { authorized: false, error: message } } : {}),
208+
}
209+
await mkdir(join(workspace, ".codebox"), { recursive: true })
210+
const sanitized = sanitizeRuntimeSourceValue(redact(result), privateRuntimeSourceRootForSanitization)
211+
assertNoRuntimeSourcePaths(sanitized, privateRuntimeSourceRootForSanitization)
212+
await writeFile(resultPath, `${JSON.stringify(sanitized, null, 2)}\n`)
213+
await output("job_status", "failed")
214+
await output("result_path", ".codebox/agent-task-workflow-result.json")
215+
}
216+
176217
async function redactArtifactFiles(directory) {
177218
const { readdir, stat } = await import("node:fs/promises")
178219
for (const entry of await readdir(directory, { withFileTypes: true })) {
@@ -189,6 +230,7 @@ async function redactArtifactFiles(directory) {
189230
}
190231
}
191232

233+
async function executeNativeAgentTask() {
192234
const request = JSON.parse(await readFile(requestPath, "utf8"))
193235
const verificationCommands = commandEntries(request.verification_commands, "verification_commands")
194236
const driftChecks = commandEntries(request.drift_checks, "drift_checks")
@@ -202,8 +244,6 @@ const runtimeInputPath = join(workspace, ".codebox", "native-agent-task-input.js
202244
const resultPath = join(workspace, ".codebox", "agent-task-workflow-result.json")
203245
const controlledCodeboxPath = resolve(requestPath, "..")
204246
const nativeResultPath = join(controlledCodeboxPath, "native-agent-task-result.json")
205-
let privateRuntimeSourceRoot = ""
206-
let privateRuntimeSourceRootForSanitization = ""
207247
let cleaningPrivateRuntimeSources = false
208248
async function cleanupPrivateRuntimeSources() {
209249
if (cleaningPrivateRuntimeSources || !privateRuntimeSourceRoot) return
@@ -232,11 +272,9 @@ await mkdir(artifactsPath, { recursive: true })
232272

233273
const accessError = accessFailure(request)
234274
if (accessError) {
235-
const result = { schema: "wp-codebox/agent-task-workflow-result/v1", run_id: runId, status: "failed", success: false, request_path: requestPath, access: { authorized: false, error: accessError } }
236-
await writeFile(resultPath, `${JSON.stringify(result, null, 2)}\n`)
237-
await output("job_status", "failed")
238-
process.exitCode = 1
239-
process.exit()
275+
const error = new Error(accessError)
276+
error.code = "wp-codebox.agent-task.policy"
277+
throw error
240278
}
241279

242280
const materializedPackage = request.run_agent && !request.dry_run
@@ -404,3 +442,20 @@ await output("declared_artifacts_json", result.artifacts.declarations)
404442
await output("result_path", ".codebox/agent-task-workflow-result.json")
405443

406444
if (!success) process.exitCode = 1
445+
}
446+
447+
try {
448+
await executeNativeAgentTask()
449+
} catch (error) {
450+
try {
451+
await writeNormalizedFailure(error)
452+
} finally {
453+
if (privateRuntimeSourceRoot) {
454+
const root = privateRuntimeSourceRoot
455+
privateRuntimeSourceRoot = ""
456+
await rm(root, { recursive: true, force: true })
457+
}
458+
}
459+
console.error(bounded(error instanceof Error ? error.message : String(error), MAX_OUTPUT_CHARS))
460+
process.exitCode = 1
461+
}

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

Lines changed: 135 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -8,83 +8,178 @@ const MAX_UPLOAD_FILE_BYTES = 4 * 1024 * 1024
88
const workspace = resolve(process.env.AGENT_TASK_WORKSPACE || process.cwd())
99
const uploadPath = resolve(process.env.AGENT_TASK_UPLOAD_PATH || join(workspace, ".codebox", "agent-task-upload"))
1010
const requestPath = resolve(process.env.AGENT_TASK_REQUEST_PATH || join(workspace, ".codebox", "agent-task-request.json"))
11+
const artifactsPath = join(workspace, ".codebox", "agent-task-artifacts")
1112
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)
1213
const runtimeSourceRoot = process.env.WP_CODEBOX_RUNTIME_SOURCE_ROOT ? resolve(process.env.WP_CODEBOX_RUNTIME_SOURCE_ROOT) : ""
1314
const runtimeSourcePrefix = process.env.WP_CODEBOX_RUNTIME_SOURCE_PREFIX ? resolve(process.env.WP_CODEBOX_RUNTIME_SOURCE_PREFIX) : ""
1415
const runtimeSourceRoots = [runtimeSourceRoot, runtimeSourcePrefix].filter(Boolean)
15-
const RUNTIME_SOURCE_TREE = /(^|\/)(prepared-plugins|agents-api|ai-provider-for-openai)(\/|$)/
16-
const RUNTIME_SOURCE_FILE = /^(agents-api\.php|plugin\.php)$/
16+
const SOURCE_TREE = /(^|\/)(prepared-plugins|prepared-source-packages|source-package[^/]*)(\/|$)/i
17+
const SOURCE_FILE = /\.(?:php|phtml|js|mjs|cjs|jsx|ts|tsx)$/i
1718
const RUNTIME_SOURCE_CONTENT = /(?:Plugin Name:|WP_Agents_Registry|OpenAiProvider)/
1819

1920
function redact(value) {
2021
return secretValues.reduce((output, secret) => output.split(secret).join("[REDACTED]"), value)
2122
}
2223

24+
function sanitizeText(text) {
25+
return sanitizeRuntimeSourceJson(text, runtimeSourceRoots)
26+
}
27+
28+
function compactNativeInput(text) {
29+
const privateFields = new Set(["source_package_root", "component_contracts", "extra_plugins", "provider_plugins", "runtime_overlays", "prepared_sources"])
30+
const compact = (value) => {
31+
if (Array.isArray(value)) return value.map(compact)
32+
const entry = record(value)
33+
if (!Object.keys(entry).length) return value
34+
return Object.fromEntries(Object.entries(entry).flatMap(([key, item]) => privateFields.has(key) ? [] : [[key, compact(item)]]))
35+
}
36+
try {
37+
return `${JSON.stringify(compact(JSON.parse(sanitizeText(text))), null, 2)}\n`
38+
} catch {
39+
return sanitizeText(text)
40+
}
41+
}
42+
2343
function isPrivateRuntimePath(value) {
24-
if (!runtimeSourceRoot || typeof value !== "string") return false
44+
if (!runtimeSourceRoots.length || typeof value !== "string") return false
2545
const path = resolve(value)
26-
const contained = relative(runtimeSourceRoot, path)
27-
return path === runtimeSourceRoot || (contained !== ".." && !contained.startsWith(`..${String.fromCharCode(47)}`) && !isAbsolute(contained))
46+
return runtimeSourceRoots.some((root) => {
47+
const contained = relative(root, path)
48+
return path === root || (contained !== ".." && !contained.startsWith(`..${String.fromCharCode(47)}`) && !isAbsolute(contained))
49+
})
2850
}
2951

30-
function sanitizeText(text) {
31-
return sanitizeRuntimeSourceJson(text, runtimeSourceRoots)
52+
function safeRelativeArtifactPath(value) {
53+
if (typeof value !== "string" || !value.trim() || isAbsolute(value)) return ""
54+
const path = value.replace(/\\/g, "/").replace(/^\.\//, "")
55+
if (path.split("/").some((part) => !part || part === "." || part === "..")) return ""
56+
return path
3257
}
3358

34-
async function stageFile(source, destination) {
35-
if (isPrivateRuntimePath(source)) {
36-
throw new Error("Runtime source files must never be staged for artifact upload.")
37-
}
59+
function sourceCategory(path, absolutePath) {
60+
if (isPrivateRuntimePath(absolutePath)) return "private-runtime"
61+
if (SOURCE_TREE.test(path)) return "source-tree"
62+
if (SOURCE_FILE.test(path)) return "source-file"
63+
return ""
64+
}
65+
66+
async function stageTextFile(source, destination, options = {}) {
3867
const metadata = await lstat(source).catch(() => null)
3968
if (!metadata?.isFile() || metadata.size > MAX_UPLOAD_FILE_BYTES) return false
40-
if (RUNTIME_SOURCE_TREE.test(source) || RUNTIME_SOURCE_FILE.test(source.split("/").pop() || "")) {
41-
throw new Error("Prepared runtime plugin sources must never be staged for artifact upload.")
42-
}
4369
const handle = await open(source, constants.O_RDONLY | constants.O_NOFOLLOW).catch(() => null)
4470
if (!handle) return false
4571
const openedMetadata = await handle.stat()
4672
const contents = openedMetadata.isFile() && openedMetadata.size <= MAX_UPLOAD_FILE_BYTES ? await handle.readFile() : null
4773
await handle.close()
4874
if (!contents || contents.includes(0) || !isUtf8(contents)) return false
49-
await mkdir(resolve(destination, ".."), { recursive: true })
50-
let text = contents.toString("utf8")
51-
if (RUNTIME_SOURCE_CONTENT.test(text)) {
52-
throw new Error("Prepared runtime plugin source contents must never be staged for artifact upload.")
53-
}
54-
text = sanitizeText(text)
75+
const text = redact(options.compactNativeInput ? compactNativeInput(contents.toString("utf8")) : sanitizeText(contents.toString("utf8")))
5576
assertNoRuntimeSourcePaths(text, runtimeSourceRoots, "Runtime source paths must never be persisted in artifact uploads.")
56-
await writeFile(destination, redact(text))
77+
if (RUNTIME_SOURCE_CONTENT.test(text)) throw new Error("Prepared runtime plugin source contents must never be staged for artifact upload.")
78+
await mkdir(resolve(destination, ".."), { recursive: true })
79+
await writeFile(destination, text)
5780
return true
5881
}
5982

60-
async function stageDirectory(source, destination) {
61-
const metadata = await lstat(source).catch(() => null)
62-
if (!metadata?.isDirectory()) return
63-
for (const entry of await readdir(source, { withFileTypes: true })) {
64-
const entrySource = join(source, entry.name)
65-
const entryDestination = join(destination, entry.name)
66-
if (entry.isDirectory()) await stageDirectory(entrySource, entryDestination)
67-
else if (entry.isFile()) await stageFile(entrySource, entryDestination)
83+
function record(value) {
84+
return value && typeof value === "object" && !Array.isArray(value) ? value : {}
85+
}
86+
87+
function declarations(request) {
88+
return (Array.isArray(record(request).artifacts?.declarations) ? record(request).artifacts.declarations : [])
89+
.flatMap((declaration) => {
90+
const entry = record(declaration)
91+
return typeof entry.name === "string" && entry.name.trim()
92+
? [{ name: entry.name.trim(), type: typeof entry.type === "string" ? entry.type.trim() : "" }]
93+
: []
94+
})
95+
}
96+
97+
function declaredArtifactPaths(result, allowed) {
98+
const paths = new Set()
99+
const visit = (value) => {
100+
if (Array.isArray(value)) return value.forEach(visit)
101+
const entry = record(value)
102+
if (!Object.keys(entry).length) return
103+
const artifact = record(entry.artifact)
104+
const path = safeRelativeArtifactPath(artifact.path)
105+
const declared = allowed.some((candidate) => candidate.name === entry.name && (!candidate.type || candidate.type === entry.type))
106+
if (path && declared) paths.add(path)
107+
Object.values(entry).forEach(visit)
108+
}
109+
visit(result)
110+
return [...paths].sort()
111+
}
112+
113+
async function exclusions(root, declaredPaths) {
114+
const counts = new Map()
115+
const count = (category) => counts.set(category, (counts.get(category) || 0) + 1)
116+
const visit = async (directory) => {
117+
const entries = await readdir(directory, { withFileTypes: true }).catch(() => [])
118+
for (const entry of entries) {
119+
const source = join(directory, entry.name)
120+
const path = relative(root, source).replaceAll("\\", "/")
121+
if (entry.isDirectory()) await visit(source)
122+
else if (entry.isFile()) {
123+
const category = sourceCategory(path, source)
124+
if (category) count(category)
125+
else if (!declaredPaths.has(path)) count("undeclared-artifact")
126+
} else count("special-file")
127+
}
68128
}
129+
await visit(root)
130+
return [...counts.entries()].sort(([left], [right]) => left.localeCompare(right)).map(([category, count]) => ({ category, count }))
131+
}
132+
133+
function runtimeProvenance(request) {
134+
const sources = Array.isArray(record(request).runtime_sources) ? record(request).runtime_sources : []
135+
return sources.flatMap((source) => {
136+
const entry = record(source)
137+
if (typeof entry.role !== "string") return []
138+
const provenance = { role: entry.role }
139+
if (record(entry.source).type === "https_zip") {
140+
const sourceInfo = record(entry.source)
141+
provenance.source = Object.fromEntries(["type", "url", "sha256", "archive_root"].flatMap((key) => typeof sourceInfo[key] === "string" ? [[key, sourceInfo[key]]] : []))
142+
} else Object.assign(provenance, ...["repository", "revision", "digest"].flatMap((key) => typeof entry[key] === "string" ? [{ [key]: entry[key] }] : []))
143+
return [provenance]
144+
})
69145
}
70146

71-
async function assertNoPrivateRuntimePaths(directory) {
147+
async function finalScan(directory) {
72148
for (const entry of await readdir(directory, { withFileTypes: true })) {
73149
const path = join(directory, entry.name)
74-
if (entry.isDirectory()) await assertNoPrivateRuntimePaths(path)
150+
const relativePath = relative(uploadPath, path).replaceAll("\\", "/")
151+
if (sourceCategory(relativePath, path)) throw new Error("Prepared runtime plugin sources must never be persisted in artifact uploads.")
152+
if (entry.isDirectory()) await finalScan(path)
75153
else if (entry.isFile()) {
76-
const contents = await readFile(path, "utf8")
77-
assertNoRuntimeSourcePaths(contents, runtimeSourceRoots, "Runtime source paths must never be persisted in artifact uploads.")
78-
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.")
79-
}
154+
const bytes = await readFile(path)
155+
const text = isUtf8(bytes) ? bytes.toString("utf8") : ""
156+
assertNoRuntimeSourcePaths(text, runtimeSourceRoots, "Runtime source paths must never be persisted in artifact uploads.")
157+
if (RUNTIME_SOURCE_CONTENT.test(text)) throw new Error("Prepared runtime plugin source contents must never be persisted in artifact uploads.")
158+
} else throw new Error("Only regular files may be persisted in artifact uploads.")
80159
}
81160
}
82161

162+
const parseJsonOrEmpty = (text) => {
163+
try { return JSON.parse(text) } catch { return {} }
164+
}
165+
const request = parseJsonOrEmpty(await readFile(requestPath, "utf8").catch(() => "{}"))
166+
const resultSource = join(workspace, ".codebox", "agent-task-workflow-result.json")
167+
const result = parseJsonOrEmpty(await readFile(resultSource, "utf8").catch(() => "{}"))
168+
const declaredPaths = new Set(declaredArtifactPaths(result, declarations(request)))
169+
83170
await rm(uploadPath, { recursive: true, force: true })
84171
await mkdir(uploadPath, { recursive: true })
85-
await stageFile(requestPath, join(uploadPath, ".codebox", "agent-task-request.json"))
86-
for (const path of [".codebox/agent-task-workflow-result.json", ".codebox/native-agent-task-input.json"]) {
87-
await stageFile(join(workspace, path), join(uploadPath, path))
172+
await stageTextFile(requestPath, join(uploadPath, ".codebox", "agent-task-request.json"))
173+
await stageTextFile(resultSource, join(uploadPath, ".codebox", "agent-task-workflow-result.json"))
174+
await stageTextFile(join(workspace, ".codebox", "native-agent-task-input.json"), join(uploadPath, ".codebox", "native-agent-task-input.json"), { compactNativeInput: true })
175+
for (const path of declaredPaths) {
176+
const source = resolve(artifactsPath, path)
177+
if (relative(artifactsPath, source).startsWith("..") || sourceCategory(path, source)) {
178+
throw new Error("Declared reviewer artifacts must not reference source files or private runtime internals.")
179+
}
180+
await stageTextFile(source, join(uploadPath, ".codebox", "agent-task-artifacts", path))
88181
}
89-
await stageDirectory(join(workspace, ".codebox", "agent-task-artifacts"), join(uploadPath, ".codebox", "agent-task-artifacts"))
90-
await assertNoPrivateRuntimePaths(uploadPath)
182+
await mkdir(join(uploadPath, ".codebox", "agent-task-artifacts"), { recursive: true })
183+
await writeFile(join(uploadPath, ".codebox", "agent-task-artifacts", "runtime-provenance.json"), `${JSON.stringify({ schema: "wp-codebox/agent-task-runtime-provenance/v1", sources: runtimeProvenance(request) }, null, 2)}\n`)
184+
await writeFile(join(uploadPath, ".codebox", "agent-task-artifacts", "exclusions.json"), `${JSON.stringify({ schema: "wp-codebox/agent-task-upload-exclusions/v1", exclusions: await exclusions(artifactsPath, declaredPaths) }, null, 2)}\n`)
185+
await finalScan(uploadPath)

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

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -299,16 +299,15 @@ jobs:
299299
with:
300300
name: codebox-agent-task-request-${{ github.run_id }}
301301
path: |
302-
workspace/.codebox/agent-task-upload/.codebox/agent-task-request.json
303-
workspace/.codebox/agent-task-upload/.codebox/agent-task-workflow-result.json
304-
workspace/.codebox/agent-task-upload/.codebox/native-agent-task-input.json
302+
workspace/.codebox/agent-task-upload
303+
include-hidden-files: true
305304

306305
- name: Upload Codebox task result
307306
if: always()
308307
uses: actions/upload-artifact@v4
309308
with:
310309
name: codebox-agent-task-result-${{ github.run_id }}
311310
path: |
312-
workspace/.codebox/agent-task-upload/.codebox/agent-task-workflow-result.json
313-
workspace/.codebox/agent-task-upload/.codebox/agent-task-artifacts
311+
workspace/.codebox/agent-task-upload
314312
if-no-files-found: ignore
313+
include-hidden-files: true

0 commit comments

Comments
 (0)