feat(sandbox): VS Code Remote-SSH support with platform detection fix and network policy (!42)

Closes #34

## Summary

- Add `nav ssh-proxy` name mode (`--cluster`/`--name`) and `nav sandbox ssh-config` for VS Code Remote-SSH integration
- Fix VS Code Remote-SSH platform misdetection (was defaulting to Windows/PowerShell)
- Fix sandbox network policy for VS Code server connectivity
- Fix sandbox user shell configuration

## Problem

VS Code Remote-SSH was failing to connect to Navigator sandboxes due to three issues:

1. **Platform misdetection**: VS Code sends `uname -rsv` after a `ready:` marker and expects a response within ~1ms. Forced PTY allocation in `shell_request`, interactive bash (`-i`), and leaked supervisor env vars added enough latency that VS Code timed out and defaulted to "Platform: windows", then tried `powershell`.
2. **Broken network policy**: The `vscode` entry in `dev-sandbox-policy.yaml` was at the YAML top-level instead of nested under `network_policies:` (indentation bug). The VS Code server binary also needed a glob entry.
3. **No login shell**: The sandbox user had `/usr/sbin/nologin` as its shell.

## Changes

| File | Change |
|------|--------|
| `crates/navigator-sandbox/src/ssh.rs` | Remove forced PTY in `shell_request`, remove `-i` flag from pipe exec, add `env_clear()` + explicit `SHELL`/`PATH` to both shell spawn paths |
| `deploy/docker/sandbox/Dockerfile.base` | Change sandbox user shell to `/bin/bash`, set home to `/sandbox`, add `.bashrc`/`.profile` |
| `dev-sandbox-policy.yaml` | Fix `vscode:` indentation under `network_policies:`, add `/sandbox/.vscode-server/*` binary glob |
| `crates/navigator-cli/src/main.rs` | Add `--cluster`/`--name` to `SshProxy`, add `SshConfig`/`Policy`/`Logs` subcommands (rebase merge) |
| `crates/navigator-cli/src/ssh.rs` | Update `print_ssh_config` to use `--cluster` flag |
| `examples/vscode-remote-sandbox.md` | User-facing example and docs |

## How to use

```bash
# Create a persistent sandbox
nav sandbox create --keep my-sandbox

# Generate SSH config
nav sandbox ssh-config my-sandbox >> ~/.ssh/config

# Open VS Code
code --remote ssh-remote+nav-my-sandbox /sandbox
```

## Testing

- All 307 tests pass
- Clippy clean on modified crates
This commit is contained in:
Drew Newberry
2026-02-26 09:06:37 -08:00
parent 757217f4bd
commit 5fd4885a7c
8 changed files with 337 additions and 143 deletions
+73 -6
View File
@@ -128,18 +128,39 @@ enum Commands {
},
/// SSH proxy (used by `ProxyCommand`).
///
/// Two mutually exclusive modes:
///
/// **Token mode** (used internally by `sandbox connect`):
/// `nav ssh-proxy --gateway <url> --sandbox-id <id> --token <token>`
///
/// **Name mode** (for use in `~/.ssh/config`):
/// `nav ssh-proxy --cluster <name> --name <sandbox-name>`
SshProxy {
/// Gateway URL (e.g., <https://gw.example.com:443/proxy/connect>).
/// Required in token mode.
#[arg(long)]
gateway: String,
gateway: Option<String>,
/// Sandbox id.
/// Sandbox id. Required in token mode.
#[arg(long)]
sandbox_id: String,
sandbox_id: Option<String>,
/// SSH session token.
/// SSH session token. Required in token mode.
#[arg(long)]
token: String,
token: Option<String>,
/// Cluster endpoint URL. Used in name mode. Deprecated: prefer --cluster.
#[arg(long)]
server: Option<String>,
/// Cluster name (resolves endpoint from stored metadata). Used in name mode.
#[arg(long, short)]
cluster: Option<String>,
/// Sandbox name. Used in name mode.
#[arg(long)]
name: Option<String>,
},
}
@@ -510,6 +531,15 @@ enum SandboxCommands {
#[arg(long, default_value = "")]
level: String,
},
/// Print an SSH config entry for a sandbox.
///
/// Outputs a Host block suitable for appending to ~/.ssh/config,
/// enabling tools like `VSCode` Remote-SSH to connect to the sandbox.
SshConfig {
/// Sandbox name.
name: String,
},
}
#[derive(Subcommand, Debug)]
@@ -1006,6 +1036,9 @@ async fn main() -> Result<()> {
)
.await?;
}
SandboxCommands::SshConfig { name } => {
run::print_ssh_config(&ctx.name, &name);
}
}
}
}
@@ -1129,8 +1162,42 @@ async fn main() -> Result<()> {
gateway,
sandbox_id,
token,
server,
cluster,
name,
}) => {
run::sandbox_ssh_proxy(&gateway, &sandbox_id, &token, &tls).await?;
match (gateway, sandbox_id, token, server, cluster, name) {
// Token mode (existing behavior): pre-created session credentials.
(Some(gw), Some(sid), Some(tok), _, _, _) => {
run::sandbox_ssh_proxy(&gw, &sid, &tok, &tls).await?;
}
// Name mode with --cluster: resolve endpoint from metadata.
(_, _, _, server_override, Some(c), Some(n)) => {
let endpoint = if let Some(srv) = server_override {
srv
} else {
let meta = load_cluster_metadata(&c).map_err(|_| {
miette::miette!(
"Unknown cluster '{c}'.\n\
Deploy it first: nav cluster admin deploy --name {c}\n\
Or list available clusters: nav cluster list"
)
})?;
meta.gateway_endpoint
};
let tls = tls.with_cluster_name(&c);
run::sandbox_ssh_proxy_by_name(&endpoint, &n, &tls).await?;
}
// Legacy name mode with --server only (no --cluster).
(_, _, _, Some(srv), None, Some(n)) => {
run::sandbox_ssh_proxy_by_name(&srv, &n, &tls).await?;
}
_ => {
return Err(miette::miette!(
"provide either --gateway/--sandbox-id/--token or --cluster/--name (or --server/--name)"
));
}
}
}
None => {
Cli::command().print_help().expect("Failed to print help");
+3 -2
View File
@@ -42,10 +42,11 @@ use std::time::{Duration, Instant};
use tonic::{Code, transport::Channel};
// Re-export SSH functions for backward compatibility
pub use crate::ssh::print_ssh_config;
pub use crate::ssh::{list_forwards, stop_forward, stop_forwards_for_sandbox};
pub use crate::ssh::{
sandbox_connect, sandbox_exec, sandbox_forward, sandbox_ssh_proxy, sandbox_sync_down,
sandbox_sync_up, sandbox_sync_up_files,
sandbox_connect, sandbox_exec, sandbox_forward, sandbox_ssh_proxy, sandbox_ssh_proxy_by_name,
sandbox_sync_down, sandbox_sync_up, sandbox_sync_up_files,
};
/// Convert a sandbox phase integer to a human-readable string.
+45 -1
View File
@@ -18,6 +18,8 @@ use tokio_rustls::TlsConnector;
struct SshSessionConfig {
proxy_command: String,
sandbox_id: String,
gateway_url: String,
token: String,
}
async fn ssh_session_config(
@@ -70,7 +72,9 @@ async fn ssh_session_config(
Ok(SshSessionConfig {
proxy_command,
sandbox_id: session.sandbox_id,
sandbox_id: session.sandbox_id.clone(),
gateway_url,
token: session.token,
})
}
@@ -768,6 +772,46 @@ pub async fn sandbox_ssh_proxy(
Ok(())
}
/// Run the SSH proxy in "name mode": create a session on the fly, then proxy.
///
/// This is equivalent to [`sandbox_ssh_proxy`] but accepts a cluster endpoint
/// and sandbox name instead of pre-created gateway/token credentials. It is
/// suitable for use as an SSH `ProxyCommand` in `~/.ssh/config` because it
/// creates a fresh session on every invocation.
pub async fn sandbox_ssh_proxy_by_name(server: &str, name: &str, tls: &TlsOptions) -> Result<()> {
let session = ssh_session_config(server, name, tls).await?;
sandbox_ssh_proxy(
&session.gateway_url,
&session.sandbox_id,
&session.token,
tls,
)
.await
}
/// Print an SSH config `Host` block for a sandbox to stdout.
///
/// The output is suitable for appending to `~/.ssh/config` so that tools like
/// `VSCode` Remote-SSH can connect to the sandbox by host alias.
///
/// The `ProxyCommand` uses `--cluster` so that `ssh-proxy` resolves the
/// gateway endpoint and TLS certificates from the cluster metadata directory
/// (`~/.config/navigator/clusters/<name>/mtls/`).
pub fn print_ssh_config(cluster: &str, name: &str) {
let exe = std::env::current_exe().expect("failed to resolve navigator executable");
let exe = shell_escape(&exe.to_string_lossy());
let proxy_cmd = format!("{exe} ssh-proxy --cluster {cluster} --name {name}");
println!("Host nav-{name}");
println!(" User sandbox");
println!(" StrictHostKeyChecking no");
println!(" UserKnownHostsFile /dev/null");
println!(" GlobalKnownHostsFile /dev/null");
println!(" LogLevel ERROR");
println!(" ProxyCommand {proxy_cmd}");
}
/// Copy all bytes from `reader` to `writer`, flushing on completion.
/// Errors are intentionally discarded – connection teardown errors are
/// expected during normal SSH session shutdown.
+127 -129
View File
@@ -17,7 +17,7 @@ use std::io::{Read, Write};
use std::net::SocketAddr;
use std::os::fd::{AsRawFd, RawFd};
use std::path::PathBuf;
use std::process::{Command, Stdio};
use std::process::Command;
use std::sync::{Arc, mpsc};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
@@ -334,6 +334,12 @@ impl russh::server::Handler for SshHandler {
session: &mut Session,
) -> Result<(), Self::Error> {
session.channel_success(channel)?;
// Only allocate a PTY when the client explicitly requested one via
// pty_request. VS Code Remote-SSH sends shell_request *without* a
// preceding pty_request and expects pipe-based I/O with clean LF line
// endings. Forcing a PTY here caused CRLF translation which made
// VS Code misdetect the platform as Windows (and then try to run
// `powershell`).
self.start_shell(channel, session.handle(), None)?;
Ok(())
}
@@ -353,6 +359,21 @@ impl russh::server::Handler for SshHandler {
Ok(())
}
async fn env_request(
&mut self,
channel: ChannelId,
variable_name: &str,
variable_value: &str,
session: &mut Session,
) -> Result<(), Self::Error> {
// Accept the env request so the client knows we handled it, but we
// don't actually propagate the variables — the sandbox environment is
// controlled via policy. We must reply so VSCode doesn't stall.
let _ = (variable_name, variable_value);
session.channel_success(channel)?;
Ok(())
}
async fn data(
&mut self,
_channel: ChannelId,
@@ -364,18 +385,6 @@ impl russh::server::Handler for SshHandler {
}
Ok(())
}
async fn channel_eof(
&mut self,
_channel: ChannelId,
_session: &mut Session,
) -> Result<(), Self::Error> {
// Drop the input sender so the writer thread exits and the child's
// stdin pipe is closed. Without this, commands like `tar xf -` that
// read until EOF on stdin would hang forever in pipe-based sessions.
self.input_sender.take();
Ok(())
}
}
impl SshHandler {
@@ -386,6 +395,8 @@ impl SshHandler {
command: Option<String>,
) -> anyhow::Result<()> {
if let Some(pty) = self.pty_request.take() {
// PTY was requested — allocate a real PTY (interactive shell or
// exec that explicitly asked for a terminal).
let (pty_master, input_sender) = spawn_pty_shell(
&self.policy,
self.workdir.clone(),
@@ -401,7 +412,10 @@ impl SshHandler {
self.pty_master = Some(pty_master);
self.input_sender = Some(input_sender);
} else {
let input_sender = spawn_pipe_shell(
// No PTY requested — use plain pipes so stdout/stderr are
// separate and output has clean LF line endings. This is the
// path VSCode Remote-SSH exec commands take.
let input_sender = spawn_pipe_exec(
&self.policy,
self.workdir.clone(),
command,
@@ -537,12 +551,15 @@ fn spawn_pty_shell(
pty.term.as_str()
};
cmd.stdin(stdin)
cmd.env_clear()
.stdin(stdin)
.stdout(stdout)
.stderr(stderr)
.env("NAVIGATOR_SANDBOX", "1")
.env("HOME", "/sandbox")
.env("USER", "sandbox")
.env("SHELL", "/bin/bash")
.env("PATH", "/app/.venv/bin:/usr/local/bin:/usr/bin:/bin")
.env("TERM", term);
// Set proxy environment variables so cooperative tools (curl, wget, etc.)
@@ -660,14 +677,13 @@ fn spawn_pty_shell(
Ok((master_file, sender))
}
/// Spawn a shell process with plain pipes (no PTY) for non-interactive sessions.
/// Spawn a command using plain pipes (no PTY).
///
/// This is used when the SSH client did not request a PTY (e.g. `ssh -T`).
/// Unlike [`spawn_pty_shell`], stdout and stderr are separate streams and no
/// terminal output processing (LF→CRLF) is applied, making this safe for
/// binary data transfer (e.g. tar archives).
/// stdout is forwarded as SSH channel data and stderr as SSH extended data
/// (type 1), preserving the separation that clients like `VSCode` Remote-SSH
/// expect. Output retains clean LF line endings (no CRLF translation).
#[allow(clippy::too_many_arguments)]
fn spawn_pipe_shell(
fn spawn_pipe_exec(
policy: &SandboxPolicy,
workdir: Option<String>,
command: Option<String>,
@@ -680,23 +696,30 @@ fn spawn_pipe_shell(
) -> anyhow::Result<mpsc::Sender<Vec<u8>>> {
let mut cmd = command.map_or_else(
|| {
let mut c = Command::new("/bin/bash");
c.arg("-i");
c
// No command — read from stdin. Do *not* pass `-i`; interactive
// mode reads .bashrc, writes prompts to stderr, and can introduce
// just enough latency for VS Code Remote-SSH's platform detection
// to time out and fall back to "windows". Plain `bash` with piped
// stdin already reads commands line-by-line (script mode), which is
// exactly what VS Code's local server expects.
Command::new("/bin/bash")
},
|command| {
let mut c = Command::new("/bin/bash");
c.arg("-lc").arg(command);
c.arg("-c").arg(command);
c
},
);
cmd.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
cmd.env_clear()
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.env("NAVIGATOR_SANDBOX", "1")
.env("HOME", "/sandbox")
.env("USER", "sandbox")
.env("SHELL", "/bin/bash")
.env("PATH", "/app/.venv/bin:/usr/local/bin:/usr/bin:/bin")
.env("TERM", "dumb");
if let Some(ref url) = proxy_url {
@@ -726,7 +749,7 @@ fn spawn_pipe_shell(
#[cfg(unix)]
{
unsafe_pipe::install_pre_exec(&mut cmd, policy.clone(), workdir.clone(), netns_fd);
unsafe_pty::install_pre_exec_no_pty(&mut cmd, policy.clone(), workdir.clone(), netns_fd);
}
let mut child = cmd.spawn()?;
@@ -735,88 +758,74 @@ fn spawn_pipe_shell(
#[cfg(target_os = "linux")]
register_managed_child(child_pid);
let child_stdin = child.stdin.take().expect("stdin was piped");
let child_stdout = child.stdout.take().expect("stdout was piped");
let child_stderr = child.stderr.take().expect("stderr was piped");
let child_stdin = child.stdin.take();
let child_stdout = child.stdout.take().expect("stdout must be piped");
let child_stderr = child.stderr.take().expect("stderr must be piped");
// Writer thread: forwards SSH channel data to the child's stdin.
// stdin writer thread
let (sender, receiver) = mpsc::channel::<Vec<u8>>();
let mut writer = child_stdin;
std::thread::spawn(move || {
let Some(mut stdin) = child_stdin else {
return;
};
while let Ok(bytes) = receiver.recv() {
if writer.write_all(&bytes).is_err() {
if stdin.write_all(&bytes).is_err() {
break;
}
let _ = writer.flush();
let _ = stdin.flush();
}
});
let runtime = tokio::runtime::Handle::current();
// We need both stdout and stderr readers to finish before sending EOF.
// Use an Arc<AtomicUsize> to count completions.
let readers_remaining = Arc::new(std::sync::atomic::AtomicUsize::new(2));
// Signal from the reader threads to the exit thread that all output has
// been forwarded.
let (reader_done_tx, reader_done_rx) = mpsc::channel::<()>();
// Stdout reader thread: forwards child stdout to the SSH channel.
let handle_stdout = handle.clone();
let runtime_stdout = runtime.clone();
let readers_remaining_stdout = readers_remaining.clone();
let reader_done_tx_stdout = reader_done_tx.clone();
// stdout reader
let stdout_handle = handle.clone();
let stdout_runtime = runtime.clone();
let reader_done_stdout = reader_done_tx.clone();
std::thread::spawn(move || {
let mut buf = [0u8; 4096];
let mut reader = child_stdout;
let mut buf = [0u8; 4096];
loop {
match reader.read(&mut buf) {
Ok(0) | Err(_) => break,
Ok(n) => {
let data = CryptoVec::from_slice(&buf[..n]);
let h = handle_stdout.clone();
drop(runtime_stdout.spawn(async move {
let h = stdout_handle.clone();
drop(stdout_runtime.spawn(async move {
let _ = h.data(channel, data).await;
}));
}
}
}
if readers_remaining_stdout.fetch_sub(1, std::sync::atomic::Ordering::AcqRel) == 1 {
let eof_handle = handle_stdout.clone();
drop(runtime_stdout.spawn(async move {
let _ = eof_handle.eof(channel).await;
}));
let _ = reader_done_tx_stdout.send(());
}
let _ = reader_done_stdout.send(());
});
// Stderr reader thread: forwards child stderr to the SSH channel as
// extended data (type 1 = stderr).
let handle_stderr = handle.clone();
let runtime_stderr = runtime.clone();
let readers_remaining_stderr = readers_remaining;
// stderr reader — sends as extended data (type 1)
let stderr_handle = handle.clone();
let stderr_runtime = runtime.clone();
std::thread::spawn(move || {
let mut buf = [0u8; 4096];
let mut reader = child_stderr;
let mut buf = [0u8; 4096];
loop {
match reader.read(&mut buf) {
Ok(0) | Err(_) => break,
Ok(n) => {
let data = CryptoVec::from_slice(&buf[..n]);
let h = handle_stderr.clone();
drop(runtime_stderr.spawn(async move {
let h = stderr_handle.clone();
drop(stderr_runtime.spawn(async move {
let _ = h.extended_data(channel, 1, data).await;
}));
}
}
}
if readers_remaining_stderr.fetch_sub(1, std::sync::atomic::Ordering::AcqRel) == 1 {
let eof_handle = handle_stderr.clone();
drop(runtime_stderr.spawn(async move {
let _ = eof_handle.eof(channel).await;
}));
let _ = reader_done_tx.send(());
}
let _ = reader_done_tx.send(());
});
// Exit thread: waits for process exit, then sends exit-status and close.
// Exit waiter thread
let handle_exit = handle;
let runtime_exit = runtime;
std::thread::spawn(move || {
@@ -824,10 +833,11 @@ fn spawn_pipe_shell(
#[cfg(target_os = "linux")]
unregister_managed_child(child_pid);
let code = status.and_then(|s| s.code()).unwrap_or(1).unsigned_abs();
// Wait for reader threads to finish forwarding output before sending
// exit-status and close.
// Wait for both reader threads.
let _ = reader_done_rx.recv_timeout(Duration::from_secs(2));
let _ = reader_done_rx.recv_timeout(Duration::from_secs(1));
drop(runtime_exit.spawn(async move {
let _ = handle_exit.eof(channel).await;
let _ = handle_exit.exit_status_request(channel, code).await;
let _ = handle_exit.close(channel).await;
}));
@@ -836,41 +846,6 @@ fn spawn_pipe_shell(
Ok(sender)
}
mod unsafe_pipe {
use super::{Command, RawFd, SandboxPolicy, drop_privileges, sandbox};
#[cfg(unix)]
use std::os::unix::process::CommandExt;
#[allow(unsafe_code)]
pub fn install_pre_exec(
cmd: &mut Command,
policy: SandboxPolicy,
workdir: Option<String>,
netns_fd: Option<RawFd>,
) {
unsafe {
cmd.pre_exec(move || {
// Enter network namespace before dropping privileges.
#[cfg(target_os = "linux")]
if let Some(fd) = netns_fd {
let result = libc::setns(fd, libc::CLONE_NEWNET);
if result != 0 {
return Err(std::io::Error::last_os_error());
}
}
#[cfg(not(target_os = "linux"))]
let _ = netns_fd;
drop_privileges(&policy).map_err(|err| std::io::Error::other(err.to_string()))?;
sandbox::apply(&policy, workdir.as_deref())
.map_err(|err| std::io::Error::other(err.to_string()))?;
Ok(())
});
}
}
}
mod unsafe_pty {
use super::{Command, RawFd, SandboxPolicy, Winsize, drop_privileges, sandbox, setsid};
#[cfg(unix)]
@@ -907,31 +882,54 @@ mod unsafe_pty {
setsid().map_err(|err| std::io::Error::other(err.to_string()))?;
set_controlling_tty(slave_fd)?;
// Enter network namespace before dropping privileges.
// This ensures SSH shell processes are isolated to the same
// network namespace as the entrypoint, forcing all traffic
// through the veth pair and CONNECT proxy.
#[cfg(target_os = "linux")]
if let Some(fd) = netns_fd {
let result = libc::setns(fd, libc::CLONE_NEWNET);
if result != 0 {
return Err(std::io::Error::last_os_error());
}
}
#[cfg(not(target_os = "linux"))]
let _ = netns_fd;
// Drop privileges before applying sandbox restrictions.
// initgroups/setgid/setuid need access to /etc/group and /etc/passwd
// which may be blocked by Landlock.
drop_privileges(&policy).map_err(|err| std::io::Error::other(err.to_string()))?;
sandbox::apply(&policy, workdir.as_deref())
.map_err(|err| std::io::Error::other(err.to_string()))?;
Ok(())
enter_netns_and_sandbox(netns_fd, &policy, workdir.as_deref())
});
}
}
/// Pre-exec hook for pipe-based (non-PTY) exec.
///
/// Skips `setsid` and `TIOCSCTTY` since there is no controlling terminal.
#[allow(unsafe_code)]
pub fn install_pre_exec_no_pty(
cmd: &mut Command,
policy: SandboxPolicy,
workdir: Option<String>,
netns_fd: Option<RawFd>,
) {
unsafe {
cmd.pre_exec(move || enter_netns_and_sandbox(netns_fd, &policy, workdir.as_deref()));
}
}
fn enter_netns_and_sandbox(
netns_fd: Option<RawFd>,
policy: &SandboxPolicy,
workdir: Option<&str>,
) -> std::io::Result<()> {
// Enter network namespace before dropping privileges.
// This ensures SSH shell processes are isolated to the same
// network namespace as the entrypoint, forcing all traffic
// through the veth pair and CONNECT proxy.
#[cfg(target_os = "linux")]
if let Some(fd) = netns_fd {
#[allow(unsafe_code)]
let result = unsafe { libc::setns(fd, libc::CLONE_NEWNET) };
if result != 0 {
return Err(std::io::Error::last_os_error());
}
}
#[cfg(not(target_os = "linux"))]
let _ = netns_fd;
// Drop privileges before applying sandbox restrictions.
// initgroups/setgid/setuid need access to /etc/group and /etc/passwd
// which may be blocked by Landlock.
drop_privileges(policy).map_err(|err| std::io::Error::other(err.to_string()))?;
sandbox::apply(policy, workdir).map_err(|err| std::io::Error::other(err.to_string()))?;
Ok(())
}
}
fn to_u16(value: u32) -> u16 {
+9 -1
View File
@@ -53,7 +53,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
# Create supervisor and sandbox users/groups
RUN groupadd -r supervisor && useradd -r -g supervisor -s /usr/sbin/nologin supervisor && \
groupadd -r sandbox && useradd -r -g sandbox -s /usr/sbin/nologin sandbox
groupadd -r sandbox && useradd -r -g sandbox -d /sandbox -s /bin/bash sandbox
# Stage 3: Python dependencies builder
FROM base AS builder
@@ -142,6 +142,14 @@ RUN mkdir -p /var/navigator /sandbox /var/log && \
chown supervisor:supervisor /var/log/navigator.log && \
chmod 0664 /var/log/navigator.log && \
chown sandbox:sandbox /sandbox && \
# Minimal shell init files so interactive and non-interactive shells
# get a sane PATH and prompt. Without these, bash sources nothing
# under /sandbox and tools like VS Code Remote-SSH may mis-detect
# the platform.
printf 'export PATH="/app/.venv/bin:$PATH"\nexport PS1="\\u@\\h:\\w\\$ "\n' \
> /sandbox/.bashrc && \
printf '[ -f ~/.bashrc ] && . ~/.bashrc\n' > /sandbox/.profile && \
chown sandbox:sandbox /sandbox/.bashrc /sandbox/.profile && \
# Install agent skills into the sandbox user's home directory
# so Claude and other agents can discover them at runtime.
# Sources: ai-pim-utils (.deb) and custom navigator skills.
+2 -4
View File
@@ -120,12 +120,10 @@ binary_allowed(policy, exec) if {
# Binary matching: glob pattern against path, any ancestor, or any cmdline path.
binary_allowed(policy, exec) if {
some b
b := policy.binaries[_]
some b in policy.binaries
contains(b.path, "*")
all_paths := array.concat(array.concat([exec.path], exec.ancestors), exec.cmdline_paths)
some p
p := all_paths[_]
some p in all_paths
glob.match(b.path, ["/"], p)
}
+14
View File
@@ -132,6 +132,20 @@ network_policies:
- { path: /usr/local/bin/claude }
- { path: /usr/bin/gh }
vscode:
name: vscode
endpoints:
- { host: update.code.visualstudio.com, port: 443 }
- { host: "*.vo.msecnd.net", port: 443 }
- { host: vscode.download.prss.microsoft.com, port: 443 }
- { host: marketplace.visualstudio.com, port: 443 }
- { host: "*.gallerycdn.vsassets.io", port: 443 }
binaries:
- { path: /usr/bin/curl }
- { path: /usr/bin/wget }
- { path: "/sandbox/.vscode-server/**" }
- { path: "/sandbox/.vscode-remote-containers/**" }
inference:
allowed_routes:
- local
+64
View File
@@ -0,0 +1,64 @@
# Open VSCode in a Sandbox
Connect VSCode to a running sandbox using the
[Remote - SSH](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-ssh)
extension so you get a full IDE experience inside the sandbox environment.
## Prerequisites
- A running navigator cluster (`navigator cluster admin deploy`)
- [VSCode](https://code.visualstudio.com/) with the
[Remote - SSH](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-ssh)
extension installed
- The `navigator` CLI on your `PATH`
## Quick start
### 1. Create a sandbox
```bash
navigator sandbox create --keep --name my-sandbox
```
`--keep` prevents the sandbox from being cleaned up when the shell exits so
VSCode can reconnect to it later.
### 2. Generate an SSH config entry
```bash
navigator sandbox ssh-config my-sandbox >> ~/.ssh/config
```
This will append a block like:
```text
Host nav-my-sandbox
User sandbox
StrictHostKeyChecking no
UserKnownHostsFile /dev/null
GlobalKnownHostsFile /dev/null
LogLevel ERROR
ProxyCommand navigator ssh-proxy --cluster <cluster-name> --name my-sandbox
```
### 3. Open VSCode
Open VSCode and run **Remote-SSH: Connect to Host...** from the command
palette (`Cmd+Shift+P` / `Ctrl+Shift+P`). Select `nav-my-sandbox` from the
list. VSCode will open a remote window connected to the sandbox.
Alternatively, from the terminal:
```bash
code --remote ssh-remote+nav-my-sandbox /sandbox
```
### 4. Clean up
When you are done, delete the sandbox:
```bash
navigator sandbox delete my-sandbox
```
This also removes any active port forwards.