Skip to content

Commit 22aceec

Browse files
committed
Harden runner workspace evidence and uploads
1 parent f723ac1 commit 22aceec

8 files changed

Lines changed: 223 additions & 21 deletions

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

Lines changed: 58 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ const secretValues = ["OPENAI_API_KEY", "MODEL_PROVIDER_SECRET_1", "MODEL_PROVID
1313
const runtimeSourceRoot = process.env.WP_CODEBOX_RUNTIME_SOURCE_ROOT ? resolve(process.env.WP_CODEBOX_RUNTIME_SOURCE_ROOT) : ""
1414
const runtimeSourcePrefix = process.env.WP_CODEBOX_RUNTIME_SOURCE_PREFIX ? resolve(process.env.WP_CODEBOX_RUNTIME_SOURCE_PREFIX) : ""
1515
const runtimeSourceRoots = [runtimeSourceRoot, runtimeSourcePrefix].filter(Boolean)
16+
const privateUploadRoots = [...runtimeSourceRoots, workspace]
1617
const SOURCE_TREE = /(^|\/)(prepared-plugins|prepared-source-packages|source-package[^/]*)(\/|$)/i
1718
const SOURCE_FILE = /\.(?:php|phtml|js|mjs|cjs|jsx|ts|tsx)$/i
1819
const PHP_OPENING_TAG = /<\?(?:php|=)(?:\s|$)/i
@@ -32,16 +33,32 @@ function redact(value) {
3233
}
3334

3435
function sanitizeText(text) {
35-
return sanitizeRuntimeSourceJson(text, runtimeSourceRoots)
36+
return sanitizeRuntimeSourceJson(text, privateUploadRoots)
3637
}
3738

3839
function compactNativeInput(text) {
3940
const privateFields = new Set(["source_package_root", "component_contracts", "extra_plugins", "provider_plugins", "runtime_overlays", "prepared_sources"])
40-
const compact = (value) => {
41+
let seedProvenance = {}
42+
try {
43+
const parsed = JSON.parse(sanitizeText(text))
44+
seedProvenance = record(record(record(parsed).task_input).runtime_task?.input?.metadata).runner_workspace_seed
45+
} catch {}
46+
const compact = (value, key = "") => {
4147
if (Array.isArray(value)) return value.map(compact)
4248
const entry = record(value)
4349
if (!Object.keys(entry).length) return value
44-
return Object.fromEntries(Object.entries(entry).flatMap(([key, item]) => privateFields.has(key) ? [] : [[key, compact(item)]]))
50+
if (key === "seed" && typeof entry.source === "string") {
51+
const provenance = record(seedProvenance)
52+
return {
53+
kind: "runner-workspace-seed",
54+
digest: record(provenance.digest).sha256,
55+
files: provenance.files,
56+
bytes: provenance.bytes,
57+
excludes: provenance.excludes,
58+
excluded: provenance.excluded,
59+
}
60+
}
61+
return Object.fromEntries(Object.entries(entry).flatMap(([childKey, item]) => privateFields.has(childKey) ? [] : [[childKey, compact(item, childKey)]]))
4562
}
4663
try {
4764
return `${JSON.stringify(compact(JSON.parse(sanitizeText(text))), null, 2)}\n`
@@ -50,6 +67,38 @@ function compactNativeInput(text) {
5067
}
5168
}
5269

70+
function assertNoSeedSnapshotPaths(text) {
71+
if (/wp-codebox-runner-workspace-seed-/i.test(text)) throw new Error("Temporary runner workspace seed paths must never be persisted in artifact uploads.")
72+
try {
73+
const visit = (value) => {
74+
if (Array.isArray(value)) return value.forEach(visit)
75+
const entry = record(value)
76+
if (!Object.keys(entry).length) return
77+
if (record(entry.seed).source && isAbsolute(record(entry.seed).source)) throw new Error("Absolute runner workspace seed paths must never be persisted in artifact uploads.")
78+
Object.values(entry).forEach(visit)
79+
}
80+
visit(JSON.parse(text))
81+
} catch (error) {
82+
if (error instanceof Error && /seed paths/.test(error.message)) throw error
83+
}
84+
}
85+
86+
function sanitizeSeedSnapshotJson(text) {
87+
try {
88+
const compact = (value, key = "") => {
89+
if (typeof value === "string") return value.replace(/\/?[^\s"']*wp-codebox-runner-workspace-seed-[^\s"']*/gi, "[runner-workspace-seed]")
90+
if (Array.isArray(value)) return value.map((entry) => compact(entry))
91+
const entry = record(value)
92+
if (!Object.keys(entry).length) return value
93+
if (key === "seed" && typeof entry.source === "string") return { kind: "runner-workspace-seed" }
94+
return Object.fromEntries(Object.entries(entry).map(([childKey, item]) => [childKey, compact(item, childKey)]))
95+
}
96+
return `${JSON.stringify(compact(JSON.parse(text)), null, 2)}\n`
97+
} catch {
98+
return text
99+
}
100+
}
101+
53102
function isPrivateRuntimePath(value) {
54103
if (!runtimeSourceRoots.length || typeof value !== "string") return false
55104
const path = resolve(value)
@@ -82,8 +131,10 @@ async function stageTextFile(source, destination, options = {}) {
82131
const contents = openedMetadata.isFile() && openedMetadata.size <= MAX_UPLOAD_FILE_BYTES ? await handle.readFile() : null
83132
await handle.close()
84133
if (!contents || contents.includes(0) || !isUtf8(contents)) return false
85-
const text = redact(options.compactNativeInput ? compactNativeInput(contents.toString("utf8")) : sanitizeText(contents.toString("utf8")))
86-
assertNoRuntimeSourcePaths(text, runtimeSourceRoots, "Runtime source paths must never be persisted in artifact uploads.")
134+
const sanitized = options.compactNativeInput ? compactNativeInput(contents.toString("utf8")) : sanitizeText(contents.toString("utf8"))
135+
const text = redact(sanitizeSeedSnapshotJson(sanitized))
136+
assertNoRuntimeSourcePaths(text, privateUploadRoots, "Runtime source or workspace paths must never be persisted in artifact uploads.")
137+
assertNoSeedSnapshotPaths(text)
87138
if (containsRuntimeSourceContent(text)) throw new Error("Prepared runtime plugin source contents must never be staged for artifact upload.")
88139
await mkdir(resolve(destination, ".."), { recursive: true })
89140
await writeFile(destination, text)
@@ -163,7 +214,8 @@ async function finalScan(directory) {
163214
else if (entry.isFile()) {
164215
const bytes = await readFile(path)
165216
const text = isUtf8(bytes) ? bytes.toString("utf8") : ""
166-
assertNoRuntimeSourcePaths(text, runtimeSourceRoots, "Runtime source paths must never be persisted in artifact uploads.")
217+
assertNoRuntimeSourcePaths(text, privateUploadRoots, "Runtime source or workspace paths must never be persisted in artifact uploads.")
218+
assertNoSeedSnapshotPaths(text)
167219
if (containsRuntimeSourceContent(text)) throw new Error("Prepared runtime plugin source contents must never be persisted in artifact uploads.")
168220
} else throw new Error("Only regular files may be persisted in artifact uploads.")
169221
}

.github/scripts/run-agent-task/runner-workspace-seed-snapshot.mjs

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,11 @@ import { chmod, copyFile, lstat, mkdir, mkdtemp, readdir, readFile, rm } from "n
33
import { tmpdir } from "node:os"
44
import { join, relative, resolve } from "node:path"
55

6-
export const RUNNER_WORKSPACE_SEED_EXCLUDES = [".git/**", ".codebox/**", "node_modules/**", "vendor/**", "dist/**", "build/**", "coverage/**", ".cache/**"]
7-
const EXCLUDED_ROOT_NAMES = new Set(RUNNER_WORKSPACE_SEED_EXCLUDES.map((pattern) => pattern.slice(0, -3)))
6+
// Snapshot policy is intentionally allow-by-exception: disposable build trees and
7+
// credential-bearing files never enter the agent-readable workspace. `.env.example`
8+
// is the one documented environment-template exception.
9+
export const RUNNER_WORKSPACE_SEED_EXCLUDES = [".git/**", ".codebox/**", "node_modules/**", "vendor/**", "dist/**", "build/**", "coverage/**", ".cache/**", ".env", ".env.*", ".npmrc", ".yarnrc.yml", ".pypirc", ".netrc", "auth.json", "id_rsa", "id_ed25519", "*.pem", "*.key", "credential files"]
10+
const EXCLUDED_ROOT_NAMES = new Set(RUNNER_WORKSPACE_SEED_EXCLUDES.filter((pattern) => pattern.endsWith("/**")).map((pattern) => pattern.slice(0, -3)))
811
const MAX_FILES = 10_000
912
const MAX_BYTES = 256 * 1024 * 1024
1013

@@ -18,22 +21,40 @@ function fileMode(stat) {
1821
return stat.mode & 0o111 ? 0o755 : 0o644
1922
}
2023

24+
function secretCategory(path) {
25+
const name = path.split("/").at(-1)?.toLowerCase() || ""
26+
if ((name === ".env" || name.startsWith(".env.")) && name !== ".env.example") return "environment"
27+
if ([".npmrc", ".yarnrc.yml", ".pypirc", ".netrc", "auth.json", "credentials", "credentials.json", "secrets.json", "token.json"].includes(name)) return "credentials"
28+
if (["id_rsa", "id_ed25519", "id_ecdsa", "id_dsa"].includes(name) || /\.(?:pem|key|p12|pfx)$/i.test(name)) return "private-key"
29+
return ""
30+
}
31+
2132
export async function createRunnerWorkspaceSeedSnapshot(source) {
2233
const sourceRoot = resolve(source)
2334
const root = await mkdtemp(join(tmpdir(), "wp-codebox-runner-workspace-seed-"))
2435
const digest = createHash("sha256")
2536
let fileCount = 0
2637
let byteCount = 0
38+
const excluded = new Map()
39+
const exclude = (category) => excluded.set(category, (excluded.get(category) || 0) + 1)
2740

2841
try {
2942
async function copyTree(currentSource, currentTarget) {
3043
const entries = await readdir(currentSource, { withFileTypes: true })
3144
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
32-
if (EXCLUDED_ROOT_NAMES.has(entry.name)) continue
45+
if (EXCLUDED_ROOT_NAMES.has(entry.name)) {
46+
exclude("generated-tree")
47+
continue
48+
}
3349
const input = join(currentSource, entry.name)
3450
const output = join(currentTarget, entry.name)
3551
const stat = await lstat(input)
3652
const path = relative(sourceRoot, input).replaceAll("\\", "/")
53+
const category = secretCategory(path)
54+
if (category) {
55+
exclude(category)
56+
continue
57+
}
3758
if (stat.isSymbolicLink() || (!stat.isDirectory() && !stat.isFile())) {
3859
throw snapshotError(`Runner workspace seed contains unsupported filesystem entry: ${path}`)
3960
}
@@ -67,6 +88,10 @@ export async function createRunnerWorkspaceSeedSnapshot(source) {
6788
files: fileCount,
6889
bytes: byteCount,
6990
excludes: RUNNER_WORKSPACE_SEED_EXCLUDES,
91+
excluded: {
92+
files: [...excluded.values()].reduce((total, count) => total + count, 0),
93+
categories: [...excluded.entries()].sort(([left], [right]) => left.localeCompare(right)).map(([category, count]) => ({ category, count })),
94+
},
7095
},
7196
}
7297
} catch (error) {
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Runner Workspace Seed Policy
2+
3+
Runner workspaces are copied from a bounded, disposable snapshot. The snapshot excludes generated trees and credential material before an agent can read it: `.env` and `.env.*` except `.env.example`, `.npmrc`, `.yarnrc.yml` (it can carry registry auth), `.pypirc`, `.netrc`, `auth.json`, common credential files, SSH private-key names, and private-key extensions.
4+
5+
Snapshot provenance records only a digest, copied file and byte counts, the policy patterns, and aggregate exclusion categories/counts. It never records excluded path names or the temporary snapshot location.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@
8585
"generate:fanout-aggregation-contract": "tsx scripts/generate-fanout-aggregation-contract-fixture.ts",
8686
"smoke": "tsx scripts/run-smoke.ts",
8787
"test:redaction": "tsx tests/redaction.test.ts",
88-
"test:agent-task-contracts": "tsx tests/agent-task-contracts.test.ts && npm run test:agent-task-workflow-interface && npm run test:runtime-sources-materialization && tsx tests/agent-task-reusable-workflow.test.ts",
88+
"test:agent-task-contracts": "tsx tests/agent-task-contracts.test.ts && tsx tests/agent-task-canonical-evidence.test.ts && npm run test:agent-task-workflow-interface && npm run test:runtime-sources-materialization && tsx tests/agent-task-reusable-workflow.test.ts",
8989
"test:agent-task-workflow-interface": "tsx tests/run-agent-task-reusable-workflow-interface.test.ts",
9090
"test:agent-task-runtime-package-staging": "tsx tests/agent-task-runtime-package-staging.test.ts",
9191
"test:external-native-package-materialization": "tsx tests/external-native-package-materialization.test.ts",

packages/cli/src/commands/agent-task-run.ts

Lines changed: 68 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import { lstat, mkdir, mkdtemp, readFile, rename, rm, writeFile } from "node:fs/promises"
1+
import { lstat, mkdir, mkdtemp, readFile, realpath, rename, rm, writeFile } from "node:fs/promises"
22
import { tmpdir } from "node:os"
3-
import { dirname, join } from "node:path"
4-
import { AGENT_TASK_RUN_REQUEST_SCHEMA, HEADLESS_AGENT_TASK_REQUEST_SCHEMA, artifactResultEnvelope, buildAgentTaskRecipe, DEFAULT_WORDPRESS_VERSION, headlessAgentTaskRequestToRunInput, normalizeAgentRuntimeExecutionChanges, normalizeAgentRuntimeWorkload, normalizeAgentTaskRunResult, normalizeAgentTerminalResult, normalizeArtifactResultTypedArtifacts, normalizeHeadlessAgentTaskRequest, normalizeHeadlessAgentTaskResult, normalizeTaskInput, parseCommandJson, parseCommandOptions, resolveEffectiveRuntimeToolPolicy, type AgentTaskRunInput, type AgentTaskRunResultSummary, type AgentTerminalResult, type ArtifactResultEnvelope, type HeadlessAgentTaskResult, type SandboxToolPolicySnapshot, type TypedArtifactDTO } from "@automattic/wp-codebox-core"
3+
import { dirname, isAbsolute, join, relative, resolve } from "node:path"
4+
import { AGENT_TASK_RUN_REQUEST_SCHEMA, HEADLESS_AGENT_TASK_REQUEST_SCHEMA, artifactResultEnvelope, buildAgentTaskRecipe, DEFAULT_WORDPRESS_VERSION, headlessAgentTaskRequestToRunInput, normalizeAgentRuntimeExecutionChanges, normalizeAgentRuntimeWorkload, normalizeAgentTaskRunResult, normalizeAgentTerminalResult, normalizeArtifactResultTypedArtifacts, normalizeHeadlessAgentTaskRequest, normalizeHeadlessAgentTaskResult, normalizeTaskInput, parseCommandJson, parseCommandOptions, publicArtifactRefGroups, resolveEffectiveRuntimeToolPolicy, type AgentTaskRunInput, type AgentTaskRunResultSummary, type AgentTerminalResult, type ArtifactResultEnvelope, type HeadlessAgentTaskResult, type SandboxToolPolicySnapshot, type TypedArtifactDTO } from "@automattic/wp-codebox-core"
55
import { stripUndefined } from "@automattic/wp-codebox-core/internals"
66
import { runRecipeRunCommand } from "./recipe-run.js"
77

@@ -168,7 +168,7 @@ export async function runAgentTask(input: AgentTaskRunInput, options: AgentTaskR
168168
const agentBundle = objectValue(input.agent_bundle) || {}
169169
const metadataRuntime = objectValue(objectValue(run.metadata)?.agent_runtime)
170170
const agentResult = objectValue(run.agentResult) || objectValue(runRecord.agentResult) || objectValue(artifactsRecord.agentResult) || {}
171-
const executionChanges = normalizeAgentRuntimeExecutionChanges(agentResult)
171+
const executionChanges = await validatedExecutionChanges(agentResult, artifacts)
172172
const workload = normalizeAgentRuntimeWorkload(metadataRuntime?.workload ?? run, {
173173
requiredOutputs: stringRecord(agentBundle.engine_data_outputs),
174174
toolRecorders: agentBundle.tool_recorders,
@@ -715,6 +715,70 @@ function artifactResultDiagnostics(diagnostics: Array<Record<string, unknown>>):
715715
}))
716716
}
717717

718+
const MAX_CANONICAL_EXECUTION_ARTIFACT_BYTES = 5 * 1024 * 1024
719+
720+
/**
721+
* Counters are runner diagnostics, not proof of a change. Only captured canonical
722+
* artifacts under the recipe's trusted root may satisfy semantic output policy.
723+
*/
724+
export async function validatedExecutionChanges(agentResult: Record<string, unknown>, artifactRoot: string) {
725+
const counters = normalizeAgentRuntimeExecutionChanges(agentResult)
726+
if (!counters || counters.changed_files_count <= 0 || counters.patch_bytes <= 0) return undefined
727+
728+
const groups = publicArtifactRefGroups(agentResult)
729+
// Recipe capture exposes the canonical descriptors as changedFiles/patch; some
730+
// runners additionally project them into public refs. Treat either shape as a
731+
// single reference, never a counter-only substitute.
732+
const directChanged = objectValue(agentResult.changedFiles)
733+
const directPatch = objectValue(agentResult.patch)
734+
const changedRefs = groups.changed_files.length > 0 ? groups.changed_files : (stringValue(directChanged?.artifact) ? [{ path: stringValue(directChanged?.artifact), sha256: stringValue(directChanged?.sha256) }] : [])
735+
const patchRefs = groups.patches.length > 0 ? groups.patches : (stringValue(directPatch?.artifact) ? [{ path: stringValue(directPatch?.artifact), sha256: stringValue(directPatch?.sha256) }] : [])
736+
if (changedRefs.length !== 1 || patchRefs.length !== 1) return undefined
737+
const [changedRef] = changedRefs
738+
const [patchRef] = patchRefs
739+
if (!changedRef?.path || !patchRef?.path) return undefined
740+
741+
try {
742+
const root = await realpath(resolve(artifactRoot))
743+
const bundleDirectory = stringValue(objectValue(agentResult.artifacts)?.directory)
744+
const base = bundleDirectory ? await trustedArtifactPath(root, bundleDirectory, true) : root
745+
const [changedPath, patchPath] = await Promise.all([trustedArtifactPath(base, changedRef.path), trustedArtifactPath(base, patchRef.path)])
746+
const [changedText, patch] = await Promise.all([readBoundedArtifact(changedPath), readBoundedArtifact(patchPath)])
747+
const changed = objectValue(JSON.parse(changedText))
748+
const files = Array.isArray(changed?.files) ? changed.files : []
749+
const paths = files.map((file) => stringValue(objectValue(file)?.relativePath)).filter(Boolean)
750+
if (changed?.schema !== "wp-codebox/changed-files/v1" || paths.length === 0 || paths.length !== counters.changed_files_count || !patch.trim() || Buffer.byteLength(patch) !== counters.patch_bytes) return undefined
751+
const patchPaths = patch.split("\n").flatMap((line) => {
752+
if (!line.startsWith("--- ") && !line.startsWith("+++ ")) return []
753+
const path = line.slice(4).split("\t", 1)[0].trim().replace(/^[ab]\//, "")
754+
return path === "/dev/null" ? [] : [path]
755+
})
756+
if (patchPaths.length === 0 || paths.some((path) => !patchPaths.includes(path)) || patchPaths.some((path) => !paths.includes(path))) return undefined
757+
return normalizeAgentRuntimeExecutionChanges({
758+
changedFiles: { count: paths.length, artifact: changedRef.path, bytes: Buffer.byteLength(changedText), sha256: changedRef.sha256 },
759+
patch: { bytes: Buffer.byteLength(patch), artifact: patchRef.path, sha256: patchRef.sha256 },
760+
})
761+
} catch {
762+
return undefined
763+
}
764+
}
765+
766+
async function trustedArtifactPath(root: string, artifactPath: string, directory = false): Promise<string> {
767+
const candidate = isAbsolute(artifactPath) ? resolve(artifactPath) : resolve(root, artifactPath)
768+
const stat = await lstat(candidate)
769+
if ((!directory && !stat.isFile()) || (directory && !stat.isDirectory()) || stat.isSymbolicLink() || (!directory && stat.size > MAX_CANONICAL_EXECUTION_ARTIFACT_BYTES)) throw new Error("Canonical execution artifact must be a bounded regular file.")
770+
const resolved = await realpath(candidate)
771+
const contained = relative(root, resolved)
772+
if (resolved !== root && (contained === ".." || contained.startsWith(`..${String.fromCharCode(47)}`) || isAbsolute(contained))) throw new Error("Canonical execution artifact escapes the trusted artifact root.")
773+
return resolved
774+
}
775+
776+
async function readBoundedArtifact(path: string): Promise<string> {
777+
const bytes = await readFile(path)
778+
if (bytes.length > MAX_CANONICAL_EXECUTION_ARTIFACT_BYTES || bytes.includes(0)) throw new Error("Canonical execution artifact must be bounded text.")
779+
return bytes.toString("utf8")
780+
}
781+
718782
async function readJsonRecord(path: string): Promise<Record<string, unknown> | undefined> {
719783
if (!path) return undefined
720784
try {

0 commit comments

Comments
 (0)