Skip to content

Commit 95ef029

Browse files
authored
Upload canonical sanitized agent transcripts (#1792)
* Upload canonical sanitized agent transcripts * Harden canonical transcript upload staging * Redact reviewer transcript tool content
1 parent 40d5f68 commit 95ef029

5 files changed

Lines changed: 182 additions & 10 deletions

File tree

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

Lines changed: 142 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +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"
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

79
const MAX_UPLOAD_FILE_BYTES = 4 * 1024 * 1024
10+
const MAX_TRANSCRIPT_EXECUTIONS = 64
11+
const MAX_REVIEW_TEXT_BYTES = 32 * 1024
812
const workspace = resolve(process.env.AGENT_TASK_WORKSPACE || process.cwd())
913
const uploadPath = resolve(process.env.AGENT_TASK_UPLOAD_PATH || join(workspace, ".codebox", "agent-task-upload"))
1014
const requestPath = resolve(process.env.AGENT_TASK_REQUEST_PATH || join(workspace, ".codebox", "agent-task-request.json"))
@@ -14,6 +18,7 @@ const runtimeSourceRoot = process.env.WP_CODEBOX_RUNTIME_SOURCE_ROOT ? resolve(p
1418
const runtimeSourcePrefix = process.env.WP_CODEBOX_RUNTIME_SOURCE_PREFIX ? resolve(process.env.WP_CODEBOX_RUNTIME_SOURCE_PREFIX) : ""
1519
const runtimeSourceRoots = [runtimeSourceRoot, runtimeSourcePrefix].filter(Boolean)
1620
const privateUploadRoots = [...runtimeSourceRoots, workspace]
21+
const codeboxRoot = resolve(fileURLToPath(new URL("../../..", import.meta.url)))
1722
const SOURCE_TREE = /(^|\/)(prepared-plugins|prepared-source-packages|source-package[^/]*)(\/|$)/i
1823
const SOURCE_FILE = /\.(?:php|phtml|js|mjs|cjs|jsx|ts|tsx)$/i
1924
const PHP_OPENING_TAG = /<\?(?:php|=)(?:\s|$)/i
@@ -34,6 +39,7 @@ function redact(value) {
3439

3540
function sanitizeText(text) {
3641
return sanitizeRuntimeSourceJson(text, privateUploadRoots)
42+
.replace(/\/(?:Users|home|private|var|tmp|opt|Volumes)\/[^\s"'\\]+/g, "[host-path]")
3743
}
3844

3945
function compactNativeInput(text) {
@@ -135,12 +141,129 @@ async function stageTextFile(source, destination, options = {}) {
135141
const text = redact(sanitizeSeedSnapshotJson(sanitized))
136142
assertNoRuntimeSourcePaths(text, privateUploadRoots, "Runtime source or workspace paths must never be persisted in artifact uploads.")
137143
assertNoSeedSnapshotPaths(text)
138-
if (containsRuntimeSourceContent(text)) throw new Error("Prepared runtime plugin source contents must never be staged for artifact upload.")
144+
if (!options.allowTargetCode && containsRuntimeSourceContent(text)) throw new Error("Prepared runtime plugin source contents must never be staged for artifact upload.")
139145
await mkdir(resolve(destination, ".."), { recursive: true })
140146
await writeFile(destination, text)
141147
return true
142148
}
143149

150+
async function canonicalTranscript(result) {
151+
const publicCore = await import(pathToFileURL(join(codeboxRoot, "packages/runtime-core/dist/public.js")).href)
152+
const refs = publicCore.normalizePublicArtifactRefDTOs(record(result).runtime_result)
153+
.filter((ref) => ref.kind === "codebox-transcript")
154+
if (refs.length === 0) return undefined
155+
if (refs.length !== 1 || !safeRelativeArtifactPath(refs[0].path)) throw new Error("Canonical transcript requires exactly one trusted codebox-transcript path.")
156+
return refs[0]
157+
}
158+
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 omittedText(value) {
175+
if (typeof value !== "string") return undefined
176+
const bytes = Buffer.byteLength(value)
177+
return { bytes, sha256: digest(Buffer.from(value)) }
178+
}
179+
180+
function projectArguments(value) {
181+
const entry = record(value)
182+
const paths = [entry.path, ...(Array.isArray(entry.paths) ? entry.paths : [])].map(safeTargetPath).filter(Boolean).slice(0, 32)
183+
const counts = Object.fromEntries(["bytes", "byte_count", "match_count", "matches", "change_count", "changes", "count"].flatMap((key) => typeof entry[key] === "number" ? [[key, entry[key]]] : []))
184+
const payloads = Object.fromEntries(["content", "patch", "diff", "write", "old_string", "new_string", "text"].flatMap((key) => omittedText(entry[key]) ? [[key, omittedText(entry[key])]] : []))
185+
return Object.fromEntries(Object.entries({ paths: paths.length ? paths : undefined, counts: Object.keys(counts).length ? counts : undefined, omitted_payloads: Object.keys(payloads).length ? payloads : undefined }).filter(([, item]) => item !== undefined))
186+
}
187+
188+
function projectToolCall(value) {
189+
const entry = record(value)
190+
const tool = boundedText(entry.tool_id ?? entry.toolId ?? entry.name ?? entry.tool_name)
191+
const args = projectArguments(entry.args ?? entry.arguments)
192+
const paths = [...(args.paths ?? []), ...[safeTargetPath(entry.path)].filter(Boolean)].slice(0, 32)
193+
const result = record(entry.result ?? entry.output)
194+
const resultSummary = projectArguments({ ...result, content: result.content ?? entry.content, path: result.path ?? entry.path })
195+
return Object.fromEntries(Object.entries({ tool, paths: paths.length ? paths : undefined, status: boundedText(entry.status), arguments: Object.keys(args).length ? args : undefined, result: Object.keys(resultSummary).length ? resultSummary : undefined, error_code: boundedText(record(entry.error).code ?? entry.error_code), error: boundedText(record(entry.error).message ?? entry.error) }).filter(([, item]) => item !== undefined))
196+
}
197+
198+
function projectParsed(value) {
199+
const parsed = record(value)
200+
const messages = Array.isArray(parsed.messages) ? parsed.messages : Array.isArray(parsed.model_messages) ? parsed.model_messages : []
201+
const tools = Array.isArray(parsed.tool_calls) ? parsed.tool_calls : Array.isArray(parsed.toolCalls) ? parsed.toolCalls : []
202+
const results = Array.isArray(parsed.tool_results) ? parsed.tool_results : Array.isArray(parsed.toolResults) ? parsed.toolResults : []
203+
const errors = Array.isArray(parsed.errors) ? parsed.errors : parsed.error ? [parsed.error] : []
204+
const agent = record(parsed.agent ?? parsed.agent_metadata)
205+
return Object.fromEntries(Object.entries({
206+
agent: Object.fromEntries(["id", "name", "model", "provider", "status"].flatMap((key) => boundedText(agent[key]) ? [[key, boundedText(agent[key])]] : [])),
207+
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) }] : []),
208+
tool_calls: tools.slice(0, MAX_TRANSCRIPT_EXECUTIONS).map(projectToolCall),
209+
tool_results: results.slice(0, MAX_TRANSCRIPT_EXECUTIONS).map(projectToolCall),
210+
errors: errors.slice(0, MAX_TRANSCRIPT_EXECUTIONS).flatMap((error) => boundedText(record(error).message ?? error) ? [boundedText(record(error).message ?? error)] : []),
211+
}).filter(([, item]) => Array.isArray(item) ? item.length > 0 : Object.keys(item).length > 0))
212+
}
213+
214+
async function trustedTranscriptFile(path) {
215+
const root = await realpath(artifactsPath).catch(() => "")
216+
if (!root) return { unavailable: "artifact-root-missing" }
217+
const parts = path.split("/")
218+
let current = root
219+
for (const part of parts) {
220+
current = join(current, part)
221+
const stat = await lstat(current).catch((error) => error?.code === "ENOENT" ? undefined : Promise.reject(error))
222+
if (!stat) return { unavailable: "referenced-file-missing" }
223+
if (stat.isSymbolicLink()) throw new Error("Canonical transcript must not traverse symlinks.")
224+
}
225+
const stat = await lstat(current)
226+
if (!stat.isFile() || stat.size > MAX_UPLOAD_FILE_BYTES) throw new Error("Canonical transcript must be a bounded regular file.")
227+
const resolved = await realpath(current)
228+
const contained = relative(root, resolved)
229+
if (contained === ".." || contained.startsWith(`..${String.fromCharCode(47)}`) || isAbsolute(contained)) throw new Error("Canonical transcript escapes the trusted artifact root.")
230+
return { source: resolved }
231+
}
232+
233+
async function stageCanonicalTranscript(result) {
234+
const ref = await canonicalTranscript(result)
235+
if (!ref) return undefined
236+
const path = safeRelativeArtifactPath(ref.path)
237+
if (sourceCategory(path, resolve(artifactsPath, path))) throw new Error("Canonical transcript must stay under the trusted artifact root.")
238+
const trusted = await trustedTranscriptFile(path)
239+
if (trusted.unavailable) return { unavailable: trusted.unavailable, provenance: { kind: ref.kind, artifact_path: path } }
240+
const source = trusted.source
241+
const bytes = await readFile(source)
242+
if (bytes.includes(0) || !isUtf8(bytes)) throw new Error("Canonical transcript must be UTF-8 JSON.")
243+
const actualDigest = digest(bytes)
244+
const expectedDigest = typeof ref.sha256 === "string" ? ref.sha256 : record(ref.digest).value
245+
if (expectedDigest && expectedDigest !== actualDigest) throw new Error("Canonical transcript digest does not match its normalized ref.")
246+
const raw = parseJsonOrEmpty(bytes.toString("utf8"))
247+
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.")
248+
const projection = {
249+
schema: "wp-codebox/reviewer-agent-transcript/v1",
250+
executions: raw.executions.map((execution, index) => {
251+
const entry = record(execution)
252+
if (typeof entry.command !== "string" || typeof entry.exitCode !== "number") throw new Error("Canonical transcript execution is malformed.")
253+
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))
254+
}),
255+
}
256+
const destination = join(uploadPath, ".codebox", "agent-task-artifacts", "transcript.json")
257+
await mkdir(resolve(destination, ".."), { recursive: true })
258+
await writeFile(destination, `${JSON.stringify(projection, null, 2)}\n`)
259+
const projectionDigest = digest(await readFile(destination))
260+
return {
261+
path: ".codebox/agent-task-artifacts/transcript.json",
262+
sha256: projectionDigest,
263+
provenance: { kind: ref.kind, artifact_path: path, source_sha256: actualDigest, projection_sha256: projectionDigest },
264+
}
265+
}
266+
144267
function record(value) {
145268
return value && typeof value === "object" && !Array.isArray(value) ? value : {}
146269
}
@@ -171,7 +294,7 @@ function declaredArtifactPaths(result, allowed) {
171294
return [...paths].sort()
172295
}
173296

174-
async function exclusions(root, declaredPaths) {
297+
async function exclusions(root, declaredPaths, transcript) {
175298
const counts = new Map()
176299
const count = (category) => counts.set(category, (counts.get(category) || 0) + 1)
177300
const visit = async (directory) => {
@@ -188,7 +311,10 @@ async function exclusions(root, declaredPaths) {
188311
}
189312
}
190313
await visit(root)
191-
return [...counts.entries()].sort(([left], [right]) => left.localeCompare(right)).map(([category, count]) => ({ category, count }))
314+
return {
315+
exclusions: [...counts.entries()].sort(([left], [right]) => left.localeCompare(right)).map(([category, count]) => ({ category, count })),
316+
...(transcript ? { canonical_transcripts: [transcript] } : {}),
317+
}
192318
}
193319

194320
function runtimeProvenance(request) {
@@ -216,7 +342,7 @@ async function finalScan(directory) {
216342
const text = isUtf8(bytes) ? bytes.toString("utf8") : ""
217343
assertNoRuntimeSourcePaths(text, privateUploadRoots, "Runtime source or workspace paths must never be persisted in artifact uploads.")
218344
assertNoSeedSnapshotPaths(text)
219-
if (containsRuntimeSourceContent(text)) throw new Error("Prepared runtime plugin source contents must never be persisted in artifact uploads.")
345+
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.")
220346
} else throw new Error("Only regular files may be persisted in artifact uploads.")
221347
}
222348
}
@@ -237,11 +363,20 @@ await stageTextFile(join(workspace, ".codebox", "native-agent-task-input.json"),
237363
for (const path of declaredPaths) {
238364
const source = resolve(artifactsPath, path)
239365
if (relative(artifactsPath, source).startsWith("..") || sourceCategory(path, source)) {
240-
throw new Error("Declared reviewer artifacts must not reference source files or private runtime internals.")
366+
// Package declarations cannot authorize source trees or escape the root.
367+
// Keep staging independent of an untrusted alias, including transcripts.
368+
continue
241369
}
242370
await stageTextFile(source, join(uploadPath, ".codebox", "agent-task-artifacts", path))
243371
}
372+
const transcript = await stageCanonicalTranscript(result)
373+
if (transcript?.provenance?.source_sha256) {
374+
const stagedResultPath = join(uploadPath, ".codebox", "agent-task-workflow-result.json")
375+
const stagedResult = parseJsonOrEmpty(await readFile(stagedResultPath, "utf8"))
376+
stagedResult.artifact_upload = { canonical_transcript: transcript.provenance }
377+
await writeFile(stagedResultPath, `${JSON.stringify(stagedResult, null, 2)}\n`)
378+
}
244379
await mkdir(join(uploadPath, ".codebox", "agent-task-artifacts"), { recursive: true })
245380
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`)
381+
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`)
247382
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 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.
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: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,15 @@ 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 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
143151
`workspace/.codebox/` with `if: always()`, including execution failures. A request
144152
artifact alone is not a successful task result.
145153

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"))

0 commit comments

Comments
 (0)