-
Notifications
You must be signed in to change notification settings - Fork 5
fix(ci): harden privileged workflows #1003
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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') | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Under Also, if ! push=$(gh api "repos/$REPO/collaborators/$COMMENTER/permission" --jq '.user.permissions.push' 2>/tmp/perm-err); then
echo "::error::Permission lookup for @$COMMENTER failed (token scope or API error):"; cat /tmp/perm-err; exit 1
fi
[ "$push" = "true" ] || { echo "::error::Refusing mutation-capable triage from @$COMMENTER (no push access)."; exit 1; }Separately, aao-ipr-bot's 2026-07-29 question is still open: this is the only repository-permission check in the chain running on |
||
| 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) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The SIGPIPE diagnosis is right — reproduced at exit 141 on a 3 MB body. But body_safe=${body:0:8192}Verified Same at |
||
|
|
||
| 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" | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This header and This diff also turned the |
||
| # the callable implementation and scripts at that adcontextprotocol/adcp commit. | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This workflow now consumes |
||
|
|
||
| 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: | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The upstream callable at the pinned commit Upstream that invariant was enforced structurally — the job lived in another repo and this one could not add steps to it. Inlined, it is a file anyone here can edit, on |
||
| - 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 | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The previous line binds env:
GITHUB_TOKEN: ${{ github.token }}
LEDGER_DIR: ${{ github.workspace }}/.ipr-ledger
CODE_DIR: ${{ github.workspace }}/.ipr-code
run: |
set -euo pipefail
node "$CODE_DIR/scripts/ipr/check-and-record.mjs" |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The new header rests this workflow's trust argument on drift landing as a human-reviewed PR, but the fetch still records no ref — not in the PR title at Swap the |
||
| 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`. | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The randomized delimiter closes the
GITHUB_OUTPUTinjection, and it also changes what reaches the prompt:argsnow carries through intact, multi-line and unbounded. It lands at:238insidenudge_note, which:273emits above the issue body and outside both<<<UNTRUSTED_…>>>fences. The header at:12-14states the contract — untrusted content is passed "as data (fenced, size-capped)" — andargsgets neither cap nor fence. The old code truncated the injection at the first newline; this delivers arbitrary multi-line text into the instruction region of a prompt whose agent can push commits./triageis gated atpermission: writeupstream, so this is not escalation. It is the one field this diff made unbounded, and the routine's credentials are not the commenter's. Cap and fence it like the body: