Skip to content

Commit dea6812

Browse files
committed
Mirror project files via hardlink so Glob/Grep can see them
Claude Code's Glob and Grep are backed by `rg --files`, which skips symlinks unless `--follow` is passed. The sandbox mirrored individual files as symlinks, so `rg --files` over the sandbox tree returned zero results — Glob across patterns like `**/*.tsx` came back empty even when the project contained hundreds of matching files. Verified on a recent run: 0 files via `rg --files`, 384 via `rg --files --follow`. Switch the file mirror from `symlink()` to `link()`, with a `copyFile()` fallback on EXDEV for projects on a different filesystem from `~/.mdredd`. Hardlinks are indistinguishable from real files to anything that walks the FS, so Glob/Grep work natively. The realpath-of-symlink target validation still happens against the source — link() doesn't follow symlinks, so we link the realpath rather than the symlink path. Side effect: realpath() of a hardlinked file in the sandbox stays inside the sandbox (each hardlink path is a first-class name), unlike symlinks which leaked the source path. The old "realpath escapes the sandbox" known-limit goes away.
1 parent e12225d commit dea6812

4 files changed

Lines changed: 88 additions & 15 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ Tests are plain tsx scripts, not a framework. Each file declares scenarios via a
6262
1. **Preflight** (`src/server/preflight.ts`) — verifies the `claude` binary is on PATH and supports the required CLI flags (offline checks: `--version` + `--help` parsing). No live API ping — auth issues surface in the first run's stderr via SSE, which is more diagnosable than a gate-zero blocker on transient API hiccups. Computes a per-project `storageRoot` at `~/.mdredd/projects/<projectKey>/` (`projectKey = sha256(resolve(cwd)).slice(0, 12)`); acquires `<storageRoot>/.lock` via `proper-lockfile` (single-instance per project); writes a `.lock.meta.json` sidecar with pid/port for human debugging. Mass-marks any `preparing`/`streaming` runs from a previous boot as `abandoned` and ensures a `~/.mdredd/.gitignore` exists at the global root.
6363
2. **Sandbox** (`src/server/sandbox.ts`) — for each run, builds `~/.mdredd/projects/<projectKey>/<runFolder>/project/` as the child `claude` cwd. **This is the central isolation primitive — read its file-level docstring before changing anything.** Key invariants:
6464
- An **empty `.git/`** is planted on a `sandbox` branch so Claude Code's upward project-root walk terminates inside the run dir. This prevents host git status, branch, recent commits, and per-project auto-memory from leaking into the child's system prompt.
65-
- Top-level entries of the user's project are mirrored by recursively walking the source tree, creating real directories on the sandbox side and **symlinking only individual files**. Filtering applies at every level: `HARD_EXCLUDED` (`.git`, `.claude`, `node_modules`, `.DS_Store`), root + nested `.gitignore`, the user's global git excludes file, symlinks whose realpath escapes `cwd`, and symlink cycles.
65+
- Top-level entries of the user's project are mirrored by recursively walking the source tree, creating real directories on the sandbox side and **hardlinking individual files** (with a copy fallback on `EXDEV` when source and storage live on different filesystems). Hardlinks rather than symlinks because Claude Code's `Glob` and `Grep` are backed by `rg --files`, which skips symlinks without `--follow` — symlinked leaves would make the entire tree invisible to glob discovery. Filtering applies at every level: `HARD_EXCLUDED` (`.git`, `.claude`, `node_modules`, `.DS_Store`), root + nested `.gitignore`, the user's global git excludes file, source-side symlinks whose realpath escapes `cwd`, and symlink cycles.
6666
- Filtering subtleties — easy to regress when refactoring `sandbox.ts`: the ignore chain is walked **most-specific to least-specific** using `Ignore.test()` so a nested `!keep.log` overrides a root `*.log` (matches git's per-directory precedence — don't switch to `.ignores()`, which short-circuits and ignores negation). `Mirror.walk` records the **realpath of every directory it descends into** (not only symlink targets), so a symlink pointing back at a real-dir ancestor (`a/b/loop -> a`) is rejected on first encounter rather than after a wasted level of mirroring. Storage-root exclusion uses **realpath** (`realIsStorageRoot` against `classified.realTarget ?? <cwdReal>/<name>`), not the entry's path string — a top-level symlink like `alias -> .storage` would otherwise pass a name-based guard and let the mirror copy the sandbox's own state into a run dir.
6767
- In `write` mode, a `.claude/settings.json` allows `Write`/`Edit` only against `../outputs/**` (the per-run outputs dir, one level above the child's cwd). Read-only mode passes a tools allowlist of `Read,Glob,Grep,WebSearch,WebFetch`; write mode adds `Write,Edit`.
6868
3. **Spawn** (`src/server/runner.ts`) — invokes `claude -p <prompt> --output-format stream-json --include-partial-messages --verbose --model <m> --tools <list> --allowedTools <list> --strict-mcp-config --setting-sources project --disable-slash-commands`. The runner **strips** `NODE_OPTIONS`, `GIT_DIR`, `GIT_WORK_TREE`, `GIT_INDEX_FILE`, `GIT_COMMON_DIR`, `GIT_CEILING_DIRECTORIES`, `CLAUDE_PROJECT_DIR`, `CLAUDE_PROJECT_NAME` from the spawn environment so a parent shell can't override the planted sandbox. `HOME` / `CLAUDE_CONFIG_DIR` are kept so the child reads the user's auth.

README.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ Each variant run gets its own sandbox under `~/.mdredd/projects/<projectKey>/<ru
5959
│ ├── CLAUDE.md ← (CLAUDE.md variants) the variant being tested
6060
│ ├── .claude/skills/<name>/SKILL.md ← (skill variants)
6161
│ ├── .claude/agents/<name>.md ← (agent variants)
62-
│ └── <top-level entries> ← symlinked from your project, see below
62+
│ └── <top-level entries> ← hardlinked from your project, see below
6363
├── outputs/ ← write target in Write mode; empty in read-only
6464
├── variant.md ← exact bytes of the variant we ran
6565
├── config.json ← run config + token usage + cost
@@ -72,13 +72,13 @@ Each variant run gets its own sandbox under `~/.mdredd/projects/<projectKey>/<ru
7272
### What the child claude sees
7373

7474
- **The variant file**, written at its canonical path (`CLAUDE.md`, `.claude/skills/<name>/SKILL.md`, or `.claude/agents/<name>.md`).
75-
- **Symlinks to every top-level entry of your project** that isn't excluded — so `Read`, `Glob`, and `Grep` resolve to your real files. This is intentional: it keeps stack detection realistic, so skills like `pest-testing`, `inertia-react-development`, etc. that Claude Code auto-suggests from `composer.json` / `package.json` still load the way they would in a real session.
75+
- **A mirror of every top-level entry of your project** that isn't excluded: directories are recreated, individual files are hardlinked back to your sources (copy fallback when source and `~/.mdredd` live on different filesystems). `Read`, `Glob`, and `Grep` therefore see real files at every leaf — Claude Code's ripgrep-backed `Glob`/`Grep` skip symlinks without `--follow`, so hardlinks rather than symlinks are required for glob discovery to work at all. Stack detection stays realistic: skills like `pest-testing`, `inertia-react-development`, etc. that Claude Code auto-suggests from `composer.json` / `package.json` still load the way they would in a real session.
7676
- **Your global Claude Code auth** (`HOME` / `CLAUDE_CONFIG_DIR` are passed through unmodified) so the child can talk to the API.
7777
- **Your user-global instructions at `~/.claude/CLAUDE.md`** and any user-global skills/agents/plugins/MCP servers you have installed — these are part of "how Claude behaves on your machine" and are deliberately not stripped.
7878

7979
### What the child claude does **not** see
8080

81-
- **Your project's real `.git/`.** A self-contained empty `.git/` is planted in the sandbox before any symlinks, so Claude Code's upward project-root walk terminates inside the run folder. Result: `git status` is clean, `git branch --show-current` returns `sandbox`, `git log` reports no commits — none of your branch name, working-tree status, or recent commit subjects can be auto-injected into the child's system prompt.
81+
- **Your project's real `.git/`.** A self-contained empty `.git/` is planted in the sandbox before any files are mirrored, so Claude Code's upward project-root walk terminates inside the run folder. Result: `git status` is clean, `git branch --show-current` returns `sandbox`, `git log` reports no commits — none of your branch name, working-tree status, or recent commit subjects can be auto-injected into the child's system prompt.
8282
- **Your project's auto-memory.** Because Claude Code derives the per-project memory directory (`~/.claude/projects/<encoded-cwd>/memory/`) from where it found `.git`, the planted sandbox `.git/` redirects this lookup to a per-run path that's empty by default. Your project's accumulated `feedback_*.md` / `project_*.md` notes do not bleed in.
8383
- **Your project's `.claude/` directory.** Hard-excluded so an on-disk skill or agent file with the same name can't shadow the variant under test.
8484
- **mdredd's own storage** (`~/.mdredd/`), to keep variant runs out of each other's sandboxes.
@@ -95,4 +95,4 @@ Two artifacts make this auditable:
9595
### Known limits
9696

9797
- A baseline ~5–10k cache-creation tokens still come from Claude Code's own system prompt, tool schemas, and your user-global config (`~/.claude/CLAUDE.md`, user-level skills). That overhead is the same for every variant in a session, so it cancels out in A/B comparisons — but it's not zero.
98-
- Symlinks mean `realpath()` of any file inside the sandbox resolves outside it. If a future Claude Code version starts using `realpath` for project resolution instead of `.git` walking, the planted `.git/` won't catch that path. Watch `init.json`'s `memory_paths.auto` after Claude Code updates.
98+
- Hardlinks share an inode with your source files. The planted `.claude/settings.json` deny rules (`Write(**)`, `Edit(**)` with a single `../outputs/**` allow) keep the child from writing to anything but the per-run outputs directory, so this isolation is path-based and survives the inode sharing — but if you ever loosen those rules in your fork, writes through the sandbox path will modify the underlying source file.

src/server/sandbox.ts

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { lstat, readdir, readFile, realpath, symlink, writeFile } from 'node:fs/promises';
1+
import { copyFile, link, lstat, readdir, readFile, realpath, writeFile } from 'node:fs/promises';
22
import { homedir } from 'node:os';
33
import { join, sep } from 'node:path';
44
import ignore, { type Ignore } from 'ignore';
@@ -30,15 +30,19 @@ export interface SandboxResult {
3030
*
3131
* <storageRoot>/<runFolder>/
3232
* project/ ← child claude's cwd
33-
* <user's cwd mirrored as a tree of real dirs + per-file symlinks>
33+
* <user's cwd mirrored as a tree of real dirs + per-file hardlinks>
3434
* CLAUDE.md or .claude/skills/<name>/SKILL.md or .claude/agents/<name>.md
3535
* .claude/settings.json (write mode only)
3636
* outputs/ ← write target for write mode; empty in read-only
3737
*
3838
* The mirror walks the source tree recursively, creating real directories on
39-
* the sandbox side and symlinking only individual files. This lets us apply
40-
* filtering (gitignore, hard-exclude, symlink-target validation) at every
41-
* level, not just at the top.
39+
* the sandbox side and hardlinking individual files (falling back to copy on
40+
* EXDEV when the storage root sits on a different filesystem from the source).
41+
* This lets us apply filtering (gitignore, hard-exclude, symlink-target
42+
* validation) at every level, not just at the top. Hardlinks rather than
43+
* symlinks because Claude Code's `Glob` and `Grep` tools are backed by
44+
* `rg --files`, which skips symlinks unless `--follow` is passed — symlinking
45+
* the leaves makes the entire tree invisible to glob-based discovery.
4246
*
4347
* Filtered at every level:
4448
* - HARD_EXCLUDED entries (`.git`, `.claude`, `node_modules`, `.DS_Store`)
@@ -217,10 +221,18 @@ class Mirror {
217221
await this.walk(walkSource, subDest, rel, chain, nextAncestors, false);
218222
this.recordMirror(isTopLevel, name);
219223
} else if (classified.isFile) {
220-
// Symlink the file itself. For symlink-to-file entries we point at the
221-
// realpath rather than re-creating an indirect chain — the realpath was
222-
// already verified to be inside cwd above.
223-
await symlink(classified.realTarget ?? source, join(dest, name));
224+
// Link the realpath rather than the source path. `link()` does not
225+
// follow symlinks, so a symlink-to-file source would otherwise produce
226+
// another symlink-leaf and reproduce the rg-can't-see-symlinks bug
227+
// the file-level docstring describes. Realpath was verified above.
228+
const linkSource = classified.realTarget ?? source;
229+
const linkDest = join(dest, name);
230+
try {
231+
await link(linkSource, linkDest);
232+
} catch (err) {
233+
if ((err as NodeJS.ErrnoException).code !== 'EXDEV') throw err;
234+
await copyFile(linkSource, linkDest);
235+
}
224236
this.recordMirror(isTopLevel, name);
225237
} else {
226238
this.recordSkip(isTopLevel, name, 'unsupported file type');

test/sandbox.spec.ts

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,14 @@
1-
import { mkdir, mkdtemp, readFile, readdir, rm, stat, symlink, writeFile } from 'node:fs/promises';
1+
import {
2+
lstat,
3+
mkdir,
4+
mkdtemp,
5+
readFile,
6+
readdir,
7+
rm,
8+
stat,
9+
symlink,
10+
writeFile,
11+
} from 'node:fs/promises';
212
import { tmpdir } from 'node:os';
313
import { join, relative } from 'node:path';
414
import { buildSandbox } from '../src/server/sandbox.js';
@@ -329,6 +339,57 @@ await scenario('sandbox: read-only mode plants no .claude/settings.json', async
329339
});
330340
});
331341

342+
await scenario(
343+
'sandbox: regular files are mirrored as hardlinks (visible to rg --files)',
344+
async () => {
345+
await withCwd(async (cwd) => {
346+
// ripgrep-backed Glob/Grep skip symlinks without --follow. Hardlinking
347+
// leaves makes the sandbox tree indistinguishable from a real one to
348+
// anything that walks the filesystem.
349+
await writeFile(join(cwd, 'a.tsx'), 'export {}');
350+
351+
const sb = await build(cwd);
352+
const mirrored = join(sb.projectDir, 'a.tsx');
353+
const linkStat = await lstat(mirrored);
354+
if (linkStat.isSymbolicLink()) {
355+
throw new Error('mirrored regular file should not be a symlink');
356+
}
357+
const srcStat = await lstat(join(cwd, 'a.tsx'));
358+
if (linkStat.ino !== srcStat.ino) {
359+
// EXDEV fallback would copy instead — only acceptable when source and
360+
// sandbox live on different filesystems. The temp dir helper places
361+
// both under tmpdir(), so they should share a filesystem here.
362+
throw new Error('hardlink should share inode with source');
363+
}
364+
});
365+
},
366+
);
367+
368+
await scenario(
369+
'sandbox: symlink-to-file is mirrored as a hardlink to the realpath target',
370+
async () => {
371+
await withCwd(async (cwd) => {
372+
// A symlink-to-file inside cwd resolves to a real file we already verified
373+
// is inside cwd; the mirror should hardlink the realpath, not the symlink
374+
// (linking the symlink would re-create a symlink-leaf and reproduce the
375+
// visibility bug we're fixing).
376+
await writeFile(join(cwd, 'real.tsx'), 'export {}');
377+
await symlink(join(cwd, 'real.tsx'), join(cwd, 'aliased.tsx'));
378+
379+
const sb = await build(cwd);
380+
const mirrored = join(sb.projectDir, 'aliased.tsx');
381+
const linkStat = await lstat(mirrored);
382+
if (linkStat.isSymbolicLink()) {
383+
throw new Error('symlink-to-file should be mirrored as a real file, not a symlink');
384+
}
385+
const realSrc = await lstat(join(cwd, 'real.tsx'));
386+
if (linkStat.ino !== realSrc.ino) {
387+
throw new Error('hardlink should share inode with the realpath target');
388+
}
389+
});
390+
},
391+
);
392+
332393
await scenario('sandbox: top-level symlink to storage root is refused', async () => {
333394
await withCwd(async (cwd) => {
334395
// The path-string guard alone misses a symlink whose name doesn't match

0 commit comments

Comments
 (0)