Skip to content

Commit 20aa346

Browse files
committed
Canonicalize reviewer evidence before sanitization
1 parent b617c35 commit 20aa346

4 files changed

Lines changed: 99 additions & 24 deletions

File tree

.github/scripts/run-agent-task/execute-native-agent-task.mjs

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { rmSync } from "node:fs"
2-
import { appendFile, mkdir, readFile, rm, writeFile } from "node:fs/promises"
2+
import { appendFile, lstat, mkdir, readFile, realpath, rm, writeFile } from "node:fs/promises"
33
import { isAbsolute, join, relative, resolve } from "node:path"
44
import { pathToFileURL } from "node:url"
55
import { spawn } from "node:child_process"
6+
import { createHash } from "node:crypto"
67
import { canonicalExternalNativeAgentIdentity, materializeExternalNativePackage, materializeRuntimeSources, normalizeExternalPackageSource, normalizeRuntimeSources, parseExternalPackageSourcePolicy, sha256BytesV1, validateRuntimeSourceModel } from "./materialize-external-native-package.mjs"
78
import { readNativeResult } from "./native-result-file.mjs"
89
import { assertNoRuntimeSourcePaths, sanitizeRuntimeSourceJson, sanitizeRuntimeSourceText, sanitizeRuntimeSourceValue } from "./runtime-source-sanitizer.mjs"
@@ -20,6 +21,7 @@ const secretValues = ["OPENAI_API_KEY", "MODEL_PROVIDER_SECRET_1", "MODEL_PROVID
2021
let privateRuntimeSourceRoot = ""
2122
let privateRuntimeSourceRootForSanitization = ""
2223
let runnerWorkspaceSeedSnapshot
24+
let reviewerEvidence
2325

2426
const SIGNAL_EXIT_CODES = { SIGINT: 130, SIGTERM: 143, SIGHUP: 129 }
2527
let materializedSourceCleanup
@@ -312,6 +314,7 @@ async function writeNormalizedFailure(error, request = {}) {
312314
success: false,
313315
request_path: workflowPath(requestPath),
314316
failure: { ...failure, message },
317+
...(reviewerEvidence ? { reviewer_evidence: reviewerEvidence } : {}),
315318
...(accessError ? { access: { authorized: false, error: message } } : {}),
316319
}
317320
await mkdir(join(workspace, ".codebox"), { recursive: true })
@@ -322,11 +325,59 @@ async function writeNormalizedFailure(error, request = {}) {
322325
await output("result_path", ".codebox/agent-task-workflow-result.json")
323326
}
324327

325-
async function redactArtifactFiles(directory) {
328+
function underRoot(root, path) {
329+
const contained = relative(root, path)
330+
return contained !== ".." && !contained.startsWith(`..${String.fromCharCode(47)}`) && !isAbsolute(contained)
331+
}
332+
333+
async function canonicalReviewerTranscript(nativeRuntimeResult, artifactsPath) {
334+
const publicCore = await import(pathToFileURL(join(codeboxRoot, "packages/runtime-core/dist/public.js")).href)
335+
const refs = publicCore.normalizePublicArtifactRefDTOs(nativeRuntimeResult)
336+
.filter((ref) => ref.kind === "codebox-transcript" && typeof ref.path === "string" && ref.path)
337+
if (refs.length === 0) return undefined
338+
339+
const root = await realpath(artifactsPath)
340+
const artifactRoot = resolve(artifactsPath)
341+
const transcripts = new Map()
342+
for (const ref of refs) {
343+
// Resolve first so containment, rather than spelling, defines a trusted path.
344+
const requested = resolve(artifactRoot, ref.path)
345+
const canonical = await realpath(requested).catch((error) => error?.code === "ENOENT" ? "" : Promise.reject(error))
346+
if (!canonical) continue
347+
if (!underRoot(root, canonical)) throw new Error("Canonical transcript escapes the trusted artifact root.")
348+
const requestedRelative = relative(artifactRoot, requested)
349+
if (!underRoot(artifactRoot, requested)) throw new Error("Canonical transcript escapes the trusted artifact root.")
350+
let current = artifactRoot
351+
for (const part of requestedRelative.split("/").filter(Boolean)) {
352+
current = join(current, part)
353+
const metadata = await lstat(current)
354+
if (metadata.isSymbolicLink()) throw new Error("Canonical transcript must not traverse symlinks.")
355+
}
356+
const metadata = await lstat(current)
357+
if (!metadata.isFile()) throw new Error("Canonical transcript must be a regular file.")
358+
const source = await realpath(current)
359+
const bytes = await readFile(source)
360+
const raw = JSON.parse(bytes.toString("utf8"))
361+
if (raw?.schema !== "wp-codebox/agent-transcript/v1") throw new Error("Canonical transcript must use wp-codebox/agent-transcript/v1.")
362+
transcripts.set(source, {
363+
schema: raw.schema,
364+
kind: "codebox-transcript",
365+
path: relative(root, source).replaceAll("\\", "/"),
366+
source_sha256: createHash("sha256").update(bytes).digest("hex"),
367+
size_bytes: bytes.length,
368+
})
369+
}
370+
if (transcripts.size === 0) return undefined
371+
if (transcripts.size !== 1) throw new Error("Canonical transcript requires exactly one distinct existing file.")
372+
return { transcript: [...transcripts.values()][0] }
373+
}
374+
375+
async function redactArtifactFiles(directory, artifactRoot = directory) {
326376
const { readdir, stat } = await import("node:fs/promises")
327377
for (const entry of await readdir(directory, { withFileTypes: true })) {
328378
const path = join(directory, entry.name)
329-
if (entry.isDirectory()) await redactArtifactFiles(path)
379+
if (reviewerEvidence?.transcript && path === resolve(artifactRoot, reviewerEvidence.transcript.path)) continue
380+
if (entry.isDirectory()) await redactArtifactFiles(path, artifactRoot)
330381
if (entry.isFile() && path.endsWith(".json") && (await stat(path)).size <= 4 * 1024 * 1024) {
331382
const contents = await readFile(path, "utf8").catch(() => null)
332383
if (contents !== null) {
@@ -467,6 +518,7 @@ const nativeRuntimeResult = request.run_agent && !request.dry_run
467518
? await readNativeResult(nativeResultPath, controlledCodeboxPath, secretValues, redact)
468519
: {}
469520
await rm(nativeResultPath, { force: true })
521+
reviewerEvidence = await canonicalReviewerTranscript(nativeRuntimeResult, artifactsPath)
470522
let runtimeResult = sanitizeRuntimeSourceValue(nativeRuntimeResult, privateRuntimeSourceRootForSanitization)
471523
assertNoRuntimeSourcePaths(runtimeResult, privateRuntimeSourceRootForSanitization)
472524
let workspaceApply = { status: "no-op", changedFiles: [] }
@@ -559,6 +611,7 @@ const result = {
559611
runtime_input_path: ".codebox/native-agent-task-input.json",
560612
execution: { stdout_truncated: execution.stdout_truncated, stderr_truncated: execution.stderr_truncated },
561613
runtime_result: redact(runtimeRecord),
614+
...(reviewerEvidence ? { reviewer_evidence: reviewerEvidence } : {}),
562615
verification,
563616
publication,
564617
transcript: { artifact_name: request.artifacts?.transcript_name || "agent-task-transcript" },

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

Lines changed: 27 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import { constants } from "node:fs"
22
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"
5-
import { fileURLToPath, pathToFileURL } from "node:url"
65
import { isAbsolute, join, relative, resolve } from "node:path"
76
import { assertNoRuntimeSourcePaths, sanitizeRuntimeSourceJson } from "./runtime-source-sanitizer.mjs"
87

@@ -18,7 +17,6 @@ const runtimeSourceRoot = process.env.WP_CODEBOX_RUNTIME_SOURCE_ROOT ? resolve(p
1817
const runtimeSourcePrefix = process.env.WP_CODEBOX_RUNTIME_SOURCE_PREFIX ? resolve(process.env.WP_CODEBOX_RUNTIME_SOURCE_PREFIX) : ""
1918
const runtimeSourceRoots = [runtimeSourceRoot, runtimeSourcePrefix].filter(Boolean)
2019
const privateUploadRoots = [...runtimeSourceRoots, workspace]
21-
const codeboxRoot = resolve(fileURLToPath(new URL("../../..", import.meta.url)))
2220
const SOURCE_TREE = /(^|\/)(prepared-plugins|prepared-source-packages|source-package[^/]*)(\/|$)/i
2321
const SOURCE_FILE = /\.(?:php|phtml|js|mjs|cjs|jsx|ts|tsx)$/i
2422
const PHP_OPENING_TAG = /<\?(?:php|=)(?:\s|$)/i
@@ -147,13 +145,21 @@ async function stageTextFile(source, destination, options = {}) {
147145
return true
148146
}
149147

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]
148+
function canonicalTranscript(result) {
149+
const descriptor = record(record(result).reviewer_evidence).transcript
150+
if (descriptor === undefined) return undefined
151+
const transcript = record(descriptor)
152+
if (Object.keys(transcript).length !== 5
153+
|| transcript.schema !== "wp-codebox/agent-transcript/v1"
154+
|| transcript.kind !== "codebox-transcript"
155+
|| typeof transcript.path !== "string"
156+
|| !/^[a-f0-9]{64}$/.test(transcript.source_sha256)
157+
|| !Number.isSafeInteger(transcript.size_bytes)
158+
|| transcript.size_bytes < 0
159+
|| transcript.size_bytes > MAX_UPLOAD_FILE_BYTES) {
160+
throw new Error("Reviewer evidence transcript descriptor is malformed.")
161+
}
162+
return transcript
157163
}
158164

159165
function digest(bytes) {
@@ -214,7 +220,12 @@ function projectParsed(value) {
214220
async function trustedTranscriptFile(path) {
215221
const root = await realpath(artifactsPath).catch(() => "")
216222
if (!root) return { unavailable: "artifact-root-missing" }
217-
const parts = path.split("/")
223+
// Resolve before evaluating containment so harmless relative spelling is not
224+
// mistaken for an escape while aliases and symlinks still fail closed.
225+
const requested = resolve(root, path)
226+
const requestedRelative = relative(root, requested)
227+
if (requestedRelative === ".." || requestedRelative.startsWith(`..${String.fromCharCode(47)}`) || isAbsolute(requestedRelative)) throw new Error("Canonical transcript escapes the trusted artifact root.")
228+
const parts = requestedRelative.split("/").filter(Boolean)
218229
let current = root
219230
for (const part of parts) {
220231
current = join(current, part)
@@ -233,18 +244,16 @@ async function trustedTranscriptFile(path) {
233244
async function stageCanonicalTranscript(result) {
234245
const ref = await canonicalTranscript(result)
235246
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 } }
247+
const trusted = await trustedTranscriptFile(ref.path)
248+
if (trusted.unavailable) return { unavailable: trusted.unavailable, provenance: { kind: ref.kind, artifact_path: ref.path } }
240249
const source = trusted.source
241250
const bytes = await readFile(source)
242251
if (bytes.includes(0) || !isUtf8(bytes)) throw new Error("Canonical transcript must be UTF-8 JSON.")
243252
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.")
253+
if (ref.source_sha256 !== actualDigest) throw new Error("Canonical transcript digest does not match its reviewer evidence descriptor.")
254+
if (ref.size_bytes !== bytes.length) throw new Error("Canonical transcript size does not match its reviewer evidence descriptor.")
246255
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.")
256+
if (raw.schema !== ref.schema || !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.")
248257
const projection = {
249258
schema: "wp-codebox/reviewer-agent-transcript/v1",
250259
executions: raw.executions.map((execution, index) => {
@@ -260,7 +269,7 @@ async function stageCanonicalTranscript(result) {
260269
return {
261270
path: ".codebox/agent-task-artifacts/transcript.json",
262271
sha256: projectionDigest,
263-
provenance: { kind: ref.kind, artifact_path: path, source_sha256: actualDigest, projection_sha256: projectionDigest },
272+
provenance: { kind: ref.kind, artifact_path: ref.path, source_sha256: actualDigest, projection_sha256: projectionDigest },
264273
}
265274
}
266275

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,8 @@ add_filter( 'pre_http_request', static function( $preempt, $args, $url ) {
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" } })
8989
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")
90+
assert.equal(exclusions.canonical_transcripts.length, 1, "available canonical evidence is projected once")
91+
assert.match(exclusions.canonical_transcripts[0].provenance.artifact_path, /^runtime-[a-z0-9-]+\/files\/transcript\.json$/)
9192
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(() => ""))))
9293
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, "\\$&")))
9394
const stagedInput = JSON.parse(await readFile(join(workspace, ".codebox", "agent-task-upload", ".codebox", "native-agent-task-input.json"), "utf8"))

0 commit comments

Comments
 (0)