Skip to content

Commit 10e57c3

Browse files
committed
Harden canonical transcript upload staging
1 parent 1cd0419 commit 10e57c3

5 files changed

Lines changed: 115 additions & 26 deletions

File tree

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

Lines changed: 95 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import { constants } from "node:fs"
2-
import { lstat, mkdir, open, readdir, readFile, rm, writeFile } from "node:fs/promises"
2+
import { lstat, mkdir, open, readdir, readFile, realpath, rm, writeFile } from "node:fs/promises"
33
import { isUtf8 } from "node:buffer"
44
import { createHash } from "node:crypto"
55
import { fileURLToPath, pathToFileURL } from "node:url"
66
import { isAbsolute, join, relative, resolve } from "node:path"
77
import { assertNoRuntimeSourcePaths, sanitizeRuntimeSourceJson } from "./runtime-source-sanitizer.mjs"
88

99
const MAX_UPLOAD_FILE_BYTES = 4 * 1024 * 1024
10+
const MAX_TRANSCRIPT_EXECUTIONS = 64
11+
const MAX_REVIEW_TEXT_BYTES = 32 * 1024
1012
const workspace = resolve(process.env.AGENT_TASK_WORKSPACE || process.cwd())
1113
const uploadPath = resolve(process.env.AGENT_TASK_UPLOAD_PATH || join(workspace, ".codebox", "agent-task-upload"))
1214
const requestPath = resolve(process.env.AGENT_TASK_REQUEST_PATH || join(workspace, ".codebox", "agent-task-request.json"))
@@ -154,20 +156,99 @@ async function canonicalTranscript(result) {
154156
return refs[0]
155157
}
156158

159+
function digest(bytes) {
160+
return createHash("sha256").update(bytes).digest("hex")
161+
}
162+
163+
function boundedText(value) {
164+
if (typeof value !== "string") return undefined
165+
const text = redact(sanitizeText(value)).slice(0, MAX_REVIEW_TEXT_BYTES)
166+
return containsRuntimeSourceContent(text) ? "[redacted-source-content]" : text
167+
}
168+
169+
function safeTargetPath(value) {
170+
const path = safeRelativeArtifactPath(value)
171+
return path ? `workspace/${path}` : undefined
172+
}
173+
174+
function projectToolCall(value) {
175+
const entry = record(value)
176+
const tool = boundedText(entry.tool_id ?? entry.toolId ?? entry.name ?? entry.tool_name)
177+
const path = safeTargetPath(record(entry.args ?? entry.arguments).path ?? entry.path)
178+
const result = record(entry.result ?? entry.output)
179+
const rawContent = result.content ?? entry.content
180+
const content = path && /workspace/i.test(tool ?? "") && typeof rawContent === "string"
181+
? redact(sanitizeText(rawContent)).slice(0, MAX_REVIEW_TEXT_BYTES)
182+
: undefined
183+
return Object.fromEntries(Object.entries({ tool, path, status: boundedText(entry.status), arguments: path ? { path } : undefined, content, error: boundedText(record(entry.error).message ?? entry.error) }).filter(([, item]) => item !== undefined))
184+
}
185+
186+
function projectParsed(value) {
187+
const parsed = record(value)
188+
const messages = Array.isArray(parsed.messages) ? parsed.messages : Array.isArray(parsed.model_messages) ? parsed.model_messages : []
189+
const tools = Array.isArray(parsed.tool_calls) ? parsed.tool_calls : Array.isArray(parsed.toolCalls) ? parsed.toolCalls : []
190+
const results = Array.isArray(parsed.tool_results) ? parsed.tool_results : Array.isArray(parsed.toolResults) ? parsed.toolResults : []
191+
const errors = Array.isArray(parsed.errors) ? parsed.errors : parsed.error ? [parsed.error] : []
192+
const agent = record(parsed.agent ?? parsed.agent_metadata)
193+
return Object.fromEntries(Object.entries({
194+
agent: Object.fromEntries(["id", "name", "model", "provider", "status"].flatMap((key) => boundedText(agent[key]) ? [[key, boundedText(agent[key])]] : [])),
195+
model_messages: messages.slice(0, MAX_TRANSCRIPT_EXECUTIONS).flatMap((message) => boundedText(record(message).content ?? record(message).text ?? message) ? [{ role: boundedText(record(message).role), content: boundedText(record(message).content ?? record(message).text ?? message) }] : []),
196+
tool_calls: tools.slice(0, MAX_TRANSCRIPT_EXECUTIONS).map(projectToolCall),
197+
tool_results: results.slice(0, MAX_TRANSCRIPT_EXECUTIONS).map(projectToolCall),
198+
errors: errors.slice(0, MAX_TRANSCRIPT_EXECUTIONS).flatMap((error) => boundedText(record(error).message ?? error) ? [boundedText(record(error).message ?? error)] : []),
199+
}).filter(([, item]) => Array.isArray(item) ? item.length > 0 : Object.keys(item).length > 0))
200+
}
201+
202+
async function trustedTranscriptFile(path) {
203+
const root = await realpath(artifactsPath).catch(() => "")
204+
if (!root) return { unavailable: "artifact-root-missing" }
205+
const parts = path.split("/")
206+
let current = root
207+
for (const part of parts) {
208+
current = join(current, part)
209+
const stat = await lstat(current).catch((error) => error?.code === "ENOENT" ? undefined : Promise.reject(error))
210+
if (!stat) return { unavailable: "referenced-file-missing" }
211+
if (stat.isSymbolicLink()) throw new Error("Canonical transcript must not traverse symlinks.")
212+
}
213+
const stat = await lstat(current)
214+
if (!stat.isFile() || stat.size > MAX_UPLOAD_FILE_BYTES) throw new Error("Canonical transcript must be a bounded regular file.")
215+
const resolved = await realpath(current)
216+
const contained = relative(root, resolved)
217+
if (contained === ".." || contained.startsWith(`..${String.fromCharCode(47)}`) || isAbsolute(contained)) throw new Error("Canonical transcript escapes the trusted artifact root.")
218+
return { source: resolved }
219+
}
220+
157221
async function stageCanonicalTranscript(result) {
158222
const ref = await canonicalTranscript(result)
159223
if (!ref) return undefined
160224
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.")
225+
if (sourceCategory(path, resolve(artifactsPath, path))) throw new Error("Canonical transcript must stay under the trusted artifact root.")
226+
const trusted = await trustedTranscriptFile(path)
227+
if (trusted.unavailable) return { unavailable: trusted.unavailable, provenance: { kind: ref.kind, artifact_path: path } }
228+
const source = trusted.source
229+
const bytes = await readFile(source)
230+
if (bytes.includes(0) || !isUtf8(bytes)) throw new Error("Canonical transcript must be UTF-8 JSON.")
231+
const actualDigest = digest(bytes)
232+
const expectedDigest = typeof ref.sha256 === "string" ? ref.sha256 : record(ref.digest).value
233+
if (expectedDigest && expectedDigest !== actualDigest) throw new Error("Canonical transcript digest does not match its normalized ref.")
234+
const raw = parseJsonOrEmpty(bytes.toString("utf8"))
235+
if (raw.schema !== "wp-codebox/agent-transcript/v1" || !Array.isArray(raw.executions) || raw.executions.length > MAX_TRANSCRIPT_EXECUTIONS) throw new Error("Canonical transcript must be a bounded wp-codebox/agent-transcript/v1 envelope.")
236+
const projection = {
237+
schema: "wp-codebox/reviewer-agent-transcript/v1",
238+
executions: raw.executions.map((execution, index) => {
239+
const entry = record(execution)
240+
if (typeof entry.command !== "string" || typeof entry.exitCode !== "number") throw new Error("Canonical transcript execution is malformed.")
241+
return Object.fromEntries(Object.entries({ execution_index: typeof entry.executionIndex === "number" ? entry.executionIndex : index, command: boundedText(entry.command), status: entry.exitCode === 0 ? "succeeded" : "failed", exit_code: entry.exitCode, parsed: Object.keys(record(entry.parsed)).length ? projectParsed(entry.parsed) : undefined, error: boundedText(entry.stderr) }).filter(([, item]) => item !== undefined))
242+
}),
243+
}
163244
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.")
245+
await mkdir(resolve(destination, ".."), { recursive: true })
246+
await writeFile(destination, `${JSON.stringify(projection, null, 2)}\n`)
247+
const projectionDigest = digest(await readFile(destination))
167248
return {
168249
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 } : {}) },
250+
sha256: projectionDigest,
251+
provenance: { kind: ref.kind, artifact_path: path, source_sha256: actualDigest, projection_sha256: projectionDigest },
171252
}
172253
}
173254

@@ -277,6 +358,12 @@ for (const path of declaredPaths) {
277358
await stageTextFile(source, join(uploadPath, ".codebox", "agent-task-artifacts", path))
278359
}
279360
const transcript = await stageCanonicalTranscript(result)
361+
if (transcript?.provenance?.source_sha256) {
362+
const stagedResultPath = join(uploadPath, ".codebox", "agent-task-workflow-result.json")
363+
const stagedResult = parseJsonOrEmpty(await readFile(stagedResultPath, "utf8"))
364+
stagedResult.artifact_upload = { canonical_transcript: transcript.provenance }
365+
await writeFile(stagedResultPath, `${JSON.stringify(stagedResult, null, 2)}\n`)
366+
}
280367
await mkdir(join(uploadPath, ".codebox", "agent-task-artifacts"), { recursive: true })
281368
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`)
282369
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`)

docs/agent-runtime-contract.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,7 @@ 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.
179+
The reusable agent-task workflow separately handles one normalized `codebox-transcript` ref as optional reviewer evidence. A missing referenced file is recorded as unavailable; an existing file must be a bounded `wp-codebox/agent-transcript/v1` regular JSON file whose canonical path stays under the artifact root without symlink traversal. When the normalized ref provides a digest it must match the original bytes. The workflow stages a compact `wp-codebox/reviewer-agent-transcript/v1` projection at `.codebox/agent-task-artifacts/transcript.json`, not the raw transcript or command stdout. Its provenance records verified source and projection SHA-256 values in both the exclusions manifest and staged result. The projection retains execution command/status, typed workspace tool content, model messages, tool calls/results, errors, and agent metadata while removing setup stacks, package/bootstrap payloads, plugin inventories, unknown fields, secrets, and private host/runtime/source/snapshot paths. Package declarations cannot authorize alternate transcript paths or dependency source files.
180180

181181
## Runner Workspace Publication
182182

docs/agent-task-reusable-workflow.md

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -140,13 +140,14 @@ runner token, so a fabricated publication result cannot satisfy the gate.
140140
The result artifact includes the executable task input, normalized runtime
141141
result, evaluated projections, verification records, and runner-owned
142142
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
143+
normalized runtime result is handled independently of package declarations at
144+
`.codebox/agent-task-artifacts/transcript.json`. Missing canonical evidence is
145+
recorded as unavailable. Present evidence is verified against its normalized
146+
digest when supplied, then converted from `wp-codebox/agent-transcript/v1` into
147+
a compact `wp-codebox/reviewer-agent-transcript/v1` projection. The projection
148+
records verified source and output digests in `exclusions.json`, retains bounded
149+
diagnostic tool and model data, and excludes raw stdout, source trees, secrets,
150+
private paths, setup stacks, and package payloads. Runtime input, result, and diagnostics are uploaded from
150151
`workspace/.codebox/` with `if: always()`, including execution failures. A request
151152
artifact alone is not a successful task result.
152153

tests/execute-native-agent-task-playground-e2e.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,8 @@ add_filter( 'pre_http_request', static function( $preempt, $args, $url ) {
8686
const serialized = JSON.stringify(result)
8787
for (const privateValue of ["e2e-github-secret", "e2e-openai-secret", "PRIVATE_WORKSPACE_SENTINEL", "PRIVATE_NPM_SENTINEL", "PRIVATE_NETRC_SENTINEL", "PRIVATE_KEY_SENTINEL", "PRIVATE_CODEBOX_SENTINEL", "PRIVATE_NODE_MODULES_SENTINEL", sources.root, workspace]) assert.doesNotMatch(serialized, new RegExp(privateValue.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")))
8888
await execFileAsync(process.execPath, [uploader], { cwd: workspace, env: { ...process.env, AGENT_TASK_WORKSPACE: workspace, AGENT_TASK_REQUEST_PATH: join(workspace, ".codebox", "agent-task-request.json"), AGENT_TASK_UPLOAD_PATH: join(workspace, ".codebox", "agent-task-upload"), GITHUB_TOKEN: "e2e-github-secret", OPENAI_API_KEY: "e2e-openai-secret" } })
89+
const exclusions = JSON.parse(await readFile(join(workspace, ".codebox", "agent-task-upload", ".codebox", "agent-task-artifacts", "exclusions.json"), "utf8"))
90+
assert.equal(exclusions.canonical_transcripts[0].unavailable, "referenced-file-missing", "missing canonical evidence remains optional")
8991
const staged = JSON.stringify(await Promise.all((await readdir(join(workspace, ".codebox", "agent-task-upload"), { recursive: true })).map((path) => readFile(join(workspace, ".codebox", "agent-task-upload", path), "utf8").catch(() => ""))))
9092
for (const privateValue of ["wp-codebox-runner-workspace-seed-", workspace, "PRIVATE_WORKSPACE_SENTINEL", "PRIVATE_NPM_SENTINEL", "PRIVATE_NETRC_SENTINEL", "PRIVATE_KEY_SENTINEL"]) assert.doesNotMatch(staged, new RegExp(privateValue.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")))
9193
const stagedInput = JSON.parse(await readFile(join(workspace, ".codebox", "agent-task-upload", ".codebox", "native-agent-task-input.json"), "utf8"))

tests/runtime-sources-materialization.test.ts

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import assert from "node:assert/strict"
22
import { execFile } from "node:child_process"
3+
import { createHash } from "node:crypto"
34
import { access, chmod, mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises"
45
import { join, relative } from "node:path"
56
import { promisify } from "node:util"
@@ -165,18 +166,16 @@ await withTempDir("wp-codebox-runtime-source-upload-", async (directory) => {
165166
await execFileAsync(process.execPath, [script.pathname], { env: { ...process.env, AGENT_TASK_WORKSPACE: workspace, AGENT_TASK_UPLOAD_PATH: upload, WP_CODEBOX_RUNTIME_SOURCE_ROOT: privateRoot } })
166167
assert.match(await readFile(join(upload, ".codebox", "agent-task-artifacts", "safe.json"), "utf8"), /OpenAiProvider/)
167168
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" } }] }))
169+
const transcriptSource = JSON.stringify({ schema: "wp-codebox/agent-transcript/v1", executions: [{ executionIndex: 0, command: "wp-codebox.agent-sandbox-run", exitCode: 1, stderr: "Repeated workspace error", parsed: { agent: { id: "fixture", provider: "openai" }, messages: [{ role: "assistant", content: "Target snippet: <?php final class Target_Plugin {}" }], tool_calls: [{ tool_id: "workspace.read", args: { path: "src/plugin.php" }, result: { content: "<?php final class Target_Plugin {}" }, error: "Repeated workspace error" }], token: "secret-transcript-value", private_path: `${privateRoot}/source.php`, host_path: "/Users/example/private-log.txt" } }] })
170+
await writeFile(join(artifacts, "files", "transcript.json"), transcriptSource)
171+
const transcriptDigest = createHash("sha256").update(transcriptSource).digest("hex")
172+
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: transcriptDigest }] } } }, typed_artifacts: [{ name: "reviewer-report", type: "report", artifact: { path: "prepared-plugins/agents-api/agents-api.php" } }] }))
176173
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" } })
177174
const transcript = await readFile(join(upload, ".codebox", "agent-task-artifacts", "transcript.json"), "utf8")
178-
assert.match(transcript, /Target snippet: <\?php/)
175+
assert.match(transcript, /<\?php final class Target_Plugin/)
176+
assert.match(transcript, /\[redacted-source-content\]/)
179177
assert.match(transcript, /Repeated workspace error/)
178+
assert.match(transcript, /workspace\/src\/plugin.php/)
180179
assert.doesNotMatch(transcript, /private-runtime-source|secret-transcript-value|\/Users\/example/)
181180
const transcriptExclusions = await readFile(join(upload, ".codebox", "agent-task-artifacts", "exclusions.json"), "utf8")
182181
assert.match(transcriptExclusions, /canonical_transcripts/)
@@ -185,7 +184,7 @@ await withTempDir("wp-codebox-runtime-source-upload-", async (directory) => {
185184
await writeFile(outsideTranscript, JSON.stringify({ secret: "outside" }))
186185
await rm(join(artifacts, "files", "transcript.json"))
187186
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")
187+
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 } }), /must not traverse symlinks/, "Canonical transcript refs cannot follow symlinks outside artifacts")
189188
await rm(join(artifacts, "files", "transcript.json"))
190189
await writeFile(join(artifacts, "files", "transcript.json"), JSON.stringify({ tool_calls: [] }))
191190
await writeFile(join(artifacts, "leak.json"), `runtime log ${privateRoot}/source.php`)

0 commit comments

Comments
 (0)