Skip to content

Commit 47c91a2

Browse files
authored
Propagate native provider and isolate prepared sources (#1770)
* Propagate native provider and isolate prepared sources * Harden runtime provider source isolation
1 parent 3aa94b3 commit 47c91a2

9 files changed

Lines changed: 182 additions & 26 deletions

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

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { rmSync } from "node:fs"
22
import { appendFile, mkdir, readFile, rm, writeFile } from "node:fs/promises"
3-
import { join, resolve } from "node:path"
3+
import { isAbsolute, join, relative, resolve } from "node:path"
44
import { spawn } from "node:child_process"
5-
import { materializeExternalNativePackage, materializeRuntimeSources, normalizeExternalPackageSource, normalizeRuntimeSources, parseExternalPackageSourcePolicy } from "./materialize-external-native-package.mjs"
5+
import { materializeExternalNativePackage, materializeRuntimeSources, normalizeExternalPackageSource, normalizeRuntimeSources, parseExternalPackageSourcePolicy, validateRuntimeSourceModel } from "./materialize-external-native-package.mjs"
66
import { readNativeResult } from "./native-result-file.mjs"
77

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

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

271277
const taskInput = {
@@ -280,8 +286,6 @@ const taskInput = {
280286
target: { kind: "repo", materialization: { root: workspace } },
281287
expected_artifacts: request.artifacts?.expected || [],
282288
structured_artifacts: request.artifacts?.declarations || [],
283-
provider: request.model?.provider,
284-
model: request.model?.name,
285289
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]),
286290
...runtimeSourceInputs,
287291
allowed_tools: runnerWorkspaceTools,
@@ -306,6 +310,8 @@ const taskInput = {
306310
workflow: { id: "agents/chat" },
307311
input: {
308312
prompt: request.prompt,
313+
provider: requestedModel.provider,
314+
model: requestedModel.name,
309315
runner_workspace: { ...record(request.runner_workspace), allowed_repos: request.access.allowed_repos },
310316
target_repo: request.target_repo,
311317
writable_paths: request.writable_paths,
@@ -338,6 +344,7 @@ assertNoPrivateRuntimePaths(nativeRuntimeResult)
338344
const runtimeResult = omitPrivateRuntimeSourcePaths(nativeRuntimeResult)
339345
assertNoPrivateRuntimePaths(runtimeResult)
340346
await cleanupPrivateRuntimeSources()
347+
assertNoPrivateRuntimePaths(runtimeResult)
341348

342349
await redactArtifactFiles(artifactsPath)
343350

.github/scripts/run-agent-task/materialize-external-native-package.mjs

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ const MAX_PACKAGE_BYTES = 1024 * 1024
1212
const SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
1313
const ROLE = /^(component|provider_plugin|bundled_library)$/
1414
const HTTPS_URL = /^https:\/\//i
15+
const PROVIDER_ID = /^[a-z0-9]+(?:[._-][a-z0-9]+)*$/
16+
const MODEL_ID = /^[A-Za-z0-9]+(?:[._:/-][A-Za-z0-9.]+)*$/
1517
const MAX_ARTIFACT_BYTES = 32 * 1024 * 1024
1618
const MAX_ZIP_ENTRIES = 10_000
1719
const MAX_ZIP_UNCOMPRESSED_BYTES = 128 * 1024 * 1024
@@ -159,6 +161,17 @@ export function normalizeRuntimeSources(value, policy = {}) {
159161
return sources
160162
}
161163

164+
export function validateRuntimeSourceModel(model, sources) {
165+
const provider = string(model?.provider).toLowerCase()
166+
const name = string(model?.name)
167+
if (!PROVIDER_ID.test(provider) || !MODEL_ID.test(name)) throw new Error("model.provider and model.name must be non-empty valid identifiers.")
168+
const declaredProviders = new Set(sources
169+
.filter((source) => source.role === "provider_plugin")
170+
.flatMap((source) => source.metadata.providers ?? []))
171+
if (declaredProviders.size > 0 && !declaredProviders.has(provider)) throw new Error(`Requested provider ${provider} is not declared by an authorized runtime provider plugin.`)
172+
return { provider, name }
173+
}
174+
162175
export function normalizeRuntimeSource(value, policy = {}, label = "runtime_source") {
163176
const source = value && typeof value === "object" && !Array.isArray(value) ? value : {}
164177
if (source.version !== 1) throw new Error(`${label}.version must be 1.`)
@@ -229,7 +242,11 @@ function normalizeRuntimeMetadata(role, metadata, label) {
229242
if (slug && !SLUG.test(slug)) throw new Error(`${label}.metadata.slug must be a stable plugin slug.`)
230243
if (pluginFile && !normalizePath(pluginFile)) throw new Error(`${label}.metadata.pluginFile must be a safe relative path.`)
231244
if (activate !== undefined && typeof activate !== "boolean") throw new Error(`${label}.metadata.activate must be boolean when provided.`)
232-
return { ...(slug ? { slug } : {}), ...(pluginFile ? { pluginFile } : {}), ...(activate === undefined ? { activate: true } : { activate }) }
245+
const providers = metadata.providers
246+
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.`)
247+
const canonicalProviders = [...new Set(providers.map((provider) => string(provider).toLowerCase()))].sort()
248+
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.`)
249+
return { ...(slug ? { slug } : {}), ...(pluginFile ? { pluginFile } : {}), ...(activate === undefined ? { activate: true } : { activate }), providers: canonicalProviders }
233250
}
234251
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.`)
235252
const target = string(metadata.target)
@@ -405,8 +422,9 @@ function pluginFileWithinSource(pluginFile, slug) {
405422
}
406423

407424
export function runtimeSourceProvenance(descriptor) {
408-
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 } : {}) } }
409-
return { role: descriptor.role, repository: descriptor.repository, revision: descriptor.revision, path: descriptor.path, ...(descriptor.digest ? { digest: descriptor.digest } : {}) }
425+
const provider = descriptor.role === "provider_plugin" && descriptor.metadata.providers ? { providers: descriptor.metadata.providers } : {}
426+
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 }
427+
return { role: descriptor.role, repository: descriptor.repository, revision: descriptor.revision, path: descriptor.path, ...(descriptor.digest ? { digest: descriptor.digest } : {}), ...provider }
410428
}
411429

412430
export function assertPrivateRuntimeRoot(root, forbiddenRoots = []) {

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

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
import { constants } from "node:fs"
22
import { lstat, mkdir, open, readdir, readFile, rm, writeFile } from "node:fs/promises"
33
import { isUtf8 } from "node:buffer"
4-
import { join, resolve } from "node:path"
4+
import { isAbsolute, join, relative, resolve } from "node:path"
55

66
const MAX_UPLOAD_FILE_BYTES = 4 * 1024 * 1024
77
const workspace = resolve(process.env.AGENT_TASK_WORKSPACE || process.cwd())
88
const uploadPath = resolve(process.env.AGENT_TASK_UPLOAD_PATH || join(workspace, ".codebox", "agent-task-upload"))
99
const requestPath = resolve(process.env.AGENT_TASK_REQUEST_PATH || join(workspace, ".codebox", "agent-task-request.json"))
1010
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)
1111
const runtimeSourceRoot = process.env.WP_CODEBOX_RUNTIME_SOURCE_ROOT ? resolve(process.env.WP_CODEBOX_RUNTIME_SOURCE_ROOT) : ""
12+
const RUNTIME_SOURCE_TREE = /(^|\/)(prepared-plugins|agents-api|ai-provider-for-openai)(\/|$)/
13+
const RUNTIME_SOURCE_FILE = /^(agents-api\.php|plugin\.php)$/
14+
const RUNTIME_SOURCE_CONTENT = /(?:Plugin Name:|WP_Agents_Registry|OpenAiProvider)/
1215

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

2529
function omitPrivateRuntimeSourcePaths(value) {
2630
if (Array.isArray(value)) return value.map(omitPrivateRuntimeSourcePaths)
2731
if (!value || typeof value !== "object") return value
2832
return Object.fromEntries(Object.entries(value).flatMap(([key, entry]) => {
2933
if (PRIVATE_RUNTIME_PATH_FIELDS.has(key) && isPrivateRuntimePath(entry)) return []
34+
if (key === "runtime_sources" && Array.isArray(entry)) return [[key, entry.map(runtimeSourceProvenance)]]
3035
return [[key, omitPrivateRuntimeSourcePaths(entry)]]
3136
}))
3237
}
3338

39+
function runtimeSourceProvenance(source) {
40+
if (!source || typeof source !== "object" || Array.isArray(source)) return source
41+
const descriptor = source
42+
const provenance = { role: descriptor.role }
43+
if (descriptor.source?.type === "https_zip") {
44+
provenance.source = { type: "https_zip", url: descriptor.source.url, sha256: descriptor.source.sha256, ...(descriptor.source.archive_root ? { archive_root: descriptor.source.archive_root } : {}) }
45+
} else {
46+
Object.assign(provenance, ...["repository", "revision", "path", "digest"].flatMap((key) => descriptor[key] ? [{ [key]: descriptor[key] }] : []))
47+
}
48+
if (descriptor.role === "provider_plugin" && Array.isArray(descriptor.metadata?.providers)) provenance.providers = descriptor.metadata.providers
49+
return provenance
50+
}
51+
3452
function sanitizeText(text) {
3553
try {
3654
return `${JSON.stringify(omitPrivateRuntimeSourcePaths(JSON.parse(text)), null, 2)}\n`
@@ -45,6 +63,9 @@ async function stageFile(source, destination) {
4563
}
4664
const metadata = await lstat(source).catch(() => null)
4765
if (!metadata?.isFile() || metadata.size > MAX_UPLOAD_FILE_BYTES) return false
66+
if (RUNTIME_SOURCE_TREE.test(source) || RUNTIME_SOURCE_FILE.test(source.split("/").pop() || "")) {
67+
throw new Error("Prepared runtime plugin sources must never be staged for artifact upload.")
68+
}
4869
const handle = await open(source, constants.O_RDONLY | constants.O_NOFOLLOW).catch(() => null)
4970
if (!handle) return false
5071
const openedMetadata = await handle.stat()
@@ -53,6 +74,9 @@ async function stageFile(source, destination) {
5374
if (!contents || contents.includes(0) || !isUtf8(contents)) return false
5475
await mkdir(resolve(destination, ".."), { recursive: true })
5576
let text = contents.toString("utf8")
77+
if (RUNTIME_SOURCE_CONTENT.test(text)) {
78+
throw new Error("Prepared runtime plugin source contents must never be staged for artifact upload.")
79+
}
5680
text = sanitizeText(text)
5781
if (runtimeSourceRoot && text.includes(runtimeSourceRoot)) throw new Error("Runtime source paths must never be persisted in artifact uploads.")
5882
await writeFile(destination, redact(text))
@@ -77,6 +101,7 @@ async function assertNoPrivateRuntimePaths(directory) {
77101
else if (entry.isFile()) {
78102
const contents = await readFile(path, "utf8")
79103
if (runtimeSourceRoot && contents.includes(runtimeSourceRoot)) throw new Error("Runtime source paths must never be persisted in artifact uploads.")
104+
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.")
80105
}
81106
}
82107
}

.github/workflows/run-agent-task.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -283,7 +283,7 @@ jobs:
283283
AGENT_TASK_WORKSPACE: ${{ github.workspace }}/workspace
284284
AGENT_TASK_REQUEST_PATH: ${{ github.workspace }}/.codebox/agent-task-request.json
285285
AGENT_TASK_UPLOAD_PATH: ${{ github.workspace }}/workspace/.codebox/agent-task-upload
286-
WP_CODEBOX_RUNTIME_SOURCE_ROOT: ${{ runner.temp }}/wp-codebox-runtime-sources-
286+
WP_CODEBOX_RUNTIME_SOURCE_ROOT: ${{ steps.execute.outputs.runtime_source_root }}
287287
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
288288
MODEL_PROVIDER_SECRET_1: ${{ secrets.MODEL_PROVIDER_SECRET_1 }}
289289
MODEL_PROVIDER_SECRET_2: ${{ secrets.MODEL_PROVIDER_SECRET_2 }}

docs/agent-task-reusable-workflow.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ This release-coherence contract fixes [#1759](https://github.com/Automattic/wp-c
5656
- `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.
5757
- `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.
5858

59-
`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.
59+
`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.
6060
- `target_repo`: `OWNER/REPO` target repository.
6161
- `prompt`, `writable_paths`, provider/model, `max_turns`, `time_budget_ms`, callback data, and artifact declarations: native task inputs.
6262
- `runner_workspace`: JSON runner-workspace publication request owned by the package.

0 commit comments

Comments
 (0)