Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 13 additions & 6 deletions .github/scripts/run-agent-task/execute-native-agent-task.mjs
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { rmSync } from "node:fs"
import { appendFile, mkdir, readFile, rm, writeFile } from "node:fs/promises"
import { join, resolve } from "node:path"
import { isAbsolute, join, relative, resolve } from "node:path"
import { spawn } from "node:child_process"
import { materializeExternalNativePackage, materializeRuntimeSources, normalizeExternalPackageSource, normalizeRuntimeSources, parseExternalPackageSourcePolicy } from "./materialize-external-native-package.mjs"
import { materializeExternalNativePackage, materializeRuntimeSources, normalizeExternalPackageSource, normalizeRuntimeSources, parseExternalPackageSourcePolicy, validateRuntimeSourceModel } from "./materialize-external-native-package.mjs"
import { readNativeResult } from "./native-result-file.mjs"

const requestPath = process.env.AGENT_TASK_REQUEST_PATH || ".codebox/agent-task-request.json"
Expand Down Expand Up @@ -99,7 +99,8 @@ function record(value) {
function isPrivateRuntimePath(value) {
if (!privateRuntimeSourceRoot || typeof value !== "string") return false
const path = resolve(value)
return path === privateRuntimeSourceRoot || path.startsWith(`${privateRuntimeSourceRoot}/`)
const contained = relative(privateRuntimeSourceRoot, path)
return path === privateRuntimeSourceRoot || (contained !== ".." && !contained.startsWith(`..${String.fromCharCode(47)}`) && !isAbsolute(contained))
}

function omitPrivateRuntimeSourcePaths(value) {
Expand Down Expand Up @@ -213,6 +214,7 @@ const runId = `${request.workload?.id || "agent-task"}-${process.env.GITHUB_RUN_
const externalPackagePolicy = parseExternalPackageSourcePolicy(string(process.env.EXTERNAL_PACKAGE_SOURCE_POLICY))
const externalPackageSource = normalizeExternalPackageSource(request.external_package_source, externalPackagePolicy)
const runtimeSources = normalizeRuntimeSources(request.runtime_sources ?? [], externalPackagePolicy)
const requestedModel = validateRuntimeSourceModel(request.model, runtimeSources)
const artifactsPath = join(workspace, ".codebox", "agent-task-artifacts")
const runtimeInputPath = join(workspace, ".codebox", "native-agent-task-input.json")
const resultPath = join(workspace, ".codebox", "agent-task-workflow-result.json")
Expand Down Expand Up @@ -261,11 +263,15 @@ const materializedRuntimeSources = request.run_agent && !request.dry_run
? await materializeRuntimeSources(runtimeSources, { policy: externalPackagePolicy, forbiddenRoots: [workspace, artifactsPath] })
: undefined
privateRuntimeSourceRoot = materializedRuntimeSources?.root ?? ""
await output("runtime_source_root", privateRuntimeSourceRoot)
const runtimeSourceInputs = (materializedRuntimeSources?.lowered ?? []).reduce((input, lowered) => {
for (const [key, entries] of Object.entries(lowered)) input[key] = [...(input[key] ?? []), ...entries]
return input
}, {})
const sourcePackageRoot = privateRuntimeSourceRoot ? join(privateRuntimeSourceRoot, "prepared-packages") : artifactsPath
// Runtime source preparation must remain beside the private checkout, never in
// the target artifact directory that is collected after the run.
const privatePreparationRoot = privateRuntimeSourceRoot ? join(privateRuntimeSourceRoot, "prepared-runtime-sources") : ""
const sourcePackageRoot = privatePreparationRoot || artifactsPath
const executionInputPath = privateRuntimeSourceRoot ? join(privateRuntimeSourceRoot, "native-agent-task-input.json") : runtimeInputPath

const taskInput = {
Expand All @@ -280,8 +286,6 @@ const taskInput = {
target: { kind: "repo", materialization: { root: workspace } },
expected_artifacts: request.artifacts?.expected || [],
structured_artifacts: request.artifacts?.declarations || [],
provider: request.model?.provider,
model: request.model?.name,
secret_env: ["OPENAI_API_KEY", "MODEL_PROVIDER_SECRET_1", "MODEL_PROVIDER_SECRET_2", "MODEL_PROVIDER_SECRET_3", "MODEL_PROVIDER_SECRET_4", "MODEL_PROVIDER_SECRET_5"].filter((name) => process.env[name]),
...runtimeSourceInputs,
allowed_tools: runnerWorkspaceTools,
Expand All @@ -306,6 +310,8 @@ const taskInput = {
workflow: { id: "agents/chat" },
input: {
prompt: request.prompt,
provider: requestedModel.provider,
model: requestedModel.name,
runner_workspace: { ...record(request.runner_workspace), allowed_repos: request.access.allowed_repos },
target_repo: request.target_repo,
writable_paths: request.writable_paths,
Expand Down Expand Up @@ -338,6 +344,7 @@ assertNoPrivateRuntimePaths(nativeRuntimeResult)
const runtimeResult = omitPrivateRuntimeSourcePaths(nativeRuntimeResult)
assertNoPrivateRuntimePaths(runtimeResult)
await cleanupPrivateRuntimeSources()
assertNoPrivateRuntimePaths(runtimeResult)

await redactArtifactFiles(artifactsPath)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ const MAX_PACKAGE_BYTES = 1024 * 1024
const SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
const ROLE = /^(component|provider_plugin|bundled_library)$/
const HTTPS_URL = /^https:\/\//i
const PROVIDER_ID = /^[a-z0-9]+(?:[._-][a-z0-9]+)*$/
const MODEL_ID = /^[A-Za-z0-9]+(?:[._:/-][A-Za-z0-9.]+)*$/
const MAX_ARTIFACT_BYTES = 32 * 1024 * 1024
const MAX_ZIP_ENTRIES = 10_000
const MAX_ZIP_UNCOMPRESSED_BYTES = 128 * 1024 * 1024
Expand Down Expand Up @@ -159,6 +161,17 @@ export function normalizeRuntimeSources(value, policy = {}) {
return sources
}

export function validateRuntimeSourceModel(model, sources) {
const provider = string(model?.provider).toLowerCase()
const name = string(model?.name)
if (!PROVIDER_ID.test(provider) || !MODEL_ID.test(name)) throw new Error("model.provider and model.name must be non-empty valid identifiers.")
const declaredProviders = new Set(sources
.filter((source) => source.role === "provider_plugin")
.flatMap((source) => source.metadata.providers ?? []))
if (declaredProviders.size > 0 && !declaredProviders.has(provider)) throw new Error(`Requested provider ${provider} is not declared by an authorized runtime provider plugin.`)
return { provider, name }
}

export function normalizeRuntimeSource(value, policy = {}, label = "runtime_source") {
const source = value && typeof value === "object" && !Array.isArray(value) ? value : {}
if (source.version !== 1) throw new Error(`${label}.version must be 1.`)
Expand Down Expand Up @@ -229,7 +242,11 @@ function normalizeRuntimeMetadata(role, metadata, label) {
if (slug && !SLUG.test(slug)) throw new Error(`${label}.metadata.slug must be a stable plugin slug.`)
if (pluginFile && !normalizePath(pluginFile)) throw new Error(`${label}.metadata.pluginFile must be a safe relative path.`)
if (activate !== undefined && typeof activate !== "boolean") throw new Error(`${label}.metadata.activate must be boolean when provided.`)
return { ...(slug ? { slug } : {}), ...(pluginFile ? { pluginFile } : {}), ...(activate === undefined ? { activate: true } : { activate }) }
const providers = metadata.providers
if (!Array.isArray(providers) || providers.length === 0 || providers.some((provider) => !PROVIDER_ID.test(string(provider)))) throw new Error(`${label}.metadata.providers must be a non-empty canonical list of provider ids.`)
const canonicalProviders = [...new Set(providers.map((provider) => string(provider).toLowerCase()))].sort()
if (canonicalProviders.length !== providers.length || providers.some((provider, index) => provider !== canonicalProviders[index])) throw new Error(`${label}.metadata.providers must be a sorted, lowercase, duplicate-free canonical allowlist.`)
return { ...(slug ? { slug } : {}), ...(pluginFile ? { pluginFile } : {}), ...(activate === undefined ? { activate: true } : { activate }), providers: canonicalProviders }
}
if (!SLUG.test(string(metadata.library)) || !SLUG.test(string(metadata.strategy))) throw new Error(`${label}.metadata must declare library and strategy for a bundled library.`)
const target = string(metadata.target)
Expand Down Expand Up @@ -405,8 +422,9 @@ function pluginFileWithinSource(pluginFile, slug) {
}

export function runtimeSourceProvenance(descriptor) {
if (descriptor.source?.type === "https_zip") return { role: descriptor.role, source: { type: "https_zip", url: descriptor.source.url, sha256: descriptor.source.sha256, ...(descriptor.source.archive_root ? { archive_root: descriptor.source.archive_root } : {}) } }
return { role: descriptor.role, repository: descriptor.repository, revision: descriptor.revision, path: descriptor.path, ...(descriptor.digest ? { digest: descriptor.digest } : {}) }
const provider = descriptor.role === "provider_plugin" && descriptor.metadata.providers ? { providers: descriptor.metadata.providers } : {}
if (descriptor.source?.type === "https_zip") return { role: descriptor.role, source: { type: "https_zip", url: descriptor.source.url, sha256: descriptor.source.sha256, ...(descriptor.source.archive_root ? { archive_root: descriptor.source.archive_root } : {}) }, ...provider }
return { role: descriptor.role, repository: descriptor.repository, revision: descriptor.revision, path: descriptor.path, ...(descriptor.digest ? { digest: descriptor.digest } : {}), ...provider }
}

export function assertPrivateRuntimeRoot(root, forbiddenRoots = []) {
Expand Down
29 changes: 27 additions & 2 deletions .github/scripts/run-agent-task/prepare-agent-task-upload.mjs
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import { constants } from "node:fs"
import { lstat, mkdir, open, readdir, readFile, rm, writeFile } from "node:fs/promises"
import { isUtf8 } from "node:buffer"
import { join, resolve } from "node:path"
import { isAbsolute, join, relative, resolve } from "node:path"

const MAX_UPLOAD_FILE_BYTES = 4 * 1024 * 1024
const workspace = resolve(process.env.AGENT_TASK_WORKSPACE || process.cwd())
const uploadPath = resolve(process.env.AGENT_TASK_UPLOAD_PATH || join(workspace, ".codebox", "agent-task-upload"))
const requestPath = resolve(process.env.AGENT_TASK_REQUEST_PATH || join(workspace, ".codebox", "agent-task-request.json"))
const secretValues = ["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", "GH_TOKEN", "ACCESS_TOKEN", "EXTERNAL_PACKAGE_SOURCE_POLICY"].map((name) => process.env[name]).filter(Boolean)
const runtimeSourceRoot = process.env.WP_CODEBOX_RUNTIME_SOURCE_ROOT ? resolve(process.env.WP_CODEBOX_RUNTIME_SOURCE_ROOT) : ""
const RUNTIME_SOURCE_TREE = /(^|\/)(prepared-plugins|agents-api|ai-provider-for-openai)(\/|$)/
const RUNTIME_SOURCE_FILE = /^(agents-api\.php|plugin\.php)$/
const RUNTIME_SOURCE_CONTENT = /(?:Plugin Name:|WP_Agents_Registry|OpenAiProvider)/

function redact(value) {
return secretValues.reduce((output, secret) => output.split(secret).join("[REDACTED]"), value)
Expand All @@ -19,18 +22,33 @@ const PRIVATE_RUNTIME_PATH_FIELDS = new Set(["source", "path", "sourceRoot", "or
function isPrivateRuntimePath(value) {
if (!runtimeSourceRoot || typeof value !== "string") return false
const path = resolve(value)
return path === runtimeSourceRoot || path.startsWith(`${runtimeSourceRoot}/`)
const contained = relative(runtimeSourceRoot, path)
return path === runtimeSourceRoot || (contained !== ".." && !contained.startsWith(`..${String.fromCharCode(47)}`) && !isAbsolute(contained))
}

function omitPrivateRuntimeSourcePaths(value) {
if (Array.isArray(value)) return value.map(omitPrivateRuntimeSourcePaths)
if (!value || typeof value !== "object") return value
return Object.fromEntries(Object.entries(value).flatMap(([key, entry]) => {
if (PRIVATE_RUNTIME_PATH_FIELDS.has(key) && isPrivateRuntimePath(entry)) return []
if (key === "runtime_sources" && Array.isArray(entry)) return [[key, entry.map(runtimeSourceProvenance)]]
return [[key, omitPrivateRuntimeSourcePaths(entry)]]
}))
}

function runtimeSourceProvenance(source) {
if (!source || typeof source !== "object" || Array.isArray(source)) return source
const descriptor = source
const provenance = { role: descriptor.role }
if (descriptor.source?.type === "https_zip") {
provenance.source = { type: "https_zip", url: descriptor.source.url, sha256: descriptor.source.sha256, ...(descriptor.source.archive_root ? { archive_root: descriptor.source.archive_root } : {}) }
} else {
Object.assign(provenance, ...["repository", "revision", "path", "digest"].flatMap((key) => descriptor[key] ? [{ [key]: descriptor[key] }] : []))
}
if (descriptor.role === "provider_plugin" && Array.isArray(descriptor.metadata?.providers)) provenance.providers = descriptor.metadata.providers
return provenance
}

function sanitizeText(text) {
try {
return `${JSON.stringify(omitPrivateRuntimeSourcePaths(JSON.parse(text)), null, 2)}\n`
Expand All @@ -45,6 +63,9 @@ async function stageFile(source, destination) {
}
const metadata = await lstat(source).catch(() => null)
if (!metadata?.isFile() || metadata.size > MAX_UPLOAD_FILE_BYTES) return false
if (RUNTIME_SOURCE_TREE.test(source) || RUNTIME_SOURCE_FILE.test(source.split("/").pop() || "")) {
throw new Error("Prepared runtime plugin sources must never be staged for artifact upload.")
}
const handle = await open(source, constants.O_RDONLY | constants.O_NOFOLLOW).catch(() => null)
if (!handle) return false
const openedMetadata = await handle.stat()
Expand All @@ -53,6 +74,9 @@ async function stageFile(source, destination) {
if (!contents || contents.includes(0) || !isUtf8(contents)) return false
await mkdir(resolve(destination, ".."), { recursive: true })
let text = contents.toString("utf8")
if (RUNTIME_SOURCE_CONTENT.test(text)) {
throw new Error("Prepared runtime plugin source contents must never be staged for artifact upload.")
}
text = sanitizeText(text)
if (runtimeSourceRoot && text.includes(runtimeSourceRoot)) throw new Error("Runtime source paths must never be persisted in artifact uploads.")
await writeFile(destination, redact(text))
Expand All @@ -77,6 +101,7 @@ async function assertNoPrivateRuntimePaths(directory) {
else if (entry.isFile()) {
const contents = await readFile(path, "utf8")
if (runtimeSourceRoot && contents.includes(runtimeSourceRoot)) throw new Error("Runtime source paths must never be persisted in artifact uploads.")
if (RUNTIME_SOURCE_TREE.test(path) || RUNTIME_SOURCE_FILE.test(entry.name) || RUNTIME_SOURCE_CONTENT.test(contents)) throw new Error("Prepared runtime plugin sources must never be persisted in artifact uploads.")
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/run-agent-task.yml
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,7 @@ jobs:
AGENT_TASK_WORKSPACE: ${{ github.workspace }}/workspace
AGENT_TASK_REQUEST_PATH: ${{ github.workspace }}/.codebox/agent-task-request.json
AGENT_TASK_UPLOAD_PATH: ${{ github.workspace }}/workspace/.codebox/agent-task-upload
WP_CODEBOX_RUNTIME_SOURCE_ROOT: ${{ runner.temp }}/wp-codebox-runtime-sources-
WP_CODEBOX_RUNTIME_SOURCE_ROOT: ${{ steps.execute.outputs.runtime_source_root }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
MODEL_PROVIDER_SECRET_1: ${{ secrets.MODEL_PROVIDER_SECRET_1 }}
MODEL_PROVIDER_SECRET_2: ${{ secrets.MODEL_PROVIDER_SECRET_2 }}
Expand Down
2 changes: 1 addition & 1 deletion docs/agent-task-reusable-workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ This release-coherence contract fixes [#1759](https://github.com/Automattic/wp-c
- `external_package_source`: immutable descriptor with `repository`, full commit `revision`, one package-relative `.agent.json` `path`, and `digest`. Packages are supported only from publicly accessible GitHub repositories, fetched from canonical `https://github.com/OWNER/REPOSITORY.git` without credentials. `digest` is exactly `sha256-bytes-v1:<lowercase-sha256>` over the raw file bytes; filenames and JSON content are UTF-8-safe and are not normalized before hashing.
- `EXTERNAL_PACKAGE_SOURCE_POLICY`: required reusable-workflow secret, supplied by the caller's operator-controlled secret configuration. Its strict version 1 JSON shape is `{"version":1,"repositories":{"owner/repository":["agents/example.agent.json"]}}`. Every entry is an exact standalone `.agent.json` path. The policy is validated in runner memory, is never part of task input, and is not uploaded.

`runtime_sources` is an optional versioned JSON array for immutable public runtime dependencies. Git descriptors declare `version: 1`, `role` (`component`, `provider_plugin`, or `bundled_library`), `repository`, a full 40-character `revision`, exact directory `path`, optional `sha256-git-archive-v1` digest, and role metadata. Component and provider descriptors may instead declare `source: {"type":"https_zip","url":"https://...","sha256":"<64 lowercase hex>","archive_root":"plugin-directory"}`. The operator-controlled policy separately authorizes Git sources with `runtime_sources` and exact ZIP URLs with `runtime_artifacts`, for example `{"version":1,"repositories":{},"runtime_artifacts":[{"url":"https://downloads.wordpress.org/plugin/example.zip","sha256":"<64 lowercase hex>"}]}`. Every exact `runtime_artifacts` URL requires `sha256`; the descriptor digest must exactly equal that trusted policy digest, so callers cannot authorize mutable current bytes. ZIP downloads require HTTPS, an exact allowlisted URL, a matching descriptor digest, bounded transfer and extraction sizes, and redirects only to another allowlisted URL on the original host. The runner verifies the digest before extraction; rejects traversal, symlink, special-file, encrypted, ZIP64, oversized, and multi-root archives; stages under a private temp root outside the workspace and artifacts; removes the staging root after execution; and strips private materialization paths from upload staging before scanning staged files for leaks. Runtime source descriptors lower into existing component, provider plugin, and runtime overlay inputs; artifacts retain provenance only.
`runtime_sources` is an optional versioned JSON array for immutable public runtime dependencies. Git descriptors declare `version: 1`, `role` (`component`, `provider_plugin`, or `bundled_library`), `repository`, a full 40-character `revision`, exact directory `path`, optional `sha256-git-archive-v1` digest, and role metadata. Every provider-plugin descriptor must declare `metadata.providers` as a sorted, lowercase, duplicate-free non-empty provider allowlist. The selected non-empty provider/model must be in that allowlist, and the model is passed only with that selected provider to the runtime package turn. Component and provider descriptors may instead declare `source: {"type":"https_zip","url":"https://...","sha256":"<64 lowercase hex>","archive_root":"plugin-directory"}`. The operator-controlled policy separately authorizes Git sources with `runtime_sources` and exact ZIP URLs with `runtime_artifacts`, for example `{"version":1,"repositories":{},"runtime_artifacts":[{"url":"https://downloads.wordpress.org/plugin/example.zip","sha256":"<64 lowercase hex>"}]}`. Every exact `runtime_artifacts` URL requires `sha256`; the descriptor digest must exactly equal that trusted policy digest, so callers cannot authorize mutable current bytes. ZIP downloads require HTTPS, an exact allowlisted URL, a matching descriptor digest, bounded transfer and extraction sizes, and redirects only to another allowlisted URL on the original host. The runner verifies the digest before extraction; rejects traversal, symlink, special-file, encrypted, ZIP64, oversized, and multi-root archives; stages under a private temp root outside the workspace and artifacts; removes the staging root after execution; and strips private materialization paths using the exact `mkdtemp` root handed from the executor as an ephemeral step output before scanning staged files for leaks. Runtime source descriptors lower into existing component, provider plugin, and runtime overlay inputs; artifacts retain provenance only.
- `target_repo`: `OWNER/REPO` target repository.
- `prompt`, `writable_paths`, provider/model, `max_turns`, `time_budget_ms`, callback data, and artifact declarations: native task inputs.
- `runner_workspace`: JSON runner-workspace publication request owned by the package.
Expand Down
Loading
Loading