mirror of
https://github.com/anthropics/claude-code-action.git
synced 2026-08-03 17:58:30 +08:00
143 lines
5.0 KiB
TypeScript
143 lines
5.0 KiB
TypeScript
import { execFileSync } from "child_process";
|
|
import {
|
|
appendFileSync,
|
|
cpSync,
|
|
existsSync,
|
|
mkdirSync,
|
|
readFileSync,
|
|
rmSync,
|
|
} from "fs";
|
|
import { dirname } from "path";
|
|
|
|
// 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",
|
|
"CLAUDE.md",
|
|
"CLAUDE.local.md",
|
|
".husky",
|
|
];
|
|
|
|
const CLAUDE_PR_EXCLUDE_PATTERN = "/.claude-pr/";
|
|
|
|
function ensureClaudePrExcludedFromGit(): void {
|
|
const excludePath = execFileSync(
|
|
"git",
|
|
["rev-parse", "--git-path", "info/exclude"],
|
|
{ encoding: "utf8" },
|
|
).trim();
|
|
|
|
const excludeContents = existsSync(excludePath)
|
|
? readFileSync(excludePath, "utf8")
|
|
: "";
|
|
|
|
if (excludeContents.split(/\r?\n/).includes(CLAUDE_PR_EXCLUDE_PATTERN)) {
|
|
return;
|
|
}
|
|
|
|
mkdirSync(dirname(excludePath), { recursive: true });
|
|
|
|
const prefix =
|
|
excludeContents.length === 0 || excludeContents.endsWith("\n") ? "" : "\n";
|
|
appendFileSync(excludePath, `${prefix}${CLAUDE_PR_EXCLUDE_PATTERN}\n`);
|
|
}
|
|
|
|
/**
|
|
* 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)`,
|
|
);
|
|
|
|
// Snapshot every PR-authored sensitive path into .claude-pr/ before deletion
|
|
// so review agents can inspect what the PR changes without those files ever
|
|
// being executed. Captured before the security delete so it reflects the
|
|
// PR-authored version.
|
|
rmSync(".claude-pr", { recursive: true, force: true });
|
|
for (const p of SENSITIVE_PATHS) {
|
|
if (existsSync(p)) {
|
|
cpSync(p, `.claude-pr/${p}`, { recursive: true, dereference: true });
|
|
}
|
|
}
|
|
if (existsSync(".claude-pr")) {
|
|
console.log(
|
|
"Preserved PR's sensitive paths -> .claude-pr/ for review agents (not executed)",
|
|
);
|
|
ensureClaudePrExcludedFromGit();
|
|
}
|
|
|
|
// Delete PR-controlled versions BEFORE fetching so the attacker-controlled
|
|
// .gitmodules is absent during the network operation. If git reads .gitmodules
|
|
// during fetch (fetch.recurseSubmodules=on-demand, the git default), it will
|
|
// attempt to fetch submodule objects and block on credential prompts in CI —
|
|
// causing an indefinite hang. Deleting first closes that window.
|
|
//
|
|
// 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 });
|
|
}
|
|
|
|
// --no-recurse-submodules: explicitly suppress submodule fetching regardless of
|
|
// fetch.recurseSubmodules config. Defense-in-depth alongside the delete above.
|
|
execFileSync(
|
|
"git",
|
|
["fetch", "origin", baseBranch, "--depth=1", "--no-recurse-submodules"],
|
|
{
|
|
stdio: "inherit",
|
|
env: process.env,
|
|
},
|
|
);
|
|
|
|
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.
|
|
}
|
|
}
|