Skip to content

Commit 9bddc16

Browse files
committed
Allowlist native agent-task uploads
1 parent d690aa2 commit 9bddc16

5 files changed

Lines changed: 179 additions & 54 deletions

File tree

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

Lines changed: 132 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -8,83 +8,175 @@ 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)
68108
}
109+
visit(result)
110+
return [...paths].sort()
69111
}
70112

71-
async function assertNoPrivateRuntimePaths(directory) {
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+
}
128+
}
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+
})
145+
}
146+
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 request = JSON.parse(await readFile(requestPath, "utf8"))
163+
const resultSource = join(workspace, ".codebox", "agent-task-workflow-result.json")
164+
const result = JSON.parse(await readFile(resultSource, "utf8").catch(() => "{}"))
165+
const declaredPaths = new Set(declaredArtifactPaths(result, declarations(request)))
166+
83167
await rm(uploadPath, { recursive: true, force: true })
84168
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))
169+
await stageTextFile(requestPath, join(uploadPath, ".codebox", "agent-task-request.json"))
170+
await stageTextFile(resultSource, join(uploadPath, ".codebox", "agent-task-workflow-result.json"))
171+
await stageTextFile(join(workspace, ".codebox", "native-agent-task-input.json"), join(uploadPath, ".codebox", "native-agent-task-input.json"), { compactNativeInput: true })
172+
for (const path of declaredPaths) {
173+
const source = resolve(artifactsPath, path)
174+
if (relative(artifactsPath, source).startsWith("..") || sourceCategory(path, source)) {
175+
throw new Error("Declared reviewer artifacts must not reference source files or private runtime internals.")
176+
}
177+
await stageTextFile(source, join(uploadPath, ".codebox", "agent-task-artifacts", path))
88178
}
89-
await stageDirectory(join(workspace, ".codebox", "agent-task-artifacts"), join(uploadPath, ".codebox", "agent-task-artifacts"))
90-
await assertNoPrivateRuntimePaths(uploadPath)
179+
await mkdir(join(uploadPath, ".codebox", "agent-task-artifacts"), { recursive: true })
180+
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`)
181+
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`)
182+
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
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"run_id": "29306539573",
3+
"workflow": "Build With WordPress Skills Agent",
4+
"raw_layout": {
5+
"request": ".codebox/agent-task-request.json",
6+
"result": "workspace/.codebox/agent-task-workflow-result.json",
7+
"native_input": "workspace/.codebox/native-agent-task-input.json",
8+
"runtime_source": "workspace/.codebox/agent-task-artifacts/prepared-plugins/agents-api/agents-api.php"
9+
},
10+
"observed": {
11+
"native_execution": "failed",
12+
"upload_preparation": "failed-on-runtime-source",
13+
"uploaded": [".codebox/agent-task-request.json"]
14+
}
15+
}

tests/agent-task-reusable-workflow.test.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ assert.match(workflow, /Install WP Codebox runtime/)
3838
assert.match(workflow, /Checkout target workspace/)
3939
assert.match(workflow, /Execute native agent task/)
4040
assert.match(workflow, /execute-native-agent-task\.mjs/)
41-
assert.match(workflow, /agent-task-artifacts/)
41+
assert.match(workflow, /workspace\/\.codebox\/agent-task-upload/)
4242
assert.match(workflow, /prepare-agent-task-upload\.mjs/)
4343
assert.match(workflow, /agent-task-upload/)
4444
assert.match(workflow, /if: always\(\)/)
@@ -321,8 +321,9 @@ await execFileAsync("node", [new URL("../.github/scripts/run-agent-task/prepare-
321321
env: { ...process.env, AGENT_TASK_WORKSPACE: tmp, OPENAI_API_KEY: "secret-agent-value", GITHUB_TOKEN: "secret-github-value", EXTERNAL_PACKAGE_SOURCE_POLICY: '{"private":"policy"}' },
322322
})
323323
const uploadArtifactsPath = join(tmp, ".codebox", "agent-task-upload", ".codebox", "agent-task-artifacts")
324-
assert.match(await readFile(join(uploadArtifactsPath, "safe.txt"), "utf8"), /\[REDACTED\]/)
325-
assert.doesNotMatch(await readFile(join(uploadArtifactsPath, "safe.txt"), "utf8"), /secret-github-value/)
324+
const exclusions = await readFile(join(uploadArtifactsPath, "exclusions.json"), "utf8")
325+
assert.match(exclusions, /"category": "undeclared-artifact"/)
326+
assert.doesNotMatch(exclusions, /secret-agent-value|secret-github-value/)
326327
assert.doesNotMatch(await readFile(join(tmp, ".codebox", "agent-task-upload", ".codebox", "agent-task-request.json"), "utf8"), /secret-agent-value|secret-github-value|\{"private":"policy"\}/)
327328
for (const name of ["oversize.txt", "binary.bin", "linked-secret.txt"]) {
328329
await assert.rejects(readFile(join(uploadArtifactsPath, name), "utf8"), /ENOENT/)

0 commit comments

Comments
 (0)