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..2e070ee2 100644 --- a/.github/scripts/run-agent-task/prepare-agent-task-upload.mjs +++ b/.github/scripts/run-agent-task/prepare-agent-task-upload.mjs @@ -1,10 +1,14 @@ 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" 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")) @@ -14,6 +18,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 +39,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 +141,129 @@ 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] +} + +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 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 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 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) { + 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) + 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") + 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: projectionDigest, + provenance: { kind: ref.kind, artifact_path: path, source_sha256: actualDigest, projection_sha256: projectionDigest }, + } +} + function record(value) { return value && typeof value === "object" && !Array.isArray(value) ? value : {} } @@ -171,7 +294,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 +311,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 +342,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 +363,20 @@ 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) +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", 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..e8cddcd2 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 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 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..1a771404 100644 --- a/docs/agent-task-reusable-workflow.md +++ b/docs/agent-task-reusable-workflow.md @@ -139,7 +139,15 @@ 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 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 9f6aaab7..54a74b66 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" @@ -164,6 +165,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 }) + 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: { 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"))