Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -666,7 +666,7 @@ Certain sensitive files and directories are **always blocked from writes**, even

- IDE directories: `.vscode/`, `.idea/`
- Claude config directories: `.claude/commands/`, `.claude/agents/`
- Git hooks and config: `.git/hooks/`, `.git/config`
- Git hooks and config: `.git/hooks/`, `.git/config` — in the working directory's repository, in nested repositories, and in the submodule git directories a repository keeps under `.git/modules/`. A `.git` _file_ (a linked worktree's or submodule checkout's `gitdir:` pointer) is read-only itself, and the hooks and config it leads to (the main repository's, for a worktree) are blocked as well; creating a new one is still allowed.

These paths are blocked automatically - you don't need to add them to `denyWrite`. For example, even with `allowWrite: ["."]`, writing to `.bashrc` or `.git/hooks/pre-commit` will fail:

Expand All @@ -678,9 +678,9 @@ $ 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.
**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 (a blocked _directory_, such as a repository's `.git/hooks/`, does cover files created in it later). macOS uses glob patterns which block both existing and new files. The Linux scan ignores `.gitignore` and similar ignore files, since the sandboxed command can write those.

**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`:
**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 (a path of at most three segments below the working directory, so a nested repository directly beneath it — `pkg/.git/HEAD` — is found and its hooks and config blocked). You can configure this with `mandatoryDenySearchDepth`:

```json
{
Expand Down
158 changes: 109 additions & 49 deletions src/sandbox/linux-sandbox-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import {
encodeSandboxedCommand,
DANGEROUS_FILES,
getDangerousDirectories,
gitDirDenyPaths,
gitFileDenyPaths,
} from './sandbox-utils.js'
import type {
FsReadRestrictionConfig,
Expand Down Expand Up @@ -267,6 +269,42 @@ function findFirstNonExistentComponent(targetPath: string): string {
return targetPath // Shouldn't reach here if called correctly
}

/**
* Git directories of the submodules kept under `modulesDir` (a repository's
* `.git/modules`), nested submodules included: a directory there holding a
* HEAD file is one, and its own `modules/` may hold more. A submodule's
* name is its path, so a git directory can sit several levels down
* (`modules/vendor/lib/HEAD`); the walk stops `maxDepth` levels in.
*/
function submoduleGitDirs(modulesDir: string, maxDepth: number): string[] {
const found: string[] = []
const pending: Array<{ dir: string; depth: number }> = [
{ dir: modulesDir, depth: 0 },
]
while (pending.length > 0) {
const { dir, depth } = pending.pop()!
let entries: fs.Dirent[]
try {
entries = fs.readdirSync(dir, { withFileTypes: true })
} catch {
continue
}
for (const entry of entries) {
if (!entry.isDirectory()) continue
const child = path.join(dir, entry.name)
if (fs.existsSync(path.join(child, 'HEAD'))) {
found.push(child)
if (depth + 1 < maxDepth) {
pending.push({ dir: path.join(child, 'modules'), depth: depth + 1 })
}
} else if (depth + 1 < maxDepth) {
pending.push({ dir: child, depth: depth + 1 })
}
}
}
return found
}

/**
* Get mandatory deny paths using ripgrep (Linux only).
* Uses a SINGLE ripgrep call with multiple glob patterns for efficiency.
Expand All @@ -292,27 +330,31 @@ async function linuxGetMandatoryDenyPaths(
...dangerousDirectories.map(d => path.resolve(cwd, d)),
]

// Git hooks and config are only denied when .git exists as a directory.
// In git worktrees, .git is a file (e.g., "gitdir: /path/..."), so
// .git/hooks can never exist — denying it would cause bwrap to fail.
// When .git doesn't exist at all, mounting at .git would block its
// cwd's own repository. A .git DIRECTORY gets its hooks/ and config
// denied, plus those of every submodule git directory it keeps under
// .git/modules (the hooks a `git commit` inside the submodule runs). A .git
// FILE (linked worktree, submodule checkout) is denied itself along with
// the hooks/config git consults through it (gitFileDenyPaths); .git/hooks
// beneath a file can never exist and denying it would make bwrap fail. When .git
// doesn't exist at all nothing is denied: a mount at .git would block its
// creation and break git init.
const dotGitPath = path.resolve(cwd, '.git')
let dotGitIsDirectory = false
let dotGitStat: fs.Stats | undefined
try {
dotGitIsDirectory = fs.statSync(dotGitPath).isDirectory()
dotGitStat = fs.statSync(dotGitPath)
} catch {
// .git doesn't exist
}

if (dotGitIsDirectory) {
// Git hooks always blocked for security
denyPaths.push(path.resolve(cwd, '.git/hooks'))

// Git config conditionally blocked based on allowGitConfig setting
if (!allowGitConfig) {
denyPaths.push(path.resolve(cwd, '.git/config'))
if (dotGitStat?.isDirectory()) {
denyPaths.push(...gitDirDenyPaths(dotGitPath, allowGitConfig))
for (const moduleGitDir of submoduleGitDirs(
path.join(dotGitPath, 'modules'),
maxDepth,
)) {
denyPaths.push(...gitDirDenyPaths(moduleGitDir, allowGitConfig))
}
} else if (dotGitStat?.isFile()) {
denyPaths.push(...gitFileDenyPaths(dotGitPath, allowGitConfig))
}

// Build iglob args for all patterns in one ripgrep call
Expand All @@ -323,23 +365,37 @@ async function linuxGetMandatoryDenyPaths(
for (const dirName of dangerousDirectories) {
iglobArgs.push('--iglob', `**/${dirName}/**`)
}
// Git hooks always blocked in nested repos
iglobArgs.push('--iglob', '**/.git/hooks/**')

// Git config conditionally blocked in nested repos
// A nested repository is recognised by any file directly inside its .git
// directory — HEAD is always there — so its hooks/ and config are denied
// at the depth the repository itself is found, not one level further down
// where the hook files sit (with the default depth, a repository directly
// under cwd has .git/config within reach but .git/hooks/* beyond it).
// A FILE named .git is a worktree/submodule pointer (gitFileDenyPaths).
iglobArgs.push(
'--iglob',
'**/.git/HEAD',
'--iglob',
'**/.git/hooks/**',
'--iglob',
'**/.git',
)
if (!allowGitConfig) {
iglobArgs.push('--iglob', '**/.git/config')
}

// Single ripgrep call to find all dangerous paths in subdirectories
// Limit depth for performance - deeply nested dangerous files are rare
// and the security benefit doesn't justify the traversal cost
// and the security benefit doesn't justify the traversal cost.
// --no-ignore: .gitignore, .ignore and .rgignore are writable inside the
// sandbox, so honouring them would let one command hide a nested
// repository from the next command's scan.
let matches: string[] = []
try {
matches = await ripGrep(
[
'--files',
'--hidden',
'--no-ignore',
'--max-depth',
String(maxDepth),
...iglobArgs,
Expand All @@ -354,39 +410,43 @@ async function linuxGetMandatoryDenyPaths(
logForDebugging(`[Sandbox] ripgrep scan failed: ${error}`)
}

// Process matches
// Each match is cwd-relative. One inside a dangerous directory (whose name
// may span segments: .claude/commands) denies the directory, so files
// created in it later are covered too. One inside a nested .git directory
// marks a repository: deny that directory's hooks/ and config. One that IS
// a file named .git is a worktree/submodule pointer. Anything else is a
// dangerous file denied by itself. Segments are compared on the relative
// path, so a dangerous name in cwd's own location never counts.
const dirPatterns = dangerousDirectories.map(d =>
normalizeCaseForComparison(d).split('/'),
)
const runAt = (segments: string[], parts: string[]): number =>
segments.findIndex((_, i) =>
parts.every((part, j) => segments[i + j] === part),
)
for (const match of matches) {
const absolutePath = path.resolve(cwd, match)

// File inside a dangerous directory -> add the directory path
let foundDir = false
for (const dirName of [...dangerousDirectories, '.git']) {
const normalizedDirName = normalizeCaseForComparison(dirName)
const segments = absolutePath.split(path.sep)
const dirIndex = segments.findIndex(
s => normalizeCaseForComparison(s) === normalizedDirName,
)
if (dirIndex !== -1) {
// For .git, we want hooks/ or config, not the whole .git dir
if (dirName === '.git') {
const gitDir = segments.slice(0, dirIndex + 1).join(path.sep)
if (match.includes('.git/hooks')) {
denyPaths.push(path.join(gitDir, 'hooks'))
} else if (match.includes('.git/config')) {
denyPaths.push(path.join(gitDir, 'config'))
}
} else {
denyPaths.push(segments.slice(0, dirIndex + 1).join(path.sep))
}
foundDir = true
break
}
const relative = match.split('/')
const lowered = relative.map(normalizeCaseForComparison)
const absoluteOf = (n: number): string =>
path.resolve(cwd, relative.slice(0, n).join('/'))

const dirParts = dirPatterns.find(parts => runAt(lowered, parts) !== -1)
if (dirParts) {
denyPaths.push(absoluteOf(runAt(lowered, dirParts) + dirParts.length))
continue
}

// Dangerous file match
if (!foundDir) {
denyPaths.push(absolutePath)
const gitAt = lowered.indexOf('.git')
if (gitAt !== -1 && gitAt < relative.length - 1) {
denyPaths.push(...gitDirDenyPaths(absoluteOf(gitAt + 1), allowGitConfig))
continue
}
if (gitAt === relative.length - 1) {
denyPaths.push(
...gitFileDenyPaths(absoluteOf(relative.length), allowGitConfig),
)
continue
}
denyPaths.push(absoluteOf(relative.length))
}

return [...new Set(denyPaths)]
Expand Down
48 changes: 47 additions & 1 deletion src/sandbox/macos-sandbox-utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { quote } from '../utils/shell-quote.js'
import { spawn } from 'child_process'
import * as fs from 'fs'
import * as path from 'path'
import { logForDebugging } from '../utils/debug.js'
import { whichSync } from '../utils/which.js'
Expand All @@ -13,6 +14,7 @@ import {
containsGlobChars,
globToRegex,
DANGEROUS_FILES,
gitFileDenyPaths,
getDangerousDirectories,
} from './sandbox-utils.js'
import { shouldIgnoreViolation } from './sandbox-violation-store.js'
Expand Down Expand Up @@ -92,19 +94,49 @@ export function macGetMandatoryDenyPatterns(allowGitConfig = false): string[] {
denyPaths.push(`**/${dirName}/**`)
}

// Git hooks are always blocked for security
// Git hooks are always blocked for security — in cwd's repository, in
// nested repositories, and in the submodule git directories a repository
// keeps under .git/modules (the hooks a commit inside the submodule runs)
denyPaths.push(path.resolve(cwd, '.git/hooks'))
denyPaths.push('**/.git/hooks/**')
denyPaths.push('**/.git/modules/**/hooks/**')

// Git config - conditionally blocked based on allowGitConfig setting
if (!allowGitConfig) {
denyPaths.push(path.resolve(cwd, '.git/config'))
denyPaths.push('**/.git/config')
denyPaths.push('**/.git/modules/**/config')
}

// cwd checked out as a linked worktree or submodule: .git is a file
// pointing at the real git directory. The file is denied, and so are the
// hooks/config git consults through it (nested .git files are covered by
// gitPointerFileDenyFilter, by vnode type).
const dotGit = path.resolve(cwd, '.git')
try {
if (fs.statSync(dotGit).isFile()) {
denyPaths.push(...gitFileDenyPaths(dotGit, allowGitConfig))
}
} catch {
// no .git here
}

return [...new Set(denyPaths)]
}

/**
* SBPL filter for a regular file named `.git` anywhere under `cwd`: a linked
* worktree's or submodule checkout's `gitdir:` pointer, which repointed at a
* directory the command prepared is as good as writing that directory's
* config. Matched by vnode type so an ordinary repository's .git DIRECTORY
* stays writable. The write rules re-allow file-write-create for it: only
* an existing pointer is protected, and `git worktree add` / `git submodule
* update --init` can still lay down new ones.
*/
export function gitPointerFileDenyFilter(cwd: string): string {
return `(require-all (vnode-type REGULAR-FILE) (regex ${escapePath(globToRegex(path.join(cwd, '**', '.git')))}))`
}

export interface SandboxViolationEvent {
line: string
command?: string
Expand Down Expand Up @@ -794,7 +826,21 @@ function generateWriteRules(
for (const normalizedPath of ungrouped) {
denyFilters.add(denyPathFilter(normalizedPath))
}
const gitPointerFilter = gitPointerFileDenyFilter(
normalizePathForSandbox('.'),
)
denyFilters.add(gitPointerFilter)
rules.push(...renderRule('deny', ['file-write*'], denyFilters, logTag))
// An existing .git pointer file cannot be rewritten, replaced or removed;
// creating one where none exists stays possible.
rules.push(
...renderRule(
'allow',
['file-write-create'],
new Set([gitPointerFilter]),
logTag,
),
)

// Block file movement to prevent bypass via mv/rename. A grouped path
// contributes its regex, the pin for its parent directory, and the
Expand Down
54 changes: 54 additions & 0 deletions src/sandbox/sandbox-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,60 @@ export function getDangerousDirectories(): string[] {
]
}

/**
* The paths inside a git directory through which a write becomes code the
* host's git runs later: hooks/ always, config (core.fsmonitor, core.editor,
* core.hooksPath, …) unless the caller allows it.
*/
export function gitDirDenyPaths(
gitDir: string,
allowGitConfig: boolean,
): string[] {
return allowGitConfig
? [path.join(gitDir, 'hooks')]
: [path.join(gitDir, 'hooks'), path.join(gitDir, 'config')]
}

/**
* Deny paths for a `.git` FILE — a linked worktree's or submodule checkout's
* `gitdir:` pointer. The file itself is denied (repointing it at a directory
* the command prepared is as good as writing that directory's config), and
* so are the hooks/config git actually consults for it: those of the git
* directory it names (a submodule's, under the superproject's .git/modules),
* or, when that directory has a `commondir` (a linked worktree's), those of
* the common directory — the main repository's .git — plus the worktree's
* own config.worktree when one exists.
*/
export function gitFileDenyPaths(
gitFile: string,
allowGitConfig: boolean,
): string[] {
const denyPaths = [gitFile]
try {
const pointer = fs
.readFileSync(gitFile, 'utf8')
.match(/^gitdir:\s*(.+?)\s*$/m)
if (!pointer) return denyPaths
const gitDir = path.resolve(path.dirname(gitFile), pointer[1]!)
if (!fs.statSync(gitDir).isDirectory()) return denyPaths
let hooksAndConfigDir = gitDir
try {
const common = fs.readFileSync(path.join(gitDir, 'commondir'), 'utf8')
hooksAndConfigDir = path.resolve(gitDir, common.trim())
const worktreeConfig = path.join(gitDir, 'config.worktree')
if (!allowGitConfig && fs.existsSync(worktreeConfig)) {
denyPaths.push(worktreeConfig)
}
} catch {
// no commondir: a submodule (or standalone) git directory
}
denyPaths.push(...gitDirDenyPaths(hooksAndConfigDir, allowGitConfig))
} catch {
// Unreadable, or the pointer dangles: the file itself stays denied.
}
return denyPaths
}

/**
* Normalizes a path for case-insensitive comparison.
* This prevents bypassing security checks using mixed-case paths on case-insensitive
Expand Down
Loading
Loading