diff --git a/.github/scripts/run-agent-task/build-codebox-task-request.mjs b/.github/scripts/run-agent-task/build-codebox-task-request.mjs index dcbb7c5a3..ccb5446ed 100644 --- a/.github/scripts/run-agent-task/build-codebox-task-request.mjs +++ b/.github/scripts/run-agent-task/build-codebox-task-request.mjs @@ -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: { @@ -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.") } diff --git a/.github/scripts/run-agent-task/execute-native-agent-task.mjs b/.github/scripts/run-agent-task/execute-native-agent-task.mjs index ad6e83c6a..0e7aec80f 100644 --- a/.github/scripts/run-agent-task/execute-native-agent-task.mjs +++ b/.github/scripts/run-agent-task/execute-native-agent-task.mjs @@ -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 "" } diff --git a/.github/workflows/agent-task-contracts.yml b/.github/workflows/agent-task-contracts.yml new file mode 100644 index 000000000..53712dd40 --- /dev/null +++ b/.github/workflows/agent-task-contracts.yml @@ -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 diff --git a/.github/workflows/run-agent-task.yml b/.github/workflows/run-agent-task.yml index 3a30feeb4..4a21e70cf 100644 --- a/.github/workflows/run-agent-task.yml +++ b/.github/workflows/run-agent-task.yml @@ -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 @@ -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. @@ -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 @@ -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 }} @@ -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 }} @@ -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 diff --git a/contracts/agent-task-workflow-request.fixture.json b/contracts/agent-task-workflow-request.fixture.json index 125c3d6f5..8e0c8c22f 100644 --- a/contracts/agent-task-workflow-request.fixture.json +++ b/contracts/agent-task-workflow-request.fixture.json @@ -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" ] diff --git a/contracts/run-agent-task-reusable-workflow-interface.v1.json b/contracts/run-agent-task-reusable-workflow-interface.v1.json index f11ad029c..76df95c43 100644 --- a/contracts/run-agent-task-reusable-workflow-interface.v1.json +++ b/contracts/run-agent-task-reusable-workflow-interface.v1.json @@ -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 }, diff --git a/docs/agent-task-reusable-workflow.md b/docs/agent-task-reusable-workflow.md index fa93ee62d..65d259f1c 100644 --- a/docs/agent-task-reusable-workflow.md +++ b/docs/agent-task-reusable-workflow.md @@ -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 }} @@ -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 diff --git a/package.json b/package.json index 62dbcd9a4..eb04edf81 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/tests/agent-task-reusable-workflow.test.ts b/tests/agent-task-reusable-workflow.test.ts index 2f2a9f029..b873373f6 100644 --- a/tests/agent-task-reusable-workflow.test.ts +++ b/tests/agent-task-reusable-workflow.test.ts @@ -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/) @@ -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)) { @@ -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/) @@ -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", @@ -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", @@ -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"]}}', }, }) @@ -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, environment: Record) => { + 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], { @@ -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/) @@ -187,7 +240,8 @@ 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__/) @@ -195,7 +249,7 @@ assert.match(outputs, /result_path<<__WP_CODEBOX_OUTPUT__\n\.codebox\/agent-task // 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") @@ -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/)