Skip to content

Commit 667eccf

Browse files
committed
feat(sandbox): upgrade Landlock to ABI V2 and fix sandbox venv PATH
Upgrade Landlock from ABI V1 to V2 to enable the REFER right, which permits cross-directory rename() under sandboxed paths. This unblocks uv package installation which was failing with EXDEV (os error 18). - Upgrade ABI::V1 to ABI::V2 in landlock.rs - Change default LandlockCompatibility from BestEffort to HardRequirement so kernels that lack V2 fail loudly instead of silently degrading - Move PATH definition to Dockerfile.base ENV and have ssh.rs inherit it from the process environment instead of hardcoding, ensuring both interactive and non-interactive sessions see /sandbox/.venv/bin - Add pypi network policy to dev-sandbox-policy.yaml allowing pip and uv to reach pypi.org and files.pythonhosted.org - Add e2e tests for PATH visibility, pip install, uv pip install, and uv run --with ephemeral dependency injection Closes #149
1 parent 1634226 commit 667eccf

6 files changed

Lines changed: 150 additions & 7 deletions

File tree

‎crates/navigator-sandbox/src/policy.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,8 +86,8 @@ pub struct ProcessPolicy {
8686

8787
#[derive(Debug, Clone, Default)]
8888
pub enum LandlockCompatibility {
89-
#[default]
9089
BestEffort,
90+
#[default]
9191
HardRequirement,
9292
}
9393

‎crates/navigator-sandbox/src/sandbox/linux/landlock.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ pub fn apply(policy: &SandboxPolicy, workdir: Option<&str>) -> Result<()> {
3030
}
3131

3232
let result: Result<()> = (|| {
33-
let abi = ABI::V1;
33+
let abi = ABI::V2;
3434
let access_all = AccessFs::from_all(abi);
3535
let access_read = AccessFs::from_read(abi);
3636

‎crates/navigator-sandbox/src/ssh.rs‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -645,6 +645,11 @@ fn spawn_pty_shell(
645645
pty.term.as_str()
646646
};
647647

648+
// Inherit PATH from the container environment (set via Dockerfile ENV) so
649+
// that sandbox sessions see the same venv layout without hardcoding paths
650+
// in Rust. Falls back to a minimal default if the variable is unset.
651+
let path = std::env::var("PATH").unwrap_or_else(|_| "/usr/local/bin:/usr/bin:/bin".into());
652+
648653
cmd.env_clear()
649654
.stdin(stdin)
650655
.stdout(stdout)
@@ -653,7 +658,7 @@ fn spawn_pty_shell(
653658
.env("HOME", "/sandbox")
654659
.env("USER", "sandbox")
655660
.env("SHELL", "/bin/bash")
656-
.env("PATH", "/app/.venv/bin:/usr/local/bin:/usr/bin:/bin")
661+
.env("PATH", &path)
657662
.env("TERM", term);
658663

659664
// Set proxy environment variables so cooperative tools (curl, wget, etc.)
@@ -802,6 +807,8 @@ fn spawn_pipe_exec(
802807
},
803808
);
804809

810+
let path = std::env::var("PATH").unwrap_or_else(|_| "/usr/local/bin:/usr/bin:/bin".into());
811+
805812
cmd.env_clear()
806813
.stdin(std::process::Stdio::piped())
807814
.stdout(std::process::Stdio::piped())
@@ -810,7 +817,7 @@ fn spawn_pipe_exec(
810817
.env("HOME", "/sandbox")
811818
.env("USER", "sandbox")
812819
.env("SHELL", "/bin/bash")
813-
.env("PATH", "/app/.venv/bin:/usr/local/bin:/usr/bin:/bin")
820+
.env("PATH", &path)
814821
.env("TERM", "dumb");
815822

816823
if let Some(ref url) = proxy_url {

‎deploy/docker/sandbox/Dockerfile.base‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -166,8 +166,11 @@ COPY --from=rust-builder /build/out/navigator-sandbox /usr/local/bin/
166166
# Copy navigator Python SDK into the virtual environment
167167
COPY python/navigator/ /app/.venv/lib/python3.12/site-packages/navigator/
168168

169-
# Add venv to PATH
170-
ENV PATH="/app/.venv/bin:$PATH"
169+
# Add venvs to PATH -- /sandbox/.venv (writable, user-installed packages)
170+
# takes priority over /app/.venv (read-only, build-time packages).
171+
# This ENV is also read by ssh.rs at runtime so that the hardcoded PATH
172+
# in the Rust code stays in sync with the container layout.
173+
ENV PATH="/sandbox/.venv/bin:/app/.venv/bin:/usr/local/bin:/usr/bin:/bin"
171174

172175
# Copy custom navigator skills into the image
173176
# To add a skill, create a subdirectory under deploy/docker/sandbox/skills/
@@ -188,7 +191,7 @@ RUN mkdir -p /var/navigator /sandbox /var/log && \
188191
# get a sane PATH and prompt. Without these, bash sources nothing
189192
# under /sandbox and tools like VS Code Remote-SSH may mis-detect
190193
# the platform.
191-
printf 'export PATH="/app/.venv/bin:$PATH"\nexport PS1="\\u@\\h:\\w\\$ "\n' \
194+
printf 'export PATH="/sandbox/.venv/bin:/app/.venv/bin:/usr/local/bin:/usr/bin:/bin"\nexport PS1="\\u@\\h:\\w\\$ "\n' \
192195
> /sandbox/.bashrc && \
193196
printf '[ -f ~/.bashrc ] && . ~/.bashrc\n' > /sandbox/.profile && \
194197
chown sandbox:sandbox /sandbox/.bashrc /sandbox/.profile && \

‎deploy/docker/sandbox/dev-sandbox-policy.yaml‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,20 @@ network_policies:
9595
- { path: /usr/local/bin/claude }
9696
- { path: /usr/bin/gh }
9797

98+
pypi:
99+
name: pypi
100+
endpoints:
101+
- { host: pypi.org, port: 443 }
102+
- { host: files.pythonhosted.org, port: 443 }
103+
binaries:
104+
- { path: /sandbox/.venv/bin/python }
105+
- { path: /sandbox/.venv/bin/python3 }
106+
- { path: /sandbox/.venv/bin/pip }
107+
- { path: /app/.venv/bin/python }
108+
- { path: /app/.venv/bin/python3 }
109+
- { path: /app/.venv/bin/pip }
110+
- { path: /usr/local/bin/uv }
111+
98112
vscode:
99113
name: vscode
100114
endpoints:

‎e2e/python/test_sandbox_venv.py‎

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
"""Tests for the writable sandbox venv, PATH, and package installation.
5+
6+
Verifies that:
7+
- /sandbox/.venv/bin is in PATH for both interactive and non-interactive sessions
8+
- pip install works inside the sandbox (pypi policy in dev-sandbox-policy.yaml)
9+
- uv pip install works (validates Landlock V2 cross-directory rename support)
10+
- uv run --with works for ephemeral dependency injection
11+
- Installed packages are importable after installation
12+
13+
All tests use the default dev sandbox policy -- no custom policy overrides.
14+
"""
15+
16+
from __future__ import annotations
17+
18+
from typing import TYPE_CHECKING
19+
20+
if TYPE_CHECKING:
21+
from collections.abc import Callable
22+
23+
from navigator import Sandbox
24+
25+
26+
def test_sandbox_venv_in_path(
27+
sandbox: Callable[..., Sandbox],
28+
) -> None:
29+
"""Non-interactive exec sees /sandbox/.venv/bin in PATH."""
30+
with sandbox(delete_on_exit=True) as sb:
31+
result = sb.exec(["bash", "-c", "echo $PATH"], timeout_seconds=20)
32+
assert result.exit_code == 0, result.stderr
33+
path_dirs = result.stdout.strip().split(":")
34+
assert "/sandbox/.venv/bin" in path_dirs, (
35+
f"Expected /sandbox/.venv/bin in PATH, got: {result.stdout.strip()}"
36+
)
37+
# /sandbox/.venv/bin must come before /app/.venv/bin
38+
sandbox_idx = path_dirs.index("/sandbox/.venv/bin")
39+
app_idx = path_dirs.index("/app/.venv/bin")
40+
assert sandbox_idx < app_idx, (
41+
"/sandbox/.venv/bin must precede /app/.venv/bin in PATH"
42+
)
43+
44+
45+
def test_pip_install_in_sandbox(
46+
sandbox: Callable[..., Sandbox],
47+
) -> None:
48+
"""pip install works inside the sandbox and installed packages are importable."""
49+
with sandbox(delete_on_exit=True) as sb:
50+
install = sb.exec(
51+
["pip", "install", "--quiet", "cowsay"],
52+
timeout_seconds=60,
53+
)
54+
assert install.exit_code == 0, (
55+
f"pip install failed:\nstdout: {install.stdout}\nstderr: {install.stderr}"
56+
)
57+
58+
# Verify the package is importable
59+
verify = sb.exec(
60+
["python", "-c", "import cowsay; print(cowsay.char_names[0])"],
61+
timeout_seconds=20,
62+
)
63+
assert verify.exit_code == 0, (
64+
f"import failed:\nstdout: {verify.stdout}\nstderr: {verify.stderr}"
65+
)
66+
assert verify.stdout.strip(), "Expected non-empty output from cowsay"
67+
68+
69+
def test_uv_pip_install_in_sandbox(
70+
sandbox: Callable[..., Sandbox],
71+
) -> None:
72+
"""uv pip install works inside the sandbox (validates Landlock V2 REFER support).
73+
74+
Under Landlock V1 this would fail with EXDEV (cross-device link, os error 18)
75+
because uv uses cross-directory rename() for cache population and installation.
76+
Landlock V2 adds the REFER right which permits this.
77+
"""
78+
with sandbox(delete_on_exit=True) as sb:
79+
install = sb.exec(
80+
["uv", "pip", "install", "--quiet", "cowsay"],
81+
timeout_seconds=60,
82+
)
83+
assert install.exit_code == 0, (
84+
f"uv pip install failed:\nstdout: {install.stdout}\nstderr: {install.stderr}"
85+
)
86+
87+
# Verify the package is importable
88+
verify = sb.exec(
89+
["python", "-c", "import cowsay; print(cowsay.char_names[0])"],
90+
timeout_seconds=20,
91+
)
92+
assert verify.exit_code == 0, (
93+
f"import failed after uv install:\n"
94+
f"stdout: {verify.stdout}\nstderr: {verify.stderr}"
95+
)
96+
assert verify.stdout.strip(), "Expected non-empty output from cowsay"
97+
98+
99+
def test_uv_run_with_ephemeral_dependency(
100+
sandbox: Callable[..., Sandbox],
101+
) -> None:
102+
"""uv run --with installs a dependency on-the-fly and runs a script using it."""
103+
with sandbox(delete_on_exit=True) as sb:
104+
result = sb.exec(
105+
[
106+
"uv",
107+
"run",
108+
"--with",
109+
"cowsay",
110+
"python",
111+
"-c",
112+
"import cowsay; print(cowsay.char_names[0])",
113+
],
114+
timeout_seconds=60,
115+
)
116+
assert result.exit_code == 0, (
117+
f"uv run --with failed:\nstdout: {result.stdout}\nstderr: {result.stderr}"
118+
)
119+
assert result.stdout.strip(), "Expected non-empty output from uv run"

0 commit comments

Comments
 (0)