Skip to content
Open
Show file tree
Hide file tree
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
26 changes: 15 additions & 11 deletions .github/workflows/claude.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,20 +14,24 @@ permissions: {}

jobs:
claude:
# Only trusted collaborators can trigger: this job checks out the (possibly
# fork) PR head with a write-scoped token, so gating on author_association
# blocks the pwn-request path (outside contributors are NONE/CONTRIBUTOR).
# author_association narrows who can trigger, but does not discriminate a
# human org member from a machine account in the same org — the token
# below carries no write scope regardless of who passes this gate.
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude') && contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association)) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude') && contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association)) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude') && contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.review.author_association)) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')) && contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.issue.author_association))
runs-on: ubuntu-24.04
timeout-minutes: 60
# JAR-687: author_association cannot tell a human org member from a
# machine account in the same org — every agent workforce account is
# MEMBER (JAR-681) — so this run must not hold write capability regardless
# of who or what passes the gate above.
permissions:
contents: write
pull-requests: write
issues: write
contents: read
pull-requests: read
issues: read
id-token: write
actions: read
steps:
Expand All @@ -39,10 +43,11 @@ jobs:
private-key: ${{ secrets.APP_PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: ${{ github.event.repository.name }}
# Scope the token to what Claude needs (matches the App grants in README).
permission-contents: write
permission-issues: write
permission-pull-requests: write
# Read-only: this run is triggered by content any GitHub account or
# machine principal can author — JAR-687.
permission-contents: read
permission-issues: read
permission-pull-requests: read

# Token is used by claude-code-action to push commits; no artifacts are
# uploaded, so persisting it is safe.
Expand Down Expand Up @@ -88,4 +93,3 @@ jobs:

claude_args: |
--model claude-opus-4-6
--dangerously-skip-permissions
47 changes: 47 additions & 0 deletions tests/test_claude_workflow_security.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
"""JAR-687 — the `claude` job in `.github/workflows/claude.yml` triggers on
`issue_comment`, `pull_request_review_comment`, `issues` and
`pull_request_review`. `author_association` gates the trigger, but that field
cannot tell a human org member from a machine account in the same org — every
agent workforce account is `MEMBER` (JAR-681). The run must not hold write
capability regardless of who or what passes the gate, and must not skip
permission checks.
"""

from pathlib import Path

import pytest

pytestmark = pytest.mark.unit

WORKFLOW = Path(__file__).resolve().parents[1] / ".github" / "workflows" / "claude.yml"


def _job_text() -> str:
text = WORKFLOW.read_text()
# One job in this file — slice from its own `permissions:` block onward so
# the repo-level `permissions: {}` line above it isn't what gets read below.
return text[text.index("runs-on:") :]


def test_untrusted_trigger_mints_no_write_scope() -> None:
job = _job_text()
for scope in ("contents", "issues", "pull-requests"):
assert f"\n {scope}: write" not in job, (
f"job permissions grants {scope}: write to a run triggered by "
"issue/comment/review content — JAR-687"
)
for scope in ("permission-contents", "permission-issues", "permission-pull-requests"):
assert f"{scope}: write" not in job, (
f"App token is minted with {scope}: write on an untrusted-authorable trigger — JAR-687"
)
assert f"{scope}: read" in job, (
f"App token must set {scope}: read explicitly rather than inherit "
"the App installation's grant — JAR-687"
)


def test_permission_checks_are_not_skipped() -> None:
job = _job_text()
assert "--dangerously-skip-permissions" not in job, (
"a run triggered by issue/comment/review content must not skip permission checks — JAR-687"
)