|
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" |
2 | 2 | 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" |
5 | 5 | import { stripUndefined } from "@automattic/wp-codebox-core/internals" |
6 | 6 | import { runRecipeRunCommand } from "./recipe-run.js" |
7 | 7 |
|
@@ -168,7 +168,7 @@ export async function runAgentTask(input: AgentTaskRunInput, options: AgentTaskR |
168 | 168 | const agentBundle = objectValue(input.agent_bundle) || {} |
169 | 169 | const metadataRuntime = objectValue(objectValue(run.metadata)?.agent_runtime) |
170 | 170 | const agentResult = objectValue(run.agentResult) || objectValue(runRecord.agentResult) || objectValue(artifactsRecord.agentResult) || {} |
171 | | - const executionChanges = normalizeAgentRuntimeExecutionChanges(agentResult) |
| 171 | + const executionChanges = await validatedExecutionChanges(agentResult, artifacts) |
172 | 172 | const workload = normalizeAgentRuntimeWorkload(metadataRuntime?.workload ?? run, { |
173 | 173 | requiredOutputs: stringRecord(agentBundle.engine_data_outputs), |
174 | 174 | toolRecorders: agentBundle.tool_recorders, |
@@ -715,6 +715,70 @@ function artifactResultDiagnostics(diagnostics: Array<Record<string, unknown>>): |
715 | 715 | })) |
716 | 716 | } |
717 | 717 |
|
| 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 | + |
718 | 782 | async function readJsonRecord(path: string): Promise<Record<string, unknown> | undefined> { |
719 | 783 | if (!path) return undefined |
720 | 784 | try { |
|
0 commit comments