Skip to content

linux: a file above a write deny is kept there - #705

Merged
ronleizrowice-ant merged 5 commits into
mainfrom
fix/linux-a-file-above-a-write-deny-is-held
Oct 16, 2026
Merged

ronleizrowice-ant merged 5 commits into
mainfrom
fix/linux-a-file-above-a-write-deny-is-held

Conversation

@ronleizrowice-ant

@ronleizrowice-ant ronleizrowice-ant commented Oct 14, 2026 •

Copy link
Copy Markdown
Collaborator

Linux. Nothing can be created below a file for as long as the file is there. So where a file lies above a write-denied path, in a directory the command may write, the file is kept there: it is bound read-only onto itself, as a denied path that exists is.

What changes

  • Any write deny below a file, configured or built in (denyWrite: ["f/x"], and f is a file): f is bound read-only. Several denies below one file lay one bind.
  • Where the working directory is a worktree or a submodule, its .git is a file, and that file is denied itself.
  • The file is not tracked for removal afterwards. It is the caller's own.
  • Where the command may not write anyway, nothing is laid, as before. Where it may write the file and not its directory, the file stays writable: nothing can take its place there. Nothing is ever mounted below the file, so a command in a worktree starts as before.
  • A fifo or a socket above a write deny no longer keeps a command from starting (bwrap: Can't mkdir parents for …: Not a directory).

Who sees a difference

With a worktree or a submodule as the working directory, its .git file can be read, and cannot be changed, moved or removed. git status, log and diff work there as before.

Not here

A .git file below the working directory. The first commit denied those too, and the second takes it back: git rewrites and removes that file itself. With it denied, git checkout --recurse-submodules (or submodule.recurse) to a commit that moves the submodule failed and left the tree half switched; git rm and rm -rf of a submodule stopped half way; and each file cost two mounts, which is felt from some fifty of them on. A test now holds that such a file is left alone.

Also not here: a .git that is a link (#693); a directory with no .git at all, where one can be made, so that git init works. macOS denies these names by path.

Testing

  • 19 new tests, the plan and under real bubblewrap, and one more assertion in an older one. 7 fail on main. Each of the two parts taken back fails its own.
  • With real git worktree add and git submodule add, through the library and the CLI, on Node and two builds of Bun. Seven git commands that touch a nested .git file, the recursing checkout among them, end as they do on main.
  • A file that is empty, unreadable, executable, a fifo or a listening socket; allowGitConfig; enableWeakerNestedSandbox; outside every write root; four commands at once in one worktree. The working directory holds afterwards what it held, and the file is byte for byte the same.
  • 200 nested .git files: a command starts as fast as on main, with as many arguments.
  • The whole suite: nothing fails that does not on main.

Nothing can be created below a file for as long as it is there. So where a
file lies above a write-denied path, in a directory the command may write, the
file is bound read-only onto itself. It is not tracked for removal: it is the
caller's own.

A .git that is a file, as in a worktree or a submodule, is denied itself, in
the working directory and, as far as the scan goes, below it.
@ronleizrowice-ant ronleizrowice-ant changed the title linux: a file above a write deny is kept there [on hold] linux: a file above a write deny is kept there Oct 14, 2026
The commit before this one also denied the .git file of a worktree or a
submodule below the working directory. git rewrites and removes that file
itself in more commands than it allowed for: a checkout that recurses into a
submodule whose commit changes failed and left the tree half switched, and
git rm and rm -rf of a submodule stopped half way. It also cost two mounts a
file. The working directory's own .git file stays denied.
@ronleizrowice-ant ronleizrowice-ant changed the title [on hold] linux: a file above a write deny is kept there linux: a file above a write deny is kept there Oct 14, 2026
Comment thread src/sandbox/linux-sandbox-utils.ts Outdated
// creation and break git init.
const dotGitPath = path.resolve(cwd, '.git')
let dotGitIsDirectory = false
let dotGitIsDirectory: boolean | undefined

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the difference between (true, false, undefined) here?

ronleizrowice-ant and others added 3 commits October 15, 2026 15:26
A boolean that was true, false or undefined stood for a directory, a file and
nothing there. No change in behaviour.
DotGitKind takes the place of three strings, and says at each member what is
denied there. No change in behaviour.
…y be written is not held

The kinds of mount point for an absent deny path were strings, with
undefined for "none can be made". They are one enum now, read by one
exhaustive switch. The debug line names what is mounted.

A file above an absent write deny was bound read-only onto itself even
where the command may write that file and not its directory. There
nothing can take the file's place, so it is left writable, as it was
before this branch.

@antdres antdres left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/stamp

@ronleizrowice-ant
ronleizrowice-ant merged commit f38088d into main Oct 16, 2026
16 checks passed
@ronleizrowice-ant
ronleizrowice-ant deleted the fix/linux-a-file-above-a-write-deny-is-held branch October 16, 2026 01:30
ronleizrowice-ant added a commit that referenced this pull request Oct 20, 2026
Joins that needed a decision:

- The placeholder planning of #688 and #705 (one decision per destination,
  asked of the record and not of the host; the kind settled beforehand by
  whether a deny lies beneath; a file above the path held in place; no mount
  point where none can be made) replaces this branch's upgrade in place. What
  this branch adds to it: an empty file a manifest names is covered like an
  absent path and named, and what the plan makes goes into the wrap's manifest
  instead of the set in memory. A held file that a manifest names is named by
  the look at everything the profile binds from.
- The count of wraps and the forced clean-ups of #671 are kept in this
  branch's terms: what the count protects is the manifest and the --args
  profile of a command that has not started.
- #673's cases on a file found at a deny path assert that a leftover stays;
  here a manifest says which is one. The suite is this branch's own.
- #675: one return value for the plan, with the mandatory denies' share.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SEZ8LUnnuZdEgiFocuvVDm
Co-authored-by: ronleizrowice <ronleizrowice@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants