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
17 changes: 12 additions & 5 deletions .github/workflows/claude.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,13 @@ jobs:
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
runs-on: ubuntu-latest
timeout-minutes: 60
# JAR-687: issues/issue_comment/pull_request_review(_comment) content can be
# authored by any GitHub account or machine principal, so this run must not
# hold write capability — no association class discriminates that (JAR-681).
permissions:
contents: write
pull-requests: write
issues: write
contents: read
pull-requests: read
issues: read
id-token: write
actions: read
steps:
Expand All @@ -34,12 +37,17 @@ jobs:
private-key: ${{ secrets.APP_PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: ${{ github.event.repository.name }}
permission-contents: read
permission-issues: read
permission-pull-requests: read

# head_ref is empty for these triggers, so this resolves to the default
# branch — never fork-PR code. Avoids CodeQL's untrusted-checkout flag.
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
ref: ${{ github.head_ref || github.ref_name }}
ref: ${{ github.ref_name }}
token: ${{ steps.app-token.outputs.token }}

# A bare URL in plugin_marketplaces resolves to grimoire's default branch
Expand Down Expand Up @@ -77,4 +85,3 @@ jobs:

claude_args: |
--model claude-opus-4-6
--dangerously-skip-permissions
40 changes: 40 additions & 0 deletions tests/test_claude_workflow_security.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
"""JAR-687 — the `claude` job in `.github/workflows/claude.yml` triggers on
`issue_comment`, `pull_request_review_comment`, `issues` and
`pull_request_review`: events whose content any GitHub account can author.
That run must not hold write capability, and must not skip permission checks.
"""

from pathlib import Path

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
# a repo-level `permissions: {}` line (if any) 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"
)