-
Notifications
You must be signed in to change notification settings - Fork 1.6k
Restore .claude/ and .mcp.json from PR base branch before CLI runs #1066
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
Merged
+112
−1
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,79 @@ | ||
| import { execFileSync } from "child_process"; | ||
| import { rmSync } from "fs"; | ||
|
|
||
| // Paths that are both PR-controllable and read from cwd at CLI startup. | ||
| // | ||
| // Deliberately excluded from the CLI's broader auto-edit blocklist: | ||
| // .git/ — not tracked by git; PR commits cannot place files there. | ||
| // Restoring it would also undo the PR checkout entirely. | ||
| // .gitconfig — git reads ~/.gitconfig and .git/config, never cwd/.gitconfig. | ||
| // .bashrc etc. — shells source these from $HOME; checkout cannot reach $HOME. | ||
| // .vscode/.idea— IDE config; nothing in the CLI's startup path reads them. | ||
| const SENSITIVE_PATHS = [ | ||
| ".claude", | ||
| ".mcp.json", | ||
| ".claude.json", | ||
| ".gitmodules", | ||
| ".ripgreprc", | ||
| ]; | ||
|
|
||
| /** | ||
| * Restores security-sensitive config paths from the PR base branch. | ||
| * | ||
| * The CLI's non-interactive mode trusts cwd: it reads `.mcp.json`, | ||
| * `.claude/settings.json`, and `.claude/settings.local.json` from the working | ||
| * directory and acts on them before any tool-permission gating — executing | ||
| * hooks (including SessionStart), setting env vars (NODE_OPTIONS, LD_PRELOAD, | ||
| * PATH), running apiKeyHelper/awsAuthRefresh shell commands, and auto-approving | ||
| * MCP servers. When this action checks out a PR head, all of these are | ||
| * attacker-controlled. | ||
| * | ||
| * Rather than enumerate every dangerous key, this replaces the entire `.claude/` | ||
| * tree and `.mcp.json` with the versions from the PR base branch, which a | ||
| * maintainer has reviewed and merged. Paths absent on base are deleted. | ||
| * | ||
| * Known limitation: if a PR legitimately modifies `.claude/` and the CLI later | ||
| * commits with `git add -A`, the revert will be included in that commit. This | ||
| * is a narrow UX tradeoff for closing the RCE surface. | ||
| * | ||
| * @param baseBranch - PR base branch name. Must be pre-validated (branch.ts | ||
| * calls validateBranchName on it before returning). | ||
| */ | ||
| export function restoreConfigFromBase(baseBranch: string): void { | ||
| console.log( | ||
| `Restoring ${SENSITIVE_PATHS.join(", ")} from origin/${baseBranch} (PR head is untrusted)`, | ||
| ); | ||
|
|
||
| // Fetch base first — if this fails we haven't touched the workspace and the | ||
| // caller sees a clean error. | ||
| execFileSync("git", ["fetch", "origin", baseBranch, "--depth=1"], { | ||
| stdio: "inherit", | ||
| }); | ||
|
|
||
| // Delete PR-controlled versions. If the restore below fails for a given path, | ||
| // that path stays deleted — the safe fallback (no attacker-controlled config). | ||
| // A bare `git checkout` alone wouldn't remove files the PR added, so nuke first. | ||
| for (const p of SENSITIVE_PATHS) { | ||
| rmSync(p, { recursive: true, force: true }); | ||
| } | ||
|
|
||
| for (const p of SENSITIVE_PATHS) { | ||
| try { | ||
| execFileSync("git", ["checkout", `origin/${baseBranch}`, "--", p], { | ||
| stdio: "pipe", | ||
| }); | ||
| } catch { | ||
| // Path doesn't exist on base — it stays deleted. | ||
| } | ||
| } | ||
|
|
||
| // `git checkout <ref> -- <path>` stages the restored files. Unstage so the | ||
| // revert doesn't silently leak into commits the CLI makes later. | ||
| try { | ||
| execFileSync("git", ["reset", "--", ...SENSITIVE_PATHS], { | ||
| stdio: "pipe", | ||
| }); | ||
| } catch { | ||
| // Nothing was staged, or paths don't exist on HEAD — either is fine. | ||
| } | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
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.
🟡
restoreConfigFromBaserequires its parameter to be pre-validated (per its docstring), andvalidateBranchName()is called on thepull_requestevent path (line 245) but not on theissue_commentfallback path whererestoreBase = baseBranchfrom agent mode. AddingvalidateBranchName(restoreBase)before line 248 would close this defense-in-depth gap, though practical risk is near-zero since the fallback value comes from admin-controlled sources.Extended reasoning...
The Bug
When the event is
issue_commenton a PR, the code at lines 237-250 takes the fallback path whererestoreBaseis set tobaseBranch(from the mode prepare function) rather than readingcontext.payload.pull_request.base.ref. In agent mode, thisbaseBranchvalue comes fromprocess.env.BASE_BRANCH || context.inputs.baseBranch || "main"(agent/index.ts:88-89) and is never passed throughvalidateBranchName().The Inconsistency
The
restoreConfigFromBasedocstring at restore-config.ts:25-26 explicitly states: "Must be pre-validated (branch.ts calls validateBranchName on it before returning)." The PR event path correctly callsvalidateBranchName(restoreBase)at line 245, but theissue_commentfallback skips this validation entirely. For tag mode this is fine becausesetupBranchvalidatesbaseBranchat branch.ts:176, but agent mode does not validate itsbaseBranchanywhere in its pipeline.Step-by-Step Proof
issue_commentevent fires on a PR in a repo using agent mode.prepareAgentModeruns and computesbaseBranch = process.env.BASE_BRANCH || context.inputs.baseBranch || "main"(agent/index.ts:88-89).isEntityContext(context) && context.isPRis true, so we enter the block.restoreBase = baseBranch(the unvalidated value from agent mode).isPullRequestEvent/isPullRequestReviewEvent/isPullRequestReviewCommentEventchecks all fail (this is anissue_commentevent), so the inner block withvalidateBranchNameat line 245 is skipped.restoreConfigFromBase(restoreBase)is called with the unvalidatedbaseBranch.Practical Impact
The practical risk is effectively zero. The
BASE_BRANCHenv var is set in the workflow YAML (which runs from the default branch, not the PR branch),context.inputs.baseBranchresolves to the sameprocess.env.BASE_BRANCH(context.ts:150), and the hardcoded default is"main". None of these are attacker-controlled. Additionally,execFileSyncwith array args prevents shell injection. However,validateBranchNamealso blocks leading dashes (preventing git option injection like--upload-pack=...), so including it would be a worthwhile defense-in-depth measure.Suggested Fix
Add
validateBranchName(restoreBase)before therestoreConfigFromBase(restoreBase)call at line 248, or move the validation outside the event-typeifblock so it applies to all paths uniformly: