From 299f265a000d25fa628cd1e20ef3d05e6b97cb69 Mon Sep 17 00:00:00 2001 From: ant-kurt <209710463+ant-kurt@users.noreply.github.com> Date: Thu, 20 Aug 2026 18:24:49 -0700 Subject: [PATCH 1/3] fix: pin mandatory-deny directories at any depth on macOS; add mandatory denies on Windows macOS: generateMoveBlockingRules derived rename protection from a deny glob's static prefix, so for the cwd-anchored `**/.git/config` and `**/.git/hooks/**` globs only the literal `/.git` was pinned. A sandboxed command could prepare a directory holding a config with core.fsmonitor and rename it onto `packages/app/.git` (or mkdir / symlink it); Seatbelt checks the rename target, not the children carried with it. Every glob-shaped ancestor (`**/.git`, `**/.claude`, ...) is now denied file-write-create / file-write-unlink at any depth, exact match, so writes inside an existing nested .git keep working. Creating or removing a nested .git (git init, clone, worktree add, rm -rf of a nested repo) inside the sandbox is now denied. Windows: the ACL stamp only covered the caller's own denyWrite. The mandatory set (.git/hooks, .git/config, shell rc files, IDE dirs) is now expanded to existing paths under cwd, bounded by mandatoryDenySearchDepth via a new expandGlobPattern maxDepth option, and unioned into denyWrite. Linux is unchanged: bubblewrap can only mask paths that exist at command start (documented limitation). --- README.md | 10 ++- src/sandbox/macos-sandbox-utils.ts | 28 +++++++ src/sandbox/sandbox-manager.ts | 19 ++++- src/sandbox/sandbox-utils.ts | 31 +++++++ src/sandbox/windows-sandbox-utils.ts | 34 +++++++- test/sandbox/macos-seatbelt.test.ts | 92 +++++++++++++++++++++ test/sandbox/windows-mandatory-deny.test.ts | 78 +++++++++++++++++ 7 files changed, 286 insertions(+), 6 deletions(-) create mode 100644 test/sandbox/windows-mandatory-deny.test.ts diff --git a/README.md b/README.md index 14d89686..a23de3ac 100644 --- a/README.md +++ b/README.md @@ -678,9 +678,15 @@ $ srt 'echo "bad" > .git/hooks/pre-commit' /bin/bash: .git/hooks/pre-commit: Operation not permitted ``` -**Note (Linux):** On Linux, mandatory deny paths only block files that already exist. Non-existent files in these patterns cannot be blocked by bubblewrap's bind-mount approach. macOS uses glob patterns which block both existing and new files. +On macOS the directories that hold these paths (`.git`, `.vscode`, `.idea`, `.claude`) are also pinned at any depth, so a sandboxed command cannot create, rename, or delete them — for example `mv decoy packages/app/.git` or `git init` in a subdirectory fails, while writes inside an existing nested `.git` still work. -**Linux search depth:** On Linux, the sandbox uses `ripgrep` to scan for dangerous files in subdirectories within allowed write paths. By default, it searches up to 3 levels deep for performance. You can configure this with `mandatoryDenySearchDepth`: +| Platform | Covers | Paths created after the command starts | +| --- | --- | --- | +| macOS | Pattern rules, any depth | Blocked | +| Linux | Existing paths, up to `mandatoryDenySearchDepth` | Not blocked | +| Windows | Existing paths, up to `mandatoryDenySearchDepth` | Not blocked | + +**Search depth (Linux/Windows):** the project directory is scanned for these paths at startup, 3 levels deep by default. Configure with `mandatoryDenySearchDepth`: ```json { diff --git a/src/sandbox/macos-sandbox-utils.ts b/src/sandbox/macos-sandbox-utils.ts index 876661f9..bd6b7390 100644 --- a/src/sandbox/macos-sandbox-utils.ts +++ b/src/sandbox/macos-sandbox-utils.ts @@ -412,6 +412,28 @@ function renderRule( * Get all ancestor directories for a path, up to (but not including) root * Example: /private/tmp/test/file.txt -> ["/private/tmp/test", "/private/tmp", "/private"] */ +/** + * Glob-containing ancestors of a glob, nearest first, stopping before a + * bare `**`: `**\/.git/hooks` → [`**\/.git`]; `**\/.env` → []. + */ +function getGlobAncestorPatterns(normalizedGlob: string): string[] { + const ancestors: string[] = [] + let current = path.posix.dirname(normalizedGlob) + while ( + current !== '/' && + current !== '.' && + current !== '**' && + !current.endsWith('/**') && + containsGlobChars(current) + ) { + ancestors.push(current) + const parent = path.posix.dirname(current) + if (parent === current) break + current = parent + } + return ancestors +} + function getAncestorDirectories(pathStr: string): string[] { const ancestors: string[] = [] let currentPath = path.dirname(pathStr) @@ -462,6 +484,12 @@ function generateMoveBlockingRules( if (containsGlobChars(normalizedPath)) { // For glob patterns, block moves of the directory containing the // pattern's static prefix, then of its ancestors + // Pin the glob's ancestor directories too (exact match, no subtree): + // a rename of a prepared directory onto `/.git` is checked + // against the target only, not the children it carries. + for (const ancestor of getGlobAncestorPatterns(normalizedPath)) { + filters.add(`(regex ${escapePath(globToRegex(ancestor))})`) + } baseDir = globBaseDir(normalizedPath) if (baseDir === '/') continue filters.add(`(literal ${escapePath(baseDir)})`) diff --git a/src/sandbox/sandbox-manager.ts b/src/sandbox/sandbox-manager.ts index a7d57319..6166de4a 100644 --- a/src/sandbox/sandbox-manager.ts +++ b/src/sandbox/sandbox-manager.ts @@ -61,6 +61,7 @@ import { wrapCommandWithSandboxWindows, parseWindowsBinShell, expandWindowsFsPaths, + windowsGetMandatoryDenyPatterns, stampWindowsAcl, restoreWindowsAcl, grantWindowsAcl, @@ -1301,7 +1302,17 @@ function computeWindowsFsAccessSet(c: SandboxRuntimeConfig): { ], { mode: 'deny' }, ) - const denyWrite = expand(fs?.denyWrite ?? [], { mode: 'deny' }) + // Existing paths only, depth-bounded like the Linux ripgrep scan. + const mandatoryDenyWrite = expand( + windowsGetMandatoryDenyPatterns(process.cwd(), getAllowGitConfig()), + { mode: 'deny', maxDepth: getMandatoryDenySearchDepth() }, + ) + const denyWrite = [ + ...new Set([ + ...expand(fs?.denyWrite ?? [], { mode: 'deny' }), + ...mandatoryDenyWrite, + ]), + ] return { // `allowRead` also serves as `allowWithinDeny`: a file under a // denied dir gets an explicit ALLOW ACE for the sandbox user, @@ -1331,6 +1342,8 @@ function rawWindowsFsInputs(c: SandboxRuntimeConfig) { allowRead: [...(c.filesystem.allowRead ?? [])], allowWrite: [...c.filesystem.allowWrite], credFiles: getCredentialDenyReadPaths(c.credentials), + allowGitConfig: c.filesystem.allowGitConfig ?? false, + mandatoryDenySearchDepth: c.mandatoryDenySearchDepth, } } @@ -1350,7 +1363,9 @@ function sameRawWindowsFsInputs( setEq(a.denyWrite, b.denyWrite) && setEq(a.allowRead, b.allowRead) && setEq(a.allowWrite, b.allowWrite) && - setEq(a.credFiles, b.credFiles) + setEq(a.credFiles, b.credFiles) && + a.allowGitConfig === b.allowGitConfig && + a.mandatoryDenySearchDepth === b.mandatoryDenySearchDepth ) } diff --git a/src/sandbox/sandbox-utils.ts b/src/sandbox/sandbox-utils.ts index 93358517..f59fc85d 100644 --- a/src/sandbox/sandbox-utils.ts +++ b/src/sandbox/sandbox-utils.ts @@ -871,6 +871,12 @@ export interface ExpandGlobOptions { * don't need it). */ caseInsensitive?: boolean + /** + * Descend at most this many directory levels below the pattern's + * static base directory (`1` = the base directory's own entries). + * Default: unbounded. + */ + maxDepth?: number } /** @@ -925,6 +931,31 @@ export function expandGlobPattern( // List all entries recursively under the base directory const results: string[] = [] try { + if (opts.maxDepth !== undefined) { + const stack: Array<{ dir: string; depth: number }> = [ + { dir: baseDir, depth: 1 }, + ] + while (stack.length > 0) { + const { dir, depth } = stack.pop()! + let entries: fs.Dirent[] + try { + entries = fs.readdirSync(dir, { withFileTypes: true }) + } catch { + continue + } + for (const entry of entries) { + const fullPath = path.join(dir, entry.name) + if (regex.test(toFwd(fullPath))) { + results.push(fullPath) + } + if (entry.isDirectory() && depth < opts.maxDepth) { + stack.push({ dir: fullPath, depth: depth + 1 }) + } + } + } + return results + } + const entries = fs.readdirSync(baseDir, { recursive: true, withFileTypes: true, diff --git a/src/sandbox/windows-sandbox-utils.ts b/src/sandbox/windows-sandbox-utils.ts index 02124721..efa527e4 100644 --- a/src/sandbox/windows-sandbox-utils.ts +++ b/src/sandbox/windows-sandbox-utils.ts @@ -13,6 +13,8 @@ import { containsGlobCharsWin, expandGlobPattern, isUncPath, + DANGEROUS_FILES, + getDangerousDirectories, } from './sandbox-utils.js' // Re-export so existing tests (glob-expand.test.ts) and any // out-of-tree caller keep their import path. `buildGitConfigEnv` is @@ -1660,6 +1662,31 @@ export function uninstallWindowsSandbox( return {} } +/** + * Mandatory write-deny globs rooted at `cwd` — the same set + * macGetMandatoryDenyPatterns() / linuxGetMandatoryDenyPaths() use. + * Globs so that {@link expandWindowsFsPaths} stamps existing paths + * only; a literal `mode: 'deny'` entry would materialize placeholder + * files in the project. + */ +export function windowsGetMandatoryDenyPatterns( + cwd: string, + allowGitConfig: boolean, +): string[] { + const out: string[] = [] + for (const fileName of DANGEROUS_FILES) { + out.push(path.join(cwd, '**', fileName)) + } + for (const dirName of getDangerousDirectories()) { + out.push(path.join(cwd, '**', dirName)) + } + out.push(path.join(cwd, '**', '.git', 'hooks')) + if (!allowGitConfig) { + out.push(path.join(cwd, '**', '.git', 'config')) + } + return out +} + /** * Resolve any Windows filesystem-config path list — `allowRead`/ * `allowWrite` grants and `denyRead`/`denyWrite` stamps — to @@ -1691,7 +1718,7 @@ export function uninstallWindowsSandbox( */ export function expandWindowsFsPaths( patterns: readonly string[], - opts?: { mode?: 'grant' | 'deny' }, + opts?: { mode?: 'grant' | 'deny'; maxDepth?: number }, ): string[] { const out = new Set() for (const raw of patterns) { @@ -1704,7 +1731,10 @@ export function expandWindowsFsPaths( continue } const candidates = isGlob - ? expandGlobPattern(norm, { caseInsensitive: true }) + ? expandGlobPattern(norm, { + caseInsensitive: true, + maxDepth: opts?.maxDepth, + }) : [norm] for (const c of candidates) { const st = fs.statSync(c, { throwIfNoEntry: false }) diff --git a/test/sandbox/macos-seatbelt.test.ts b/test/sandbox/macos-seatbelt.test.ts index 1f2d8c4b..a26a3da0 100644 --- a/test/sandbox/macos-seatbelt.test.ts +++ b/test/sandbox/macos-seatbelt.test.ts @@ -1023,3 +1023,95 @@ describe.if(isMacOS)('macOS Seatbelt allowMachLookup', () => { expect(result.status).toBe(0) }) }) + +/** + * Nested `.git` directory swap: the mandatory deny globs have no static + * prefix, so only the literal `/.git` was move-protected and a prepared + * directory could be renamed onto `/.git`. The fix pins every + * glob-shaped ancestor (`**\/.git`) against create/unlink at any depth. + */ +describe.if(isMacOS)('macOS Seatbelt Nested .git Swap Prevention', () => { + const TEST_BASE_DIR = join( + realpathSync(tmpdir()), + 'seatbelt-git-swap-test-' + Date.now(), + ) + const TEST_ALLOWED_DIR = join(TEST_BASE_DIR, 'allowed') + const NESTED_DIR = join(TEST_ALLOWED_DIR, 'packages', 'app') + const DECOY_DIR = join(NESTED_DIR, '_gd') + const NESTED_GIT = join(NESTED_DIR, '.git') + const EXISTING_DIR = join(TEST_ALLOWED_DIR, 'packages', 'lib') + const EXISTING_GIT = join(EXISTING_DIR, '.git') + + // The mandatory `.git` globs are anchored to process.cwd(); mirror them here. + const writeConfig: FsWriteRestrictionConfig = { + allowOnly: [TEST_ALLOWED_DIR], + denyWithinAllow: [ + join(TEST_ALLOWED_DIR, '**/.git/config'), + join(TEST_ALLOWED_DIR, '**/.git/hooks/**'), + ], + } + + function run(command: string) { + const wrapped = wrapCommandWithSandboxMacOS({ + command, + needsNetworkRestriction: false, + readConfig: undefined, + writeConfig, + }) + const result = spawnSync(wrapped, { + shell: true, + encoding: 'utf8', + timeout: 5000, + }) + // A nested Seatbelt fails with "sandbox_apply: Operation not permitted", + // which would satisfy the deny assertions without running the command. + expect(result.stderr || '').not.toContain('sandbox_apply') + return result + } + + beforeAll(() => { + mkdirSync(DECOY_DIR, { recursive: true }) + writeFileSync( + join(DECOY_DIR, 'config'), + '[core]\n\tfsmonitor = /tmp/payload\n', + ) + mkdirSync(EXISTING_GIT, { recursive: true }) + writeFileSync(join(EXISTING_GIT, 'index'), 'old') + }) + + afterAll(() => { + if (existsSync(TEST_BASE_DIR)) { + rmSync(TEST_BASE_DIR, { recursive: true, force: true }) + } + }) + + it('blocks renaming a decoy directory onto a nested .git', () => { + const result = run(`mv ${DECOY_DIR} ${NESTED_GIT}`) + expect(result.status).not.toBe(0) + expect((result.stderr || '').toLowerCase()).toContain( + 'operation not permitted', + ) + expect(existsSync(NESTED_GIT)).toBe(false) + expect(existsSync(DECOY_DIR)).toBe(true) + }) + + it('blocks creating a nested .git directory or symlink directly', () => { + expect(run(`mkdir ${NESTED_GIT}`).status).not.toBe(0) + expect(run(`ln -s ${DECOY_DIR} ${NESTED_GIT}`).status).not.toBe(0) + expect(existsSync(NESTED_GIT)).toBe(false) + }) + + it('blocks renaming an existing nested .git away', () => { + const result = run(`mv ${EXISTING_GIT} ${EXISTING_DIR}/gone`) + expect(result.status).not.toBe(0) + expect(existsSync(EXISTING_GIT)).toBe(true) + }) + + it('still allows ordinary writes inside a nested .git', () => { + const result = run( + `echo new > ${EXISTING_GIT}/index.lock && mv ${EXISTING_GIT}/index.lock ${EXISTING_GIT}/index && mkdir -p ${EXISTING_GIT}/refs/heads`, + ) + expect(result.status).toBe(0) + expect(readFileSync(join(EXISTING_GIT, 'index'), 'utf8').trim()).toBe('new') + }) +}) diff --git a/test/sandbox/windows-mandatory-deny.test.ts b/test/sandbox/windows-mandatory-deny.test.ts new file mode 100644 index 00000000..c5b5d48d --- /dev/null +++ b/test/sandbox/windows-mandatory-deny.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect, beforeAll, afterAll } from 'bun:test' +import { mkdirSync, rmSync, writeFileSync, realpathSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expandGlobPattern } from '../../src/sandbox/sandbox-utils.js' +import { windowsGetMandatoryDenyPatterns } from '../../src/sandbox/windows-sandbox-utils.js' + +describe('windowsGetMandatoryDenyPatterns', () => { + const cwd = join('C:', 'proj') + + it('covers git hooks and config at the root and nested, plus dangerous files', () => { + const pats = windowsGetMandatoryDenyPatterns(cwd, false) + expect(pats).toContain(join(cwd, '**', '.git', 'hooks')) + expect(pats).toContain(join(cwd, '**', '.git', 'config')) + expect(pats).toContain(join(cwd, '**', '.gitconfig')) + expect(pats).toContain(join(cwd, '**', '.bashrc')) + expect(pats).toContain(join(cwd, '**', '.vscode')) + expect(pats).toContain(join(cwd, '**', '.claude', 'commands')) + expect(pats).not.toContain(join(cwd, '**', '.git')) + expect(pats).not.toContain(join(cwd, '.git')) + }) + + it('drops .git/config when allowGitConfig is set, keeps hooks', () => { + const pats = windowsGetMandatoryDenyPatterns(cwd, true) + expect(pats).not.toContain(join(cwd, '**', '.git', 'config')) + expect(pats).toContain(join(cwd, '**', '.git', 'hooks')) + }) +}) + +describe('expandGlobPattern maxDepth', () => { + const base = join(realpathSync(tmpdir()), 'glob-depth-' + Date.now()) + + beforeAll(() => { + for (const d of ['', 'a', join('a', 'b', 'c')]) { + mkdirSync(join(base, d, '.git', 'hooks'), { recursive: true }) + writeFileSync(join(base, d, '.git', 'config'), '') + } + mkdirSync(join(base, 'node_modules', 'x', 'y'), { recursive: true }) + writeFileSync(join(base, 'node_modules', 'x', 'y', '.bashrc'), '') + }) + + afterAll(() => rmSync(base, { recursive: true, force: true })) + + it('finds matches at every depth when unbounded', () => { + const r = expandGlobPattern(join(base, '**', '.git', 'config')).sort() + expect(r).toEqual( + [ + join(base, '.git', 'config'), + join(base, 'a', '.git', 'config'), + join(base, 'a', 'b', 'c', '.git', 'config'), + ].sort(), + ) + }) + + it('stops descending at maxDepth (the Linux ripgrep default is 3)', () => { + const r = expandGlobPattern(join(base, '**', '.git', 'config'), { + maxDepth: 3, + }).sort() + expect(r).toEqual( + [join(base, '.git', 'config'), join(base, 'a', '.git', 'config')].sort(), + ) + expect( + expandGlobPattern(join(base, '**', '.bashrc'), { maxDepth: 3 }), + ).toEqual([]) + expect( + expandGlobPattern(join(base, '**', '.bashrc'), { maxDepth: 4 }), + ).toEqual([join(base, 'node_modules', 'x', 'y', '.bashrc')]) + }) + + it('matches directories too (hooks is a directory target)', () => { + const r = expandGlobPattern(join(base, '**', '.git', 'hooks'), { + maxDepth: 3, + }).sort() + expect(r).toEqual( + [join(base, '.git', 'hooks'), join(base, 'a', '.git', 'hooks')].sort(), + ) + }) +}) From 319475b7d220cca1e736428195144adb07d65bee Mon Sep 17 00:00:00 2001 From: ant-kurt <209710463+ant-kurt@users.noreply.github.com> Date: Thu, 20 Aug 2026 22:47:12 -0700 Subject: [PATCH 2/3] windows: skip node_modules in the mandatory deny scan --- README.md | 8 +++++--- src/sandbox/sandbox-manager.ts | 9 +++++++-- src/sandbox/sandbox-utils.ts | 8 +++++++- src/sandbox/windows-sandbox-utils.ts | 7 ++++++- test/sandbox/windows-mandatory-deny.test.ts | 9 +++++++++ 5 files changed, 34 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index a23de3ac..baeda392 100644 --- a/README.md +++ b/README.md @@ -680,11 +680,13 @@ $ srt 'echo "bad" > .git/hooks/pre-commit' On macOS the directories that hold these paths (`.git`, `.vscode`, `.idea`, `.claude`) are also pinned at any depth, so a sandboxed command cannot create, rename, or delete them — for example `mv decoy packages/app/.git` or `git init` in a subdirectory fails, while writes inside an existing nested `.git` still work. -| Platform | Covers | Paths created after the command starts | +| Platform | Covers | Paths created after the scan | | --- | --- | --- | | macOS | Pattern rules, any depth | Blocked | -| Linux | Existing paths, up to `mandatoryDenySearchDepth` | Not blocked | -| Windows | Existing paths, up to `mandatoryDenySearchDepth` | Not blocked | +| Linux | Direct writes to existing paths, up to `mandatoryDenySearchDepth` | Not blocked | +| Windows | Direct writes to existing paths, up to `mandatoryDenySearchDepth` | Not blocked | + +A nested repository whose `.git` sits `L` directories below the project is within the scan when `mandatoryDenySearchDepth >= L + 2`. `node_modules` is never scanned. **Search depth (Linux/Windows):** the project directory is scanned for these paths at startup, 3 levels deep by default. Configure with `mandatoryDenySearchDepth`: diff --git a/src/sandbox/sandbox-manager.ts b/src/sandbox/sandbox-manager.ts index 6166de4a..72a6e40a 100644 --- a/src/sandbox/sandbox-manager.ts +++ b/src/sandbox/sandbox-manager.ts @@ -1302,10 +1302,15 @@ function computeWindowsFsAccessSet(c: SandboxRuntimeConfig): { ], { mode: 'deny' }, ) - // Existing paths only, depth-bounded like the Linux ripgrep scan. + // Existing paths only, depth-bounded and skipping node_modules like + // the Linux ripgrep scan. const mandatoryDenyWrite = expand( windowsGetMandatoryDenyPatterns(process.cwd(), getAllowGitConfig()), - { mode: 'deny', maxDepth: getMandatoryDenySearchDepth() }, + { + mode: 'deny', + maxDepth: getMandatoryDenySearchDepth(), + skipDirNames: ['node_modules'], + }, ) const denyWrite = [ ...new Set([ diff --git a/src/sandbox/sandbox-utils.ts b/src/sandbox/sandbox-utils.ts index f59fc85d..a342f05b 100644 --- a/src/sandbox/sandbox-utils.ts +++ b/src/sandbox/sandbox-utils.ts @@ -877,6 +877,8 @@ export interface ExpandGlobOptions { * Default: unbounded. */ maxDepth?: number + /** Directory names not descended into (e.g. `node_modules`). */ + skipDirNames?: readonly string[] } /** @@ -948,7 +950,11 @@ export function expandGlobPattern( if (regex.test(toFwd(fullPath))) { results.push(fullPath) } - if (entry.isDirectory() && depth < opts.maxDepth) { + if ( + entry.isDirectory() && + depth < opts.maxDepth && + !opts.skipDirNames?.includes(entry.name) + ) { stack.push({ dir: fullPath, depth: depth + 1 }) } } diff --git a/src/sandbox/windows-sandbox-utils.ts b/src/sandbox/windows-sandbox-utils.ts index efa527e4..7060d3d7 100644 --- a/src/sandbox/windows-sandbox-utils.ts +++ b/src/sandbox/windows-sandbox-utils.ts @@ -1718,7 +1718,11 @@ export function windowsGetMandatoryDenyPatterns( */ export function expandWindowsFsPaths( patterns: readonly string[], - opts?: { mode?: 'grant' | 'deny'; maxDepth?: number }, + opts?: { + mode?: 'grant' | 'deny' + maxDepth?: number + skipDirNames?: readonly string[] + }, ): string[] { const out = new Set() for (const raw of patterns) { @@ -1734,6 +1738,7 @@ export function expandWindowsFsPaths( ? expandGlobPattern(norm, { caseInsensitive: true, maxDepth: opts?.maxDepth, + skipDirNames: opts?.skipDirNames, }) : [norm] for (const c of candidates) { diff --git a/test/sandbox/windows-mandatory-deny.test.ts b/test/sandbox/windows-mandatory-deny.test.ts index c5b5d48d..db88d98b 100644 --- a/test/sandbox/windows-mandatory-deny.test.ts +++ b/test/sandbox/windows-mandatory-deny.test.ts @@ -67,6 +67,15 @@ describe('expandGlobPattern maxDepth', () => { ).toEqual([join(base, 'node_modules', 'x', 'y', '.bashrc')]) }) + it('does not descend into skipDirNames', () => { + expect( + expandGlobPattern(join(base, '**', '.bashrc'), { + maxDepth: 4, + skipDirNames: ['node_modules'], + }), + ).toEqual([]) + }) + it('matches directories too (hooks is a directory target)', () => { const r = expandGlobPattern(join(base, '**', '.git', 'hooks'), { maxDepth: 3, From e026f4b1a66f45182dedb5f8084e6acc9f57d92a Mon Sep 17 00:00:00 2001 From: ant-kurt <209710463+ant-kurt@users.noreply.github.com> Date: Thu, 20 Aug 2026 23:00:35 -0700 Subject: [PATCH 3/3] Drop the Windows mandatory-deny changes; they move to a separate change --- README.md | 3 +- src/sandbox/sandbox-manager.ts | 24 +----- src/sandbox/sandbox-utils.ts | 37 --------- src/sandbox/windows-sandbox-utils.ts | 39 +-------- test/sandbox/windows-mandatory-deny.test.ts | 87 --------------------- 5 files changed, 5 insertions(+), 185 deletions(-) delete mode 100644 test/sandbox/windows-mandatory-deny.test.ts diff --git a/README.md b/README.md index baeda392..d7cd0711 100644 --- a/README.md +++ b/README.md @@ -684,11 +684,10 @@ On macOS the directories that hold these paths (`.git`, `.vscode`, `.idea`, `.cl | --- | --- | --- | | macOS | Pattern rules, any depth | Blocked | | Linux | Direct writes to existing paths, up to `mandatoryDenySearchDepth` | Not blocked | -| Windows | Direct writes to existing paths, up to `mandatoryDenySearchDepth` | Not blocked | A nested repository whose `.git` sits `L` directories below the project is within the scan when `mandatoryDenySearchDepth >= L + 2`. `node_modules` is never scanned. -**Search depth (Linux/Windows):** the project directory is scanned for these paths at startup, 3 levels deep by default. Configure with `mandatoryDenySearchDepth`: +**Search depth (Linux):** the project directory is scanned for these paths with `ripgrep`, 3 levels deep by default. Configure with `mandatoryDenySearchDepth`: ```json { diff --git a/src/sandbox/sandbox-manager.ts b/src/sandbox/sandbox-manager.ts index 72a6e40a..a7d57319 100644 --- a/src/sandbox/sandbox-manager.ts +++ b/src/sandbox/sandbox-manager.ts @@ -61,7 +61,6 @@ import { wrapCommandWithSandboxWindows, parseWindowsBinShell, expandWindowsFsPaths, - windowsGetMandatoryDenyPatterns, stampWindowsAcl, restoreWindowsAcl, grantWindowsAcl, @@ -1302,22 +1301,7 @@ function computeWindowsFsAccessSet(c: SandboxRuntimeConfig): { ], { mode: 'deny' }, ) - // Existing paths only, depth-bounded and skipping node_modules like - // the Linux ripgrep scan. - const mandatoryDenyWrite = expand( - windowsGetMandatoryDenyPatterns(process.cwd(), getAllowGitConfig()), - { - mode: 'deny', - maxDepth: getMandatoryDenySearchDepth(), - skipDirNames: ['node_modules'], - }, - ) - const denyWrite = [ - ...new Set([ - ...expand(fs?.denyWrite ?? [], { mode: 'deny' }), - ...mandatoryDenyWrite, - ]), - ] + const denyWrite = expand(fs?.denyWrite ?? [], { mode: 'deny' }) return { // `allowRead` also serves as `allowWithinDeny`: a file under a // denied dir gets an explicit ALLOW ACE for the sandbox user, @@ -1347,8 +1331,6 @@ function rawWindowsFsInputs(c: SandboxRuntimeConfig) { allowRead: [...(c.filesystem.allowRead ?? [])], allowWrite: [...c.filesystem.allowWrite], credFiles: getCredentialDenyReadPaths(c.credentials), - allowGitConfig: c.filesystem.allowGitConfig ?? false, - mandatoryDenySearchDepth: c.mandatoryDenySearchDepth, } } @@ -1368,9 +1350,7 @@ function sameRawWindowsFsInputs( setEq(a.denyWrite, b.denyWrite) && setEq(a.allowRead, b.allowRead) && setEq(a.allowWrite, b.allowWrite) && - setEq(a.credFiles, b.credFiles) && - a.allowGitConfig === b.allowGitConfig && - a.mandatoryDenySearchDepth === b.mandatoryDenySearchDepth + setEq(a.credFiles, b.credFiles) ) } diff --git a/src/sandbox/sandbox-utils.ts b/src/sandbox/sandbox-utils.ts index a342f05b..93358517 100644 --- a/src/sandbox/sandbox-utils.ts +++ b/src/sandbox/sandbox-utils.ts @@ -871,14 +871,6 @@ export interface ExpandGlobOptions { * don't need it). */ caseInsensitive?: boolean - /** - * Descend at most this many directory levels below the pattern's - * static base directory (`1` = the base directory's own entries). - * Default: unbounded. - */ - maxDepth?: number - /** Directory names not descended into (e.g. `node_modules`). */ - skipDirNames?: readonly string[] } /** @@ -933,35 +925,6 @@ export function expandGlobPattern( // List all entries recursively under the base directory const results: string[] = [] try { - if (opts.maxDepth !== undefined) { - const stack: Array<{ dir: string; depth: number }> = [ - { dir: baseDir, depth: 1 }, - ] - while (stack.length > 0) { - const { dir, depth } = stack.pop()! - let entries: fs.Dirent[] - try { - entries = fs.readdirSync(dir, { withFileTypes: true }) - } catch { - continue - } - for (const entry of entries) { - const fullPath = path.join(dir, entry.name) - if (regex.test(toFwd(fullPath))) { - results.push(fullPath) - } - if ( - entry.isDirectory() && - depth < opts.maxDepth && - !opts.skipDirNames?.includes(entry.name) - ) { - stack.push({ dir: fullPath, depth: depth + 1 }) - } - } - } - return results - } - const entries = fs.readdirSync(baseDir, { recursive: true, withFileTypes: true, diff --git a/src/sandbox/windows-sandbox-utils.ts b/src/sandbox/windows-sandbox-utils.ts index 7060d3d7..02124721 100644 --- a/src/sandbox/windows-sandbox-utils.ts +++ b/src/sandbox/windows-sandbox-utils.ts @@ -13,8 +13,6 @@ import { containsGlobCharsWin, expandGlobPattern, isUncPath, - DANGEROUS_FILES, - getDangerousDirectories, } from './sandbox-utils.js' // Re-export so existing tests (glob-expand.test.ts) and any // out-of-tree caller keep their import path. `buildGitConfigEnv` is @@ -1662,31 +1660,6 @@ export function uninstallWindowsSandbox( return {} } -/** - * Mandatory write-deny globs rooted at `cwd` — the same set - * macGetMandatoryDenyPatterns() / linuxGetMandatoryDenyPaths() use. - * Globs so that {@link expandWindowsFsPaths} stamps existing paths - * only; a literal `mode: 'deny'` entry would materialize placeholder - * files in the project. - */ -export function windowsGetMandatoryDenyPatterns( - cwd: string, - allowGitConfig: boolean, -): string[] { - const out: string[] = [] - for (const fileName of DANGEROUS_FILES) { - out.push(path.join(cwd, '**', fileName)) - } - for (const dirName of getDangerousDirectories()) { - out.push(path.join(cwd, '**', dirName)) - } - out.push(path.join(cwd, '**', '.git', 'hooks')) - if (!allowGitConfig) { - out.push(path.join(cwd, '**', '.git', 'config')) - } - return out -} - /** * Resolve any Windows filesystem-config path list — `allowRead`/ * `allowWrite` grants and `denyRead`/`denyWrite` stamps — to @@ -1718,11 +1691,7 @@ export function windowsGetMandatoryDenyPatterns( */ export function expandWindowsFsPaths( patterns: readonly string[], - opts?: { - mode?: 'grant' | 'deny' - maxDepth?: number - skipDirNames?: readonly string[] - }, + opts?: { mode?: 'grant' | 'deny' }, ): string[] { const out = new Set() for (const raw of patterns) { @@ -1735,11 +1704,7 @@ export function expandWindowsFsPaths( continue } const candidates = isGlob - ? expandGlobPattern(norm, { - caseInsensitive: true, - maxDepth: opts?.maxDepth, - skipDirNames: opts?.skipDirNames, - }) + ? expandGlobPattern(norm, { caseInsensitive: true }) : [norm] for (const c of candidates) { const st = fs.statSync(c, { throwIfNoEntry: false }) diff --git a/test/sandbox/windows-mandatory-deny.test.ts b/test/sandbox/windows-mandatory-deny.test.ts deleted file mode 100644 index db88d98b..00000000 --- a/test/sandbox/windows-mandatory-deny.test.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { describe, it, expect, beforeAll, afterAll } from 'bun:test' -import { mkdirSync, rmSync, writeFileSync, realpathSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { expandGlobPattern } from '../../src/sandbox/sandbox-utils.js' -import { windowsGetMandatoryDenyPatterns } from '../../src/sandbox/windows-sandbox-utils.js' - -describe('windowsGetMandatoryDenyPatterns', () => { - const cwd = join('C:', 'proj') - - it('covers git hooks and config at the root and nested, plus dangerous files', () => { - const pats = windowsGetMandatoryDenyPatterns(cwd, false) - expect(pats).toContain(join(cwd, '**', '.git', 'hooks')) - expect(pats).toContain(join(cwd, '**', '.git', 'config')) - expect(pats).toContain(join(cwd, '**', '.gitconfig')) - expect(pats).toContain(join(cwd, '**', '.bashrc')) - expect(pats).toContain(join(cwd, '**', '.vscode')) - expect(pats).toContain(join(cwd, '**', '.claude', 'commands')) - expect(pats).not.toContain(join(cwd, '**', '.git')) - expect(pats).not.toContain(join(cwd, '.git')) - }) - - it('drops .git/config when allowGitConfig is set, keeps hooks', () => { - const pats = windowsGetMandatoryDenyPatterns(cwd, true) - expect(pats).not.toContain(join(cwd, '**', '.git', 'config')) - expect(pats).toContain(join(cwd, '**', '.git', 'hooks')) - }) -}) - -describe('expandGlobPattern maxDepth', () => { - const base = join(realpathSync(tmpdir()), 'glob-depth-' + Date.now()) - - beforeAll(() => { - for (const d of ['', 'a', join('a', 'b', 'c')]) { - mkdirSync(join(base, d, '.git', 'hooks'), { recursive: true }) - writeFileSync(join(base, d, '.git', 'config'), '') - } - mkdirSync(join(base, 'node_modules', 'x', 'y'), { recursive: true }) - writeFileSync(join(base, 'node_modules', 'x', 'y', '.bashrc'), '') - }) - - afterAll(() => rmSync(base, { recursive: true, force: true })) - - it('finds matches at every depth when unbounded', () => { - const r = expandGlobPattern(join(base, '**', '.git', 'config')).sort() - expect(r).toEqual( - [ - join(base, '.git', 'config'), - join(base, 'a', '.git', 'config'), - join(base, 'a', 'b', 'c', '.git', 'config'), - ].sort(), - ) - }) - - it('stops descending at maxDepth (the Linux ripgrep default is 3)', () => { - const r = expandGlobPattern(join(base, '**', '.git', 'config'), { - maxDepth: 3, - }).sort() - expect(r).toEqual( - [join(base, '.git', 'config'), join(base, 'a', '.git', 'config')].sort(), - ) - expect( - expandGlobPattern(join(base, '**', '.bashrc'), { maxDepth: 3 }), - ).toEqual([]) - expect( - expandGlobPattern(join(base, '**', '.bashrc'), { maxDepth: 4 }), - ).toEqual([join(base, 'node_modules', 'x', 'y', '.bashrc')]) - }) - - it('does not descend into skipDirNames', () => { - expect( - expandGlobPattern(join(base, '**', '.bashrc'), { - maxDepth: 4, - skipDirNames: ['node_modules'], - }), - ).toEqual([]) - }) - - it('matches directories too (hooks is a directory target)', () => { - const r = expandGlobPattern(join(base, '**', '.git', 'hooks'), { - maxDepth: 3, - }).sort() - expect(r).toEqual( - [join(base, '.git', 'hooks'), join(base, 'a', '.git', 'hooks')].sort(), - ) - }) -})