From 36c97b33703fdd229c974c3f65df612137ed2dab Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Wed, 29 Jul 2026 09:32:33 -0400 Subject: [PATCH] fix(ci): harden privileged workflows --- .github/workflows/claude-issue-triage.yml | 134 ++++++++++++++++------ .github/workflows/ipr-agreement.yml | 68 +++++++++-- .github/workflows/sync-agent-roles.yml | 31 +++-- 3 files changed, 175 insertions(+), 58 deletions(-) diff --git a/.github/workflows/claude-issue-triage.yml b/.github/workflows/claude-issue-triage.yml index ddb62166..c12d14cc 100644 --- a/.github/workflows/claude-issue-triage.yml +++ b/.github/workflows/claude-issue-triage.yml @@ -5,9 +5,9 @@ name: Claude Issue Triage # invokes `/triage` via comment (dispatched by # `.github/workflows/slash-command-dispatch.yml`). # -# Both issue comments and PR comments are routed to the same routine; the -# payload's `is_pr` flag and `pr` block tell the routine which context it's -# in so it can pick the right response (issue triage vs PR-feedback fix). +# Issue comments are routed to the routine. PR comments are routed only after +# the workflow verifies that their author has write, maintain, or admin access, +# because the PR-feedback mode may push a follow-up commit to the PR head branch. # # The issue/PR body is fetched fresh and passed as *data* (fenced, size- # capped) — the routine's prompt treats anything inside the fence as @@ -51,12 +51,12 @@ jobs: # by slash-command-dispatch path); # skip `no-triage` issues (same reason # as above). - # Both issue and PR comments fire — the - # routine receives `is_pr` to branch on. - # - repository_dispatch → always allow (slash-dispatch already - # gated by member-association check; - # manual /triage intentionally overrides - # the `no-triage` label). + # Both issue and PR comments fire; PR + # commenters are permission-checked below. + # - repository_dispatch → start the job, then independently verify + # the commenter's current repository write + # permission below. Manual /triage + # intentionally overrides `no-triage`. if: >- ( github.event_name == 'issues' && @@ -82,31 +82,90 @@ jobs: steps: - name: Resolve issue number + event kind id: ctx + env: + # Event and client-payload fields are untrusted. Passing them via env + # keeps expression expansion out of the shell program itself. + EVENT_NAME: ${{ github.event_name }} + EVENT_ACTION: ${{ github.event.action || '' }} + SOURCE_NUMBER: ${{ github.event.issue.number || github.event.client_payload.github.payload.issue.number || '' }} + SOURCE_COMMENTER: ${{ github.event.comment.user.login || github.event.client_payload.github.payload.comment.user.login || '' }} + SOURCE_COMMENT_ID: ${{ github.event.comment.id || github.event.client_payload.github.payload.comment.id || '' }} + DISPATCH_ARGS: ${{ github.event.client_payload.slash_command.args.all || '' }} run: | set -euo pipefail - if [ "${{ github.event_name }}" = "issues" ]; then - echo "number=${{ github.event.issue.number }}" >> "$GITHUB_OUTPUT" - echo "kind=auto" >> "$GITHUB_OUTPUT" - echo "action=${{ github.event.action }}" >> "$GITHUB_OUTPUT" - echo "commenter=" >> "$GITHUB_OUTPUT" - echo "args=" >> "$GITHUB_OUTPUT" - echo "comment_id=" >> "$GITHUB_OUTPUT" - elif [ "${{ github.event_name }}" = "issue_comment" ]; then - echo "number=${{ github.event.issue.number }}" >> "$GITHUB_OUTPUT" - echo "kind=comment" >> "$GITHUB_OUTPUT" - echo "action=created" >> "$GITHUB_OUTPUT" - echo "commenter=${{ github.event.comment.user.login }}" >> "$GITHUB_OUTPUT" - echo "args=" >> "$GITHUB_OUTPUT" - echo "comment_id=${{ github.event.comment.id }}" >> "$GITHUB_OUTPUT" + if [ "$EVENT_NAME" = "issues" ]; then + number=$SOURCE_NUMBER + kind=auto + action=$EVENT_ACTION + commenter='' + args='' + comment_id='' + elif [ "$EVENT_NAME" = "issue_comment" ]; then + number=$SOURCE_NUMBER + kind=comment + action=created + commenter=$SOURCE_COMMENTER + args='' + comment_id=$SOURCE_COMMENT_ID else - echo "number=${{ github.event.client_payload.github.payload.issue.number }}" >> "$GITHUB_OUTPUT" - echo "kind=manual" >> "$GITHUB_OUTPUT" - echo "action=triage" >> "$GITHUB_OUTPUT" - echo "commenter=${{ github.event.client_payload.github.payload.comment.user.login }}" >> "$GITHUB_OUTPUT" - echo "args=${{ github.event.client_payload.slash_command.args.all }}" >> "$GITHUB_OUTPUT" - echo "comment_id=${{ github.event.client_payload.github.payload.comment.id }}" >> "$GITHUB_OUTPUT" + number=$SOURCE_NUMBER + kind=manual + action=triage + commenter=$SOURCE_COMMENTER + args=$DISPATCH_ARGS + comment_id=$SOURCE_COMMENT_ID + fi + + if [[ ! "$number" =~ ^[0-9]+$ ]]; then + echo "::error::Invalid issue number in event payload." + exit 1 + fi + if [[ -n "$commenter" && ! "$commenter" =~ ^[A-Za-z0-9-]{1,39}$ ]]; then + echo "::error::Invalid commenter login in event payload." + exit 1 + fi + if [[ -n "$comment_id" && ! "$comment_id" =~ ^[0-9]+$ ]]; then + echo "::error::Invalid comment ID in event payload." + exit 1 fi + delimiter="args-$(openssl rand -hex 16)" + { + echo "number=$number" + echo "kind=$kind" + echo "action=$action" + echo "commenter=$commenter" + echo "comment_id=$comment_id" + echo "args<<$delimiter" + printf '%s\n' "$args" + echo "$delimiter" + } >> "$GITHUB_OUTPUT" + + - name: Authorize mutation-capable trigger + if: >- + github.event_name == 'repository_dispatch' || + (github.event_name == 'issue_comment' && github.event.issue.pull_request) + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + COMMENTER: ${{ steps.ctx.outputs.commenter }} + run: | + set -euo pipefail + if [ -z "$COMMENTER" ]; then + echo "::error::Cannot authorize an empty commenter." + exit 1 + fi + permission=$(gh api "repos/$REPO/collaborators/$COMMENTER/permission" --jq '.permission') + case "$permission" in + admin|maintain|write) + echo "Authorized @$COMMENTER with repository permission: $permission" + ;; + *) + echo "::error::Refusing mutation-capable triage from @$COMMENTER (permission: $permission)." + exit 1 + ;; + esac + - name: POST to routine /fire id: fire env: @@ -141,7 +200,9 @@ jobs: # routine can branch on context (head/base ref, draft status, etc.). is_pr=$(echo "$issue" | jq -r 'if .pull_request then "true" else "false" end') - body_safe=$(printf '%s' "$body" | tr -d '\000' | head -c 8192) + # `head` intentionally closes the pipe at the cap; tolerate the + # resulting SIGPIPE from an upstream writer under `pipefail`. + body_safe=$(printf '%s' "$body" | tr -d '\000' | head -c 8192 || true) pr_block="" if [ "$is_pr" = "true" ]; then @@ -169,7 +230,7 @@ jobs: comment_body=$(echo "$comment" | jq -r '.body // ""') comment_author=$(echo "$comment" | jq -r '.user.login') comment_assoc=$(echo "$comment" | jq -r '.author_association // "NONE"') - comment_body_safe=$(printf '%s' "$comment_body" | tr -d '\000' | head -c 4096) + comment_body_safe=$(printf '%s' "$comment_body" | tr -d '\000' | head -c 4096 || true) fi nudge_note="" @@ -240,9 +301,6 @@ jobs: fi echo "HTTP $http_code" - sed 's/[Bb]earer [A-Za-z0-9._-]*/Bearer [REDACTED]/g' /tmp/fire-response.json - echo - if [ "${http_code:-000}" -ge 400 ]; then echo "::error::Failed to fire routine (HTTP $http_code) for issue #${ISSUE_NUMBER}" exit 1 @@ -252,18 +310,18 @@ jobs: - name: React +1 on manual-nudge comment (success) if: steps.ctx.outputs.kind == 'manual' && success() && steps.ctx.outputs.comment_id != '' - uses: peter-evans/create-or-update-comment@v5 + uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0 with: token: ${{ secrets.TRIAGE_DISPATCH_PAT }} - repository: ${{ github.event.client_payload.github.payload.repository.full_name }} + repository: ${{ github.repository }} comment-id: ${{ steps.ctx.outputs.comment_id }} reactions: "+1" - name: React -1 on manual-nudge comment (failure) if: steps.ctx.outputs.kind == 'manual' && failure() && steps.ctx.outputs.comment_id != '' - uses: peter-evans/create-or-update-comment@v5 + uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0 with: token: ${{ secrets.TRIAGE_DISPATCH_PAT }} - repository: ${{ github.event.client_payload.github.payload.repository.full_name }} + repository: ${{ github.repository }} comment-id: ${{ steps.ctx.outputs.comment_id }} reactions: "-1" diff --git a/.github/workflows/ipr-agreement.yml b/.github/workflows/ipr-agreement.yml index e65bca72..d1ee039d 100644 --- a/.github/workflows/ipr-agreement.yml +++ b/.github/workflows/ipr-agreement.yml @@ -1,9 +1,13 @@ name: IPR Agreement -# Delegates to the reusable workflow in adcontextprotocol/adcp. Signatures -# land in the central ledger at adcontextprotocol/adcp:signatures/ipr-signatures.json. -# See https://github.com/adcontextprotocol/adcp/blob/main/governance/ipr-bot-setup.md -# for the GitHub App configuration and how to rotate / revoke credentials. +# Checks signatures against the central ledger in adcontextprotocol/adcp. +# Executable code is checked out at an immutable reviewed commit, separately +# from the mutable main-branch ledger that receives signature records. +# +# Action refs are pinned to immutable SHAs. Update them in a dedicated PR by +# verifying the SHA against the upstream release tag, reviewing release notes, +# and running actionlint. Update the pinned IPR code commit only after reviewing +# the callable implementation and scripts at that adcontextprotocol/adcp commit. on: issue_comment: @@ -12,12 +16,60 @@ on: types: [opened, synchronize, reopened] permissions: + contents: read pull-requests: write statuses: write +concurrency: + group: adcp-ipr-signature-write + cancel-in-progress: false + jobs: check: - uses: adcontextprotocol/adcp/.github/workflows/ipr-check-callable.yml@main - secrets: - IPR_APP_ID: ${{ secrets.IPR_APP_ID }} - IPR_APP_PRIVATE_KEY: ${{ secrets.IPR_APP_PRIVATE_KEY }} + runs-on: ubuntu-latest + timeout-minutes: 5 + if: >- + github.event_name == 'pull_request_target' || + (github.event_name == 'issue_comment' && + github.event.issue.pull_request != null && + contains(github.event.comment.body, 'I have read the IPR Policy')) + steps: + - name: Mint AAO IPR Bot installation token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: ${{ secrets.IPR_APP_ID }} + private-key: ${{ secrets.IPR_APP_PRIVATE_KEY }} + owner: adcontextprotocol + repositories: adcp + + - name: Checkout reviewed IPR executable code + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + repository: adcontextprotocol/adcp + ref: 82a671607c92945f0fec513c4375af583fdea914 + token: ${{ github.token }} + path: .ipr-code + fetch-depth: 1 + persist-credentials: false + + - name: Checkout mutable central IPR ledger + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + repository: adcontextprotocol/adcp + ref: main + token: ${{ steps.app-token.outputs.token }} + path: .ipr-ledger + fetch-depth: 1 + persist-credentials: true + + - name: Setup Node + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 + with: + node-version: '22' + + - name: Check and record IPR signature + env: + GITHUB_TOKEN: ${{ github.token }} + LEDGER_DIR: ${{ github.workspace }}/.ipr-ledger + run: node ${{ github.workspace }}/.ipr-code/scripts/ipr/check-and-record.mjs diff --git a/.github/workflows/sync-agent-roles.yml b/.github/workflows/sync-agent-roles.yml index 72cc2e6e..f0e19c36 100644 --- a/.github/workflows/sync-agent-roles.yml +++ b/.github/workflows/sync-agent-roles.yml @@ -1,8 +1,14 @@ name: Sync agent roles from adcp -# Mirrors `.agents/roles/` + `scripts/import-claude-agents.mjs` from -# the canonical source in adcontextprotocol/adcp. Opens a PR if drift -# is detected. Runs weekly on Mondays and on-demand. +# Mirrors `.agents/roles/` from the canonical source in +# adcontextprotocol/adcp. The importer remains the reviewed local copy: code +# fetched from a mutable upstream branch must never execute with this +# workflow's write token. Opens a PR if drift is detected. Runs weekly on +# Mondays and on-demand. +# +# Action refs are pinned to immutable SHAs. Update them in a dedicated PR by +# verifying each SHA against its upstream release tag, reviewing release notes, +# and running actionlint before merge. on: schedule: @@ -18,24 +24,24 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + persist-credentials: false - - uses: actions/setup-node@v6 + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 with: node-version: '22' - - name: Fetch canonical .agents/roles/ and sync script from adcp + - name: Fetch canonical .agents/roles/ from adcp run: | set -euo pipefail tmp=$(mktemp -d) curl -fsSL https://github.com/adcontextprotocol/adcp/archive/refs/heads/main.tar.gz \ | tar -xz -C "$tmp" --strip-components=1 \ - adcp-main/.agents/roles \ - adcp-main/scripts/import-claude-agents.mjs + adcp-main/.agents/roles rm -rf .agents/roles - mkdir -p .agents scripts + mkdir -p .agents cp -r "$tmp/.agents/roles" .agents/roles - cp "$tmp/scripts/import-claude-agents.mjs" scripts/import-claude-agents.mjs rm -rf "$tmp" - name: Regenerate .claude/agents/ from synced roles @@ -64,7 +70,7 @@ jobs: Sync `.agents/roles/` from canonical source in `adcontextprotocol/adcp`. EOF - - uses: peter-evans/create-pull-request@v8 + - uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 if: steps.diff.outputs.changed == 'true' with: branch: claude-routine/sync-agent-roles @@ -74,7 +80,8 @@ jobs: delete-branch: true body: | Automated weekly sync from `adcontextprotocol/adcp:.agents/roles/` - and `scripts/import-claude-agents.mjs`. + The generated `.claude/agents/` files use this repository's + reviewed local `scripts/import-claude-agents.mjs` importer. Run by `.github/workflows/sync-agent-roles.yml`.