From 1cd041903617dee6def5c76ef5432cdc2b98a549 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Tue, 14 Jul 2026 11:39:32 -0400 Subject: [PATCH 1/3] Upload canonical sanitized agent transcripts --- .../prepare-agent-task-upload.mjs | 48 ++++++++++++++++--- docs/agent-runtime-contract.md | 2 + docs/agent-task-reusable-workflow.md | 9 +++- tests/runtime-sources-materialization.test.ts | 28 ++++++++++- 4 files changed, 78 insertions(+), 9 deletions(-) diff --git a/.github/scripts/run-agent-task/prepare-agent-task-upload.mjs b/.github/scripts/run-agent-task/prepare-agent-task-upload.mjs index e5c1d961..242f4a5c 100644 --- a/.github/scripts/run-agent-task/prepare-agent-task-upload.mjs +++ b/.github/scripts/run-agent-task/prepare-agent-task-upload.mjs @@ -1,6 +1,8 @@ import { constants } from "node:fs" import { lstat, mkdir, open, readdir, readFile, rm, writeFile } from "node:fs/promises" import { isUtf8 } from "node:buffer" +import { createHash } from "node:crypto" +import { fileURLToPath, pathToFileURL } from "node:url" import { isAbsolute, join, relative, resolve } from "node:path" import { assertNoRuntimeSourcePaths, sanitizeRuntimeSourceJson } from "./runtime-source-sanitizer.mjs" @@ -14,6 +16,7 @@ const runtimeSourceRoot = process.env.WP_CODEBOX_RUNTIME_SOURCE_ROOT ? resolve(p const runtimeSourcePrefix = process.env.WP_CODEBOX_RUNTIME_SOURCE_PREFIX ? resolve(process.env.WP_CODEBOX_RUNTIME_SOURCE_PREFIX) : "" const runtimeSourceRoots = [runtimeSourceRoot, runtimeSourcePrefix].filter(Boolean) const privateUploadRoots = [...runtimeSourceRoots, workspace] +const codeboxRoot = resolve(fileURLToPath(new URL("../../..", import.meta.url))) const SOURCE_TREE = /(^|\/)(prepared-plugins|prepared-source-packages|source-package[^/]*)(\/|$)/i const SOURCE_FILE = /\.(?:php|phtml|js|mjs|cjs|jsx|ts|tsx)$/i const PHP_OPENING_TAG = /<\?(?:php|=)(?:\s|$)/i @@ -34,6 +37,7 @@ function redact(value) { function sanitizeText(text) { return sanitizeRuntimeSourceJson(text, privateUploadRoots) + .replace(/\/(?:Users|home|private|var|tmp|opt|Volumes)\/[^\s"'\\]+/g, "[host-path]") } function compactNativeInput(text) { @@ -135,12 +139,38 @@ async function stageTextFile(source, destination, options = {}) { const text = redact(sanitizeSeedSnapshotJson(sanitized)) assertNoRuntimeSourcePaths(text, privateUploadRoots, "Runtime source or workspace paths must never be persisted in artifact uploads.") assertNoSeedSnapshotPaths(text) - if (containsRuntimeSourceContent(text)) throw new Error("Prepared runtime plugin source contents must never be staged for artifact upload.") + if (!options.allowTargetCode && containsRuntimeSourceContent(text)) throw new Error("Prepared runtime plugin source contents must never be staged for artifact upload.") await mkdir(resolve(destination, ".."), { recursive: true }) await writeFile(destination, text) return true } +async function canonicalTranscript(result) { + const publicCore = await import(pathToFileURL(join(codeboxRoot, "packages/runtime-core/dist/public.js")).href) + const refs = publicCore.normalizePublicArtifactRefDTOs(record(result).runtime_result) + .filter((ref) => ref.kind === "codebox-transcript") + if (refs.length === 0) return undefined + if (refs.length !== 1 || !safeRelativeArtifactPath(refs[0].path)) throw new Error("Canonical transcript requires exactly one trusted codebox-transcript path.") + return refs[0] +} + +async function stageCanonicalTranscript(result) { + const ref = await canonicalTranscript(result) + if (!ref) return undefined + const path = safeRelativeArtifactPath(ref.path) + const source = resolve(artifactsPath, path) + if (relative(artifactsPath, source).startsWith("..") || sourceCategory(path, source)) throw new Error("Canonical transcript must stay under the trusted artifact root.") + const destination = join(uploadPath, ".codebox", "agent-task-artifacts", "transcript.json") + if (!await stageTextFile(source, destination, { allowTargetCode: true })) throw new Error("Canonical transcript must be a bounded regular UTF-8 file.") + const bytes = await readFile(destination) + if (!Object.keys(record(parseJsonOrEmpty(bytes.toString("utf8")))).length) throw new Error("Canonical transcript must be a JSON object.") + return { + path: ".codebox/agent-task-artifacts/transcript.json", + sha256: createHash("sha256").update(bytes).digest("hex"), + provenance: { kind: ref.kind, artifact_path: path, ...(ref.sha256 ? { source_sha256: ref.sha256 } : {}) }, + } +} + function record(value) { return value && typeof value === "object" && !Array.isArray(value) ? value : {} } @@ -171,7 +201,7 @@ function declaredArtifactPaths(result, allowed) { return [...paths].sort() } -async function exclusions(root, declaredPaths) { +async function exclusions(root, declaredPaths, transcript) { const counts = new Map() const count = (category) => counts.set(category, (counts.get(category) || 0) + 1) const visit = async (directory) => { @@ -188,7 +218,10 @@ async function exclusions(root, declaredPaths) { } } await visit(root) - return [...counts.entries()].sort(([left], [right]) => left.localeCompare(right)).map(([category, count]) => ({ category, count })) + return { + exclusions: [...counts.entries()].sort(([left], [right]) => left.localeCompare(right)).map(([category, count]) => ({ category, count })), + ...(transcript ? { canonical_transcripts: [transcript] } : {}), + } } function runtimeProvenance(request) { @@ -216,7 +249,7 @@ async function finalScan(directory) { const text = isUtf8(bytes) ? bytes.toString("utf8") : "" assertNoRuntimeSourcePaths(text, privateUploadRoots, "Runtime source or workspace paths must never be persisted in artifact uploads.") assertNoSeedSnapshotPaths(text) - if (containsRuntimeSourceContent(text)) throw new Error("Prepared runtime plugin source contents must never be persisted in artifact uploads.") + 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.") } else throw new Error("Only regular files may be persisted in artifact uploads.") } } @@ -237,11 +270,14 @@ await stageTextFile(join(workspace, ".codebox", "native-agent-task-input.json"), for (const path of declaredPaths) { const source = resolve(artifactsPath, path) if (relative(artifactsPath, source).startsWith("..") || sourceCategory(path, source)) { - throw new Error("Declared reviewer artifacts must not reference source files or private runtime internals.") + // Package declarations cannot authorize source trees or escape the root. + // Keep staging independent of an untrusted alias, including transcripts. + continue } await stageTextFile(source, join(uploadPath, ".codebox", "agent-task-artifacts", path)) } +const transcript = await stageCanonicalTranscript(result) await mkdir(join(uploadPath, ".codebox", "agent-task-artifacts"), { recursive: true }) 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`) -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`) +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`) await finalScan(uploadPath) diff --git a/docs/agent-runtime-contract.md b/docs/agent-runtime-contract.md index 6359c3bd..7649fcc7 100644 --- a/docs/agent-runtime-contract.md +++ b/docs/agent-runtime-contract.md @@ -176,6 +176,8 @@ Generic command runners and host tool registries should write tool-call evidence 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. +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. + ## Runner Workspace Publication Runner workspace publication is a separate exported contract in runtime-core: diff --git a/docs/agent-task-reusable-workflow.md b/docs/agent-task-reusable-workflow.md index 3f602444..9adb94db 100644 --- a/docs/agent-task-reusable-workflow.md +++ b/docs/agent-task-reusable-workflow.md @@ -139,7 +139,14 @@ runner token, so a fabricated publication result cannot satisfy the gate. The result artifact includes the executable task input, normalized runtime result, evaluated projections, verification records, and runner-owned -publication result. Runtime input, result, and diagnostics are uploaded from +publication result. A single canonical `codebox-transcript` ref from the +normalized runtime result is staged independently of package declarations at +`.codebox/agent-task-artifacts/transcript.json`. The uploader accepts only a +bounded regular UTF-8 file beneath the trusted artifact root, records its +source provenance and staged digest in `exclusions.json`, and sanitizes secrets +and private runtime, workspace, and snapshot paths while preserving +target-relative tool arguments, errors, and model messages. Runtime source +trees remain excluded. Runtime input, result, and diagnostics are uploaded from `workspace/.codebox/` with `if: always()`, including execution failures. A request artifact alone is not a successful task result. diff --git a/tests/runtime-sources-materialization.test.ts b/tests/runtime-sources-materialization.test.ts index 9f6aaab7..4f58a188 100644 --- a/tests/runtime-sources-materialization.test.ts +++ b/tests/runtime-sources-materialization.test.ts @@ -164,6 +164,30 @@ await withTempDir("wp-codebox-runtime-source-upload-", async (directory) => { await writeFile(join(workspace, ".codebox", "agent-task-workflow-result.json"), JSON.stringify({ typed_artifacts: [{ name: "reviewer-report", type: "report", artifact: { path: "safe.json" } }] })) await execFileAsync(process.execPath, [script.pathname], { env: { ...process.env, AGENT_TASK_WORKSPACE: workspace, AGENT_TASK_UPLOAD_PATH: upload, WP_CODEBOX_RUNTIME_SOURCE_ROOT: privateRoot } }) assert.match(await readFile(join(upload, ".codebox", "agent-task-artifacts", "safe.json"), "utf8"), /OpenAiProvider/) + await mkdir(join(artifacts, "files"), { recursive: true }) + await writeFile(join(artifacts, "files", "transcript.json"), JSON.stringify({ + model_message: "Target snippet: { assert.match(exclusionManifest, /"category": "source-tree"/) assert.doesNotMatch(exclusionManifest, /prepared-plugins|agents-api|private-runtime-source/) 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" } }] })) - 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/) - assert.ok(await readFile(join(upload, ".codebox", "agent-task-workflow-result.json"), "utf8"), "Declared artifact rejection preserves the normalized control result") + await execFileAsync(process.execPath, [script.pathname], { env: { ...process.env, AGENT_TASK_WORKSPACE: workspace, AGENT_TASK_UPLOAD_PATH: upload, WP_CODEBOX_RUNTIME_SOURCE_ROOT: privateRoot } }) + 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") await rm(join(artifacts, "prepared-plugins"), { recursive: true, force: true }) for (const path of ["runtime-source-disguised.json", "runtime-source-disguised.txt"]) { await writeFile(join(artifacts, path), await readFile(new URL(`../fixtures/agent-task-upload/${path}`, import.meta.url), "utf8")) From 10e57c3faab27341245391abaa25dfcf93c64a13 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Tue, 14 Jul 2026 11:56:20 -0400 Subject: [PATCH 2/3] Harden canonical transcript upload staging --- .../prepare-agent-task-upload.mjs | 103 ++++++++++++++++-- docs/agent-runtime-contract.md | 2 +- docs/agent-task-reusable-workflow.md | 15 +-- ...e-native-agent-task-playground-e2e.test.ts | 2 + tests/runtime-sources-materialization.test.ts | 19 ++-- 5 files changed, 115 insertions(+), 26 deletions(-) diff --git a/.github/scripts/run-agent-task/prepare-agent-task-upload.mjs b/.github/scripts/run-agent-task/prepare-agent-task-upload.mjs index 242f4a5c..7233f8e4 100644 --- a/.github/scripts/run-agent-task/prepare-agent-task-upload.mjs +++ b/.github/scripts/run-agent-task/prepare-agent-task-upload.mjs @@ -1,5 +1,5 @@ import { constants } from "node:fs" -import { lstat, mkdir, open, readdir, readFile, rm, writeFile } from "node:fs/promises" +import { lstat, mkdir, open, readdir, readFile, realpath, rm, writeFile } from "node:fs/promises" import { isUtf8 } from "node:buffer" import { createHash } from "node:crypto" import { fileURLToPath, pathToFileURL } from "node:url" @@ -7,6 +7,8 @@ import { isAbsolute, join, relative, resolve } from "node:path" import { assertNoRuntimeSourcePaths, sanitizeRuntimeSourceJson } from "./runtime-source-sanitizer.mjs" const MAX_UPLOAD_FILE_BYTES = 4 * 1024 * 1024 +const MAX_TRANSCRIPT_EXECUTIONS = 64 +const MAX_REVIEW_TEXT_BYTES = 32 * 1024 const workspace = resolve(process.env.AGENT_TASK_WORKSPACE || process.cwd()) const uploadPath = resolve(process.env.AGENT_TASK_UPLOAD_PATH || join(workspace, ".codebox", "agent-task-upload")) const requestPath = resolve(process.env.AGENT_TASK_REQUEST_PATH || join(workspace, ".codebox", "agent-task-request.json")) @@ -154,20 +156,99 @@ async function canonicalTranscript(result) { return refs[0] } +function digest(bytes) { + return createHash("sha256").update(bytes).digest("hex") +} + +function boundedText(value) { + if (typeof value !== "string") return undefined + const text = redact(sanitizeText(value)).slice(0, MAX_REVIEW_TEXT_BYTES) + return containsRuntimeSourceContent(text) ? "[redacted-source-content]" : text +} + +function safeTargetPath(value) { + const path = safeRelativeArtifactPath(value) + return path ? `workspace/${path}` : undefined +} + +function projectToolCall(value) { + const entry = record(value) + const tool = boundedText(entry.tool_id ?? entry.toolId ?? entry.name ?? entry.tool_name) + const path = safeTargetPath(record(entry.args ?? entry.arguments).path ?? entry.path) + const result = record(entry.result ?? entry.output) + const rawContent = result.content ?? entry.content + const content = path && /workspace/i.test(tool ?? "") && typeof rawContent === "string" + ? redact(sanitizeText(rawContent)).slice(0, MAX_REVIEW_TEXT_BYTES) + : undefined + 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)) +} + +function projectParsed(value) { + const parsed = record(value) + const messages = Array.isArray(parsed.messages) ? parsed.messages : Array.isArray(parsed.model_messages) ? parsed.model_messages : [] + const tools = Array.isArray(parsed.tool_calls) ? parsed.tool_calls : Array.isArray(parsed.toolCalls) ? parsed.toolCalls : [] + const results = Array.isArray(parsed.tool_results) ? parsed.tool_results : Array.isArray(parsed.toolResults) ? parsed.toolResults : [] + const errors = Array.isArray(parsed.errors) ? parsed.errors : parsed.error ? [parsed.error] : [] + const agent = record(parsed.agent ?? parsed.agent_metadata) + return Object.fromEntries(Object.entries({ + agent: Object.fromEntries(["id", "name", "model", "provider", "status"].flatMap((key) => boundedText(agent[key]) ? [[key, boundedText(agent[key])]] : [])), + 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) }] : []), + tool_calls: tools.slice(0, MAX_TRANSCRIPT_EXECUTIONS).map(projectToolCall), + tool_results: results.slice(0, MAX_TRANSCRIPT_EXECUTIONS).map(projectToolCall), + errors: errors.slice(0, MAX_TRANSCRIPT_EXECUTIONS).flatMap((error) => boundedText(record(error).message ?? error) ? [boundedText(record(error).message ?? error)] : []), + }).filter(([, item]) => Array.isArray(item) ? item.length > 0 : Object.keys(item).length > 0)) +} + +async function trustedTranscriptFile(path) { + const root = await realpath(artifactsPath).catch(() => "") + if (!root) return { unavailable: "artifact-root-missing" } + const parts = path.split("/") + let current = root + for (const part of parts) { + current = join(current, part) + const stat = await lstat(current).catch((error) => error?.code === "ENOENT" ? undefined : Promise.reject(error)) + if (!stat) return { unavailable: "referenced-file-missing" } + if (stat.isSymbolicLink()) throw new Error("Canonical transcript must not traverse symlinks.") + } + const stat = await lstat(current) + if (!stat.isFile() || stat.size > MAX_UPLOAD_FILE_BYTES) throw new Error("Canonical transcript must be a bounded regular file.") + const resolved = await realpath(current) + const contained = relative(root, resolved) + if (contained === ".." || contained.startsWith(`..${String.fromCharCode(47)}`) || isAbsolute(contained)) throw new Error("Canonical transcript escapes the trusted artifact root.") + return { source: resolved } +} + async function stageCanonicalTranscript(result) { const ref = await canonicalTranscript(result) if (!ref) return undefined const path = safeRelativeArtifactPath(ref.path) - const source = resolve(artifactsPath, path) - if (relative(artifactsPath, source).startsWith("..") || sourceCategory(path, source)) throw new Error("Canonical transcript must stay under the trusted artifact root.") + if (sourceCategory(path, resolve(artifactsPath, path))) throw new Error("Canonical transcript must stay under the trusted artifact root.") + const trusted = await trustedTranscriptFile(path) + if (trusted.unavailable) return { unavailable: trusted.unavailable, provenance: { kind: ref.kind, artifact_path: path } } + const source = trusted.source + const bytes = await readFile(source) + if (bytes.includes(0) || !isUtf8(bytes)) throw new Error("Canonical transcript must be UTF-8 JSON.") + const actualDigest = digest(bytes) + const expectedDigest = typeof ref.sha256 === "string" ? ref.sha256 : record(ref.digest).value + if (expectedDigest && expectedDigest !== actualDigest) throw new Error("Canonical transcript digest does not match its normalized ref.") + const raw = parseJsonOrEmpty(bytes.toString("utf8")) + 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.") + const projection = { + schema: "wp-codebox/reviewer-agent-transcript/v1", + executions: raw.executions.map((execution, index) => { + const entry = record(execution) + if (typeof entry.command !== "string" || typeof entry.exitCode !== "number") throw new Error("Canonical transcript execution is malformed.") + 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)) + }), + } const destination = join(uploadPath, ".codebox", "agent-task-artifacts", "transcript.json") - if (!await stageTextFile(source, destination, { allowTargetCode: true })) throw new Error("Canonical transcript must be a bounded regular UTF-8 file.") - const bytes = await readFile(destination) - if (!Object.keys(record(parseJsonOrEmpty(bytes.toString("utf8")))).length) throw new Error("Canonical transcript must be a JSON object.") + await mkdir(resolve(destination, ".."), { recursive: true }) + await writeFile(destination, `${JSON.stringify(projection, null, 2)}\n`) + const projectionDigest = digest(await readFile(destination)) return { path: ".codebox/agent-task-artifacts/transcript.json", - sha256: createHash("sha256").update(bytes).digest("hex"), - provenance: { kind: ref.kind, artifact_path: path, ...(ref.sha256 ? { source_sha256: ref.sha256 } : {}) }, + sha256: projectionDigest, + provenance: { kind: ref.kind, artifact_path: path, source_sha256: actualDigest, projection_sha256: projectionDigest }, } } @@ -277,6 +358,12 @@ for (const path of declaredPaths) { await stageTextFile(source, join(uploadPath, ".codebox", "agent-task-artifacts", path)) } const transcript = await stageCanonicalTranscript(result) +if (transcript?.provenance?.source_sha256) { + const stagedResultPath = join(uploadPath, ".codebox", "agent-task-workflow-result.json") + const stagedResult = parseJsonOrEmpty(await readFile(stagedResultPath, "utf8")) + stagedResult.artifact_upload = { canonical_transcript: transcript.provenance } + await writeFile(stagedResultPath, `${JSON.stringify(stagedResult, null, 2)}\n`) +} await mkdir(join(uploadPath, ".codebox", "agent-task-artifacts"), { recursive: true }) 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`) 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`) diff --git a/docs/agent-runtime-contract.md b/docs/agent-runtime-contract.md index 7649fcc7..e8cddcd2 100644 --- a/docs/agent-runtime-contract.md +++ b/docs/agent-runtime-contract.md @@ -176,7 +176,7 @@ Generic command runners and host tool registries should write tool-call evidence 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. -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. +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. ## Runner Workspace Publication diff --git a/docs/agent-task-reusable-workflow.md b/docs/agent-task-reusable-workflow.md index 9adb94db..1a771404 100644 --- a/docs/agent-task-reusable-workflow.md +++ b/docs/agent-task-reusable-workflow.md @@ -140,13 +140,14 @@ runner token, so a fabricated publication result cannot satisfy the gate. The result artifact includes the executable task input, normalized runtime result, evaluated projections, verification records, and runner-owned publication result. A single canonical `codebox-transcript` ref from the -normalized runtime result is staged independently of package declarations at -`.codebox/agent-task-artifacts/transcript.json`. The uploader accepts only a -bounded regular UTF-8 file beneath the trusted artifact root, records its -source provenance and staged digest in `exclusions.json`, and sanitizes secrets -and private runtime, workspace, and snapshot paths while preserving -target-relative tool arguments, errors, and model messages. Runtime source -trees remain excluded. Runtime input, result, and diagnostics are uploaded from +normalized runtime result is handled independently of package declarations at +`.codebox/agent-task-artifacts/transcript.json`. Missing canonical evidence is +recorded as unavailable. Present evidence is verified against its normalized +digest when supplied, then converted from `wp-codebox/agent-transcript/v1` into +a compact `wp-codebox/reviewer-agent-transcript/v1` projection. The projection +records verified source and output digests in `exclusions.json`, retains bounded +diagnostic tool and model data, and excludes raw stdout, source trees, secrets, +private paths, setup stacks, and package payloads. Runtime input, result, and diagnostics are uploaded from `workspace/.codebox/` with `if: always()`, including execution failures. A request artifact alone is not a successful task result. diff --git a/tests/execute-native-agent-task-playground-e2e.test.ts b/tests/execute-native-agent-task-playground-e2e.test.ts index 76fa7440..98667c0f 100644 --- a/tests/execute-native-agent-task-playground-e2e.test.ts +++ b/tests/execute-native-agent-task-playground-e2e.test.ts @@ -86,6 +86,8 @@ add_filter( 'pre_http_request', static function( $preempt, $args, $url ) { const serialized = JSON.stringify(result) 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, "\\$&"))) 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" } }) + const exclusions = JSON.parse(await readFile(join(workspace, ".codebox", "agent-task-upload", ".codebox", "agent-task-artifacts", "exclusions.json"), "utf8")) + assert.equal(exclusions.canonical_transcripts[0].unavailable, "referenced-file-missing", "missing canonical evidence remains optional") 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(() => "")))) 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, "\\$&"))) const stagedInput = JSON.parse(await readFile(join(workspace, ".codebox", "agent-task-upload", ".codebox", "native-agent-task-input.json"), "utf8")) diff --git a/tests/runtime-sources-materialization.test.ts b/tests/runtime-sources-materialization.test.ts index 4f58a188..3fcd216c 100644 --- a/tests/runtime-sources-materialization.test.ts +++ b/tests/runtime-sources-materialization.test.ts @@ -1,5 +1,6 @@ import assert from "node:assert/strict" import { execFile } from "node:child_process" +import { createHash } from "node:crypto" import { access, chmod, mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises" import { join, relative } from "node:path" import { promisify } from "node:util" @@ -165,18 +166,16 @@ await withTempDir("wp-codebox-runtime-source-upload-", async (directory) => { await execFileAsync(process.execPath, [script.pathname], { env: { ...process.env, AGENT_TASK_WORKSPACE: workspace, AGENT_TASK_UPLOAD_PATH: upload, WP_CODEBOX_RUNTIME_SOURCE_ROOT: privateRoot } }) assert.match(await readFile(join(upload, ".codebox", "agent-task-artifacts", "safe.json"), "utf8"), /OpenAiProvider/) await mkdir(join(artifacts, "files"), { recursive: true }) - await writeFile(join(artifacts, "files", "transcript.json"), JSON.stringify({ - model_message: "Target snippet: { await writeFile(outsideTranscript, JSON.stringify({ secret: "outside" })) await rm(join(artifacts, "files", "transcript.json")) await symlink(outsideTranscript, join(artifacts, "files", "transcript.json")) - 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") + 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") await rm(join(artifacts, "files", "transcript.json")) await writeFile(join(artifacts, "files", "transcript.json"), JSON.stringify({ tool_calls: [] })) await writeFile(join(artifacts, "leak.json"), `runtime log ${privateRoot}/source.php`) From d0da75e61551fe3bc783012e90b7ea767b915888 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Tue, 14 Jul 2026 12:08:50 -0400 Subject: [PATCH 3/3] Redact reviewer transcript tool content --- .../prepare-agent-task-upload.mjs | 24 ++++++++++++++----- tests/runtime-sources-materialization.test.ts | 4 +++- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/.github/scripts/run-agent-task/prepare-agent-task-upload.mjs b/.github/scripts/run-agent-task/prepare-agent-task-upload.mjs index 7233f8e4..2e070ee2 100644 --- a/.github/scripts/run-agent-task/prepare-agent-task-upload.mjs +++ b/.github/scripts/run-agent-task/prepare-agent-task-upload.mjs @@ -171,16 +171,28 @@ function safeTargetPath(value) { return path ? `workspace/${path}` : undefined } +function omittedText(value) { + if (typeof value !== "string") return undefined + const bytes = Buffer.byteLength(value) + return { bytes, sha256: digest(Buffer.from(value)) } +} + +function projectArguments(value) { + const entry = record(value) + const paths = [entry.path, ...(Array.isArray(entry.paths) ? entry.paths : [])].map(safeTargetPath).filter(Boolean).slice(0, 32) + const counts = Object.fromEntries(["bytes", "byte_count", "match_count", "matches", "change_count", "changes", "count"].flatMap((key) => typeof entry[key] === "number" ? [[key, entry[key]]] : [])) + const payloads = Object.fromEntries(["content", "patch", "diff", "write", "old_string", "new_string", "text"].flatMap((key) => omittedText(entry[key]) ? [[key, omittedText(entry[key])]] : [])) + 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)) +} + function projectToolCall(value) { const entry = record(value) const tool = boundedText(entry.tool_id ?? entry.toolId ?? entry.name ?? entry.tool_name) - const path = safeTargetPath(record(entry.args ?? entry.arguments).path ?? entry.path) + const args = projectArguments(entry.args ?? entry.arguments) + const paths = [...(args.paths ?? []), ...[safeTargetPath(entry.path)].filter(Boolean)].slice(0, 32) const result = record(entry.result ?? entry.output) - const rawContent = result.content ?? entry.content - const content = path && /workspace/i.test(tool ?? "") && typeof rawContent === "string" - ? redact(sanitizeText(rawContent)).slice(0, MAX_REVIEW_TEXT_BYTES) - : undefined - 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)) + const resultSummary = projectArguments({ ...result, content: result.content ?? entry.content, path: result.path ?? entry.path }) + 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)) } function projectParsed(value) { diff --git a/tests/runtime-sources-materialization.test.ts b/tests/runtime-sources-materialization.test.ts index 3fcd216c..54a74b66 100644 --- a/tests/runtime-sources-materialization.test.ts +++ b/tests/runtime-sources-materialization.test.ts @@ -172,10 +172,12 @@ await withTempDir("wp-codebox-runtime-source-upload-", async (directory) => { 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" } }] })) 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" } }) const transcript = await readFile(join(upload, ".codebox", "agent-task-artifacts", "transcript.json"), "utf8") - assert.match(transcript, /<\?php final class Target_Plugin/) + assert.doesNotMatch(transcript, /<\?php final class Target_Plugin/) assert.match(transcript, /\[redacted-source-content\]/) assert.match(transcript, /Repeated workspace error/) assert.match(transcript, /workspace\/src\/plugin.php/) + assert.match(transcript, /"bytes": 34/) + assert.match(transcript, /"sha256": "[a-f0-9]{64}"/) assert.doesNotMatch(transcript, /private-runtime-source|secret-transcript-value|\/Users\/example/) const transcriptExclusions = await readFile(join(upload, ".codebox", "agent-task-artifacts", "exclusions.json"), "utf8") assert.match(transcriptExclusions, /canonical_transcripts/)