Skip to content
Closed
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
107 changes: 106 additions & 1 deletion .github/workflows/deep-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -123,12 +123,37 @@ jobs:

# Check out the PR head so reviewer sub-agents can read the actual code.
# Works for both same-repo and forked PRs.
#
# `allow-unsafe-pr-checkout` is REQUIRED for the fork case. checkout
# v6.1.0 (2026-07-20) backported it as an explicitly BREAKING change
# defaulting to false, so checking out fork code from a
# `pull_request_target` workflow now hard-fails. Because we track the
# moving `v6` tag, that arrived with no change on our side -- and every
# fork-PR review failed at this step until this flag was set. Bumping to
# v7 is not an escape: v7.0.1 ships the same input with the same default.
# https://github.blog/changelog/2026-06-18-safer-pull_request_target-defaults-for-github-actions-checkout/
#
# The flag's warning is about executing fork code in a credentialed
# context. This job never executes it: no `yarn install`, no build, no
# Edit/Write tools, `contents: read`, and `--setting-sources user` below
# so the fork's `.claude/settings.json` cannot widen permissions. Note
# the checkout widens the prompt-injection surface from "the diff" to
# "all files" but does not create it -- `Bash(gh pr diff:*)` already
# feeds untrusted fork text to the model with no checkout at all.
- name: Checkout PR head
uses: actions/checkout@v6
with:
repository: ${{ steps.pr.outputs.head_repo }}
ref: ${{ steps.pr.outputs.head_ref }}
fetch-depth: 0
allow-unsafe-pr-checkout: true
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Resolved
# Keep the base repo's GITHUB_TOKEN out of `.git/config` inside an
# untrusted worktree, where the reviewer's `Bash(git:*)` grant could
# read it back via `git config --get http.<url>.extraheader`.
# Nothing downstream needs git credentials: the base fetch below
# uses a public HTTPS URL, and every `gh` call takes its token from
# the environment rather than from git config.
persist-credentials: false

# Make the PR base SHA reachable AND fetch enough history that
# `git merge-base HEAD <base_sha>` succeeds. The ce-code-review skill
Expand Down Expand Up @@ -260,6 +285,15 @@ jobs:
repository: EveryInc/compound-engineering-plugin
ref: ${{ env.PLUGIN_REF }}
path: ce-plugin
# Same reason as the PR-head checkout above, and it matters even
# though this repo is not the untrusted one: `ce-plugin` is a
# subdirectory of the very worktree holding fork code, so a
# credential left in `ce-plugin/.git/config` is reachable from the
# same reviewer that reads that code. Nothing here needs it -- the
# plugin is consumed only as a local marketplace path
# (`plugin_marketplaces: ./ce-plugin`) and the repo is public, so
# even a re-fetch would not need auth.
persist-credentials: false

# --- CLI pin (TEMPORARY) ----------------------------------------------
# claude-code-action hardcodes the Claude Code CLI version it installs
Expand Down Expand Up @@ -319,6 +353,28 @@ jobs:
fi
echo "PINNED_CLAUDE=$CLAUDE_BIN" >> "$GITHUB_ENV"

# Backstop for the two `persist-credentials: false` settings above, and
# the last step before the reviewer runs -- so it covers every worktree
# any earlier step created, not just the two checkouts we know about.
# The reviewer's git grant reads from an untrusted worktree, so a
# checkout added later without the flag (or an upstream default that
# flips back) must fail loud here rather than leak quietly.
- name: Assert no persisted git credentials in the workspace
if: steps.gate.outputs.should_review == 'true'
run: |
set -euo pipefail
# `-name config -path '*/.git/*'` also catches submodule configs at
# `.git/modules/<name>/config`. `|| true` is required: `grep -l`
# exits 1 on no match, which pipefail would turn into a job failure
# on the healthy path.
LEAKED=$(find "$GITHUB_WORKSPACE" -type f -name config -path '*/.git/*' -print0 \
| xargs -0 -r grep -lEi 'extraheader|sshCommand' || true)
if [ -n "$LEAKED" ]; then
echo "::error::Persisted git credentials found in: $(echo "$LEAKED" | tr '\n' ' '). Add 'persist-credentials: false' to the checkout that created it -- the reviewer's git grant can read it back out of an untrusted worktree."
exit 1
fi
echo "No persisted git credentials under $GITHUB_WORKSPACE."

- name: Run deep review
id: review
if: steps.gate.outputs.should_review == 'true'
Expand Down Expand Up @@ -489,9 +545,58 @@ jobs:
3. Do NOT post the review yourself with `gh` or any comment tool --
the workflow posts the structured output as a sticky comment.

# Deliberately no `Bash(gh api:*)` in the allowlist below.
# --allowedTools matches by command prefix and cannot constrain an
# HTTP method, so granting `gh api` grants POST/PATCH/DELETE against a
# token holding `pull-requests: write` + `issues: write` -- a
# repo-write channel driven by a model reading untrusted fork code.
# In the pinned plugin only ce-previous-comments-reviewer reaches for
# it, and that agent already prefers
# `gh pr view <n> --json reviews,comments` (still allowed via
# `Bash(gh pr view:*)`); the sole loss is inline-comment path/line
# metadata.
#
# git is granted per read-only subcommand rather than as `Bash(git:*)`
# for the same reason: a wildcard git grant is a credential-read
# channel (`git config --file <any>/.git/config --list`) and a network
# egress channel (`git fetch`/`ls-remote`/`archive --remote` to an
# attacker URL), both driven by a model reading untrusted fork code.
# The allowlist is the primary control: a denylist cannot enumerate
# every subcommand that reaches the network or a credential
# (`git archive --remote`, `git credential fill`, `git submodule
# update --remote`), whereas an allowlist has no such tail. The
# `--disallowedTools` line is belt-and-suspenders, because
# --allowedTools only PRE-APPROVES -- an un-approved call fails here
# only because a `-p` run cannot answer the prompt -- while
# --disallowedTools is a real deny.
#
# Verified against the pinned plugin ref. Of the 21 personas in
# ce-code-review/references/persona-catalog.md, 20 invoke git zero
# times; references/diff-scope.md says why -- "The scope step in the
# SKILL.md handles discovery and passes you the resolved diff." The
# exception, ce-schema-drift-detector, is Rails-only (spawned solely
# for `db/migrate/*.rb` / `db/schema.rb`, which do not exist in this
# repo) and its reads are plain `git diff`; its one write,
# `git checkout <base> -- db/schema.rb`, is a working-tree mutation
# `mode:report-only` forbids regardless.
#
# The orchestrator's own surface is the `base:<sha>` fast path
# (SKILL.md "Skip all base-branch detection, remote resolution, and
# merge-base computation"), which needs only
# merge-base/diff/ls-files/rev-parse/log. The skill's `git fetch`
# fallbacks live in scripts/resolve-base.sh, which needs
# `Bash(bash:*)` -- never granted -- and which `base:` skips anyway;
# `git checkout` / `gh pr checkout` are already blocked by
# `mode:report-only`. `git -c <cfg> diff` cannot slip past the
# allowlist either: `-c` precedes the subcommand, so the command
# string starts `git -c` and matches no prefix.
#
# Comments must stay OUT of the block scalar below -- every line in it
# is passed to the CLI verbatim as an argument.
claude_args: |
--setting-sources user
--allowedTools "Bash(git:*),Bash(gh pr view:*),Bash(gh pr diff:*),Bash(gh pr list:*),Bash(gh issue view:*),Bash(gh issue list:*),Bash(gh search:*),Bash(gh api:*)"
--allowedTools "Bash(git diff:*),Bash(git log:*),Bash(git show:*),Bash(git blame:*),Bash(git merge-base:*),Bash(git rev-parse:*),Bash(git ls-files:*),Bash(git cat-file:*),Bash(git status:*),Bash(gh pr view:*),Bash(gh pr diff:*),Bash(gh pr list:*),Bash(gh issue view:*),Bash(gh issue list:*),Bash(gh search:*)"
--disallowedTools "Bash(git config:*),Bash(git credential:*),Bash(git fetch:*),Bash(git pull:*),Bash(git clone:*),Bash(git ls-remote:*),Bash(git remote:*),Bash(git push:*),Bash(git archive:*),Bash(git submodule:*)"
Comment on lines +598 to +599

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 security Git allowlist enables command execution

When a prompt-injected reviewer invokes the allowed git log with --output=.git/config to write a diff.external command and then invokes the allowed git diff, Git executes that command inside the action holding GITHUB_TOKEN and ANTHROPIC_API_KEY, enabling credential disclosure and repository writes. How this was verified: The allowlist permits both the documented git log --output file-write primitive and git diff execution trigger in the credentialed review action.

Fix in Claude Code Fix in Conductor Fix in Cursor Fix in Codex

--json-schema '{"type":"object","properties":{"review":{"type":"string","description":"Complete markdown review starting with <!-- deep-review --> on the first line and ## Deep Review on the second line"}},"required":["review"]}'

# --- Sandbox smoke check ----------------------------------------------
Expand Down
Loading