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:
Ashwin Bhat
2026-08-06 10:17:40 -07:00
committed by GitHub
parent 0aee57ab82
commit e1fc925862
2 changed files with 448 additions and 19 deletions
+179 -15
View File
@@ -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")) {