Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
8f4c066
fix: mandatory denies cover nested hooks at repo depth, submodule git…
ronleizrowice-ant Sep 2, 2026
b51fe2a
Merge main into fix/linux-nested-repo-hooks-depth
ronleizrowice-ant Sep 10, 2026
1dbfe26
fix: bound the .git pointer create allow to the write roots; scan rel…
ronleizrowice-ant Sep 10, 2026
e6ea51c
fix(sandbox): close the git-metadata gaps in the mandatory denies
ronleizrowice-ant Sep 10, 2026
af1e241
test: cover the git-metadata deny paths and the NUL-delimited scan
ronleizrowice-ant Sep 10, 2026
af8a067
docs(readme): what the git denies cover and which git operations break
ronleizrowice-ant Sep 10, 2026
c36ee8b
fix(sandbox): read a .git pointer and commondir the way git reads them
ronleizrowice-ant Sep 11, 2026
ea00b2e
test: check the pointer and commondir parse against real git
ronleizrowice-ant Sep 11, 2026
0824c94
docs(readme): how a .git pointer is read, and when a wrap is refused
ronleizrowice-ant Sep 11, 2026
7f33786
test: build the invalid-UTF-8 parity case only where a name can hold it
ronleizrowice-ant Sep 11, 2026
7d43e9c
fix(sandbox): resolve a pointer's .. the way the kernel does, not on …
ronleizrowice-ant Sep 11, 2026
d258cc5
fix(sandbox): deny the directory the .git/modules walk stops at
ronleizrowice-ant Sep 12, 2026
2db36ad
fix(sandbox): deny the modules beneath a bound git directory, not the…
ronleizrowice-ant Sep 12, 2026
8fad968
docs: the three whole-directory denies under .git/modules, and what o…
ronleizrowice-ant Sep 12, 2026
14de67f
Merge remote-tracking branch 'origin/main' into srt515-work
ronleizrowice-ant Sep 16, 2026
d843dec
test: the git directories read off disk compile to literal filters
ronleizrowice-ant Sep 16, 2026
5b8fe26
Merge remote-tracking branch 'origin/main' into srt515-work
ronleizrowice-ant Sep 17, 2026
951f08a
test: take the namespace probe from the shared helper
ronleizrowice-ant Sep 17, 2026
466e011
linux: deny a git directory's commondir without breaking git
ronleizrowice-ant Sep 17, 2026
2b9de7c
Merge remote-tracking branch 'origin/main' into fix/linux-nested-repo…
ronleizrowice-ant Sep 17, 2026
cc7ec6b
Merge remote-tracking branch 'origin/main' into fix/linux-nested-repo…
ronleizrowice-ant Sep 17, 2026
a7f016b
Merge remote-tracking branch 'origin/main' into fix/linux-nested-repo…
ronleizrowice-ant Sep 17, 2026
1b191c5
linux: take the git redirect placeholders off one table, and decide f…
ronleizrowice-ant Sep 17, 2026
15e482f
linux: keep the host's git working while a deny stands in for a redir…
ronleizrowice-ant Sep 17, 2026
e275b42
violations: do not fail the monitor's start-up over one repository
ronleizrowice-ant Sep 17, 2026
41aa1f9
linux: refuse the wrap when the mandatory-deny scan cannot deliver it…
ronleizrowice-ant Sep 18, 2026
5adab23
Cover the mandatory-deny scan's failure paths, whatever the uid
ronleizrowice-ant Sep 18, 2026
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
18 changes: 14 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -690,7 +690,7 @@ Filesystem restrictions are enforced at the OS level:
- A `denyRead` entry naming a FILE is lifted only by an `allowRead` entry naming that same file. An `allowRead` entry that is a symlink to it names the link, so it does not cancel the deny of its target.
- A `denyRead` entry that cannot be inspected (a parent made unsearchable, a dead network mount) hides the deepest directory above it that can be — never `/`, so when `/` is the only one left the entry mounts nothing and that deny is not enforced (the wrap logs which entry, and why, under `SRT_DEBUG`). Such a stand-in hides more than was written: nothing beneath it is readable, carve-outs named there included, and a carve-out elsewhere that resolves beneath it is not restored either.

**Write denies on paths that do not exist yet (Linux):** bubblewrap can only deny a path by mounting over it, so for a `denyWrite` path that is absent under a writable directory it first creates a mount point there: an empty, read-only file (or an empty directory for a missing intermediate component) that is visible on the host for as long as a sandbox is alive and is removed afterwards. Host tools therefore see such a path as existing while a sandboxed command runs, which matters for paths whose existence is their meaning (a lockfile such as `.git/config.lock` makes `git config` report "could not lock config file"). A process that dies without an exit event (`SIGKILL`, OOM) cannot remove its mount points. An empty regular file with no write bits found at a `denyWrite` path under a writable directory is taken to be such a leftover: it is covered with `/dev/null` like an absent path and removed after the command. A leftover empty directory cannot be told from anyone else's and is left alone.
**Write denies on paths that do not exist yet (Linux):** bubblewrap can only deny a path by mounting over it, so for a `denyWrite` path that is absent under a writable directory it first creates a mount point there: an empty, read-only file (or an empty directory for a missing intermediate component) that is visible on the host for as long as a sandbox is alive and is removed afterwards. Host tools therefore see such a path as existing while a sandboxed command runs, which matters for paths whose existence is their meaning (a lockfile such as `.git/config.lock` makes `git config` report "could not lock config file"). A process that dies without an exit event (`SIGKILL`, OOM) cannot remove its mount points. An empty regular file with no write bits found at a `denyWrite` path under a writable directory is taken to be such a leftover: it is covered with `/dev/null` like an absent path and removed after the command — except at a `commondir` or `config.worktree`, where `/dev/null` is what git cannot read, and the placeholder above is written there instead. A leftover empty directory cannot be told from anyone else's and is left alone.

**Note (Linux, large profiles):** The wrapped string runs as one argument of `sh -c`, which Linux caps at 32 pages (128 KiB with 4 KiB pages). A profile that would not fit, with 4 KiB to spare for a prefix of the caller's own, has its mounts written to an unnamed file (`O_TMPFILE`) that the wrapping process holds open and bubblewrap reads through `--args`. The string then reads `/bin/sh -c '…' srt-args /proc/<wrapping pid>/fd/<n> bwrap … --args 9 …`: still a simple command, which opens the profile on fd 9 and runs bubblewrap. The environment and the command stay on the command line; the file holds mount paths only.

Expand All @@ -715,7 +715,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: `hooks/`, `config`, `config.worktree` and `commondir` of a git directory — the working directory's repository, nested repositories, the submodule git directories they keep under `.git/modules/`, and a linked worktree's git directory. A directory under `.git/modules` the walk cannot see through is denied whole instead, which leaves it and everything beneath it read-only inside the sandbox — a submodule's `objects`, `refs` and `index` included, so git writes inside such a tree stop working. Three things produce one: a directory the walk cannot list, an entry it cannot stat — for both of which what is denied is the deepest ancestor it can reach, which may be `.git/modules` itself — and, once per branch that reaches the walk's depth bound (`MAX_SUBMODULE_WALK_DEPTH`), the `modules` directory beneath the git directory it stopped at, or that directory itself when it is not a git directory. `commondir` and `config.worktree` are denied because git reads the hooks and config through them: `commondir` moves them to another directory entirely, and `config.worktree` is read instead of `config` wherever `extensions.worktreeConfig` is on (`git sparse-checkout init` turns it on). On Linux, denying either of those two where the file is not there means mounting something at it, and git reads whichever of them it finds: it refuses to run at all against a `commondir` it cannot read, which both a bound `/dev/null` and an empty file are. So the wrap writes the mount point itself, before bubblewrap starts, holding what git concludes with no file there — `.` for `commondir`, which makes git resolve the git directory it opened as its own common directory, and nothing for `config.worktree`, which reads as no worktree config — and binds a read-only copy of the same bytes over it. The file is removed after the command; one left behind by a killed process is rewritten and removed by the next wrap, and an empty `commondir`, which git refuses outright, is repaired the same way. For as long as the command runs, git inside the sandbox and git on the host therefore both read a redirect git accepts — but not the same thing they read with no file there at all: `git rev-parse --git-common-dir` and `--git-path` print the absolute real path where they printed a relative one, so a script that compares `--git-dir` with `--git-common-dir` to decide "this is a linked worktree" answers yes for an ordinary repository until the deny is lifted. An existing `.git` _file_ (a linked worktree's or submodule checkout's `gitdir:` pointer) is read-only and cannot be removed or renamed over; creating a new one inside an allowed write path is still possible. The hooks and config a pointer leads to (the main repository's, for a worktree) are blocked as well, and so is filling in a git directory a pointer names but that does not exist yet; a pointer naming a directory that is not a git directory is not followed. A pointer is read the way git reads one — the whole file, `\n` and `\r` stripped from its end, the path ending at the first NUL — and one larger than the 1 MiB git accepts for a `.git` file is not followed, because git refuses it too. A pointer and a `commondir` are both denied under the path as written and, where a `..` in one follows a symlink, under the directory the kernel actually opens as well, since `link/../x` does not land where folding the path on paper says it does. Where the directory a pointer or a `commondir` names cannot be worked out that way at all — a `commondir` past that size, which git reads with no limit of its own, or a path whose bytes are not valid UTF-8 — the command is refused rather than sandboxed with a deny list that may cover the wrong directory. On macOS only the working directory's own `.git` file is followed, since nested pointers are matched by pattern; the working directory's own submodule git directories are enumerated exactly, while a nested repository's are matched as `.git/modules/<name>/`, which covers a single-segment submodule name.

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 @@ -727,15 +727,25 @@ $ 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.
**Git operations these denies break.** A git directory's `hooks/` and `config` are what a hook or a `core.fsmonitor` would be written to, so anything that writes or removes them fails inside the sandbox:

- removing a tree that holds a submodule checkout or a linked worktree (`rm -rf lib`, `git clean -ffdx`), because its `.git` pointer file cannot be removed;
- `git worktree remove`, `git worktree move`, `git worktree repair`, `git submodule deinit`, for the same reason;
- `git submodule update --init` for a submodule that has not been cloned yet, which copies template hooks into `.git/modules/<name>/hooks/` and writes its config;
- from a linked worktree, anything writing the main repository's config: `git push -u`, `git checkout -b x origin/y`;
- `git init` and `git clone` into a subdirectory, which create `.git/hooks/`.

**Known limit (macOS).** A pointer file or a pattern-matched path is protected where it is: a command may still rename the directory _holding_ it aside and create a fresh one in its place (`mv lib lib.old && mkdir lib && echo 'gitdir: …' > lib/.git`). On macOS that is blocked for the literal denies (the working directory's own repository and its submodule git directories) and not for the pattern ones. On Linux it is blocked for everything the scan reached: the ancestors of every denied path are pinned (see **Pinned directories** below), so renaming or removing the directory holding a denied pointer file, or the package directory above a nested repository's hooks, fails with `EBUSY`.

**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. It fails closed: a directory it cannot read is denied whole, and every other failure aborts the command rather than sandboxing it with a partial deny list — a scan that could not be run at all, one that does not finish in time, and one that fails for a reason naming no path under the working directory to deny in its place. Each of those is a `LinuxSandboxProfileError` with `deny_scan_failed` on `.code`; a missing `ripgrep` is refused earlier still, by the dependency check.

**Pinned directories (Linux):** Every existing ancestor of a protected path (a write-denied path, a read-denied file or directory, a masked credential file) up to the allowed write root covering it is made a mountpoint — "pinned" — and cannot be renamed or removed from inside the sandbox: `mv` or `rmdir` of such a directory (for example a nested repository's parent) fails with `EBUSY` ("Device or resource busy"), and `rm -rf` of a nested repository leaves the pinned directories and the protected files behind (as with `.git/hooks`). A pin is buried under the mounts above it, so it never appears on a lookup path: reads, writes, creation, renames and hard links inside or across a pinned directory are unaffected.

With `allowWrite: ["/"]` the pins reach every ancestor, including any other allowed write root that is one (`mv /work /work.bak` fails with `EBUSY` given `allowWrite: ["/", "/work"]` and a protected path inside `/work`). They stop below the top-level directory, which is bound writable over them, and that directory is the one new filesystem boundary: `mv` or `ln` between two top-level directories — say `/tmp` and `/home` — fails with `EXDEV` ("Invalid cross-device link"), as it does on any host where they are separate filesystems. `mv` falls back to a copy; `ln` and a raw `rename(2)` do not.

A wrap that carries no write restrictions at all — `filesystem.disabled` with credential masks still in force, or a library caller passing no write config while a `denyRead` entry or a mask still seeds a pin — is the same shape: the whole tree is bound writable, so it gets the same pins and the same top-level covers, and the same `EXDEV` boundary applies there too.

**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, which reaches a nested repository directly beneath the working directory. You can configure this with `mandatoryDenySearchDepth`:

```json
{
Expand Down
13 changes: 11 additions & 2 deletions src/sandbox/credential-mask-files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,9 @@ export interface MaskedFileBind {
}

/**
* Manager-owned temp dir holding the fake files.
* Manager-owned temp dir holding the fake files. The Linux backend keeps a
* second store of its own for the placeholders its git redirect denies bind
* from, which need this same guarantee for this same reason.
*
* INVARIANT: this directory must never be writable from inside the sandbox.
* The Linux layer enforces this by emitting `--ro-bind <dirPath> <dirPath>`
Expand All @@ -66,6 +68,13 @@ export interface MaskedFileBind {
export class MaskedFileStore {
private dir: string | undefined
private readonly byKey = new Map<string, string>()
private readonly dirPrefix: string

/** `dirPrefix` names this store's temp directory, so what it holds is
* recognisable on disk and one store's directory is never another's. */
constructor(dirPrefix: string = MASKED_FILE_STORE_PREFIX) {
this.dirPrefix = dirPrefix
}

/**
* Write `sentinel` to a fake file for `key` and return its path.
Expand All @@ -75,7 +84,7 @@ export class MaskedFileStore {
*/
write(key: string, sentinel: string): string {
if (this.dir === undefined) {
this.dir = fs.mkdtempSync(join(tmpdir(), 'srt-credmask-'))
this.dir = fs.mkdtempSync(join(tmpdir(), this.dirPrefix))
}
let fakePath = this.byKey.get(key)
if (fakePath === undefined) {
Expand Down
Loading
Loading