diff --git a/.github/workflows/cora-review.yml b/.github/workflows/cora-review.yml index 5a221d2..e47dc3d 100644 --- a/.github/workflows/cora-review.yml +++ b/.github/workflows/cora-review.yml @@ -13,28 +13,80 @@ permissions: contents: read pull-requests: write security-events: write + id-token: write # workload identity federation with Anthropic (no stored API key) jobs: cora-review: name: Cora Review runs-on: ubuntu-latest timeout-minutes: 30 + env: + # Anthropic workload identity federation (organization secrets or variables). + # When all four are set, the job exchanges its GitHub OIDC token for a + # short-lived Anthropic access token instead of using CORA_API_KEY. + FEDERATION_RULE_ID: ${{ secrets.ANTHROPIC_FEDERATION_RULE_ID || vars.ANTHROPIC_FEDERATION_RULE_ID }} + ORGANIZATION_ID: ${{ secrets.ANTHROPIC_ORGANIZATION_ID || vars.ANTHROPIC_ORGANIZATION_ID }} + SERVICE_ACCOUNT_ID: ${{ secrets.ANTHROPIC_SERVICE_ACCOUNT_ID || vars.ANTHROPIC_SERVICE_ACCOUNT_ID }} + WORKSPACE_ID: ${{ secrets.ANTHROPIC_WORKSPACE_ID || vars.ANTHROPIC_WORKSPACE_ID }} steps: - # SECURITY: this job checks out untrusted PR code while holding - # CORA_API_KEY (pull_request_target). A PR-supplied .cora.yaml could set - # provider.base_url and exfiltrate the key. CORA_BASE_URL (env) always - # takes precedence over project config, so require it to be pinned. + - name: Exchange the GitHub OIDC token for an Anthropic access token + id: wif + if: env.FEDERATION_RULE_ID != '' && env.ORGANIZATION_ID != '' && env.SERVICE_ACCOUNT_ID != '' && env.WORKSPACE_ID != '' + continue-on-error: true + run: | + # The OIDC token stays in this step's shell: it is never exported to the + # job environment, so later steps (checkout of the PR, the review action) + # cannot see it. Only the short-lived Anthropic access token is exported. + jwt=$(curl -sS -H "Authorization: Bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \ + "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=https://api.anthropic.com" | jq -r '.value // empty') + if [ -z "$jwt" ]; then + echo "::warning::Could not get a GitHub OIDC token; falling back to the CORA_* secrets." + exit 1 + fi + echo "::add-mask::$jwt" + body=$(jq -n \ + --arg jwt "$jwt" \ + --arg rule "$FEDERATION_RULE_ID" \ + --arg org "$ORGANIZATION_ID" \ + --arg sa "$SERVICE_ACCOUNT_ID" \ + --arg ws "$WORKSPACE_ID" \ + '{grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer", + assertion: $jwt, federation_rule_id: $rule, organization_id: $org, + service_account_id: $sa, workspace_id: $ws}') + code=$(curl -sS -o exchange.json -w '%{http_code}' \ + https://api.anthropic.com/v1/oauth/token \ + -H "content-type: application/json" --data "$body") + if [ "$code" != "200" ]; then + echo "::warning::Anthropic token exchange failed (HTTP $code: $(jq -r '.error.type // "unknown"' exchange.json 2>/dev/null)); falling back to the CORA_* secrets." + rm -f exchange.json + exit 1 + fi + token=$(jq -r '.access_token' exchange.json) + rm -f exchange.json + echo "::add-mask::$token" + echo "ACCESS_TOKEN=$token" >> "$GITHUB_ENV" + echo "Using a short-lived Anthropic access token (workload identity federation)." + + # SECURITY: this job checks out untrusted PR code while holding a credential + # (pull_request_target). A PR-supplied .cora.yaml could set provider.base_url + # and exfiltrate it. CORA_BASE_URL (env) always takes precedence over + # project config, so the base URL is pinned here, and the Anthropic token + # is only ever paired with api.anthropic.com (never with CORA_BASE_URL). - name: Require the review secrets env: CORA_API_KEY: ${{ secrets.CORA_API_KEY }} CORA_BASE_URL: ${{ secrets.CORA_BASE_URL }} CORA_MODEL: ${{ secrets.CORA_MODEL }} run: | - # An empty secret must fail the job: a skipped or fallback review is not a pass. + # No federation token: the stored secrets are required. An empty secret + # must fail the job: a skipped or fallback review is not a pass. + if [ -n "${ACCESS_TOKEN:-}" ]; then + exit 0 + fi missing=0 for name in CORA_API_KEY CORA_BASE_URL CORA_MODEL; do if [ -z "${!name}" ]; then - echo "::error::Secret $name is empty or missing. Cora Review cannot run, and that must not pass silently." + echo "::error::Secret $name is empty or missing, and workload identity federation is not available. Cora Review cannot run, and that must not pass silently." missing=1 fi done @@ -51,6 +103,8 @@ jobs: uses: codecoradev/cora-review-action@v1 with: github-token: ${{ secrets.GITHUB_TOKEN }} - cora-api-key: ${{ secrets.CORA_API_KEY }} - cora-base-url: ${{ secrets.CORA_BASE_URL }} - cora-model: ${{ secrets.CORA_MODEL }} + # With a federation token, key, base URL and model switch together so the + # token is only ever sent to api.anthropic.com. + cora-api-key: ${{ env.ACCESS_TOKEN || secrets.CORA_API_KEY }} + cora-base-url: ${{ env.ACCESS_TOKEN && 'https://api.anthropic.com/v1' || secrets.CORA_BASE_URL }} + cora-model: ${{ env.ACCESS_TOKEN && (secrets.ANTHROPIC_MODEL || vars.ANTHROPIC_MODEL || 'claude-opus-5-5') || secrets.CORA_MODEL }} diff --git a/CHANGELOG.md b/CHANGELOG.md index a5575fa..0a90eb4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Cora Review can use Anthropic workload identity federation (no stored API key).** The review job exchanges its GitHub OIDC token for a short-lived Anthropic access token (when `ANTHROPIC_FEDERATION_RULE_ID`, `ANTHROPIC_ORGANIZATION_ID`, `ANTHROPIC_SERVICE_ACCOUNT_ID` and `ANTHROPIC_WORKSPACE_ID` are set) and sends it only to `api.anthropic.com`; otherwise it falls back to `CORA_API_KEY`, `CORA_BASE_URL` and `CORA_MODEL`. A smoke test workflow verifies the exchange and a Bearer call to `/v1/chat/completions`. See `docs/examples.md`. - **Agents validate before they commit (#674).** A review of the staged diff, by an agent (`cora.review_finalize`) or by `cora review --staged`, now leaves a marker bound to that exact change set. `cora hook verify` reports it (exit 0 validated, 10 validated but blocking, 11 not validated) and `cora hook claude-guard` is a Claude Code `PreToolUse` hook that denies a `git commit` that is unreviewed, blocking, uses `--no-verify`, or is not a plain commit of the staged change. The pre-commit hook (now v2, upgraded in place by `cora hook install`) accepts a validated diff and otherwise falls back to `cora review --staged --ci --severity major`; a tooling error is reported as "not a pass". The `/cora-review` skill tells agents to review before every commit. See `docs/usage.md`. ### Fixed diff --git a/docs/examples.md b/docs/examples.md index 51dba0c..02dd0cd 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -143,6 +143,14 @@ jobs: cora-model: ${{ secrets.CORA_MODEL }} ``` +### Anthropic with workload identity federation (no stored API key) + +Instead of a long-lived `CORA_API_KEY`, the job can exchange its GitHub OIDC token for a short-lived Anthropic access token. Create a federation rule in the Anthropic Console (issuer `https://token.actions.githubusercontent.com`, audience `https://api.anthropic.com`, a **subject prefix** that matches your repository), then provide `ANTHROPIC_FEDERATION_RULE_ID`, `ANTHROPIC_ORGANIZATION_ID`, `ANTHROPIC_SERVICE_ACCOUNT_ID` and `ANTHROPIC_WORKSPACE_ID` (and optionally `ANTHROPIC_MODEL`) as organization or repository secrets or variables. + +GitHub now issues subjects with numeric ids, for example `repo:my-org@289767358/my-repo@1254014703:pull_request`; a prefix written as `repo:my-org/my-repo:` never matches it. The Anthropic Console authentication log shows the reason for a denial (`match_subject_prefix`). + +The job needs `permissions: id-token: write`. The exchanged token is sent only to `https://api.anthropic.com/v1` (never to `CORA_BASE_URL`), and when federation is unavailable the job falls back to the `CORA_*` secrets. This repository's `.github/workflows/cora-review.yml` and `anthropic-wif-smoke.yml` are a working reference. + ### Options | Input | Default | Description |