Skip to content

Reject overlapping host directories at mount registration - #741

Open
samuelcolvin wants to merge 1 commit into
mainfrom
reject-overlapping-mounts
Open

Reject overlapping host directories at mount registration#741
samuelcolvin wants to merge 1 commit into
mainfrom
reject-overlapping-mounts

Conversation

@samuelcolvin

@samuelcolvin samuelcolvin commented Aug 14, 2026

Copy link
Copy Markdown
Member

A mount's mode is selected by longest-prefix match on the virtual path, so one host file reachable through two mounts takes whichever mount the spelling selects — the weaker mode wins. Mounting a directory read-write and a subdirectory of it read-only did not protect the subdirectory (Hack Monty round 3).

MountTable::push_mount now returns Result and refuses a mount whose canonical host path equals, contains, or is contained by an existing mount's, unconditionally on mode. Via the pool this surfaces at feed time as a session-preserving ValueError, since specs only meet there. Disjoint host directories at nested virtual paths still work.

Claude-Session: https://claude.ai/code/session_015xsqqc7SY9Gaw8W5ojgMKS


Summary by cubic

Rejects mounts whose host directories overlap (same directory, ancestor, or descendant) at registration to prevent bypassing stricter modes. Previously, a file reachable through two mounts used the weaker mode via longest-prefix virtual-path routing (e.g., rw parent over ro child); now registration fails with a session-preserving ValueError.

  • monty-fs: MountTable::push_mount returns Result and rejects overlaps using canonical host paths; disjoint host directories at nested virtual paths remain valid.
  • New MountError::OverlappingMounts with a clear message naming both mounts; surfaced via the pool as PoolError::Runtime when a feed starts. JS/Python bindings and CLI help document the restriction; tests cover rejection and allowed cases.

Migration

  • Ensure feed mounts cover disjoint host directories. Overlapping mounts will now fail at feed start with ValueError.
  • Update callers that use MountTable::push_mount to handle a Result and propagate or report the error.

Written for commit 0a5efb3. Summary will update on new commits.

Review in cubic

A mount's mode is selected by longest-prefix match on the virtual path,
so one host file reachable through two mounts takes whichever mount the
spelling selects — the weaker mode wins. Mounting a directory read-write
and a subdirectory of it read-only did not protect the subdirectory
(Hack Monty round 3).

MountTable::push_mount now returns Result and refuses a mount whose
canonical host path equals, contains, or is contained by an existing
mount's, unconditionally on mode. Via the pool this surfaces at feed
time as a session-preserving ValueError, since specs only meet there.
Disjoint host directories at nested virtual paths still work.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015xsqqc7SY9Gaw8W5ojgMKS
@github-actions

Copy link
Copy Markdown

Codecov Results 📊

✅ Patch coverage is 80.85%. Project has 42170 uncovered lines.
✅ Project coverage is 56.78%. Comparing base (base) to head (head).

Files with missing lines (3)
File Patch % Lines
crates/monty-fs/src/error.rs 61.90% ⚠️ 8 Missing
crates/monty-fs/src/mount_table.rs 94.12% ⚠️ 1 Missing
crates/monty-pool/src/checkout.rs 100.00% ⚠️ 1 partials
Coverage diff
@@            Coverage Diff             @@
##          main       #PR       +/-##
==========================================
+ Coverage    56.78%    56.78%        —%
==========================================
  Files          407       407         —
  Lines        97465     97571      +106
  Branches    207515    207679      +164
==========================================
+ Hits         55336     55401       +65
- Misses       42129     42170       +41
- Partials      3977      3983        +6

Generated by Codecov Action

/// Whether two canonical host paths name the same directory or nest one inside
/// the other. Component-wise, so `/a/bc` does not overlap `/a/b`.
fn host_paths_overlap(a: &Path, b: &Path) -> bool {
a.starts_with(b) || b.starts_with(a)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 Critical src/mount_table.rs:431

host_paths_overlap permits the same directory to be mounted with conflicting modes after the directory is renamed: MountRoot::open compares the stale fs::canonicalize label (/base/shared) with the new label (/base/old-shared), even though both descriptors reference the same directory. The open-then-canonicalize TOCTOU also lets a replacement race produce a disjoint label for a descriptor inside an existing mount, bypassing the protection and allowing writes through the weaker mount; derive overlap and ancestry from the opened directory identity instead of cached path labels.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @crates/monty-fs/src/mount_table.rs around line 431:

`host_paths_overlap` permits the same directory to be mounted with conflicting modes after the directory is renamed: `MountRoot::open` compares the stale `fs::canonicalize` label (`/base/shared`) with the new label (`/base/old-shared`), even though both descriptors reference the same directory. The open-then-canonicalize TOCTOU also lets a replacement race produce a disjoint label for a descriptor inside an existing mount, bypassing the protection and allowing writes through the weaker mount; derive overlap and ancestry from the opened directory identity instead of cached path labels.

@codecov

codecov Bot commented Aug 14, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 80.85106% with 9 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
crates/monty-fs/src/error.rs 61.90% 8 Missing ⚠️
crates/monty-pool/src/checkout.rs 88.88% 0 Missing and 1 partial ⚠️

📢 Thoughts on this report? Let us know!

@macroscopeapp

macroscopeapp Bot commented Aug 14, 2026

Copy link
Copy Markdown

Approvability

Verdict: Needs human review

1 blocking correctness issue found. This PR modifies mount validation in crates/monty-fs/, which is the sandbox boundary. Per repository guidelines, changes to path security and mount handling require human sign-off regardless of complexity.

You can customize Macroscope's approvability policy. Learn more.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

3 issues found across 11 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="crates/monty-fs/src/error.rs">

<violation number="1" location="crates/monty-fs/src/error.rs:184">
P3: When both overlapping mounts use the same mode, no less restrictive mode exists, but this `ValueError` claims one would apply. Because `push_mount` rejects overlaps unconditionally, report the mode-independent overlap policy instead.</violation>
</file>

<file name="crates/monty-fs/src/mount_table.rs">

<violation number="1" location="crates/monty-fs/src/mount_table.rs:431">
P1: On case-insensitive filesystems, `canonicalize` can preserve input casing, so `Path::starts_with` treats case-only aliases as disjoint. Both mounts can then register, allowing a read-write parent to bypass a read-only child; compare filesystem identities or perform a case-aware ancestor check.</violation>
</file>

<file name="crates/monty-python/tests/test_mount_table.py">

<violation number="1" location="crates/monty-python/tests/test_mount_table.py:425">
P3: The test asserts an exact full-message match that embeds both canonical host paths via `test_dir.resolve()`. Those strings must byte-for-byte equal Rust's `fs::canonicalize`/`Path::display()` output on the OS running CI (Windows and macOS, per ci.yml). Platform-specific canonicalization output tends to diverge between the two languages — e.g. Rust's `fs::canonicalize` on Windows can yield a `\\?\`-prefixed verbatim path and Python's `Path.resolve()` does not, and macOS symlinked temp roots (like `/var` → `/private/var`) resolve differently in edge cases. A harmless formatting or path-casing difference then fails the whole test even though the behavior under test — the overlapping mounts are rejected with a clear ValueError — is correct. Anchor the assertion to the stable, intent-bearing part of the message instead of the exact canonical path spelling.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

/// Whether two canonical host paths name the same directory or nest one inside
/// the other. Component-wise, so `/a/bc` does not overlap `/a/b`.
fn host_paths_overlap(a: &Path, b: &Path) -> bool {
a.starts_with(b) || b.starts_with(a)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1: On case-insensitive filesystems, canonicalize can preserve input casing, so Path::starts_with treats case-only aliases as disjoint. Both mounts can then register, allowing a read-write parent to bypass a read-only child; compare filesystem identities or perform a case-aware ancestor check.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At crates/monty-fs/src/mount_table.rs, line 431:

<comment>On case-insensitive filesystems, `canonicalize` can preserve input casing, so `Path::starts_with` treats case-only aliases as disjoint. Both mounts can then register, allowing a read-write parent to bypass a read-only child; compare filesystem identities or perform a case-aware ancestor check.</comment>

<file context>
@@ -384,13 +417,20 @@ impl MountRoot {
+/// Whether two canonical host paths name the same directory or nest one inside
+/// the other. Component-wise, so `/a/bc` does not overlap `/a/b`.
+fn host_paths_overlap(a: &Path, b: &Path) -> bool {
+    a.starts_with(b) || b.starts_with(a)
+}
+
</file context>

Comment on lines +184 to +185
'{existing_virtual_path}', which would let the less restrictive mount's mode apply to the \
other's files",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P3: When both overlapping mounts use the same mode, no less restrictive mode exists, but this ValueError claims one would apply. Because push_mount rejects overlaps unconditionally, report the mode-independent overlap policy instead.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At crates/monty-fs/src/error.rs, line 184:

<comment>When both overlapping mounts use the same mode, no less restrictive mode exists, but this `ValueError` claims one would apply. Because `push_mount` rejects overlaps unconditionally, report the mode-independent overlap policy instead.</comment>

<file context>
@@ -151,6 +168,25 @@ impl MountError {
+                ExcType::ValueError,
+                Some(format!(
+                    "cannot mount '{}' at '{virtual_path}': its host directory overlaps the mount of '{}' at \
+                     '{existing_virtual_path}', which would let the less restrictive mount's mode apply to the \
+                     other's files",
+                    host_path.display(),
</file context>
Suggested change
'{existing_virtual_path}', which would let the less restrictive mount's mode apply to the \
other's files",
'{existing_virtual_path}', because overlapping host directories are not allowed",

with pytest.raises(MontyRuntimeError) as exc_info:
monty_run('1', mount=mounts)
resolved = test_dir.resolve()
assert str(exc_info.value) == (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P3: The test asserts an exact full-message match that embeds both canonical host paths via test_dir.resolve(). Those strings must byte-for-byte equal Rust's fs::canonicalize/Path::display() output on the OS running CI (Windows and macOS, per ci.yml). Platform-specific canonicalization output tends to diverge between the two languages — e.g. Rust's fs::canonicalize on Windows can yield a \\?\-prefixed verbatim path and Python's Path.resolve() does not, and macOS symlinked temp roots (like /var/private/var) resolve differently in edge cases. A harmless formatting or path-casing difference then fails the whole test even though the behavior under test — the overlapping mounts are rejected with a clear ValueError — is correct. Anchor the assertion to the stable, intent-bearing part of the message instead of the exact canonical path spelling.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At crates/monty-python/tests/test_mount_table.py, line 425:

<comment>The test asserts an exact full-message match that embeds both canonical host paths via `test_dir.resolve()`. Those strings must byte-for-byte equal Rust's `fs::canonicalize`/`Path::display()` output on the OS running CI (Windows and macOS, per ci.yml). Platform-specific canonicalization output tends to diverge between the two languages — e.g. Rust's `fs::canonicalize` on Windows can yield a `\\?\`-prefixed verbatim path and Python's `Path.resolve()` does not, and macOS symlinked temp roots (like `/var` → `/private/var`) resolve differently in edge cases. A harmless formatting or path-casing difference then fails the whole test even though the behavior under test — the overlapping mounts are rejected with a clear ValueError — is correct. Anchor the assertion to the stable, intent-bearing part of the message instead of the exact canonical path spelling.</comment>

<file context>
@@ -412,6 +412,23 @@ def test_multiple_mounts_different_modes(monty_run: RunMonty, test_dir: Path):
+    with pytest.raises(MontyRuntimeError) as exc_info:
+        monty_run('1', mount=mounts)
+    resolved = test_dir.resolve()
+    assert str(exc_info.value) == (
+        f"ValueError: cannot mount '{resolved / 'subdir'}' at '/m/subdir': its host directory overlaps the "
+        f"mount of '{resolved}' at '/m', which would let the less restrictive mount's mode apply to the "
</file context>

@codspeed-hq

codspeed-hq Bot commented Aug 14, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 36 untouched benchmarks
⏩ 16 skipped benchmarks1


Comparing reject-overlapping-mounts (0a5efb3) with main (8391f43)

Open in CodSpeed

Footnotes

  1. 16 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

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.

1 participant