Skip to content

Commit 91c7f84

Browse files
authored
feat(sandbox): upgrade Landlock to ABI V2 and fix sandbox venv PATH (#151)
1 parent a8e9b43 commit 91c7f84

7 files changed

Lines changed: 193 additions & 28 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: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -645,6 +645,12 @@ fn spawn_pty_shell(
645645
pty.term.as_str()
646646
};
647647

648+
// Inherit PATH from the container (set via Dockerfile ENV) so that
649+
// sandbox sessions see the same tool layout without hardcoding paths.
650+
// Tool-specific env vars (VIRTUAL_ENV, UV_PYTHON_INSTALL_DIR, etc.) are
651+
// set in /sandbox/.bashrc by the Dockerfile and sourced via login shell.
652+
let path = std::env::var("PATH").unwrap_or_else(|_| "/usr/local/bin:/usr/bin:/bin".into());
653+
648654
cmd.env_clear()
649655
.stdin(stdin)
650656
.stdout(stdout)
@@ -653,7 +659,7 @@ fn spawn_pty_shell(
653659
.env("HOME", "/sandbox")
654660
.env("USER", "sandbox")
655661
.env("SHELL", "/bin/bash")
656-
.env("PATH", "/app/.venv/bin:/usr/local/bin:/usr/bin:/bin")
662+
.env("PATH", &path)
657663
.env("TERM", term);
658664

659665
// Set proxy environment variables so cooperative tools (curl, wget, etc.)
@@ -797,11 +803,16 @@ fn spawn_pipe_exec(
797803
},
798804
|command| {
799805
let mut c = Command::new("/bin/bash");
800-
c.arg("-c").arg(command);
806+
// Use login shell (-l) so that .profile/.bashrc are sourced and
807+
// tool-specific env vars (VIRTUAL_ENV, UV_PYTHON_INSTALL_DIR, etc.)
808+
// are available without hardcoding them here.
809+
c.arg("-lc").arg(command);
801810
c
802811
},
803812
);
804813

814+
let path = std::env::var("PATH").unwrap_or_else(|_| "/usr/local/bin:/usr/bin:/bin".into());
815+
805816
cmd.env_clear()
806817
.stdin(std::process::Stdio::piped())
807818
.stdout(std::process::Stdio::piped())
@@ -810,7 +821,7 @@ fn spawn_pipe_exec(
810821
.env("HOME", "/sandbox")
811822
.env("USER", "sandbox")
812823
.env("SHELL", "/bin/bash")
813-
.env("PATH", "/app/.venv/bin:/usr/local/bin:/usr/bin:/bin")
824+
.env("PATH", &path)
814825
.env("TERM", "dumb");
815826

816827
if let Some(ref url) = proxy_url {

deploy/docker/sandbox/Dockerfile.base

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -117,8 +117,9 @@ RUN uv venv /app/.venv && \
117117
uv sync --frozen --no-dev --no-install-project 2>/dev/null || \
118118
uv sync --no-dev --no-install-project 2>/dev/null || true
119119

120-
# Install navigator SDK dependencies
121-
RUN uv pip install --python /app/.venv/bin/python --no-cache-dir cloudpickle grpcio protobuf openai
120+
# Install navigator SDK dependencies and pip (uv venvs don't include pip by
121+
# default, and sandbox users need it for `pip install` inside the sandbox).
122+
RUN uv pip install --python /app/.venv/bin/python --no-cache-dir cloudpickle grpcio protobuf openai pip
122123

123124
# Stage 4: Coding agents layer
124125
FROM base AS coding-agents
@@ -166,8 +167,14 @@ COPY --from=rust-builder /build/out/navigator-sandbox /usr/local/bin/
166167
# Copy navigator Python SDK into the virtual environment
167168
COPY python/navigator/ /app/.venv/lib/python3.12/site-packages/navigator/
168169

169-
# Add venv to PATH
170-
ENV PATH="/app/.venv/bin:$PATH"
170+
# Add venvs to PATH -- /sandbox/.venv (writable, user-installed packages)
171+
# takes priority over /app/.venv (read-only, build-time packages).
172+
# ssh.rs inherits PATH at runtime so it stays in sync with this layout.
173+
# VIRTUAL_ENV and UV_PYTHON_INSTALL_DIR are also exported in .bashrc
174+
# so that login shell sessions (interactive and exec) see them.
175+
ENV PATH="/sandbox/.venv/bin:/app/.venv/bin:/usr/local/bin:/usr/bin:/bin" \
176+
VIRTUAL_ENV="/sandbox/.venv" \
177+
UV_PYTHON_INSTALL_DIR="/sandbox/.uv/python"
171178

172179
# Copy custom navigator skills into the image
173180
# To add a skill, create a subdirectory under deploy/docker/sandbox/skills/
@@ -184,11 +191,16 @@ RUN mkdir -p /var/navigator /sandbox /var/log && \
184191
chown supervisor:supervisor /var/log/navigator.log && \
185192
chmod 0664 /var/log/navigator.log && \
186193
chown sandbox:sandbox /sandbox && \
194+
# Create a writable venv that inherits all packages from the read-only
195+
# /app/.venv. Sandbox users can `pip install` or `uv pip install` into
196+
# this venv without touching the base image layer.
197+
uv venv --python /app/.venv/bin/python --seed --system-site-packages /sandbox/.venv && \
198+
chown -R sandbox:sandbox /sandbox/.venv && \
187199
# Minimal shell init files so interactive and non-interactive shells
188200
# get a sane PATH and prompt. Without these, bash sources nothing
189201
# under /sandbox and tools like VS Code Remote-SSH may mis-detect
190202
# the platform.
191-
printf 'export PATH="/app/.venv/bin:$PATH"\nexport PS1="\\u@\\h:\\w\\$ "\n' \
203+
printf 'export PATH="/sandbox/.venv/bin:/app/.venv/bin:/usr/local/bin:/usr/bin:/bin"\nexport VIRTUAL_ENV="/sandbox/.venv"\nexport UV_PYTHON_INSTALL_DIR="/sandbox/.uv/python"\nexport PS1="\\u@\\h:\\w\\$ "\n' \
192204
> /sandbox/.bashrc && \
193205
printf '[ -f ~/.bashrc ] && . ~/.bashrc\n' > /sandbox/.profile && \
194206
chown sandbox:sandbox /sandbox/.bashrc /sandbox/.profile && \

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,28 @@ 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+
# uv python install downloads from python-build-standalone on GitHub
104+
- { host: github.com, port: 443 }
105+
- { host: objects.githubusercontent.com, port: 443 }
106+
# uv resolves python-build-standalone release metadata via the GitHub API
107+
- { host: api.github.com, port: 443 }
108+
- { host: downloads.python.org, port: 443 }
109+
binaries:
110+
- { path: /sandbox/.venv/bin/python }
111+
- { path: /sandbox/.venv/bin/python3 }
112+
- { path: /sandbox/.venv/bin/pip }
113+
- { path: /app/.venv/bin/python }
114+
- { path: /app/.venv/bin/python3 }
115+
- { path: /app/.venv/bin/pip }
116+
- { path: /usr/local/bin/uv }
117+
# Managed Python installations from uv python install
118+
- { path: "/sandbox/.uv/python/**" }
119+
98120
vscode:
99121
name: vscode
100122
endpoints:

e2e/python/test_sandbox_venv.py

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
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+
The SDK omits the policy field from the spec so the sandbox container discovers
15+
its policy from /etc/navigator/policy.yaml (the dev-sandbox-policy.yaml baked
16+
into the image), which already includes the pypi network policy.
17+
"""
18+
19+
from __future__ import annotations
20+
21+
from typing import TYPE_CHECKING
22+
23+
if TYPE_CHECKING:
24+
from collections.abc import Callable
25+
26+
from navigator import Sandbox
27+
28+
29+
def test_sandbox_venv_in_path(
30+
sandbox: Callable[..., Sandbox],
31+
) -> None:
32+
"""Non-interactive exec sees /sandbox/.venv/bin in PATH."""
33+
with sandbox(delete_on_exit=True) as sb:
34+
result = sb.exec(["bash", "-c", "echo $PATH"], timeout_seconds=20)
35+
assert result.exit_code == 0, result.stderr
36+
path_dirs = result.stdout.strip().split(":")
37+
assert "/sandbox/.venv/bin" in path_dirs, (
38+
f"Expected /sandbox/.venv/bin in PATH, got: {result.stdout.strip()}"
39+
)
40+
# /sandbox/.venv/bin must come before /app/.venv/bin
41+
sandbox_idx = path_dirs.index("/sandbox/.venv/bin")
42+
app_idx = path_dirs.index("/app/.venv/bin")
43+
assert sandbox_idx < app_idx, (
44+
"/sandbox/.venv/bin must precede /app/.venv/bin in PATH"
45+
)
46+
47+
48+
def test_pip_install_in_sandbox(
49+
sandbox: Callable[..., Sandbox],
50+
) -> None:
51+
"""pip install works inside the sandbox and installed packages are importable."""
52+
with sandbox(delete_on_exit=True) as sb:
53+
install = sb.exec(
54+
["pip", "install", "--quiet", "cowsay"],
55+
timeout_seconds=60,
56+
)
57+
assert install.exit_code == 0, (
58+
f"pip install failed:\nstdout: {install.stdout}\nstderr: {install.stderr}"
59+
)
60+
61+
# Verify the package is importable
62+
verify = sb.exec(
63+
["python", "-c", "import cowsay; print(cowsay.char_names[0])"],
64+
timeout_seconds=20,
65+
)
66+
assert verify.exit_code == 0, (
67+
f"import failed:\nstdout: {verify.stdout}\nstderr: {verify.stderr}"
68+
)
69+
assert verify.stdout.strip(), "Expected non-empty output from cowsay"
70+
71+
72+
def test_uv_pip_install_in_sandbox(
73+
sandbox: Callable[..., Sandbox],
74+
) -> None:
75+
"""uv pip install works inside the sandbox (validates Landlock V2 REFER support).
76+
77+
Under Landlock V1 this would fail with EXDEV (cross-device link, os error 18)
78+
because uv uses cross-directory rename() for cache population and installation.
79+
Landlock V2 adds the REFER right which permits this.
80+
"""
81+
with sandbox(delete_on_exit=True) as sb:
82+
install = sb.exec(
83+
[
84+
"uv",
85+
"pip",
86+
"install",
87+
"--python",
88+
"/sandbox/.venv/bin/python",
89+
"--quiet",
90+
"cowsay",
91+
],
92+
timeout_seconds=60,
93+
)
94+
assert install.exit_code == 0, (
95+
f"uv pip install failed:\nstdout: {install.stdout}\nstderr: {install.stderr}"
96+
)
97+
98+
# Verify the package is importable
99+
verify = sb.exec(
100+
["python", "-c", "import cowsay; print(cowsay.char_names[0])"],
101+
timeout_seconds=20,
102+
)
103+
assert verify.exit_code == 0, (
104+
f"import failed after uv install:\n"
105+
f"stdout: {verify.stdout}\nstderr: {verify.stderr}"
106+
)
107+
assert verify.stdout.strip(), "Expected non-empty output from cowsay"
108+
109+
110+
def test_uv_run_with_ephemeral_dependency(
111+
sandbox: Callable[..., Sandbox],
112+
) -> None:
113+
"""uv run --with installs a dependency on-the-fly and runs a script using it."""
114+
with sandbox(delete_on_exit=True) as sb:
115+
result = sb.exec(
116+
[
117+
"uv",
118+
"run",
119+
"--python",
120+
"/sandbox/.venv/bin/python",
121+
"--with",
122+
"cowsay",
123+
"python",
124+
"-c",
125+
"import cowsay; print(cowsay.char_names[0])",
126+
],
127+
timeout_seconds=60,
128+
)
129+
assert result.exit_code == 0, (
130+
f"uv run --with failed:\nstdout: {result.stdout}\nstderr: {result.stderr}"
131+
)
132+
assert result.stdout.strip(), "Expected non-empty output from uv run"

python/navigator/sandbox.py

Lines changed: 6 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@
2121
inference_pb2_grpc,
2222
navigator_pb2,
2323
navigator_pb2_grpc,
24-
sandbox_pb2,
2524
)
2625

2726
if TYPE_CHECKING:
@@ -594,24 +593,13 @@ def _sandbox_ref(sandbox: datamodel_pb2.Sandbox) -> SandboxRef:
594593
)
595594

596595

597-
def _default_policy() -> sandbox_pb2.SandboxPolicy:
598-
return sandbox_pb2.SandboxPolicy(
599-
version=1,
600-
inference=sandbox_pb2.InferencePolicy(allowed_routes=["local"]),
601-
filesystem=sandbox_pb2.FilesystemPolicy(
602-
include_workdir=True,
603-
read_only=["/usr", "/lib", "/etc", "/app"],
604-
read_write=["/sandbox", "/tmp"],
605-
),
606-
landlock=sandbox_pb2.LandlockPolicy(compatibility="best_effort"),
607-
process=sandbox_pb2.ProcessPolicy(
608-
run_as_user="sandbox", run_as_group="sandbox"
609-
),
610-
)
611-
612-
613596
def _default_spec() -> datamodel_pb2.SandboxSpec:
614-
return datamodel_pb2.SandboxSpec(policy=_default_policy())
597+
# Omit the policy field so the sandbox container discovers its policy
598+
# from /etc/navigator/policy.yaml (baked into the image at build time).
599+
# This avoids duplicating policy defaults between the SDK and the
600+
# container image and ensures sandboxes get the full dev-sandbox-policy
601+
# (including network_policies) out of the box.
602+
return datamodel_pb2.SandboxSpec()
615603

616604

617605
def _xdg_config_home() -> pathlib.Path:

0 commit comments

Comments
 (0)