Skip to content
Merged
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
93 changes: 92 additions & 1 deletion .github/workflows/claude.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,104 @@ on:
types: [submitted]

jobs:
claude:
authorize_claude_actor:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
authorized: ${{ steps.permission-gate.outputs.authorized }}
steps:
- name: Verify actor can run Claude
id: permission-gate
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice to pin actions/github-script to a full commit SHA rather than a mutable tag — good supply-chain hygiene. Worth double-checking (outside this sandbox, which blocked outbound gh api calls to other repos) that 3a2844b7e9c422d3c10d287c895573f7108da1b3 is actually the commit tagged v9 upstream, since a stale/incorrect comment here would be misleading if this pin is ever rotated by hand later.

with:
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
const permissionByLogin = new Map();

function claudeRequester() {
if (
context.eventName === 'issue_comment' ||
context.eventName === 'pull_request_review_comment'
) {
return context.payload.comment?.user?.login;
}

if (context.eventName === 'pull_request_review') {
return context.payload.review?.user?.login;
}

if (context.eventName === 'issues') {
return context.payload.issue?.user?.login;
}
Comment on lines +47 to +49

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Mutable Issue Body Authorized Wrongly

When an issues: assigned event fires, this returns the original issue author while the gate checks the current issue title/body for @claude. If a read-only user can edit an issue that was opened by a write/admin user, then a later assignment by a write/admin user can pass both permission checks and run Claude on instructions supplied by the read-only editor.


return null;
}

async function hasWriteAccessFor(username) {
if (!username) {
return false;
}

if (permissionByLogin.has(username)) {
return permissionByLogin.get(username);
}

let hasWriteAccess = false;
try {
const { data: permission } = await github.rest.repos.getCollaboratorPermissionLevel({
owner,
repo,
username
});
hasWriteAccess = ['admin', 'write'].includes(permission.permission);
} catch (error) {
core.warning(
`Unable to verify ${username} write permission for Claude; ` +
`treating the Claude request as untrusted: ${error.message || error}`
);
}

permissionByLogin.set(username, hasWriteAccess);
return hasWriteAccess;
}

const actor = context.actor;
const requester = claudeRequester();

if (!requester) {
core.setOutput('authorized', 'false');
core.setFailed('Unable to run Claude because the requester could not be determined.');
return;
}

if (!(await hasWriteAccessFor(actor))) {
core.setOutput('authorized', 'false');
core.setFailed(`Unable to run Claude because ${actor} does not have write/admin repository permission.`);
return;
}

if (!(await hasWriteAccessFor(requester))) {
core.setOutput('authorized', 'false');
core.setFailed(
`Unable to run Claude because requester ${requester} does not have write/admin repository permission.`
);
return;
}
Comment on lines +91 to +103

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

core.setFailed marks authorize_claude_actor (and thus the overall workflow run) as failed, not skipped, for every unauthorized @claude mention. On a public repo like this one, any external/non-collaborator who tags @claude in an issue/PR comment will produce a red ❌ workflow run visible in the Actions tab and as a check — which can look like a broken CI job rather than "expected access-denied". If that's intentional (e.g. for auditability), fine, but consider whether core.warning + a clean skip (leaving the job "successful" with authorized=false) would give a less noisy signal for the common case of well-meaning outside contributors.


core.setOutput('authorized', 'true');
core.info(`Authorized ${actor} and Claude requester ${requester} to run Claude.`);

claude:
needs: authorize_claude_actor
if: needs.authorize_claude_actor.outputs.authorized == 'true'
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
Expand Down
Loading