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
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,8 @@ const request = {
requires_pr: booleanEnv("SUCCESS_REQUIRES_PR"),
},
access: {
caller_repo: requiredString("CALLER_REPO").toLowerCase(),
access_token_repos: repositoryList("ACCESS_TOKEN_REPOS"),
require_access_token: booleanEnv("REQUIRE_ACCESS_TOKEN"),
allowed_repos: parseJson("ALLOWED_REPOS", [], "array"),
},
limits: {
Expand All @@ -115,6 +115,9 @@ if (request.access.allowed_repos.some((repository) => typeof repository !== "str
request.access.allowed_repos = [...new Set(request.access.allowed_repos.map((repository) => repository.toLowerCase()))]
request.access.access_token_repos = [...new Set(request.access.access_token_repos.map((repository) => repository.toLowerCase()))]
request.target_repo = request.target_repo.toLowerCase()
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(request.access.caller_repo)) {
throw new Error("CALLER_REPO must be an OWNER/REPO value.")
}
if (!request.access.allowed_repos.includes(request.target_repo)) {
throw new Error("ALLOWED_REPOS must explicitly include TARGET_REPO.")
}
Expand Down
5 changes: 4 additions & 1 deletion .github/scripts/run-agent-task/execute-native-agent-task.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -118,8 +118,11 @@ function accessFailure(request) {
const allowed = Array.isArray(access.allowed_repos) ? access.allowed_repos : []
const tokenRepos = Array.isArray(access.access_token_repos) ? access.access_token_repos : []
const target = string(request.target_repo)
const caller = string(access.caller_repo)
if (!allowed.includes(target) || !tokenRepos.includes(target)) return "Target repository is not explicitly authorized by allowed_repos and access_token_repos."
if (access.require_access_token === true && process.env.ACCESS_TOKEN_CONFIGURED !== "true") return "A configured ACCESS_TOKEN is required but unavailable."
if (!caller) return "Caller repository is required for publication authorization."
if (target !== caller && process.env.EXPLICIT_ACCESS_TOKEN_CONFIGURED !== "true") return "An explicit ACCESS_TOKEN is required for cross-repository publication."
if (!string(process.env.GITHUB_TOKEN)) return "No effective GitHub token is available for runner workspace tools."
return ""
}

Expand Down
57 changes: 57 additions & 0 deletions .github/workflows/agent-task-contracts.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
name: Agent Task Contracts

on:
pull_request:
paths:
- ".github/scripts/run-agent-task/**"
- ".github/workflows/agent-task-contracts.yml"
- ".github/workflows/run-agent-task.yml"
- "contracts/agent-task-workflow-request.fixture.json"
- "contracts/run-agent-task-reusable-workflow-interface.v1.json"
- "docs/agent-task-reusable-workflow.md"
- "package-lock.json"
- "package.json"
- "tests/agent-task-*.test.ts"
- "tests/redaction.test.ts"
- "tests/production-boundary-enforcement.test.ts"
- "tests/runtime-tool-policy.test.ts"
push:
paths:
- ".github/scripts/run-agent-task/**"
- ".github/workflows/agent-task-contracts.yml"
- ".github/workflows/run-agent-task.yml"
- "contracts/agent-task-workflow-request.fixture.json"
- "contracts/run-agent-task-reusable-workflow-interface.v1.json"
- "docs/agent-task-reusable-workflow.md"
- "package-lock.json"
- "package.json"
- "tests/agent-task-*.test.ts"
- "tests/redaction.test.ts"
- "tests/production-boundary-enforcement.test.ts"
- "tests/runtime-tool-policy.test.ts"

jobs:
contracts:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run build
- run: npm run test:agent-task-contracts
- run: npm run test:redaction
- run: npm run test:production-boundary-enforcement
- run: npm run test:runtime-tool-policy

workflow-lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker://rhysd/actionlint:1.7.7
with:
args: .github/workflows/run-agent-task.yml
12 changes: 4 additions & 8 deletions .github/workflows/run-agent-task.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,10 +63,6 @@ name: Run Agent Task (reusable)
description: Comma-separated repositories for access-token scoping.
type: string
default: ""
require_access_token:
description: Whether the run requires the configured access token.
type: boolean
default: false
allowed_repos:
description: JSON array of repositories the run may access.
type: string
Expand Down Expand Up @@ -125,7 +121,7 @@ name: Run Agent Task (reusable)
MODEL_PROVIDER_SECRET_5:
required: false
ACCESS_TOKEN:
description: GitHub token for target-repository publication only; it is never used to fetch external packages.
description: Optional explicit GitHub token for an authorized cross-repository target. Same-repository publication uses the caller token.
required: false
EXTERNAL_PACKAGE_SOURCE_POLICY:
description: Trusted versioned JSON policy authorizing external native package repositories and exact standalone .agent.json paths.
Expand All @@ -151,7 +147,7 @@ jobs:
runs-on: ubuntu-latest
env:
# Advance this only in a subsequent workflow commit after helper changes land.
WP_CODEBOX_HELPER_REVISION: 54c2f9a7bc3cd1fe20055d496c83efcfb99afb41
WP_CODEBOX_HELPER_REVISION: d9e329e7c3bb852b857870a26d8f3853feef751d
permissions:
contents: write
pull-requests: write
Expand Down Expand Up @@ -206,6 +202,7 @@ jobs:
WORKLOAD_LABEL: ${{ inputs.workload_label }}
COMPONENT_ID: ${{ inputs.component_id }}
TARGET_REPO: ${{ inputs.target_repo }}
CALLER_REPO: ${{ github.repository }}
PROMPT: ${{ inputs.prompt }}
WRITABLE_PATHS: ${{ inputs.writable_paths }}
PROVIDER: ${{ inputs.provider }}
Expand All @@ -220,7 +217,6 @@ jobs:
DRIFT_CHECKS: ${{ inputs.drift_checks }}
SUCCESS_REQUIRES_PR: ${{ inputs.success_requires_pr }}
ACCESS_TOKEN_REPOS: ${{ inputs.access_token_repos }}
REQUIRE_ACCESS_TOKEN: ${{ inputs.require_access_token }}
ALLOWED_REPOS: ${{ inputs.allowed_repos }}
MAX_TURNS: ${{ inputs.max_turns }}
TIME_BUDGET_MS: ${{ inputs.time_budget_ms }}
Expand Down Expand Up @@ -249,7 +245,7 @@ jobs:
# Runner workspace tools resolve GitHub credentials from this standard
# environment variable. It is never written to task inputs or artifacts.
GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN || github.token }}
ACCESS_TOKEN_CONFIGURED: ${{ secrets.ACCESS_TOKEN != '' }}
EXPLICIT_ACCESS_TOKEN_CONFIGURED: ${{ secrets.ACCESS_TOKEN != '' }}
EXTERNAL_PACKAGE_SOURCE_POLICY: ${{ secrets.EXTERNAL_PACKAGE_SOURCE_POLICY }}
run: node .wp-codebox-workflow/.github/scripts/run-agent-task/execute-native-agent-task.mjs

Expand Down
2 changes: 1 addition & 1 deletion contracts/agent-task-workflow-request.fixture.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,10 @@
"requires_pr": false
},
"access": {
"caller_repo": "automattic/example-target",
"access_token_repos": [
"automattic/example-target"
],
"require_access_token": false,
"allowed_repos": [
"automattic/example-target"
]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@
"drift_checks": { "required": false, "type": "string", "default": "[]" },
"success_requires_pr": { "required": false, "type": "boolean", "default": false },
"access_token_repos": { "required": false, "type": "string", "default": "" },
"require_access_token": { "required": false, "type": "boolean", "default": false },
"allowed_repos": { "required": false, "type": "string", "default": "[]" },
"max_turns": { "required": false, "type": "number", "default": 12 },
"time_budget_ms": { "required": false, "type": "number", "default": 600000 },
Expand Down
11 changes: 7 additions & 4 deletions docs/agent-task-reusable-workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ jobs:
success_requires_pr: true
access_token_repos: Automattic/example-target
allowed_repos: '["Automattic/example-target"]'
require_access_token: true
output_projections: '{"pr_url":"outputs.artifact_result.result.outputs.runner_workspace_publication.pull_request.url"}'
secrets:
EXTERNAL_PACKAGE_SOURCE_POLICY: ${{ secrets.EXTERNAL_PACKAGE_SOURCE_POLICY }}
Expand Down Expand Up @@ -58,13 +57,17 @@ and [29281470159](https://github.com/Automattic/build-with-wordpress/actions/run
- `success_requires_pr`: require a successful, published runner-workspace pull request for `target_repo`.
- `access_token_repos`: comma-separated repositories available to the supplied access token.
- `allowed_repos`: JSON repository allowlist. It and `access_token_repos` must explicitly include `target_repo`.
- `require_access_token`: require the reusable workflow's `ACCESS_TOKEN` secret.
- `output_projections`: JSON object mapping output names to dot-delimited paths in the native result. Every projection must resolve.

## Access And Publication

`ACCESS_TOKEN` is passed only as `GITHUB_TOKEN` to native agent execution and is
not serialized into task input, result, or artifact data. GitHub runner tools
For a target equal to the caller repository, the workflow passes the caller's
built-in `github.token` as `GITHUB_TOKEN` to native agent execution. An
`ACCESS_TOKEN` secret is not required in that case. For a target in another
repository, `ACCESS_TOKEN` is required explicitly and must be scoped by both
`access_token_repos` and `allowed_repos`. The caller token is never inferred to
have cross-repository access. The effective token is not serialized into task
input, result, or artifact data. GitHub runner tools
receive the normalized `allowed_repos` policy explicitly through the runtime and
fail closed for every PR, issue, and comment operation outside that set. The
checkout does not persist credentials. Verification, dependency, and drift
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@
"generate:fanout-aggregation-contract": "tsx scripts/generate-fanout-aggregation-contract-fixture.ts",
"smoke": "tsx scripts/run-smoke.ts",
"test:redaction": "tsx tests/redaction.test.ts",
"test:agent-task-contracts": "tsx tests/agent-task-contracts.test.ts && npm run test:agent-task-workflow-interface",
"test:agent-task-contracts": "tsx tests/agent-task-contracts.test.ts && npm run test:agent-task-workflow-interface && tsx tests/agent-task-reusable-workflow.test.ts",
"test:agent-task-workflow-interface": "tsx tests/run-agent-task-reusable-workflow-interface.test.ts",
"test:agent-task-runtime-package-staging": "tsx tests/agent-task-runtime-package-staging.test.ts",
"test:external-native-package-materialization": "tsx tests/external-native-package-materialization.test.ts",
Expand Down
71 changes: 63 additions & 8 deletions tests/agent-task-reusable-workflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,12 @@ assert.match(workflow, /output_projections:/)
assert.match(workflow, /verification_commands:/)
assert.match(workflow, /drift_checks:/)
assert.match(workflow, /access_token_repos:/)
assert.match(workflow, /require_access_token:/)
assert.doesNotMatch(publicWorkflowSurface, /require_access_token:/)
assert.match(workflow, /projected_outputs_json:/)
assert.match(workflow, /ACCESS_TOKEN:/)
assert.match(workflow, /GITHUB_TOKEN: \$\{\{ secrets\.ACCESS_TOKEN \|\| github\.token \}\}/)
assert.match(workflow, /ACCESS_TOKEN_CONFIGURED: \$\{\{ secrets\.ACCESS_TOKEN != '' \}\}/)
assert.match(workflow, /CALLER_REPO: \$\{\{ github\.repository \}\}/)
assert.match(workflow, /EXPLICIT_ACCESS_TOKEN_CONFIGURED: \$\{\{ secrets\.ACCESS_TOKEN != '' \}\}/)
assert.doesNotMatch(workflow, /homeboy|require_app_token|require_homeboy_app_token|REQUIRE_HOMEBOY_APP_TOKEN|Extra-Chill\/homeboy-action|agent-task run-plan/i)
assert.doesNotMatch(workflow, /docs-agent|wp-codebox\/docs-agent-runner-recipe\/v1|recipe_path|recipe_json|wp_codebox_ref/i)
assert.doesNotMatch(workflow, /datamachine-agent-ci|runtime-agent-full-run|Extra-Chill\/homeboy-extensions/)
Expand All @@ -50,8 +51,8 @@ assert.doesNotMatch(workflow, new RegExp(foreignCallerSha))
assert.match(workflow, /Validate pinned WP Codebox helper revision/)

const requiredHelperFiles = {
".github/scripts/run-agent-task/build-codebox-task-request.mjs": "a0b9052bf63b0d5b096568c21a1e7333b7474b35834ffd1bc0d748fb0cfd0859",
".github/scripts/run-agent-task/execute-native-agent-task.mjs": "b67910e538c6fb231c25dee43d0ff2e06e521e08108f8570e0389b022f9f8ccc",
".github/scripts/run-agent-task/build-codebox-task-request.mjs": "ed1fbc144428bfbf222810a8a467390e8cd5b45daa0f3e64e03162949d6c84df",
".github/scripts/run-agent-task/execute-native-agent-task.mjs": "324c6d12dd01880bc9e4cd880ea6c713cb1bfd630b4925c53d78af7283e4a459",
".github/scripts/run-agent-task/prepare-agent-task-upload.mjs": "93511e4174e705f55ff014ba68ac52fdaca771ce3f19f3921cceb661e69569da",
}
for (const [path, digest] of Object.entries(requiredHelperFiles)) {
Expand All @@ -70,7 +71,8 @@ assert.match(docs, /Automattic\/wp-codebox\/.github\/workflows\/run-agent-task.y
assert.match(docs, /external_package_source/)
assert.match(docs, /runner_workspace/)
assert.match(docs, /access_token_repos/)
assert.match(docs, /require_access_token/)
assert.match(docs, /built-in `github\.token` as `GITHUB_TOKEN`/)
assert.match(docs, /target in another\nrepository, `ACCESS_TOKEN` is required explicitly/)
assert.match(docs, /success_requires_pr/)
assert.match(docs, /intentional exposed-workflow breaking change/)
assert.match(docs, /wp-codebox\/reusable-workflow-interface\/v1/)
Expand Down Expand Up @@ -110,6 +112,7 @@ await execFileAsync("node", [new URL("../.github/scripts/run-agent-task/build-co
WORKLOAD_LABEL: "Run example maintenance",
COMPONENT_ID: "example-ci-driver",
TARGET_REPO: "Automattic/example-target",
CALLER_REPO: "Automattic/example-target",
PROMPT: "Update the configured surface.",
WRITABLE_PATHS: "README.md,docs/**",
PROVIDER: "openai",
Expand All @@ -120,7 +123,6 @@ await execFileAsync("node", [new URL("../.github/scripts/run-agent-task/build-co
DRIFT_CHECKS: "[]",
SUCCESS_REQUIRES_PR: "false",
ACCESS_TOKEN_REPOS: "Automattic/example-target",
REQUIRE_ACCESS_TOKEN: "false",
ALLOWED_REPOS: '["Automattic/example-target"]',
MAX_TURNS: "12",
TIME_BUDGET_MS: "600000",
Expand Down Expand Up @@ -152,6 +154,7 @@ await execFileAsync("node", [new URL("../.github/scripts/run-agent-task/execute-
AGENT_TASK_REQUEST_PATH: requestPath,
AGENT_TASK_WORKSPACE: tmp,
WP_CODEBOX_WORKFLOW_ROOT: new URL("..", import.meta.url).pathname,
GITHUB_TOKEN: "test-caller-token",
EXTERNAL_PACKAGE_SOURCE_POLICY: '{"version":1,"repositories":{"automattic/example-agent-packages":["packages/example-agent.agent.json"]}}',
},
})
Expand All @@ -164,6 +167,55 @@ assert.equal(result.runtime_input_path, ".codebox/native-agent-task-input.json")
assert.deepEqual(result.verification, [])
assert.doesNotMatch(JSON.stringify(result), /homeboy|agent-task-plan|run-plan/i)

const executeNativeAgentTask = new URL("../.github/scripts/run-agent-task/execute-native-agent-task.mjs", import.meta.url).pathname
const executeAccessCase = async (candidate: Record<string, unknown>, environment: Record<string, string>) => {
await writeFile(requestPath, `${JSON.stringify(candidate, null, 2)}\n`)
return execFileAsync("node", [executeNativeAgentTask], {
cwd: tmp,
env: {
...process.env,
GITHUB_OUTPUT: outputPath,
AGENT_TASK_REQUEST_PATH: requestPath,
AGENT_TASK_WORKSPACE: tmp,
WP_CODEBOX_WORKFLOW_ROOT: new URL("..", import.meta.url).pathname,
EXTERNAL_PACKAGE_SOURCE_POLICY: '{"version":1,"repositories":{"automattic/example-agent-packages":["packages/example-agent.agent.json"]}}',
...environment,
},
})
}

// Hosted run 29293335972 supplied github.token with write permissions but no
// named ACCESS_TOKEN. This is the exact same-repository environment.
const callerToken = "caller-token-from-run-29293335972"
await executeAccessCase(request, { GITHUB_TOKEN: callerToken, EXPLICIT_ACCESS_TOKEN_CONFIGURED: "false" })
const sameRepositoryResult = JSON.parse(await readFile(resultPath, "utf8"))
assert.equal(sameRepositoryResult.access.authorized, true)
assert.doesNotMatch(JSON.stringify(sameRepositoryResult), new RegExp(callerToken))

await assert.rejects(
executeAccessCase(request, { GITHUB_TOKEN: "", EXPLICIT_ACCESS_TOKEN_CONFIGURED: "false" }),
)
assert.match(JSON.parse(await readFile(resultPath, "utf8")).access.error, /No effective GitHub token/)

const crossRepositoryRequest = {
...request,
target_repo: "automattic/another-target",
access: { ...request.access, allowed_repos: ["automattic/another-target"], access_token_repos: ["automattic/another-target"] },
}
await assert.rejects(
executeAccessCase(crossRepositoryRequest, { GITHUB_TOKEN: callerToken, EXPLICIT_ACCESS_TOKEN_CONFIGURED: "false" }),
)
assert.match(JSON.parse(await readFile(resultPath, "utf8")).access.error, /explicit ACCESS_TOKEN is required for cross-repository publication/)

const targetMismatchRequest = {
...request,
target_repo: "automattic/another-target",
}
await assert.rejects(
executeAccessCase(targetMismatchRequest, { GITHUB_TOKEN: callerToken, EXPLICIT_ACCESS_TOKEN_CONFIGURED: "true" }),
)
assert.match(JSON.parse(await readFile(resultPath, "utf8")).access.error, /Target repository is not explicitly authorized/)

const malformedRequest = { ...request, verification_commands: [{ description: "Missing command" }] }
await writeFile(requestPath, `${JSON.stringify(malformedRequest, null, 2)}\n`)
await assert.rejects(execFileAsync("node", [new URL("../.github/scripts/run-agent-task/execute-native-agent-task.mjs", import.meta.url).pathname], {
Expand All @@ -174,6 +226,7 @@ await assert.rejects(execFileAsync("node", [new URL("../.github/scripts/run-agen
AGENT_TASK_REQUEST_PATH: requestPath,
AGENT_TASK_WORKSPACE: tmp,
WP_CODEBOX_WORKFLOW_ROOT: new URL("..", import.meta.url).pathname,
GITHUB_TOKEN: "test-caller-token",
EXTERNAL_PACKAGE_SOURCE_POLICY: '{"version":1,"repositories":{"automattic/example-agent-packages":["packages/example-agent.agent.json"]}}',
},
}), /verification_commands\[0\]\.command/)
Expand All @@ -187,15 +240,16 @@ await execFileAsync("npm", ["run", "test:php-runner-workspace-tools"], { cwd: ne

const outputs = await readFile(outputPath, "utf8")
assert.match(outputs, /job_status<<__WP_CODEBOX_OUTPUT__\nskipped\n__WP_CODEBOX_OUTPUT__/)
assert.match(outputs, /credential_mode<<__WP_CODEBOX_OUTPUT__\nrunner-(provider|default)-credentials\n__WP_CODEBOX_OUTPUT__/)
assert.match(outputs, /credential_mode<<__WP_CODEBOX_OUTPUT__\nrunner-access-token\n__WP_CODEBOX_OUTPUT__/)
assert.doesNotMatch(outputs, /caller-token-from-run-29293335972/)
assert.match(outputs, /request_path<<__WP_CODEBOX_OUTPUT__\n\.codebox\/agent-task-request\.json\n__WP_CODEBOX_OUTPUT__/)
assert.match(outputs, /result_path<<__WP_CODEBOX_OUTPUT__\n\.codebox\/agent-task-workflow-result\.json\n__WP_CODEBOX_OUTPUT__/)

// Uploads come only from a fail-closed staging directory. Policy data remains
// secret even though public package bytes may be present in runtime input.
const artifactsPath = join(tmp, ".codebox", "agent-task-artifacts")
await mkdir(artifactsPath, { recursive: true })
await writeFile(join(artifactsPath, "safe.txt"), "secret-agent-value")
await writeFile(join(artifactsPath, "safe.txt"), "secret-agent-value secret-github-value")
await writeFile(join(artifactsPath, "oversize.txt"), `secret-agent-value${"x".repeat(4 * 1024 * 1024)}`)
await writeFile(join(artifactsPath, "binary.bin"), Buffer.from([0, ...Buffer.from("secret-agent-value")]))
const outsideArtifact = join(tmp, "outside-secret.txt")
Expand All @@ -208,6 +262,7 @@ await execFileAsync("node", [new URL("../.github/scripts/run-agent-task/prepare-
})
const uploadArtifactsPath = join(tmp, ".codebox", "agent-task-upload", ".codebox", "agent-task-artifacts")
assert.match(await readFile(join(uploadArtifactsPath, "safe.txt"), "utf8"), /\[REDACTED\]/)
assert.doesNotMatch(await readFile(join(uploadArtifactsPath, "safe.txt"), "utf8"), /secret-github-value/)
assert.doesNotMatch(await readFile(join(tmp, ".codebox", "agent-task-upload", ".codebox", "agent-task-request.json"), "utf8"), /secret-agent-value|secret-github-value|\{"private":"policy"\}/)
for (const name of ["oversize.txt", "binary.bin", "linked-secret.txt"]) {
await assert.rejects(readFile(join(uploadArtifactsPath, name), "utf8"), /ENOENT/)
Expand Down
Loading