From ba3da79c4d04d57429d36f809fe95ca5ee97dbcb Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Fri, 17 Jul 2026 14:24:06 -0400 Subject: [PATCH] Preserve machine-applicable patches across redaction --- .../execute-native-agent-task.mjs | 17 ++++++--- .../trusted-artifact-snapshot.mjs | 38 +++++++++++++++++++ ...ecute-native-agent-task-lifecycle.test.mjs | 14 ++++--- 3 files changed, 59 insertions(+), 10 deletions(-) create mode 100644 .github/scripts/run-agent-task/trusted-artifact-snapshot.mjs diff --git a/.github/scripts/run-agent-task/execute-native-agent-task.mjs b/.github/scripts/run-agent-task/execute-native-agent-task.mjs index a4556ccc7..410234962 100644 --- a/.github/scripts/run-agent-task/execute-native-agent-task.mjs +++ b/.github/scripts/run-agent-task/execute-native-agent-task.mjs @@ -1,5 +1,6 @@ import { rmSync } from "node:fs" import { appendFile, lstat, mkdir, readFile, realpath, rm, writeFile } from "node:fs/promises" +import { isUtf8 } from "node:buffer" import { isAbsolute, join, relative, resolve } from "node:path" import { pathToFileURL } from "node:url" import { spawn } from "node:child_process" @@ -9,6 +10,7 @@ import { readNativeResult } from "./native-result-file.mjs" import { assertNoRuntimeSourcePaths, sanitizeRuntimeSourceJson, sanitizeRuntimeSourceText, sanitizeRuntimeSourceValue } from "./runtime-source-sanitizer.mjs" import { publishRunnerWorkspace } from "./runner-workspace-publisher.mjs" import { createRunnerWorkspaceSeedSnapshot, RUNNER_WORKSPACE_SEED_EXCLUDES } from "./runner-workspace-seed-snapshot.mjs" +import { createTrustedArtifactSnapshot } from "./trusted-artifact-snapshot.mjs" const requestPath = process.env.AGENT_TASK_REQUEST_PATH || ".codebox/agent-task-request.json" const workspace = resolve(process.env.AGENT_TASK_WORKSPACE || process.cwd()) @@ -474,10 +476,10 @@ async function redactArtifactFiles(directory, artifactRoot = directory) { const path = join(directory, entry.name) if (reviewerEvidence?.transcript && path === resolve(artifactRoot, reviewerEvidence.transcript.path)) continue if (entry.isDirectory()) await redactArtifactFiles(path, artifactRoot) - if (entry.isFile() && path.endsWith(".json") && (await stat(path)).size <= 4 * 1024 * 1024) { - const contents = await readFile(path, "utf8").catch(() => null) - if (contents !== null) { - const sanitized = sanitizeRuntimeSourceJson(bounded(contents, 4 * 1024 * 1024), privateRuntimeSourceRootForSanitization) + if (entry.isFile() && (await stat(path)).size <= 4 * 1024 * 1024) { + const contents = await readFile(path).catch(() => null) + if (contents && !contents.includes(0) && isUtf8(contents)) { + const sanitized = sanitizeRuntimeSourceJson(redact(bounded(contents.toString("utf8"), 4 * 1024 * 1024)), privateRuntimeSourceRootForSanitization) assertNoRuntimeSourcePaths(sanitized, privateRuntimeSourceRootForSanitization) await writeFile(path, sanitized) } @@ -636,7 +638,12 @@ if (execution.code === 0 && runtimeResult.success === true && request.runner_wor const publicCore = await import(pathToFileURL(join(codeboxRoot, "packages/runtime-core/dist/public.js")).href) const refs = publicCore.normalizePublicArtifactRefDTOs(runtimeResult) .filter((ref) => ref.kind === "codebox-patch" || ref.kind === "codebox-changed-files") - workspaceApply = await runnerWorkspaceCore.applyRunnerWorkspacePatch({ artifactRoot: artifactsPath, artifactRefs: refs, workspaceRoot: workspace, writablePaths, seedIdentity: runnerWorkspaceSeedSnapshot?.provenance.identity }) + const trustedArtifacts = await createTrustedArtifactSnapshot(artifactsPath, refs) + try { + workspaceApply = await runnerWorkspaceCore.applyRunnerWorkspacePatch({ artifactRoot: trustedArtifacts.root, artifactRefs: trustedArtifacts.refs, workspaceRoot: workspace, writablePaths, seedIdentity: runnerWorkspaceSeedSnapshot?.provenance.identity }) + } finally { + await rm(trustedArtifacts.root, { recursive: true, force: true }) + } } catch (error) { downstreamFailure = { stage: "apply", message: bounded(error instanceof Error ? error.message : String(error), MAX_WORKFLOW_OUTPUT_BYTES), ...(error?.evidence ? { evidence: error.evidence } : {}) } } diff --git a/.github/scripts/run-agent-task/trusted-artifact-snapshot.mjs b/.github/scripts/run-agent-task/trusted-artifact-snapshot.mjs new file mode 100644 index 000000000..0df2700c7 --- /dev/null +++ b/.github/scripts/run-agent-task/trusted-artifact-snapshot.mjs @@ -0,0 +1,38 @@ +import { lstat, mkdir, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { isAbsolute, join, relative, resolve } from "node:path" + +const MAX_SNAPSHOT_FILE_BYTES = 5 * 1024 * 1024 + +function underRoot(root, candidate) { + const contained = relative(root, candidate) + return candidate === root || (contained !== ".." && !contained.startsWith(`..${String.fromCharCode(47)}`) && !isAbsolute(contained)) +} + +/** + * Copies selected regular artifacts to a private temporary root before a later + * durable-artifact transformation. Callers retain their artifact references, + * with paths rewritten relative to the trusted copy. + */ +export async function createTrustedArtifactSnapshot(artifactRoot, refs, maxFileBytes = MAX_SNAPSHOT_FILE_BYTES) { + const root = await realpath(resolve(artifactRoot)) + const snapshotRoot = await mkdtemp(join(tmpdir(), "wp-codebox-trusted-artifacts-")) + try { + const copiedRefs = await Promise.all(refs.map(async (ref) => { + const requested = isAbsolute(ref.path) ? resolve(ref.path) : resolve(root, ref.path) + const stat = await lstat(requested) + if (!stat.isFile() || stat.isSymbolicLink() || stat.size > maxFileBytes) throw new Error("Trusted artifact snapshot requires a bounded regular file.") + const source = await realpath(requested) + if (!underRoot(root, source)) throw new Error("Trusted artifact snapshot escapes the artifact root.") + const path = relative(root, source).replaceAll("\\", "/") + const destination = join(snapshotRoot, path) + await mkdir(resolve(destination, ".."), { recursive: true }) + await writeFile(destination, await readFile(source)) + return { ...ref, path } + })) + return { root: snapshotRoot, refs: copiedRefs } + } catch (error) { + await rm(snapshotRoot, { recursive: true, force: true }) + throw error + } +} diff --git a/tests/execute-native-agent-task-lifecycle.test.mjs b/tests/execute-native-agent-task-lifecycle.test.mjs index cde861f2a..a9857a19e 100644 --- a/tests/execute-native-agent-task-lifecycle.test.mjs +++ b/tests/execute-native-agent-task-lifecycle.test.mjs @@ -15,7 +15,7 @@ async function run(mode = "success") { const temp = await mkdtemp(join(tmpdir(), "wp-codebox-native-lifecycle-")) const workspace = join(temp, "workspace") await mkdir(join(workspace, ".codebox"), { recursive: true }) - await writeFile(join(workspace, "README.md"), "before\n") + await writeFile(join(workspace, "README.md"), "OPENAI_API_KEY\nbefore\n") const request = { schema: "wp-codebox/agent-task-workflow-request/v1", @@ -74,7 +74,7 @@ const output = process.argv[process.argv.indexOf("--result-file") + 1] const input = JSON.parse(await readFile(process.argv[process.argv.indexOf("--input-file") + 1], "utf8")) const root = join(process.cwd(), ".codebox", "agent-task-artifacts", "files") await mkdir(root, { recursive: true }) -const patch = ${JSON.stringify(noOp ? "" : applyReject ? "--- a/README.md\n+++ b/README.md\n@@ -1 +1 @@\n-not-before\n+after\n" : "--- a/README.md\n+++ b/README.md\n@@ -1 +1 @@\n-before\n+after\n")} +const patch = ${JSON.stringify(noOp ? "" : applyReject ? "--- a/README.md\n+++ b/README.md\n@@ -1,2 +1,2 @@\n OPENAI_API_KEY\n-not-before\n+after\n" : "--- a/README.md\n+++ b/README.md\n@@ -1,2 +1,2 @@\n OPENAI_API_KEY\n-before\n+after\n")} await writeFile(join(root, "patch.diff"), patch) await writeFile(join(root, "changed-files.json"), JSON.stringify({ schema: "wp-codebox/changed-files/v1", @@ -146,6 +146,7 @@ process.stdout.write(JSON.stringify({ WP_CODEBOX_TEST_PUBLISHER_MODULE: publisher, WP_CODEBOX_TEST_PUBLISHER_HOOK: JSON.stringify({ orderPath: publisherOrderPath }), GITHUB_TOKEN: "token", + MODEL_PROVIDER_SECRET_1: "OPENAI_API_KEY", EXPLICIT_ACCESS_TOKEN_CONFIGURED: "true", EXTERNAL_PACKAGE_SOURCE_POLICY: JSON.stringify({ version: 1, @@ -169,7 +170,10 @@ process.stdout.write(JSON.stringify({ const success = await run() assert.equal(success.code, 0, success.stderr) -assert.equal(await readFile(join(success.workspace, "README.md"), "utf8"), "after\n") +assert.equal(await readFile(join(success.workspace, "README.md"), "utf8"), "OPENAI_API_KEY\nafter\n") +const durablePatch = await readFile(join(success.workspace, ".codebox", "agent-task-artifacts", "files", "patch.diff"), "utf8") +assert(!durablePatch.includes("OPENAI_API_KEY"), "durable patch artifacts redact configured secret values") +assert.match(durablePatch, /\[REDACTED\]/) assert.match(success.order, /runtime\nvalidation\nverification\ndrift\npublish\n/) assert.equal(success.result.runtime_result.metadata.runner_workspace_publication.pull_request.url, "https://github.com/owner/repo/pull/1") assert.equal(success.result.runtime_result.agent_runtime_diagnostics.prepared_paths.workspaces[0].target, "/workspace") @@ -214,12 +218,12 @@ await execFileAsync(process.execPath, [uploader], { AGENT_TASK_UPLOAD_PATH: join(mismatch.workspace, ".codebox", "agent-task-upload"), }, }) -assert.equal(await readFile(join(mismatch.workspace, ".codebox", "agent-task-upload", ".codebox", "agent-task-artifacts", "apply-failure", "rejected.patch"), "utf8"), "--- a/README.md\n+++ b/README.md\n@@ -1 +1 @@\n-before\n+after\n") +assert.equal(await readFile(join(mismatch.workspace, ".codebox", "agent-task-upload", ".codebox", "agent-task-artifacts", "apply-failure", "rejected.patch"), "utf8"), "--- a/README.md\n+++ b/README.md\n@@ -1,2 +1,2 @@\n [REDACTED]\n-before\n+after\n") assert.match(await readFile(join(mismatch.workspace, ".codebox", "agent-task-upload", ".codebox", "agent-task-artifacts", "apply-failure", "changed-files.json"), "utf8"), /README\.md/) const applyReject = await run("apply-reject") assert.equal(applyReject.code, 1) -assert.equal(await readFile(join(applyReject.workspace, "README.md"), "utf8"), "before\n", "a rejected patch does not partially mutate the matching host workspace") +assert.equal(await readFile(join(applyReject.workspace, "README.md"), "utf8"), "OPENAI_API_KEY\nbefore\n", "a rejected patch does not partially mutate the matching host workspace") assert.equal(applyReject.result.failure.stage, "apply") assert.match(applyReject.result.failure.message, /Host git apply failed/) assert.deepEqual(applyReject.result.failure.evidence.expected_identity, applyReject.result.failure.evidence.actual_identity, "the failure records the matching seed and host identities")