Skip to content

Commit 1cd0419

Browse files
committed
Upload canonical sanitized agent transcripts
1 parent 40d5f68 commit 1cd0419

4 files changed

Lines changed: 78 additions & 9 deletions

File tree

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

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { constants } from "node:fs"
22
import { lstat, mkdir, open, readdir, readFile, rm, writeFile } from "node:fs/promises"
33
import { isUtf8 } from "node:buffer"
4+
import { createHash } from "node:crypto"
5+
import { fileURLToPath, pathToFileURL } from "node:url"
46
import { isAbsolute, join, relative, resolve } from "node:path"
57
import { assertNoRuntimeSourcePaths, sanitizeRuntimeSourceJson } from "./runtime-source-sanitizer.mjs"
68

@@ -14,6 +16,7 @@ const runtimeSourceRoot = process.env.WP_CODEBOX_RUNTIME_SOURCE_ROOT ? resolve(p
1416
const runtimeSourcePrefix = process.env.WP_CODEBOX_RUNTIME_SOURCE_PREFIX ? resolve(process.env.WP_CODEBOX_RUNTIME_SOURCE_PREFIX) : ""
1517
const runtimeSourceRoots = [runtimeSourceRoot, runtimeSourcePrefix].filter(Boolean)
1618
const privateUploadRoots = [...runtimeSourceRoots, workspace]
19+
const codeboxRoot = resolve(fileURLToPath(new URL("../../..", import.meta.url)))
1720
const SOURCE_TREE = /(^|\/)(prepared-plugins|prepared-source-packages|source-package[^/]*)(\/|$)/i
1821
const SOURCE_FILE = /\.(?:php|phtml|js|mjs|cjs|jsx|ts|tsx)$/i
1922
const PHP_OPENING_TAG = /<\?(?:php|=)(?:\s|$)/i
@@ -34,6 +37,7 @@ function redact(value) {
3437

3538
function sanitizeText(text) {
3639
return sanitizeRuntimeSourceJson(text, privateUploadRoots)
40+
.replace(/\/(?:Users|home|private|var|tmp|opt|Volumes)\/[^\s"'\\]+/g, "[host-path]")
3741
}
3842

3943
function compactNativeInput(text) {
@@ -135,12 +139,38 @@ async function stageTextFile(source, destination, options = {}) {
135139
const text = redact(sanitizeSeedSnapshotJson(sanitized))
136140
assertNoRuntimeSourcePaths(text, privateUploadRoots, "Runtime source or workspace paths must never be persisted in artifact uploads.")
137141
assertNoSeedSnapshotPaths(text)
138-
if (containsRuntimeSourceContent(text)) throw new Error("Prepared runtime plugin source contents must never be staged for artifact upload.")
142+
if (!options.allowTargetCode && containsRuntimeSourceContent(text)) throw new Error("Prepared runtime plugin source contents must never be staged for artifact upload.")
139143
await mkdir(resolve(destination, ".."), { recursive: true })
140144
await writeFile(destination, text)
141145
return true
142146
}
143147

148+
async function canonicalTranscript(result) {
149+
const publicCore = await import(pathToFileURL(join(codeboxRoot, "packages/runtime-core/dist/public.js")).href)
150+
const refs = publicCore.normalizePublicArtifactRefDTOs(record(result).runtime_result)
151+
.filter((ref) => ref.kind === "codebox-transcript")
152+
if (refs.length === 0) return undefined
153+
if (refs.length !== 1 || !safeRelativeArtifactPath(refs[0].path)) throw new Error("Canonical transcript requires exactly one trusted codebox-transcript path.")
154+
return refs[0]
155+
}
156+
157+
async function stageCanonicalTranscript(result) {
158+
const ref = await canonicalTranscript(result)
159+
if (!ref) return undefined
160+
const path = safeRelativeArtifactPath(ref.path)
161+
const source = resolve(artifactsPath, path)
162+
if (relative(artifactsPath, source).startsWith("..") || sourceCategory(path, source)) throw new Error("Canonical transcript must stay under the trusted artifact root.")
163+
const destination = join(uploadPath, ".codebox", "agent-task-artifacts", "transcript.json")
164+
if (!await stageTextFile(source, destination, { allowTargetCode: true })) throw new Error("Canonical transcript must be a bounded regular UTF-8 file.")
165+
const bytes = await readFile(destination)
166+
if (!Object.keys(record(parseJsonOrEmpty(bytes.toString("utf8")))).length) throw new Error("Canonical transcript must be a JSON object.")
167+
return {
168+
path: ".codebox/agent-task-artifacts/transcript.json",
169+
sha256: createHash("sha256").update(bytes).digest("hex"),
170+
provenance: { kind: ref.kind, artifact_path: path, ...(ref.sha256 ? { source_sha256: ref.sha256 } : {}) },
171+
}
172+
}
173+
144174
function record(value) {
145175
return value && typeof value === "object" && !Array.isArray(value) ? value : {}
146176
}
@@ -171,7 +201,7 @@ function declaredArtifactPaths(result, allowed) {
171201
return [...paths].sort()
172202
}
173203

174-
async function exclusions(root, declaredPaths) {
204+
async function exclusions(root, declaredPaths, transcript) {
175205
const counts = new Map()
176206
const count = (category) => counts.set(category, (counts.get(category) || 0) + 1)
177207
const visit = async (directory) => {
@@ -188,7 +218,10 @@ async function exclusions(root, declaredPaths) {
188218
}
189219
}
190220
await visit(root)
191-
return [...counts.entries()].sort(([left], [right]) => left.localeCompare(right)).map(([category, count]) => ({ category, count }))
221+
return {
222+
exclusions: [...counts.entries()].sort(([left], [right]) => left.localeCompare(right)).map(([category, count]) => ({ category, count })),
223+
...(transcript ? { canonical_transcripts: [transcript] } : {}),
224+
}
192225
}
193226

194227
function runtimeProvenance(request) {
@@ -216,7 +249,7 @@ async function finalScan(directory) {
216249
const text = isUtf8(bytes) ? bytes.toString("utf8") : ""
217250
assertNoRuntimeSourcePaths(text, privateUploadRoots, "Runtime source or workspace paths must never be persisted in artifact uploads.")
218251
assertNoSeedSnapshotPaths(text)
219-
if (containsRuntimeSourceContent(text)) throw new Error("Prepared runtime plugin source contents must never be persisted in artifact uploads.")
252+
if (relativePath !== ".codebox/agent-task-artifacts/transcript.json" && containsRuntimeSourceContent(text)) throw new Error("Prepared runtime plugin source contents must never be persisted in artifact uploads.")
220253
} else throw new Error("Only regular files may be persisted in artifact uploads.")
221254
}
222255
}
@@ -237,11 +270,14 @@ await stageTextFile(join(workspace, ".codebox", "native-agent-task-input.json"),
237270
for (const path of declaredPaths) {
238271
const source = resolve(artifactsPath, path)
239272
if (relative(artifactsPath, source).startsWith("..") || sourceCategory(path, source)) {
240-
throw new Error("Declared reviewer artifacts must not reference source files or private runtime internals.")
273+
// Package declarations cannot authorize source trees or escape the root.
274+
// Keep staging independent of an untrusted alias, including transcripts.
275+
continue
241276
}
242277
await stageTextFile(source, join(uploadPath, ".codebox", "agent-task-artifacts", path))
243278
}
279+
const transcript = await stageCanonicalTranscript(result)
244280
await mkdir(join(uploadPath, ".codebox", "agent-task-artifacts"), { recursive: true })
245281
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`)
246-
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`)
282+
await writeFile(join(uploadPath, ".codebox", "agent-task-artifacts", "exclusions.json"), `${JSON.stringify({ schema: "wp-codebox/agent-task-upload-exclusions/v1", ...(await exclusions(artifactsPath, declaredPaths, transcript)) }, null, 2)}\n`)
247283
await finalScan(uploadPath)

docs/agent-runtime-contract.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,8 @@ Generic command runners and host tool registries should write tool-call evidence
176176

177177
The artifact verifier checks that transcript artifact refs are listed in `manifest.json`, stay within the bundle, and match their declared SHA-256 digests when present. This lets a generic command runner branch merge by materializing its command/tool transcript into `files/runtime-evidence/tool-calls/transcript.json` and appending the referenced input/output artifacts through the existing runtime-evidence manifest update path.
178178

179+
The reusable agent-task workflow separately uploads one normalized `codebox-transcript` ref for reviewer diagnostics. It accepts exactly one bounded, regular, non-symlink JSON file under its trusted artifact root, stages it as `.codebox/agent-task-artifacts/transcript.json`, and records the source ref plus staged SHA-256 in the upload exclusions manifest. This reviewer copy removes secrets and private host/runtime/source/snapshot paths while retaining target-relative tool arguments, errors, and model messages; package declarations cannot authorize alternate transcript paths or dependency source files.
180+
179181
## Runner Workspace Publication
180182

181183
Runner workspace publication is a separate exported contract in runtime-core:

docs/agent-task-reusable-workflow.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,14 @@ runner token, so a fabricated publication result cannot satisfy the gate.
139139

140140
The result artifact includes the executable task input, normalized runtime
141141
result, evaluated projections, verification records, and runner-owned
142-
publication result. Runtime input, result, and diagnostics are uploaded from
142+
publication result. A single canonical `codebox-transcript` ref from the
143+
normalized runtime result is staged independently of package declarations at
144+
`.codebox/agent-task-artifacts/transcript.json`. The uploader accepts only a
145+
bounded regular UTF-8 file beneath the trusted artifact root, records its
146+
source provenance and staged digest in `exclusions.json`, and sanitizes secrets
147+
and private runtime, workspace, and snapshot paths while preserving
148+
target-relative tool arguments, errors, and model messages. Runtime source
149+
trees remain excluded. Runtime input, result, and diagnostics are uploaded from
143150
`workspace/.codebox/` with `if: always()`, including execution failures. A request
144151
artifact alone is not a successful task result.
145152

tests/runtime-sources-materialization.test.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,30 @@ await withTempDir("wp-codebox-runtime-source-upload-", async (directory) => {
164164
await writeFile(join(workspace, ".codebox", "agent-task-workflow-result.json"), JSON.stringify({ typed_artifacts: [{ name: "reviewer-report", type: "report", artifact: { path: "safe.json" } }] }))
165165
await execFileAsync(process.execPath, [script.pathname], { env: { ...process.env, AGENT_TASK_WORKSPACE: workspace, AGENT_TASK_UPLOAD_PATH: upload, WP_CODEBOX_RUNTIME_SOURCE_ROOT: privateRoot } })
166166
assert.match(await readFile(join(upload, ".codebox", "agent-task-artifacts", "safe.json"), "utf8"), /OpenAiProvider/)
167+
await mkdir(join(artifacts, "files"), { recursive: true })
168+
await writeFile(join(artifacts, "files", "transcript.json"), JSON.stringify({
169+
model_message: "Target snippet: <?php final class Target_Plugin {}",
170+
tool_calls: [{ tool_id: "workspace.read", args: { path: "src/plugin.php" }, error: "Repeated workspace error" }, { tool_id: "workspace.read", args: { path: "src/plugin.php" }, error: "Repeated workspace error" }],
171+
private_path: `${privateRoot}/source.php`,
172+
host_path: "/Users/example/private-log.txt",
173+
token: "secret-transcript-value",
174+
}))
175+
await writeFile(join(workspace, ".codebox", "agent-task-workflow-result.json"), JSON.stringify({ runtime_result: { agent_task_run_result: { refs: { transcripts: [{ kind: "codebox-transcript", path: "files/transcript.json", sha256: "a".repeat(64) }] } } }, typed_artifacts: [{ name: "reviewer-report", type: "report", artifact: { path: "prepared-plugins/agents-api/agents-api.php" } }] }))
176+
await execFileAsync(process.execPath, [script.pathname], { env: { ...process.env, AGENT_TASK_WORKSPACE: workspace, AGENT_TASK_UPLOAD_PATH: upload, WP_CODEBOX_RUNTIME_SOURCE_ROOT: privateRoot, OPENAI_API_KEY: "secret-transcript-value" } })
177+
const transcript = await readFile(join(upload, ".codebox", "agent-task-artifacts", "transcript.json"), "utf8")
178+
assert.match(transcript, /Target snippet: <\?php/)
179+
assert.match(transcript, /Repeated workspace error/)
180+
assert.doesNotMatch(transcript, /private-runtime-source|secret-transcript-value|\/Users\/example/)
181+
const transcriptExclusions = await readFile(join(upload, ".codebox", "agent-task-artifacts", "exclusions.json"), "utf8")
182+
assert.match(transcriptExclusions, /canonical_transcripts/)
183+
assert.match(transcriptExclusions, /codebox-transcript/)
184+
const outsideTranscript = join(directory, "outside-transcript.json")
185+
await writeFile(outsideTranscript, JSON.stringify({ secret: "outside" }))
186+
await rm(join(artifacts, "files", "transcript.json"))
187+
await symlink(outsideTranscript, join(artifacts, "files", "transcript.json"))
188+
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 } }), /bounded regular UTF-8 file/, "Canonical transcript refs cannot follow symlinks outside artifacts")
189+
await rm(join(artifacts, "files", "transcript.json"))
190+
await writeFile(join(artifacts, "files", "transcript.json"), JSON.stringify({ tool_calls: [] }))
167191
await writeFile(join(artifacts, "leak.json"), `runtime log ${privateRoot}/source.php`)
168192
await writeFile(join(workspace, ".codebox", "agent-task-workflow-result.json"), JSON.stringify({ status: "failed", success: false, typed_artifacts: [{ name: "reviewer-report", type: "report", artifact: { path: "leak.json" } }] }))
169193
await execFileAsync(process.execPath, [script.pathname], { env: { ...process.env, AGENT_TASK_WORKSPACE: workspace, AGENT_TASK_UPLOAD_PATH: upload, WP_CODEBOX_RUNTIME_SOURCE_ROOT: privateRoot } })
@@ -179,8 +203,8 @@ await withTempDir("wp-codebox-runtime-source-upload-", async (directory) => {
179203
assert.match(exclusionManifest, /"category": "source-tree"/)
180204
assert.doesNotMatch(exclusionManifest, /prepared-plugins|agents-api|private-runtime-source/)
181205
await writeFile(join(workspace, ".codebox", "agent-task-workflow-result.json"), JSON.stringify({ typed_artifacts: [{ name: "reviewer-report", type: "report", artifact: { path: "prepared-plugins/agents-api/agents-api.php" } }] }))
182-
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 } }), /Declared reviewer artifacts/)
183-
assert.ok(await readFile(join(upload, ".codebox", "agent-task-workflow-result.json"), "utf8"), "Declared artifact rejection preserves the normalized control result")
206+
await execFileAsync(process.execPath, [script.pathname], { env: { ...process.env, AGENT_TASK_WORKSPACE: workspace, AGENT_TASK_UPLOAD_PATH: upload, WP_CODEBOX_RUNTIME_SOURCE_ROOT: privateRoot } })
207+
await assert.rejects(readFile(join(upload, ".codebox", "agent-task-artifacts", "prepared-plugins", "agents-api", "agents-api.php"), "utf8"), /ENOENT/, "Package-declared source aliases never reach the upload")
184208
await rm(join(artifacts, "prepared-plugins"), { recursive: true, force: true })
185209
for (const path of ["runtime-source-disguised.json", "runtime-source-disguised.txt"]) {
186210
await writeFile(join(artifacts, path), await readFile(new URL(`../fixtures/agent-task-upload/${path}`, import.meta.url), "utf8"))

0 commit comments

Comments
 (0)