mirror of
https://github.com/anthropics/claude-code-action.git
synced 2026-08-22 03:18:54 +08:00
Scope the config snapshot to files inside the working tree (#1596)
* Scope config snapshot to files inside the working tree * Record excluded snapshot entries as placeholders instead of links * Limit linked snapshot content to unmodified tracked files and tracked directories File targets reached through a link are included only when their content is unchanged from HEAD, and directory targets only when they contain tracked files; anything else is recorded as a single placeholder. Adds tests for a sensitive path that links to a tracked directory, links to untracked directories, and links to tracked files modified after checkout.
This commit is contained in:
@@ -3,11 +3,16 @@ import {
|
||||
appendFileSync,
|
||||
cpSync,
|
||||
existsSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
readlinkSync,
|
||||
realpathSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from "fs";
|
||||
import { dirname } from "path";
|
||||
import { dirname, join, posix, relative, sep } from "path";
|
||||
|
||||
// Paths that are both PR-controllable and read from cwd at CLI startup.
|
||||
//
|
||||
@@ -30,18 +35,173 @@ const SENSITIVE_PATHS = [
|
||||
|
||||
const CLAUDE_PR_EXCLUDE_PATTERN = "/.claude-pr/";
|
||||
|
||||
function snapshotSensitivePath(src: string, dest: string): void {
|
||||
try {
|
||||
cpSync(src, dest, { recursive: true, dereference: true });
|
||||
} catch (error) {
|
||||
// Symlinks whose targets are absent on the PR head (e.g. `.claude/CLAUDE.md`
|
||||
// -> `../AGENTS.md` when the PR deleted the target) make dereferenced
|
||||
// copies throw ENOENT. Preserve the symlink for the review snapshot instead.
|
||||
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
|
||||
cpSync(src, dest, { recursive: true });
|
||||
return;
|
||||
function isSameOrInside(child: string, parent: string): boolean {
|
||||
return child === parent || child.startsWith(`${parent}${sep}`);
|
||||
}
|
||||
|
||||
// Repository paths (relative to cwd, `/`-separated) that a link may resolve
|
||||
// to: `files` are tracked files whose working-tree content is unchanged from
|
||||
// HEAD, `dirs` are directories that contain at least one tracked file.
|
||||
type TrackedPaths = { files: Set<string>; dirs: Set<string> };
|
||||
|
||||
// Built from the superproject only: `git ls-files` reports a submodule as a
|
||||
// single entry, so paths inside a checked-out submodule are in neither set and
|
||||
// links into one are recorded as placeholders.
|
||||
function listTrackedPaths(): TrackedPaths {
|
||||
const gitPathList = (args: string[]) =>
|
||||
execFileSync("git", args, { encoding: "utf8", maxBuffer: Infinity })
|
||||
.split("\0")
|
||||
.filter(Boolean);
|
||||
const modified = new Set(
|
||||
gitPathList([
|
||||
"diff",
|
||||
"--name-only",
|
||||
"-z",
|
||||
"--relative",
|
||||
"--ignore-submodules",
|
||||
"HEAD",
|
||||
"--",
|
||||
]),
|
||||
);
|
||||
const tracked: TrackedPaths = { files: new Set(), dirs: new Set() };
|
||||
for (const file of gitPathList(["ls-files", "-z"])) {
|
||||
if (!modified.has(file)) {
|
||||
tracked.files.add(file);
|
||||
}
|
||||
throw error;
|
||||
for (
|
||||
let dir = posix.dirname(file);
|
||||
dir !== "." && !tracked.dirs.has(dir);
|
||||
dir = posix.dirname(dir)
|
||||
) {
|
||||
tracked.dirs.add(dir);
|
||||
}
|
||||
}
|
||||
return tracked;
|
||||
}
|
||||
|
||||
// The snapshot is scoped to tracked repository content and never contains
|
||||
// links. An entry is copied with its content only when all of these hold:
|
||||
// 1. its real target (through any links) lies inside the working tree;
|
||||
// 2. no component of the target's path inside the tree is `.git`, and the
|
||||
// target is not inside the snapshot directory itself;
|
||||
// 3. the target does not contain a directory already on the entry's own
|
||||
// path (which would recurse);
|
||||
// 4. if the entry is reached through a link (it is one, or a directory above
|
||||
// it inside the sensitive path is), a file target must be tracked in the
|
||||
// checkout with its content unchanged from HEAD, and a directory target
|
||||
// must contain at least one tracked file (see listTrackedPaths). Directory
|
||||
// targets that pass are descended into and their children are checked
|
||||
// individually.
|
||||
// Files and directories at their literal, non-linked location are unaffected
|
||||
// by rule 4 and are copied as-is. Every other entry — targets outside the
|
||||
// tree, dangling or looping links, git metadata, submodule contents, untracked
|
||||
// or locally modified files reached through a link — is recorded as a
|
||||
// placeholder file (see recordPlaceholder), so nothing in the snapshot
|
||||
// resolves anywhere else.
|
||||
function shouldSnapshotContent(
|
||||
entryPath: string,
|
||||
workTreeRealPath: string,
|
||||
tracked: TrackedPaths,
|
||||
): boolean {
|
||||
try {
|
||||
const targetRealPath = realpathSync(entryPath);
|
||||
if (!isSameOrInside(targetRealPath, workTreeRealPath)) {
|
||||
return false;
|
||||
}
|
||||
const targetParts = relative(workTreeRealPath, targetRealPath).split(sep);
|
||||
if (targetParts.includes(".git") || targetParts[0] === ".claude-pr") {
|
||||
return false;
|
||||
}
|
||||
for (let dir = dirname(entryPath); ; dir = dirname(dir)) {
|
||||
if (isSameOrInside(realpathSync(dir), targetRealPath)) {
|
||||
return false;
|
||||
}
|
||||
if (dir === dirname(dir)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
const literalPath = join(
|
||||
workTreeRealPath,
|
||||
relative(process.cwd(), entryPath),
|
||||
);
|
||||
if (targetRealPath === literalPath) {
|
||||
return true;
|
||||
}
|
||||
const targetRepoPath = targetParts.join("/");
|
||||
return statSync(targetRealPath).isDirectory()
|
||||
? tracked.dirs.has(targetRepoPath)
|
||||
: tracked.files.has(targetRepoPath);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Writes a short regular file at `dest` describing the entry that was left
|
||||
// out, so the snapshot records that something was there without linking to it.
|
||||
function recordPlaceholder(src: string, dest: string): void {
|
||||
console.warn(
|
||||
`Snapshot: ${src} not included in snapshot; recording a placeholder`,
|
||||
);
|
||||
let description = "is not included in this snapshot";
|
||||
try {
|
||||
description = `was a symbolic link to ${JSON.stringify(readlinkSync(src))}; the link target is not included in this snapshot`;
|
||||
} catch {
|
||||
// Not a link (or no longer present).
|
||||
}
|
||||
mkdirSync(dirname(dest), { recursive: true });
|
||||
writeFileSync(dest, `Snapshot placeholder: ${src} ${description}.\n`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies a sensitive path into the review snapshot. Entries that pass the
|
||||
* check above are copied dereferenced (reviewers see the effective content);
|
||||
* every other entry is recorded as a placeholder file, never as a link.
|
||||
* Applies per entry, including links nested inside a real directory.
|
||||
*/
|
||||
function snapshotSensitivePath(
|
||||
src: string,
|
||||
dest: string,
|
||||
workTreeRealPath: string,
|
||||
tracked: TrackedPaths,
|
||||
): void {
|
||||
const excluded: Array<{ src: string; dest: string }> = [];
|
||||
const keepOrExclude =
|
||||
(keep: (entry: string) => boolean) =>
|
||||
(entrySrc: string, entryDest: string) => {
|
||||
if (keep(entrySrc)) {
|
||||
return true;
|
||||
}
|
||||
excluded.push({ src: entrySrc, dest: entryDest });
|
||||
return false;
|
||||
};
|
||||
try {
|
||||
cpSync(src, dest, {
|
||||
recursive: true,
|
||||
dereference: true,
|
||||
filter: keepOrExclude((entry) =>
|
||||
shouldSnapshotContent(entry, workTreeRealPath, tracked),
|
||||
),
|
||||
});
|
||||
} catch (error) {
|
||||
// Dangling links are normally caught by the filter above. If a target
|
||||
// disappears between that check and the copy, the dereferencing copy
|
||||
// throws ENOENT; start over without following links, recording every link
|
||||
// as a placeholder, instead of failing the restore.
|
||||
if (
|
||||
!(error instanceof Error && "code" in error && error.code === "ENOENT")
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
rmSync(dest, { recursive: true, force: true });
|
||||
excluded.length = 0;
|
||||
cpSync(src, dest, {
|
||||
recursive: true,
|
||||
filter: keepOrExclude((entry) => !lstatSync(entry).isSymbolicLink()),
|
||||
});
|
||||
}
|
||||
|
||||
for (const entry of excluded) {
|
||||
recordPlaceholder(entry.src, entry.dest);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,11 +270,15 @@ export function restoreConfigFromBase(baseBranch: string): void {
|
||||
// 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.
|
||||
// PR-authored version. Links are followed only to tracked, unmodified content
|
||||
// inside the working tree; anything else is recorded as a placeholder file,
|
||||
// so the snapshot itself never contains links.
|
||||
rmSync(".claude-pr", { recursive: true, force: true });
|
||||
const workTreeRealPath = realpathSync(process.cwd());
|
||||
const tracked = listTrackedPaths();
|
||||
for (const p of SENSITIVE_PATHS) {
|
||||
if (existsSync(p)) {
|
||||
snapshotSensitivePath(p, `.claude-pr/${p}`);
|
||||
if (lstatSync(p, { throwIfNoEntry: false })) {
|
||||
snapshotSensitivePath(p, `.claude-pr/${p}`, workTreeRealPath, tracked);
|
||||
}
|
||||
}
|
||||
if (existsSync(".claude-pr")) {
|
||||
|
||||
Reference in New Issue
Block a user