Skip to content

Commit a4754a8

Browse files
committed
Fix trusted apply artifacts before redaction
1 parent 6945541 commit a4754a8

7 files changed

Lines changed: 158 additions & 36 deletions

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

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { readNativeResult } from "./native-result-file.mjs"
1010
import { assertNoRuntimeSourcePaths, sanitizeRuntimeSourceJson, sanitizeRuntimeSourceText, sanitizeRuntimeSourceValue } from "./runtime-source-sanitizer.mjs"
1111
import { publishRunnerWorkspace } from "./runner-workspace-publisher.mjs"
1212
import { createRunnerWorkspaceSeedSnapshot, RUNNER_WORKSPACE_SEED_EXCLUDES } from "./runner-workspace-seed-snapshot.mjs"
13-
import { createTrustedArtifactSnapshot } from "./trusted-artifact-snapshot.mjs"
13+
import { createTrustedArtifactApplyChannel, trustedArtifactApplyRefs } from "./trusted-artifact-snapshot.mjs"
1414

1515
const requestPath = process.env.AGENT_TASK_REQUEST_PATH || ".codebox/agent-task-request.json"
1616
const workspace = resolve(process.env.AGENT_TASK_WORKSPACE || process.cwd())
@@ -24,6 +24,7 @@ let privateRuntimeSourceRoot = ""
2424
let privateRuntimeSourceRootForSanitization = ""
2525
let runnerWorkspaceSeedSnapshot
2626
let reviewerEvidence
27+
let trustedApplyArtifactRoot
2728

2829
const SIGNAL_EXIT_CODES = { SIGINT: 130, SIGTERM: 143, SIGHUP: 129 }
2930
let materializedSourceCleanup
@@ -37,6 +38,10 @@ function claimMaterializedSourcePaths() {
3738
paths.push(privateRuntimeSourceRoot)
3839
privateRuntimeSourceRoot = ""
3940
}
41+
if (trustedApplyArtifactRoot) {
42+
paths.push(trustedApplyArtifactRoot)
43+
trustedApplyArtifactRoot = ""
44+
}
4045
return paths
4146
}
4247
// Single idempotent cleanup coordinator for the private runtime source
@@ -152,8 +157,8 @@ function safeEnvironment(extra = {}) {
152157
return { PATH: process.env.PATH || "", HOME: process.env.HOME || "", CI: process.env.CI || "true", ...extra }
153158
}
154159

155-
function agentEnvironment() {
156-
return safeEnvironment(Object.fromEntries(["OPENAI_API_KEY", "MODEL_PROVIDER_SECRET_1", "MODEL_PROVIDER_SECRET_2", "MODEL_PROVIDER_SECRET_3", "MODEL_PROVIDER_SECRET_4", "MODEL_PROVIDER_SECRET_5", "GITHUB_TOKEN"].map((name) => [name, process.env[name]]).filter(([, value]) => value)))
160+
function agentEnvironment(extra = {}) {
161+
return safeEnvironment({ ...Object.fromEntries(["OPENAI_API_KEY", "MODEL_PROVIDER_SECRET_1", "MODEL_PROVIDER_SECRET_2", "MODEL_PROVIDER_SECRET_3", "MODEL_PROVIDER_SECRET_4", "MODEL_PROVIDER_SECRET_5", "GITHUB_TOKEN"].map((name) => [name, process.env[name]]).filter(([, value]) => value)), ...extra })
157162
}
158163

159164
function command(command, args, cwd, env = safeEnvironment()) {
@@ -605,8 +610,9 @@ const taskInput = {
605610
await writeFile(executionInputPath, `${JSON.stringify(taskInput, null, 2)}\n`)
606611

607612
let execution = { code: 0, stdout: "", stderr: "", stdout_truncated: false, stderr_truncated: false }
608-
if (request.run_agent && !request.dry_run) {
609-
execution = await command("node", [codeboxCliPath, "agent-task-run", "--input-file", executionInputPath, "--result-file", nativeResultPath], workspace, agentEnvironment())
613+
if (request.run_agent && !request.dry_run) {
614+
if (request.runner_workspace?.enabled) trustedApplyArtifactRoot = await createTrustedArtifactApplyChannel()
615+
execution = await command("node", [codeboxCliPath, "agent-task-run", "--input-file", executionInputPath, "--result-file", nativeResultPath], workspace, agentEnvironment(trustedApplyArtifactRoot ? { WP_CODEBOX_TRUSTED_APPLY_ARTIFACT_ROOT: trustedApplyArtifactRoot } : {}))
610616
}
611617

612618
// Public package bytes are embedded in the runtime recipe and consumed only by
@@ -638,16 +644,16 @@ if (execution.code === 0 && runtimeResult.success === true && request.runner_wor
638644
const publicCore = await import(pathToFileURL(join(codeboxRoot, "packages/runtime-core/dist/public.js")).href)
639645
const refs = publicCore.normalizePublicArtifactRefDTOs(runtimeResult)
640646
.filter((ref) => ref.kind === "codebox-patch" || ref.kind === "codebox-changed-files")
641-
const trustedArtifacts = await createTrustedArtifactSnapshot(artifactsPath, refs)
642-
try {
643-
workspaceApply = await runnerWorkspaceCore.applyRunnerWorkspacePatch({ artifactRoot: trustedArtifacts.root, artifactRefs: trustedArtifacts.refs, workspaceRoot: workspace, writablePaths, seedIdentity: runnerWorkspaceSeedSnapshot?.provenance.identity })
644-
} finally {
645-
await rm(trustedArtifacts.root, { recursive: true, force: true })
646-
}
647+
const trustedArtifacts = await trustedArtifactApplyRefs(trustedApplyArtifactRoot, refs)
648+
workspaceApply = await runnerWorkspaceCore.applyRunnerWorkspacePatch({ artifactRoot: trustedArtifacts.root, artifactRefs: trustedArtifacts.refs, workspaceRoot: workspace, writablePaths, seedIdentity: runnerWorkspaceSeedSnapshot?.provenance.identity })
647649
} catch (error) {
648650
downstreamFailure = { stage: "apply", message: bounded(error instanceof Error ? error.message : String(error), MAX_WORKFLOW_OUTPUT_BYTES), ...(error?.evidence ? { evidence: error.evidence } : {}) }
649651
}
650652
}
653+
if (trustedApplyArtifactRoot) {
654+
await rm(trustedApplyArtifactRoot, { recursive: true, force: true })
655+
trustedApplyArtifactRoot = ""
656+
}
651657
runtimeResult = sanitizeRuntimeSourceValue(runtimeResult, runtimeSourceOutputRoots)
652658
privateRuntimeSourceRootForSanitization = runtimeSourceOutputRoots
653659
assertNoRuntimeSourcePaths(runtimeResult, privateRuntimeSourceRootForSanitization)
Lines changed: 26 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { lstat, mkdir, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises"
1+
import { createHash } from "node:crypto"
2+
import { lstat, mkdtemp, readFile, realpath } from "node:fs/promises"
23
import { tmpdir } from "node:os"
34
import { isAbsolute, join, relative, resolve } from "node:path"
45

@@ -10,29 +11,30 @@ function underRoot(root, candidate) {
1011
}
1112

1213
/**
13-
* Copies selected regular artifacts to a private temporary root before a later
14-
* durable-artifact transformation. Callers retain their artifact references,
15-
* with paths rewritten relative to the trusted copy.
14+
* Opens a workflow-owned private root that the runtime may use before durable
15+
* artifact redaction. This root is never part of the artifact bundle.
1616
*/
17-
export async function createTrustedArtifactSnapshot(artifactRoot, refs, maxFileBytes = MAX_SNAPSHOT_FILE_BYTES) {
18-
const root = await realpath(resolve(artifactRoot))
19-
const snapshotRoot = await mkdtemp(join(tmpdir(), "wp-codebox-trusted-artifacts-"))
20-
try {
21-
const copiedRefs = await Promise.all(refs.map(async (ref) => {
22-
const requested = isAbsolute(ref.path) ? resolve(ref.path) : resolve(root, ref.path)
23-
const stat = await lstat(requested)
24-
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > maxFileBytes) throw new Error("Trusted artifact snapshot requires a bounded regular file.")
25-
const source = await realpath(requested)
26-
if (!underRoot(root, source)) throw new Error("Trusted artifact snapshot escapes the artifact root.")
27-
const path = relative(root, source).replaceAll("\\", "/")
28-
const destination = join(snapshotRoot, path)
29-
await mkdir(resolve(destination, ".."), { recursive: true })
30-
await writeFile(destination, await readFile(source))
31-
return { ...ref, path }
32-
}))
33-
return { root: snapshotRoot, refs: copiedRefs }
34-
} catch (error) {
35-
await rm(snapshotRoot, { recursive: true, force: true })
36-
throw error
17+
export async function createTrustedArtifactApplyChannel() {
18+
return mkdtemp(join(tmpdir(), "wp-codebox-trusted-apply-"))
19+
}
20+
21+
/** Validates and projects only the two canonical private apply artifacts. */
22+
export async function trustedArtifactApplyRefs(root, refs, maxFileBytes = MAX_SNAPSHOT_FILE_BYTES) {
23+
const channelRoot = await realpath(resolve(root))
24+
const expectedPaths = {
25+
"codebox-patch": "files/patch.diff",
26+
"codebox-changed-files": "files/changed-files.json",
3727
}
28+
const copiedRefs = await Promise.all(refs.map(async (ref) => {
29+
const path = expectedPaths[ref.kind]
30+
if (!path) throw new Error("Trusted apply artifacts must use canonical artifact kinds.")
31+
const requested = join(channelRoot, path)
32+
const stat = await lstat(requested)
33+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > maxFileBytes) throw new Error("Trusted artifact snapshot requires a bounded regular file.")
34+
const source = await realpath(requested)
35+
if (!underRoot(channelRoot, source)) throw new Error("Trusted artifact snapshot escapes the artifact root.")
36+
const bytes = await readFile(source)
37+
return { ...ref, path, ...(ref.kind === "codebox-patch" ? { sha256: createHash("sha256").update(bytes).digest("hex") } : {}) }
38+
}))
39+
return { root: channelRoot, refs: copiedRefs }
3840
}

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@
132132
"test:source-root-preparation": "tsx tests/source-root-preparation.test.ts",
133133
"test:workspace-preload-artifacts": "tsx tests/workspace-preload-artifacts.test.ts",
134134
"test:runner-workspace-apply": "tsx tests/runner-workspace-apply.test.ts",
135+
"test:trusted-apply-artifact-channel": "tsx tests/trusted-apply-artifact-channel.integration.test.ts",
135136
"test:runner-workspace-publisher": "node tests/runner-workspace-publisher.test.mjs",
136137
"test:native-agent-task-lifecycle": "node tests/execute-native-agent-task-lifecycle.test.mjs",
137138
"test:native-agent-task-interruption": "node tests/execute-native-agent-task-interruption.test.mjs",

packages/runtime-playground/src/artifact-bundle-builder.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ import {
4545
import { normalizeJsonValue, stripUndefined } from "@automattic/wp-codebox-core/internals"
4646
import type { BrowserArtifact } from "./browser-artifacts.js"
4747
import { firstCommandWordPressAdminAuthRequirement } from "./command-auth-requirements.js"
48+
import { writeTrustedApplyArtifacts } from "./trusted-apply-artifact-channel.js"
4849
import {
4950
ArtifactRedactor,
5051
artifactContentDigest,
@@ -160,6 +161,7 @@ export class ArtifactBundleBuilder {
160161
const replayExportPackageFiles = replayExportPackageManifestFiles(source.artifactRoot, source.commands)
161162
const capturedMounts = await source.captureMountedFiles(filesDirectory, redactor)
162163
const { mountDiffs, changedFiles, patch, diagnostics: mountDiffDiagnostics } = await source.captureMountDiffs(filesDirectory, redactor)
164+
await writeTrustedApplyArtifacts(changedFiles, patch)
163165
const changedFilesJson = redactor.redact(CHANGED_FILES_ARTIFACT_PATH, `${JSON.stringify(changedFiles, null, 2)}\n`)
164166
const redactedPatch = redactor.redact("files/patch.diff", patch)
165167
const patchContentDigest = artifactContentDigest(changedFilesJson, redactedPatch)
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { lstat, mkdir, writeFile } from "node:fs/promises"
2+
import { join, resolve } from "node:path"
3+
4+
const CHANNEL_ROOT_ENV = "WP_CODEBOX_TRUSTED_APPLY_ARTIFACT_ROOT"
5+
const MAX_APPLY_ARTIFACT_BYTES = 5 * 1024 * 1024
6+
7+
/**
8+
* The workflow creates this private temporary root. It is deliberately outside
9+
* the durable bundle so canonical apply bytes never enter manifests or uploads.
10+
*/
11+
export async function writeTrustedApplyArtifacts(changedFiles: unknown, patch: string): Promise<void> {
12+
const root = process.env[CHANNEL_ROOT_ENV]
13+
if (!root) return
14+
15+
const changedFilesContents = `${JSON.stringify(changedFiles, null, 2)}\n`
16+
if (Buffer.byteLength(changedFilesContents) > MAX_APPLY_ARTIFACT_BYTES || Buffer.byteLength(patch) > MAX_APPLY_ARTIFACT_BYTES) {
17+
throw new Error("Trusted apply artifacts must be bounded.")
18+
}
19+
20+
const rootStat = await lstat(resolve(root))
21+
if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) throw new Error("Trusted apply artifact root must be a directory.")
22+
const files = join(root, "files")
23+
await mkdir(files, { recursive: true, mode: 0o700 })
24+
const filesStat = await lstat(files)
25+
if (!filesStat.isDirectory() || filesStat.isSymbolicLink()) throw new Error("Trusted apply artifact files directory must be a directory.")
26+
await Promise.all([
27+
writeFile(join(files, "changed-files.json"), changedFilesContents, { mode: 0o600 }),
28+
writeFile(join(files, "patch.diff"), patch, { mode: 0o600 }),
29+
])
30+
}

tests/execute-native-agent-task-lifecycle.test.mjs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,12 +74,21 @@ const output = process.argv[process.argv.indexOf("--result-file") + 1]
7474
const input = JSON.parse(await readFile(process.argv[process.argv.indexOf("--input-file") + 1], "utf8"))
7575
const root = join(process.cwd(), ".codebox", "agent-task-artifacts", "files")
7676
await mkdir(root, { recursive: true })
77+
const trustedRoot = process.env.WP_CODEBOX_TRUSTED_APPLY_ARTIFACT_ROOT
78+
if (trustedRoot) await mkdir(join(trustedRoot, "files"), { recursive: true })
7779
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")}
7880
await writeFile(join(root, "patch.diff"), patch)
7981
await writeFile(join(root, "changed-files.json"), JSON.stringify({
8082
schema: "wp-codebox/changed-files/v1",
8183
files: ${noOp} ? [] : [{ path: "/workspace/README.md", relativePath: "README.md", status: "modified", beforeMode: "100644", afterMode: "100644" }],
8284
}))
85+
if (trustedRoot) {
86+
await writeFile(join(trustedRoot, "files", "patch.diff"), patch)
87+
await writeFile(join(trustedRoot, "files", "changed-files.json"), JSON.stringify({
88+
schema: "wp-codebox/changed-files/v1",
89+
files: ${noOp} ? [] : [{ path: "/workspace/README.md", relativePath: "README.md", status: "modified", beforeMode: "100644", afterMode: "100644" }],
90+
}))
91+
}
8392
${mismatch ? "await writeFile(join(process.cwd(), \"README.md\"), \"diverged\\n\")" : ""}
8493
await writeFile(join(process.cwd(), ".codebox", "order"), "runtime\\n")
8594
const summary = {
@@ -169,7 +178,7 @@ process.stdout.write(JSON.stringify({
169178
}
170179

171180
const success = await run()
172-
assert.equal(success.code, 0, success.stderr)
181+
assert.equal(success.code, 0, `${success.stderr}\n${JSON.stringify(success.result)}`)
173182
assert.equal(await readFile(join(success.workspace, "README.md"), "utf8"), "OPENAI_API_KEY\nafter\n")
174183
const durablePatch = await readFile(join(success.workspace, ".codebox", "agent-task-artifacts", "files", "patch.diff"), "utf8")
175184
assert(!durablePatch.includes("OPENAI_API_KEY"), "durable patch artifacts redact configured secret values")
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import assert from "node:assert/strict"
2+
import { execFile } from "node:child_process"
3+
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"
4+
import { tmpdir } from "node:os"
5+
import { join } from "node:path"
6+
import { promisify } from "node:util"
7+
import { ArtifactBundleBuilder } from "../packages/runtime-playground/src/artifact-bundle-builder.js"
8+
import { captureMountedFiles, captureMountDiffs } from "../packages/runtime-playground/src/mounted-artifact-capture.js"
9+
import { applyRunnerWorkspacePatch } from "../packages/runtime-core/src/runner-workspace-apply.js"
10+
11+
const execFileAsync = promisify(execFile)
12+
const root = await mkdtemp(join(tmpdir(), "wp-codebox-trusted-apply-integration-"))
13+
const artifactRoot = join(root, "artifacts")
14+
const trustedRoot = join(root, "trusted")
15+
const baseline = join(root, "baseline")
16+
const mounted = join(root, "mounted")
17+
const host = join(root, "host")
18+
const secretName = "CONFIGURED_SECRET"
19+
const previousChannel = process.env.WP_CODEBOX_TRUSTED_APPLY_ARTIFACT_ROOT
20+
21+
try {
22+
for (const directory of [baseline, mounted, host, trustedRoot]) {
23+
await mkdir(directory, { recursive: true })
24+
await writeFile(join(directory, "README.md"), `${secretName}\nbefore\n`)
25+
}
26+
await writeFile(join(mounted, "README.md"), `${secretName}\nafter\n`)
27+
await execFileAsync("git", ["init"], { cwd: host })
28+
process.env.WP_CODEBOX_TRUSTED_APPLY_ARTIFACT_ROOT = trustedRoot
29+
30+
const mounts = [{ source: mounted, target: "/workspace", mode: "readwrite", metadata: { baselineSource: baseline } }]
31+
await new ArtifactBundleBuilder({
32+
artifactRoot,
33+
runtimeId: "trusted-apply-test",
34+
runtimeCreatedAt: new Date().toISOString(),
35+
spec: { environment: { blueprint: {} }, secretEnv: { [secretName]: "secret-value" } },
36+
mounts,
37+
commands: [], observations: [], snapshots: [], events: [],
38+
info: async () => ({ id: "trusted-apply-test", backend: "playground", status: "ready", environment: { version: "latest" } }),
39+
previewInfo: async () => undefined,
40+
browserReviewSummary: () => undefined,
41+
browserArtifacts: () => [],
42+
captureMountedFiles: (filesDirectory, redactor) => captureMountedFiles(filesDirectory, mounts, redactor),
43+
captureMountDiffs: (filesDirectory, redactor) => captureMountDiffs(artifactRoot, filesDirectory, mounts, redactor),
44+
redactBrowserArtifacts: async () => {}, redactPluginCheckArtifacts: async () => {}, redactThemeCheckArtifacts: async () => {},
45+
browserManifestFiles: () => [], pluginCheckArtifactPaths: () => [], themeCheckArtifactPaths: () => [], observationManifestFiles: () => [], pluginCheckManifestFiles: () => [], themeCheckManifestFiles: () => [],
46+
formatRuntimeLog: () => "", formatCommandsLog: () => "", recordArtifactsCollected: () => {},
47+
} as any).build()
48+
49+
const durablePatch = await readFile(join(artifactRoot, "files", "patch.diff"), "utf8")
50+
assert(!durablePatch.includes(secretName), "durable artifact redacts configured secret names")
51+
assert.match(durablePatch, /\[REDACTED:configured-secret-name\]/)
52+
const trustedPatch = await readFile(join(trustedRoot, "files", "patch.diff"), "utf8")
53+
assert.match(trustedPatch, new RegExp(secretName), "private apply bytes retain unchanged diff context")
54+
55+
const applied = await applyRunnerWorkspacePatch({
56+
artifactRoot: trustedRoot,
57+
artifactRefs: [
58+
{ kind: "codebox-patch", path: "files/patch.diff" },
59+
{ kind: "codebox-changed-files", path: "files/changed-files.json" },
60+
],
61+
workspaceRoot: host,
62+
writablePaths: ["README.md"],
63+
})
64+
assert.equal(applied.status, "applied", "the host applies pre-redaction bytes")
65+
assert.equal(await readFile(join(host, "README.md"), "utf8"), `${secretName}\nafter\n`)
66+
} finally {
67+
if (previousChannel === undefined) delete process.env.WP_CODEBOX_TRUSTED_APPLY_ARTIFACT_ROOT
68+
else process.env.WP_CODEBOX_TRUSTED_APPLY_ARTIFACT_ROOT = previousChannel
69+
await rm(root, { recursive: true, force: true })
70+
}
71+
72+
console.log("trusted apply artifact channel integration ok")

0 commit comments

Comments
 (0)