Skip to content

feat(filesystem): add bind-mount deny lists to hide host files/paths - #1448

Open
neurolabs wants to merge 46 commits into
superradcompany:mainfrom
neurolabs:bind-mount-deny-option
Open

feat(filesystem): add bind-mount deny lists to hide host files/paths#1448
neurolabs wants to merge 46 commits into
superradcompany:mainfrom
neurolabs:bind-mount-deny-option

Conversation

@neurolabs

@neurolabs neurolabs commented Aug 23, 2026

Copy link
Copy Markdown

Closes #1326

Summary

Adds an optional deny list of gitignore-style patterns to bind mounts. Matching host paths are hidden from the guest (ENOENT) and writes are rejected (EACCES) via the Unix/Windows passthrough backends. Usable across the CLI and Rust/Go/Python/Node SDKs.

Description

  • Shared DenyList matcher in crates/filesystem
  • Enforcement at the lookup gate plus create/mkdir/mknod/symlink/link/unlink/rmdir/rename, with readdir entry filtering, on both the unix and Windows passthrough backends (rename re-checks the destination so hidden entry types don't leak)
  • basename patterns, path patterns, and dir-only patterns (node_modules/)
  • per-mount case-sensitivity: probed similar to git's core.ignorecase for rw mounts, derived from platform/filesystem type on ro mounts
  • macOS resolves parent paths via /.vol + F_GETPATH against the canonical mount root and fails closed when a path cannot be resolved
  • performance varies with cases and patterns (tested on linux:
    • lookup overhead is negligible, denies are faster than actual lookups.
    • readdir overhead is substantial:
      • basename only (e.g. "*.log") and dirname patterns (e.g. "node_modules/") cost about 1.2x compared to a baseline
      • path patterns (e.g. "/var/log/*.log") cost about <2 compared to a baseline
    • see crates/filesystem/lib/backends/passthroughfs/unix/tests/test_deny_perf.rs for a test suite
  • Wire format: repeatable deny= mount option; runtime and SDKs all reject empty patterns and patterns containing ,/:/newline/NUL.
  • CLI: deny= on --mount-dir only (rejected with a clear error on --mount-file/--mount-disk/--mount-named); msb inspect renders a [deny=...] suffix
  • Cloud: deny lists rejected at the CloudCreateBody conversion boundary (cloud creates can't enforce them)
  • Docs (volumes, CLI reference, security/filesystem) document the semantics and the name-based limitations: rename evasion on writable mounts, symlink targets not re-checked, hard links matched by path not inode

Notes

  • Developed and tested on Linux, therefore the MacOS and Windows paths were neither compiled nor executed.

Confidence Score: 3/5

The PR is not yet safe to merge because exchange operations can still move a concurrently substituted directory onto a directory-only-denied source name.

The Linux and macOS exchange paths make their reverse-direction deny decision from a one-time destination type snapshot, then revalidate only the source before the atomic swap, leaving the previously reported host-side destination replacement bypass outstanding.

Files Needing Attention: crates/filesystem/lib/backends/passthroughfs/unix/remove_ops.rs

Reviews (12): Last reviewed commit: "Merge branch 'main' into bind-mount-deny..." | Re-trigger Greptile

Context used:

Add the ignore::gitignore-backed DenyList used to hide bind-mount paths
host-side, plus the workspace ignore dependency. Empty deny lists match
nothing; component-only patterns fast-path on the entry name while path
patterns match the reconstructed mount-relative path.

Signed-off-by: Ole Langbehn <ole.langbehn@inoio.de>
Hide gitignore-style denied paths host-side: lookup and create/mkdir/mknod/
symlink/link/unlink/rmdir/rename reject denied names (ENOENT/EACCES), and
readdir/readdirplus omit matching entries. Component-only patterns fast-path
on the entry name; path patterns reconstruct the mount-relative path via the
inode anchor chain. Add a PassthroughConfig::deny field, builder method, and
unit tests.

Signed-off-by: Ole Langbehn <ole.langbehn@inoio.de>
Hide gitignore-style denied paths host-side: lookup returns ENOENT, create/
mkdir/mknod/symlink/link/unlink/rmdir/rename return EACCES on denied names,
and readdir/readdirplus skip denied entries. Reconstructs the mount-relative
path from the parent inode's stored host path for path-pattern matching.

Signed-off-by: Ole Langbehn <ole.langbehn@inoio.de>
Move the DenyList matcher from backends::shared to backends::deny so
both the unix and windows passthrough backends can share it.

Signed-off-by: Ole Langbehn <ole.langbehn@inoio.de>
The DenyList is used only by the unix and windows passthrough backends, so
it belongs beside the other shared passthrough helper (quota.rs) rather
than at the backends root. Keeps the Unix-gated shared module intact and
shortens the cross-platform import paths to super::deny.

Signed-off-by: Ole Langbehn <ole.langbehn@inoio.de>
Signed-off-by: Ole Langbehn <ole.langbehn@inoio.de>
Signed-off-by: Ole Langbehn <ole.langbehn@inoio.de>
Plumb a host-side deny-list through the wire contract and Rust SDK so it
reaches the runtime --mount deny= grammar. VolumeMount::Bind gains a
deny Vec<String> (mirroring quota_mib) with custom Serialize/Deserialize/
Debug; MountBuilder::deny() sets it and build() validates it is bind-only;
spawn.rs renders deny= tokens into the --mount arg path only, never the
MSB_*_MOUNS env specs. Cloud and node-ts bind construction sites are
updated to compile with the new field.

Signed-off-by: Ole Langbehn <ole.langbehn@inoio.de>
Expose the bind-mount deny-list on --mount-dir as a repeatable deny=
option. CliMountOptions/CliMountOptionSupport carry deny; the key=value
parser pushes each pattern (repeatable, value split on first = only);
apply_common_mount_options forwards them via MountBuilder::deny. deny is
enabled only for --mount-dir and rejected with a clear error for
--mount-file/--mount-disk/--mount-named. inspect.rs binds the new field.

Signed-off-by: Ole Langbehn <ole.langbehn@inoio.de>
Add a host-side deny-list of gitignore-style patterns for bind mounts.
Matching entries are hidden from the guest (ENOENT) and writes to them
are forbidden (EACCES). Patterns are relative to the mount root and may
be component names (".env", "*.log") or paths ("sub/secret").

Expose Deny on MountOptions and MountConfig, plumb it through to the
MountSpec FFI struct and the native mount builder, and cover the wire
shape and propagation with tests.

Signed-off-by: Ole Langbehn <ole.langbehn@inoio.de>
Signed-off-by: Ole Langbehn <ole.langbehn@inoio.de>
join_path already returns an owned PathBuf, so the address-of operator on
the temporary adds nothing; clippy flags it (needless_borrow) and it fails
the branch's clippy -D warnings gate.

Signed-off-by: Ole Langbehn <ole.langbehn@inoio.de>
A '.*' or '*' pattern matched the '.' and '..' entries, stripping them from
guest readdir and making lookup(".") return ENOENT, which broke every path
walk through the mount. deny_matches_name now short-circuits the structural
dot entries to not-denied on both the unix and windows backends; adds
regression tests probing readdir retention and lookup(".") under a '.*'
pattern.

Signed-off-by: Ole Langbehn <ole.langbehn@inoio.de>
The --mount option block is comma-joined and the spec is colon-split, so a
deny pattern containing ',' or ':' would fail the whole spawn or, worse,
silently attenuate other mount protections; a newline or NUL would corrupt
gitignore parsing. Validate at every entry point -- the runtime mount-spec
parser, the Rust SDK MountBuilder, and the Go native apply_volume boundary --
rejecting empty patterns and the wire separators.

Signed-off-by: Ole Langbehn <ole.langbehn@inoio.de>
Mount.Named and Mount.NamedWith carried MountOptions.Deny into the mount
config, and the native layer now returns an explicit error instead of
discarding the option, matching the Rust SDK and CLI behavior for the same
mistake. The previous test that codified the silent drop is inverted.

Signed-off-by: Ole Langbehn <ole.langbehn@inoio.de>
The cloud wire format has no deny field, so converting a config with a
non-empty deny list would silently create a sandbox that exposes the files
the caller asked to hide. Reject the mount at the cloud create boundary --
which covers both the Rust SDK and the CLI -- and document the lossy
direction on the wire-type conversion.

Signed-off-by: Ole Langbehn <ole.langbehn@inoio.de>
Bind mounts with a deny list now render a [deny=...] suffix in msb inspect
so users can see why entries are hidden from the guest.

Signed-off-by: Ole Langbehn <ole.langbehn@inoio.de>
Move the private is_ignored inherent impl into the Methods section and put
the public join_path ahead of the private name_as_path helper.

Signed-off-by: Ole Langbehn <ole.langbehn@inoio.de>
Signed-off-by: Ole Langbehn <ole.langbehn@inoio.de>
Signed-off-by: Ole Langbehn <ole.langbehn@inoio.de>
Signed-off-by: Ole Langbehn <ole.langbehn@inoio.de>
…atterns

Signed-off-by: Ole Langbehn <ole.langbehn@inoio.de>
Signed-off-by: Ole Langbehn <ole.langbehn@inoio.de>
Signed-off-by: Ole Langbehn <ole.langbehn@inoio.de>
* handle case insensitive filesystems
  analogue to how git handles this, we probe the case sensitivity,
  since APFS can be case sensitive or -insensitive
* switch a couple of places from fail-open to fail-closed (safety first)
* support dir-only patterns ("node_modules/"). Before, dir-only patterns
  did not match/deny any check.
* document that renaming an ancestor (parent directory) allows circumvention
  of checks.

Signed-off-by: Ole Langbehn <ole.langbehn@inoio.de>
* Rename filesystem_is_case_insensitive to mount_is_case_insensitive and
  validate with a lowercase sibling name instead of a flipped-case one;
  document per-mount (not per-directory) sensitivity detection.
* Extract shared resolve_dirent_type for DT_UNKNOWN entries in both the
  Linux and macOS readdir paths.
* Tighten deny-path comments and expand unix/windows deny tests.

Signed-off-by: Ole Langbehn <ole.langbehn@inoio.de>
…ir-only flags

Signed-off-by: Ole Langbehn <ole.langbehn@inoio.de>
Signed-off-by: Ole Langbehn <ole.langbehn@inoio.de>
Signed-off-by: Ole Langbehn <ole.langbehn@inoio.de>
* added test coverage
* The unix builder now delegates to PassthroughFs::new(cfg), fixing a
  missing-field compile break on MacOS.
* skip mount_is_case_insensitive check when patterns.is_empty()
* windows/mod.rs:239: switch fail-open on path traversal to fail-close
* unify non-UTF-8 name semantics on Windows (EINVAL instead of
  ENOENT/EACCES)
* extend mount_is_case_insensitive() to derive case-sensitivity of ro
  mounts by platform and (on linux) fs type
* trim deny patterns in both SDK render paths, analogous to CLI
* extended documentation

Signed-off-by: Ole Langbehn <ole.langbehn@inoio.de>
@greptile-apps

greptile-apps Bot commented Aug 23, 2026

Copy link
Copy Markdown

Fix all with Greploop Fix All in Codex Fix All in Claude Code

Prompt To Fix All With AI
### Issue 1
crates/filesystem/lib/backends/passthroughfs/windows/ops.rs:231-233
**Missing identity blocks every rename**

When a bind mount on FAT32, exFAT, or another filesystem without stable file identities has any directory-only deny pattern, this branch returns `EBUSY` for every rename before checking whether the allowed source and destination require that protection. As a result, ordinary file renames between non-denied paths fail even though neither path matches the deny list.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Reviews (3): Last reviewed commit: "fix(filesystem): fix greptile "missing i..." | Re-trigger Greptile

Comment thread crates/filesystem/lib/backends/passthroughfs/unix/remove_ops.rs Outdated
Comment thread crates/filesystem/lib/backends/passthroughfs/windows/ops.rs Outdated
On filesystems without file identities (FAT32/exFAT/some network
volumes), file_identity returns None, so the identity re-check at
ops.rs:225 was a no-op. An external writer could swap the source file
for a directory between the deny-check's type decision (source_is_dir =
false) and the actual std::fs::rename, moving the directory to a
dir-only-denied destination.

Fix: when file identity is unavailable and the deny set has dir-only
patterns (where the type decision is security-relevant), the rename now
fails closed with LINUX_EBUSY instead of proceeding with an
unverifiable, stale type. Renames still work normally on filesystems
with identities, and on identity-less filesystems without dir-only
patterns.
Comment thread crates/filesystem/lib/backends/passthroughfs/windows/ops.rs Outdated
When a bind mount on FAT32, exFAT, or another filesystem without
stable file identities has any directory-only deny pattern, this
branch returns EBUSY for every rename before checking whether the
allowed source and destination require that protection. As a result,
ordinary file renames between non-denied paths fail even though
neither path matches the deny list.

**Resolution:** The rename's fail-closed EBUSY now short-circuits
on has_dir_only_patterns() and fires only when the source or
destination path actually collides with a dir-only deny pattern on
an identity-less filesystem, so FAT/exFAT renames between
non-denied paths proceed normally while dir-only-denied moves stay
blocked.
@greptile-apps

greptile-apps Bot commented Aug 24, 2026

Copy link
Copy Markdown

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  Client[CLI or SDK] --> Config[Bind mount configuration]
  Config --> Runtime[Local runtime]
  Runtime --> Backend{Passthrough backend}
  Backend --> Unix[Unix namespace operations]
  Backend --> Windows[Windows namespace operations]
  Unix --> Matcher[Deny-list matcher]
  Windows --> Matcher
  Matcher -->|allowed| Host[Host bind path]
  Matcher -->|hidden lookup| ENOENT[ENOENT]
  Matcher -->|denied mutation| EACCES[EACCES]
Loading

Reviews (7): Last reviewed commit: "fix(filesystem): greptile "Exchange bypa..." | Re-trigger Greptile

Comment thread crates/filesystem/lib/backends/passthroughfs/windows/ops.rs Outdated
On an identity-less filesystem such as FAT32 or exFAT, renaming a regular file
named node_modules to an otherwise allowed path passes the actual-type deny
check, but dir_denied repeats matching with is_dir=true and returns EBUSY. This
violates directory-only semantics and prevents a valid regular-file rename.

**Resolution:** narrow the fail-closed EBUSY guard on identity-less filesystems
(FAT/exFAT) to fire only when a file-decided source is renamed to a destination
colliding with a dir-only deny pattern, so same-named file renames and ordinary
renames keep working while a swapped-in directory can still be blocked from
landing at a denied name.
Comment thread crates/filesystem/lib/backends/passthroughfs/windows/ops.rs
Comment thread crates/filesystem/lib/backends/passthroughfs/windows/ops.rs
Comment thread crates/filesystem/lib/backends/passthroughfs/unix/remove_ops.rs
When `RENAME_EXCHANGE` or macOS `RENAME_SWAP` swaps a regular file at a
directory-only-denied name with a directory at an allowed name, the
destination directory is checked only against its original `newname`.
The exchange then moves that directory onto the denied `oldname`,
bypassing the bind-mount write policy.

**Resolution:** For macOS and Linux, RENAME_EXCHANGE now also rejects
when the destination is a directory that would land at a dir-only-denied
source name.
@greptile-apps

greptile-apps Bot commented Aug 25, 2026

Copy link
Copy Markdown

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  A[CLI or SDK mount configuration] --> B[Validated bind-mount deny patterns]
  B --> C[Local runtime]
  B --> D[Cloud conversion rejects deny list]
  C --> E[Unix or Windows passthrough backend]
  E --> F[Deny matcher]
  F --> G[Lookup and readdir hide matches]
  F --> H[Namespace mutations reject matches]
Loading

Reviews (9): Last reviewed commit: "Merge branch 'main' into bind-mount-deny..." | Re-trigger Greptile

Comment thread crates/filesystem/lib/backends/passthroughfs/unix/remove_ops.rs
@greptile-apps

greptile-apps Bot commented Aug 26, 2026

Copy link
Copy Markdown

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  Client[CLI or SDK deny patterns] --> Config[Validated bind-mount configuration]
  Config --> Runtime[Runtime mount options]
  Runtime --> Matcher[DenyList matcher]
  Guest[Guest filesystem operation] --> Backend[Unix or Windows passthrough backend]
  Backend --> Matcher
  Matcher -->|Denied lookup| ENOENT[Return ENOENT]
  Matcher -->|Denied mutation| EACCES[Return EACCES]
  Matcher -->|Allowed| Host[Perform host filesystem operation]
Loading

Reviews (10): Last reviewed commit: "Merge branch 'main' into bind-mount-deny..." | Re-trigger Greptile

@greptile-apps

greptile-apps Bot commented Sep 1, 2026

Copy link
Copy Markdown

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  Client[CLI or SDK] --> Config[Bind mount deny patterns]
  Config --> Runtime[Runtime mount configuration]
  Runtime --> Backend{Passthrough backend}
  Backend --> Unix[Unix namespace operations]
  Backend --> Windows[Windows namespace operations]
  Unix --> Matcher[DenyList matcher]
  Windows --> Matcher
  Matcher --> Allowed[Perform host operation]
  Matcher --> Denied[Return ENOENT or EACCES]
Loading

Reviews (11): Last reviewed commit: "Merge branch 'main' into bind-mount-deny..." | Re-trigger Greptile

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.

Option to hide/exclude paths inside a host bind mount from the guest

1 participant