diff --git a/docs/sandboxing.md b/docs/sandboxing.md index da37b148..2f0d8093 100644 --- a/docs/sandboxing.md +++ b/docs/sandboxing.md @@ -111,6 +111,23 @@ Your global `git config user.name` and `user.email` (if configured on the host) Agent login/config state (e.g. Claude Code's `~/.claude`, Codex's `~/.codex`) is kept in a Docker-managed named volume, not a bind mount of your real home directory, so it survives across `agent run` invocations without exposing anything else on the host. This volume is shared across every profile, project, *and image* using the Docker backend on this machine — logging in once covers all of them, even after switching to a completely different custom image or Dockerfile, since the volume is mounted at the same container path (`/home/agent`) regardless of which image runs. +### Admin shell and installing tools + +```bash +stashbase agent docker shell +``` + +This opens an interactive `bash` in the default sandbox image (`--image ` for another one) with the persistent home volume mounted and `/home/agent` as the working directory. No profile is needed. None of a run's sandboxing applies: there's no per-run network or firewall and no working-directory mount. Use it to log in, edit agent config, install tools, or inspect what the sandbox sees. It runs as the same user agent runs do (your uid on Linux, root on macOS), so files it creates stay writable for them. `--root` forces root on Linux. + +Only `$HOME` persists after the shell exits. The image points user-level installs there, so these are kept and available to every sandboxed run, in every repo and profile: + +```bash +npm i -g bun # → ~/.npm-global/bin (on PATH) +pip install --user x # → ~/.local/bin (on PATH) +``` + +These home directories come after the system paths on `PATH`, so a tool installed there never overrides the image's own binaries. `rm -rf ~/.npm-global` resets the global npm installs. System packages (`apt install …`) go into the container's own filesystem and are lost on exit. Add them with a custom `sandbox.dockerfile` instead (see [Custom images](#custom-images)). + ### Codex and subscription login Codex's normal OAuth login flow opens a browser that redirects to a local HTTP callback server. That callback listens inside the container's own network namespace, which the host browser cannot reach — the container's `localhost` is not your machine's `localhost`. Use Codex's device-code flow instead, which doesn't depend on a local callback at all: @@ -136,6 +153,24 @@ cpus = "1.5" # docker run --cpus Or per invocation, without editing the file: `--docker-memory ` / `--docker-cpus ` (same override precedence as `--docker-image`/`--docker-dockerfile`, but these don't imply the Docker backend on their own — they're only meaningful once Docker is already selected). `agent validate` checks the value looks like something Docker would accept before you ever try to run it. +### Isolated paths (e.g. `node_modules`) + +The container is Linux, but it sees your working directory exactly as it is on the host. On macOS or Windows that includes a `node_modules` installed for the host OS. Packages that ship native binaries (Nx, esbuild, swc, rollup, …) only have the host's binary there, so they fail inside the sandbox. It works the other way round too: an `npm ci` inside the sandbox would replace the host's install with Linux binaries. + +List such directories under `isolated_paths` to give the container its own copy: + +```toml +[sandbox] +backend = "docker" +isolated_paths = ["node_modules"] +``` + +Each entry (relative to the working directory) gets a Docker volume for that repo, mounted over the directory inside the container. The host's copy is never touched. The volume starts empty, so install once from inside the sandbox (`npm ci`); it persists across runs of that repo. Nested workspace folders (`apps/web/node_modules`) are listed explicitly if needed. The same works for `.venv`, `target/`, etc. On a Linux host this is usually unnecessary, since the host's binaries already match the container. + +Or per invocation, without editing the file: `--docker-isolated-paths node_modules,.venv` (comma-separated). It adds to the profile's list rather than replacing it, and like `--docker-cpus` it only applies once Docker is the selected backend. + +`stashbase agent docker cleanup --isolated-paths` lists these volumes and removes them after confirmation (`--yes` to skip). + ### Cleaning up after a crash Every per-run Docker network (and its two containers) is named after that run's own session id — the same `ags_...` id shown in "Agent session"/"Audit session" and used for the audit log filename — specifically so leftovers can be traced back to the run that created them. Normal exit paths, including Ctrl+C, tear both containers and the network down as part of `agent run` itself; only a crash or a forceful `SIGKILL` of the `stashbase` process can leave them behind. diff --git a/src/cmd/agent.rs b/src/cmd/agent.rs index 43aa7149..53ca63cd 100644 --- a/src/cmd/agent.rs +++ b/src/cmd/agent.rs @@ -94,6 +94,10 @@ pub struct AgentDockerCleanupCommand { /// Remove every leftover resource without prompting for confirmation #[arg(long)] pub yes: bool, + + /// Instead of leftover networks, remove the per-repo volumes backing profiles' `sandbox.isolated_paths` (e.g. the container's own node_modules; reinstalled on next use) + #[arg(long)] + pub isolated_paths: bool, } #[derive(Debug, Args)] @@ -271,6 +275,13 @@ pub struct AgentRunCommand { #[arg(long)] pub docker_cpus: Option, + /// Add to the profile's `[sandbox] isolated_paths` for this run only: + /// a directory relative to the working directory (e.g. "node_modules") + /// that gets its own per-repo volume inside the container. + /// Comma-separated, e.g. `--docker-isolated-paths node_modules,.venv`. + #[arg(long, value_name = "PATHS", value_delimiter = ',')] + pub docker_isolated_paths: Vec, + /// Store metadata-only proxy audit events locally #[arg( long, diff --git a/src/handlers/agent/docker.rs b/src/handlers/agent/docker.rs index 738b9623..b391b772 100644 --- a/src/handlers/agent/docker.rs +++ b/src/handlers/agent/docker.rs @@ -235,6 +235,9 @@ pub async fn handle_docker_cleanup_command( if let Some(error) = crate::handlers::run::docker_sandbox::docker_enforcement_error() { anyhow::bail!("Docker sandbox backend unavailable: {error}"); } + if command.isolated_paths { + return cleanup_isolated_path_volumes(command.yes, raw_output, silent); + } let candidates: Vec<_> = list_networks_with_liveness()? .into_iter() @@ -369,6 +372,95 @@ pub async fn handle_docker_cleanup_command( Ok(()) } +/// `cleanup --isolated-paths`: unlike leftover networks, these volumes are +/// deliberate persistent state (a repo's container-side node_modules etc.), +/// so they're only ever removed on this explicit request. Removing one just +/// means the next run starts with an empty directory there to reinstall into. +fn cleanup_isolated_path_volumes(yes: bool, raw_output: bool, silent: bool) -> Result<()> { + use crate::handlers::run::docker_sandbox::{list_isolated_path_volumes, remove_volume}; + + let volumes = list_isolated_path_volumes() + .map_err(|error| anyhow::anyhow!("failed to list isolated-path volumes: {error}"))?; + if volumes.is_empty() { + if raw_output { + println!( + "{}", + get_formatted_json_string( + &serde_json::json!({ "removed": [], "failed": [] }), + true + )? + ); + } else if !silent { + println!("No isolated-path volumes found."); + } + return Ok(()); + } + + if !silent && !raw_output { + println!("Found {} isolated-path volume(s):\n", volumes.len()); + for volume in &volumes { + println!(" {} {}/{}", volume.name, volume.repo, volume.path); + } + println!(); + } + + let should_remove = if yes { + true + } else if silent { + anyhow::bail!( + "{} isolated-path volume(s) found; re-run with --yes to remove them, or without --silent to be prompted", + volumes.len() + ); + } else { + let confirmed = crate::utils::interaction::confirm_opt(&format!( + "Remove all {} volume(s) listed above?", + volumes.len() + )) + .unwrap_or(false); + let _ = dialoguer::console::Term::stdout().show_cursor(); + confirmed + }; + if !should_remove { + if !silent && !raw_output { + println!("Nothing removed."); + } + return Ok(()); + } + + let mut removed = Vec::new(); + let mut failures = Vec::new(); + for volume in &volumes { + match remove_volume(&volume.name) { + Ok(()) => { + if !silent && !raw_output { + println!("{} {}", "Removed".green_if_tty(), volume.name); + } + removed.push(&volume.name); + } + // Most likely still mounted by a running sandbox. + Err(error) => failures.push(format!("{}: {error}", volume.name)), + } + } + + if raw_output { + println!( + "{}", + get_formatted_json_string( + &serde_json::json!({ "removed": removed, "failed": failures }), + true, + )? + ); + } + if !failures.is_empty() { + anyhow::bail!( + "failed to remove {} volume(s):\n{}", + failures.len(), + failures.join("\n") + ); + } + Ok(()) +} + /// Checks whether the Docker sandbox backend can actually run on this /// machine, without starting a real sandboxed run to find out. Reports /// each check independently (the `docker` CLI on PATH, the daemon diff --git a/src/handlers/agent/mcp.rs b/src/handlers/agent/mcp.rs index 1bd0172b..55190442 100644 --- a/src/handlers/agent/mcp.rs +++ b/src/handlers/agent/mcp.rs @@ -681,6 +681,7 @@ async fn proxied_client( sandbox_dockerfile: profile.sandbox.dockerfile.clone(), sandbox_memory: profile.sandbox.memory.clone(), sandbox_cpus: profile.sandbox.cpus.clone(), + sandbox_isolated_paths: profile.sandbox.isolated_paths.clone(), }; let proxy = Proxy::start_with_port(secrets, policy, None, None).await?; let proxy_url = proxy.child_env()["HTTPS_PROXY"].clone(); @@ -834,6 +835,7 @@ async fn remote_proxied_client( sandbox_dockerfile: profile.sandbox.dockerfile.clone(), sandbox_memory: profile.sandbox.memory.clone(), sandbox_cpus: profile.sandbox.cpus.clone(), + sandbox_isolated_paths: profile.sandbox.isolated_paths.clone(), }; let proxy = Proxy::start_remote_with_port( RemoteProxyConfig { diff --git a/src/handlers/agent/validate.rs b/src/handlers/agent/validate.rs index 8c4f6843..4cb25759 100644 --- a/src/handlers/agent/validate.rs +++ b/src/handlers/agent/validate.rs @@ -433,6 +433,11 @@ fn validate_profile(profile: &AgentProfile) -> Vec { )); } } + for path in &profile.sandbox.isolated_paths { + if let Err(error) = crate::handlers::run::docker_sandbox::normalize_isolated_path(path) { + checks.push(fail("Sandbox isolated path", error)); + } + } let mut bindings: HashMap<&str, Vec<&str>> = HashMap::new(); let mut child_envs: HashMap<&str, Vec<&str>> = HashMap::new(); @@ -1223,6 +1228,7 @@ mod tests { }; profile.sandbox.memory = Some("not-a-memory-value".to_owned()); profile.sandbox.cpus = Some("not-a-number".to_owned()); + profile.sandbox.isolated_paths = vec!["../outside".to_owned()]; let checks = validate_profile(&profile); assert!(checks @@ -1231,6 +1237,9 @@ mod tests { assert!(checks .iter() .any(|check| check.status == Status::Fail && check.name == "Sandbox CPU limit")); + assert!(checks + .iter() + .any(|check| check.status == Status::Fail && check.name == "Sandbox isolated path")); } #[test] diff --git a/src/handlers/entry/root.rs b/src/handlers/entry/root.rs index 5040cb69..0cfbb070 100644 --- a/src/handlers/entry/root.rs +++ b/src/handlers/entry/root.rs @@ -802,6 +802,11 @@ pub async fn handle_cli(args: Cli) { if let Some(cpus) = &agent_run.docker_cpus { profile.sandbox.cpus = Some(cpus.clone()); } + for path in &agent_run.docker_isolated_paths { + if !profile.sandbox.isolated_paths.contains(path) { + profile.sandbox.isolated_paths.push(path.clone()); + } + } crate::handlers::agent::validate::ensure_profile_is_valid_for_run(&profile)?; // Egress policy is meaningful only when the child cannot opt out of // its proxy environment. Contain every session to the loopback @@ -1001,6 +1006,7 @@ pub async fn handle_cli(args: Cli) { sandbox_dockerfile: profile.sandbox.dockerfile.clone(), sandbox_memory: profile.sandbox.memory.clone(), sandbox_cpus: profile.sandbox.cpus.clone(), + sandbox_isolated_paths: profile.sandbox.isolated_paths.clone(), }; let policy_fingerprint = policy.fingerprint(); let profile_source = directory_source diff --git a/src/handlers/run/docker_sandbox.rs b/src/handlers/run/docker_sandbox.rs index 96bd7bc3..faf2e955 100644 --- a/src/handlers/run/docker_sandbox.rs +++ b/src/handlers/run/docker_sandbox.rs @@ -808,6 +808,7 @@ pub(crate) fn docker_run_command( agent_image: &str, memory_limit: Option<&str>, cpus_limit: Option<&str>, + isolated_paths: &[String], ) -> Result<(String, Vec), String> { let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/")); let cwd_str = cwd.to_string_lossy().into_owned(); @@ -879,6 +880,7 @@ pub(crate) fn docker_run_command( } append_filesystem_mounts(&mut args, &cwd_str, denied_read_paths, denied_write_paths)?; + append_isolated_path_mounts(&mut args, &cwd_str, isolated_paths)?; append_ca_bundle_mount(&mut args, &cwd_str, env_vars)?; // A named Docker volume, not a bind mount of the real host home @@ -942,6 +944,190 @@ pub(crate) fn docker_run_command( Ok(("docker".to_owned(), args)) } +/// Named volumes backing a profile's `sandbox.isolated_paths` all start +/// with this, and carry `ISOLATED_PATH_LABEL` (value: the repo-relative +/// path) plus `ISOLATED_PATH_REPO_LABEL` (value: the host repo path) so +/// `agent docker status`/`cleanup` can list them without guessing. +const ISOLATED_PATH_VOLUME_PREFIX: &str = "stashbase-isolated-"; +const ISOLATED_PATH_LABEL: &str = "stashbase.isolated-path"; +const ISOLATED_PATH_REPO_LABEL: &str = "stashbase.repo"; + +/// Normalizes a `sandbox.isolated_paths` entry (`./node_modules/` → +/// `node_modules`) and rejects anything that could escape the working +/// directory or shadow it entirely — absolute paths, `..` components, or +/// the working directory itself. +pub(crate) fn normalize_isolated_path(path: &str) -> Result { + let trimmed = path.trim().trim_end_matches('/'); + let trimmed = trimmed.strip_prefix("./").unwrap_or(trimmed); + if trimmed.is_empty() || trimmed == "." { + return Err(format!( + "isolated path '{path}' must name a directory inside the working directory, not the working directory itself" + )); + } + if trimmed.starts_with('/') { + return Err(format!( + "isolated path '{path}' must be relative to the working directory" + )); + } + if trimmed + .split('/') + .any(|part| part == ".." || part.is_empty()) + { + return Err(format!( + "isolated path '{path}' must not contain '..' or empty components" + )); + } + Ok(trimmed.to_owned()) +} + +/// Per-repo, per-path volume name: the same repo + path always maps to the +/// same volume (so installs persist across runs), while two repos — or two +/// isolated paths in one repo — never share one. +pub(crate) fn isolated_path_volume_name(cwd: &str, path: &str) -> String { + use sha2::{Digest, Sha256}; + let digest = Sha256::digest(format!("{cwd}\0{path}").as_bytes()); + let hex: String = digest + .iter() + .take(8) + .map(|byte| format!("{byte:02x}")) + .collect(); + format!("{ISOLATED_PATH_VOLUME_PREFIX}{hex}") +} + +/// Mounts a per-repo named volume over each isolated path, shadowing the +/// host's copy of that directory inside the container — e.g. so a macOS +/// host's `node_modules` (darwin-only native binaries) and the Linux +/// container's never overwrite each other. Must come after the working +/// directory's own bind mount. +fn append_isolated_path_mounts( + args: &mut Vec, + cwd: &str, + isolated_paths: &[String], +) -> Result<(), String> { + for path in isolated_paths { + let path = normalize_isolated_path(path)?; + args.extend([ + "-v".to_owned(), + format!("{}:{cwd}/{path}", isolated_path_volume_name(cwd, &path)), + ]); + } + Ok(()) +} + +/// Creates the volume for an isolated path if it doesn't exist yet. A +/// fresh named volume is root-owned, which the agent container can't write +/// to when it runs as the host uid (Linux) — so, like the persistent home +/// directory in the image, it's made world-writable once at creation time +/// by a short-lived root helper container. Returns whether it was newly +/// created (i.e. is empty and still needs an install). +pub(crate) fn ensure_isolated_path_volume( + cwd: &str, + path: &str, + image: &str, +) -> Result { + let path = normalize_isolated_path(path)?; + let name = isolated_path_volume_name(cwd, &path); + let exists = std::process::Command::new("docker") + .args(["volume", "inspect", &name]) + .output() + .map_err(|error| format!("failed to run `docker volume inspect`: {error}"))? + .status + .success(); + if exists { + return Ok(false); + } + let create = std::process::Command::new("docker") + .args([ + "volume", + "create", + "--label", + &format!("{ISOLATED_PATH_LABEL}={path}"), + "--label", + &format!("{ISOLATED_PATH_REPO_LABEL}={cwd}"), + &name, + ]) + .output() + .map_err(|error| format!("failed to run `docker volume create`: {error}"))?; + if !create.status.success() { + return Err(String::from_utf8_lossy(&create.stderr).trim().to_owned()); + } + let chmod = std::process::Command::new("docker") + .args([ + "run", + "--rm", + "--user", + "0", + "-v", + &format!("{name}:/isolated"), + "--entrypoint", + "chmod", + image, + "0777", + "/isolated", + ]) + .output() + .map_err(|error| format!("failed to prepare volume {name}: {error}"))?; + if !chmod.status.success() { + return Err(format!( + "failed to prepare volume {name}: {}", + String::from_utf8_lossy(&chmod.stderr).trim() + )); + } + Ok(true) +} + +pub(crate) struct IsolatedPathVolume { + pub name: String, + pub repo: String, + pub path: String, +} + +/// Every isolated-path volume on this machine, across all repos. +pub(crate) fn list_isolated_path_volumes() -> Result, String> { + let output = std::process::Command::new("docker") + .args([ + "volume", + "ls", + "--filter", + &format!("label={ISOLATED_PATH_LABEL}"), + "--format", + &format!( + "{{{{.Name}}}}\t{{{{.Label \"{ISOLATED_PATH_REPO_LABEL}\"}}}}\t{{{{.Label \"{ISOLATED_PATH_LABEL}\"}}}}" + ), + ]) + .output() + .map_err(|error| format!("failed to run `docker volume ls`: {error}"))?; + if !output.status.success() { + return Err(String::from_utf8_lossy(&output.stderr).trim().to_owned()); + } + Ok(String::from_utf8_lossy(&output.stdout) + .lines() + .filter_map(|line| { + let mut fields = line.split('\t'); + let name = fields.next()?.trim(); + if !name.starts_with(ISOLATED_PATH_VOLUME_PREFIX) { + return None; + } + Some(IsolatedPathVolume { + name: name.to_owned(), + repo: fields.next().unwrap_or("").to_owned(), + path: fields.next().unwrap_or("").to_owned(), + }) + }) + .collect()) +} + +pub(crate) fn remove_volume(name: &str) -> Result<(), String> { + let output = std::process::Command::new("docker") + .args(["volume", "rm", name]) + .output() + .map_err(|error| format!("failed to run `docker volume rm`: {error}"))?; + if !output.status.success() { + return Err(String::from_utf8_lossy(&output.stderr).trim().to_owned()); + } + Ok(()) +} + fn append_filesystem_mounts( args: &mut Vec, cwd: &str, @@ -1602,6 +1788,7 @@ mod tests { DEFAULT_SANDBOX_IMAGE, None, None, + &[], ) .unwrap(); @@ -1633,6 +1820,7 @@ mod tests { DEFAULT_SANDBOX_IMAGE, None, None, + &[], ) .unwrap(); let cwd = std::env::current_dir() @@ -1662,6 +1850,7 @@ mod tests { DEFAULT_SANDBOX_IMAGE, None, None, + &[], ) .unwrap(); assert!(args.contains(&"--tmpfs".to_owned())); @@ -1695,6 +1884,7 @@ mod tests { DEFAULT_SANDBOX_IMAGE, None, None, + &[], ) .unwrap(); assert!(!args.contains(&"--tmpfs".to_owned())); @@ -1740,6 +1930,7 @@ mod tests { DEFAULT_SANDBOX_IMAGE, None, None, + &[], ) .unwrap(); let nested_mount = args @@ -1770,6 +1961,7 @@ mod tests { DEFAULT_SANDBOX_IMAGE, None, None, + &[], ) .unwrap(); assert!( @@ -1798,6 +1990,7 @@ mod tests { DEFAULT_SANDBOX_IMAGE, None, None, + &[], ) .unwrap(); let mount_index = args.iter().position(|arg| arg == "-v").unwrap(); @@ -1827,6 +2020,7 @@ mod tests { DEFAULT_SANDBOX_IMAGE, None, None, + &[], ) .unwrap(); assert!(args.contains(&format!("GIT_AUTHOR_NAME={name}"))); @@ -1855,6 +2049,7 @@ mod tests { DEFAULT_SANDBOX_IMAGE, None, None, + &[], ) .unwrap(); assert!(args.contains(&"GIT_AUTHOR_NAME=Explicit Override".to_owned())); @@ -1893,6 +2088,7 @@ mod tests { DEFAULT_SANDBOX_IMAGE, None, None, + &[], ) .unwrap(); // Must mount only the exact file — mounting its parent directory @@ -1923,6 +2119,7 @@ mod tests { DEFAULT_SANDBOX_IMAGE, None, None, + &[], ); assert!(result.is_err()); } @@ -1945,6 +2142,7 @@ mod tests { DEFAULT_SANDBOX_IMAGE, None, None, + &[], ); assert!(result.is_err()); } @@ -1965,6 +2163,7 @@ mod tests { DEFAULT_SANDBOX_IMAGE, None, None, + &[], ) .unwrap(); let name_index = args.iter().position(|arg| arg == "--name").unwrap(); @@ -1987,6 +2186,7 @@ mod tests { DEFAULT_SANDBOX_IMAGE, None, None, + &[], ) .unwrap(); assert!(args.contains(&"-i".to_owned())); @@ -2009,6 +2209,7 @@ mod tests { DEFAULT_SANDBOX_IMAGE, None, None, + &[], ) .unwrap(); assert!(args.contains(&"-t".to_owned())); @@ -2030,12 +2231,101 @@ mod tests { DEFAULT_SANDBOX_IMAGE, None, None, + &[], ) .unwrap(); assert!(args.contains(&format!("{PERSISTENT_HOME_VOLUME}:{CONTAINER_HOME}"))); assert!(args.contains(&format!("HOME={CONTAINER_HOME}"))); } + #[test] + fn normalize_isolated_path_accepts_relative_dirs_and_strips_decoration() { + assert_eq!( + normalize_isolated_path("node_modules").unwrap(), + "node_modules" + ); + assert_eq!( + normalize_isolated_path("./node_modules/").unwrap(), + "node_modules" + ); + assert_eq!( + normalize_isolated_path("apps/web/node_modules").unwrap(), + "apps/web/node_modules" + ); + } + + #[test] + fn normalize_isolated_path_rejects_escapes_and_the_cwd_itself() { + for path in ["", ".", "./", "/abs", "../up", "a/../b", "a//b"] { + assert!(normalize_isolated_path(path).is_err(), "accepted {path:?}"); + } + } + + #[test] + fn isolated_path_volume_name_is_stable_and_distinct_per_repo_and_path() { + let a = isolated_path_volume_name("/repo/a", "node_modules"); + assert_eq!(a, isolated_path_volume_name("/repo/a", "node_modules")); + assert_ne!(a, isolated_path_volume_name("/repo/b", "node_modules")); + assert_ne!(a, isolated_path_volume_name("/repo/a", ".venv")); + assert!(a.starts_with(ISOLATED_PATH_VOLUME_PREFIX)); + } + + #[test] + fn docker_run_command_mounts_isolated_paths_over_the_working_directory() { + let network = DockerRunNetwork { + name: "n".to_owned(), + gateway_ip: "172.30.0.1".to_owned(), + }; + let (_, args) = docker_run_command( + "claude", + &network, + &[], + &[], + &std::collections::HashMap::new(), + false, + DEFAULT_SANDBOX_IMAGE, + None, + None, + &["./node_modules/".to_owned()], + ) + .unwrap(); + let cwd = std::env::current_dir() + .unwrap() + .to_string_lossy() + .into_owned(); + let isolated = format!( + "{}:{cwd}/node_modules", + isolated_path_volume_name(&cwd, "node_modules") + ); + let isolated_at = args.iter().position(|arg| *arg == isolated).unwrap(); + let cwd_at = args + .iter() + .position(|arg| *arg == format!("{cwd}:{cwd}")) + .unwrap(); + assert!(cwd_at < isolated_at); + } + + #[test] + fn docker_run_command_rejects_isolated_path_escaping_the_working_directory() { + let network = DockerRunNetwork { + name: "n".to_owned(), + gateway_ip: "172.30.0.1".to_owned(), + }; + assert!(docker_run_command( + "claude", + &network, + &[], + &[], + &std::collections::HashMap::new(), + false, + DEFAULT_SANDBOX_IMAGE, + None, + None, + &["../elsewhere".to_owned()], + ) + .is_err()); + } + #[test] fn docker_shell_command_mounts_home_volume_and_runs_bash_in_image() { let args = docker_shell_command_args("my/image:tag", false, true); @@ -2079,6 +2369,7 @@ mod tests { DEFAULT_SANDBOX_IMAGE, None, None, + &[], ) .unwrap(); let cap_drop_index = args.iter().position(|arg| arg == "--cap-drop").unwrap(); @@ -2112,6 +2403,7 @@ mod tests { DEFAULT_SANDBOX_IMAGE, None, None, + &[], ) .unwrap(); assert!(args.contains(&"--init".to_owned())); @@ -2135,6 +2427,7 @@ mod tests { DEFAULT_SANDBOX_IMAGE, None, None, + &[], ) .unwrap(); assert!(!args.contains(&"--memory".to_owned())); @@ -2157,6 +2450,7 @@ mod tests { DEFAULT_SANDBOX_IMAGE, Some("2g"), Some("1.5"), + &[], ) .unwrap(); let memory_index = args.iter().position(|arg| arg == "--memory").unwrap(); @@ -2181,6 +2475,7 @@ mod tests { DEFAULT_SANDBOX_IMAGE, None, None, + &[], ) .unwrap(); let network_index = args.iter().position(|arg| arg == "--network").unwrap(); @@ -2203,6 +2498,7 @@ mod tests { DEFAULT_SANDBOX_IMAGE, None, None, + &[], ) .unwrap(); // Restoring `--user` is safe here: the agent container never runs @@ -2243,6 +2539,7 @@ mod tests { DEFAULT_SANDBOX_IMAGE, None, None, + &[], ) .unwrap(); // Only the cwd mount and the persistent home volume mount should diff --git a/src/handlers/run/entry.rs b/src/handlers/run/entry.rs index 7cb3d2b8..7a024ed8 100644 --- a/src/handlers/run/entry.rs +++ b/src/handlers/run/entry.rs @@ -139,6 +139,7 @@ pub async fn handle_remote_agent_run( ); let sandbox_memory = policy.sandbox_memory.clone(); let sandbox_cpus = policy.sandbox_cpus.clone(); + let sandbox_isolated_paths = policy.sandbox_isolated_paths.clone(); let command_audit_log = audit_log.clone(); let mut setup_spinner: Option = None; let (docker_network, agent_image) = if backend == crate::models::agent::SandboxBackend::Docker { @@ -276,6 +277,7 @@ pub async fn handle_remote_agent_run( &agent_image, sandbox_memory.as_deref(), sandbox_cpus.as_deref(), + &sandbox_isolated_paths, ) .await; proxy.stop().await; @@ -1355,6 +1357,10 @@ async fn handle_run( let sandbox_cpus = proxy_policy .as_ref() .and_then(|policy| policy.sandbox_cpus.clone()); + let sandbox_isolated_paths = proxy_policy + .as_ref() + .map(|policy| policy.sandbox_isolated_paths.clone()) + .unwrap_or_default(); // Proxy mode gives the child placeholders, never the loaded secret values. // The temporary proxy owns the placeholder-to-secret mapping until the command exits. @@ -1500,6 +1506,7 @@ async fn handle_run( &agent_image, sandbox_memory.as_deref(), sandbox_cpus.as_deref(), + &sandbox_isolated_paths, )); let result = command.await; proxy.stop().await; diff --git a/src/handlers/run/proxy.rs b/src/handlers/run/proxy.rs index 4e77b82f..9b835993 100644 --- a/src/handlers/run/proxy.rs +++ b/src/handlers/run/proxy.rs @@ -679,6 +679,9 @@ pub struct ProxyPolicy { /// Docker backend only: `docker run --cpus` value, e.g. "1.5". No cap /// when unset. pub sandbox_cpus: Option, + /// Docker backend only: repo-relative directories backed by a per-repo + /// volume instead of the host's copy (see `append_isolated_path_mounts`). + pub sandbox_isolated_paths: Vec, } /// How a placeholder is represented in a child request and rewritten by the proxy. @@ -790,6 +793,7 @@ impl ProxyPolicy { sandbox_dockerfile: None, sandbox_memory: None, sandbox_cpus: None, + sandbox_isolated_paths: Vec::new(), } } @@ -823,6 +827,10 @@ impl ProxyPolicy { "sandbox_cpus={}", self.sandbox_cpus.as_deref().unwrap_or("") ), + format!( + "sandbox_isolated_paths={}", + self.sandbox_isolated_paths.join(",") + ), ]; let mut egress = normalize_hosts(self.allowed_egress_hosts.clone()) .into_iter() @@ -3553,6 +3561,7 @@ mod tests { sandbox_dockerfile: None, sandbox_memory: None, sandbox_cpus: None, + sandbox_isolated_paths: Vec::new(), } } @@ -3937,6 +3946,7 @@ mod tests { sandbox_dockerfile: None, sandbox_memory: None, sandbox_cpus: None, + sandbox_isolated_paths: Vec::new(), }; let proxy = Proxy::start_remote_with_port(remote, policy, None, None) .await @@ -4068,6 +4078,7 @@ mod tests { sandbox_dockerfile: None, sandbox_memory: None, sandbox_cpus: None, + sandbox_isolated_paths: Vec::new(), } } @@ -4585,6 +4596,7 @@ mod tests { sandbox_dockerfile: None, sandbox_memory: None, sandbox_cpus: None, + sandbox_isolated_paths: Vec::new(), }; assert!(policy_allows_connect(&policy, "api.github.com")); @@ -4631,6 +4643,7 @@ mod tests { sandbox_dockerfile: None, sandbox_memory: None, sandbox_cpus: None, + sandbox_isolated_paths: Vec::new(), } } @@ -4710,6 +4723,10 @@ mod tests { let mut docker_cpus = docker.clone(); docker_cpus.sandbox_cpus = Some("1.5".to_owned()); assert_ne!(docker.fingerprint(), docker_cpus.fingerprint()); + + let mut docker_isolated = docker.clone(); + docker_isolated.sandbox_isolated_paths = vec!["node_modules".to_owned()]; + assert_ne!(docker.fingerprint(), docker_isolated.fingerprint()); } #[test] @@ -4800,6 +4817,7 @@ mod tests { sandbox_dockerfile: None, sandbox_memory: None, sandbox_cpus: None, + sandbox_isolated_paths: Vec::new(), }; assert!(secret_allows_request( &policy, @@ -4927,6 +4945,7 @@ mod tests { sandbox_dockerfile: None, sandbox_memory: None, sandbox_cpus: None, + sandbox_isolated_paths: Vec::new(), }; let proxy = Proxy::start( HashMap::from([("GITHUB_TOKEN".to_owned(), "real-token".to_owned())]), @@ -4972,6 +4991,7 @@ mod tests { sandbox_dockerfile: None, sandbox_memory: None, sandbox_cpus: None, + sandbox_isolated_paths: Vec::new(), }; assert!(policy_allows_egress(&policy, "example.com")); @@ -5003,6 +5023,7 @@ mod tests { sandbox_dockerfile: None, sandbox_memory: None, sandbox_cpus: None, + sandbox_isolated_paths: Vec::new(), }; let state = ProxyState { secrets: Arc::new(HashMap::new()), @@ -5078,6 +5099,7 @@ mod tests { sandbox_dockerfile: None, sandbox_memory: None, sandbox_cpus: None, + sandbox_isolated_paths: Vec::new(), }; let proxy = Proxy::start( HashMap::from([("GITHUB_PAT_TOKEN".to_owned(), "real-token".to_owned())]), @@ -5133,6 +5155,7 @@ mod tests { sandbox_dockerfile: None, sandbox_memory: None, sandbox_cpus: None, + sandbox_isolated_paths: Vec::new(), }; let proxy = Proxy::start( HashMap::from([("GH_TOKEN".to_owned(), "real-token".to_owned())]), @@ -5500,6 +5523,7 @@ mod tests { sandbox_dockerfile: None, sandbox_memory: None, sandbox_cpus: None, + sandbox_isolated_paths: Vec::new(), }, None, ) @@ -5542,6 +5566,7 @@ mod tests { sandbox_dockerfile: None, sandbox_memory: None, sandbox_cpus: None, + sandbox_isolated_paths: Vec::new(), }, None, ) @@ -5585,6 +5610,7 @@ mod tests { sandbox_dockerfile: None, sandbox_memory: None, sandbox_cpus: None, + sandbox_isolated_paths: Vec::new(), }, None, ) diff --git a/src/handlers/run/subprocess.rs b/src/handlers/run/subprocess.rs index 706a6c6f..663e434b 100644 --- a/src/handlers/run/subprocess.rs +++ b/src/handlers/run/subprocess.rs @@ -102,6 +102,7 @@ pub async fn run_command_with_filesystem_policy( super::docker_sandbox::DEFAULT_SANDBOX_IMAGE, None, None, + &[], ) .await } @@ -130,6 +131,7 @@ pub async fn run_command_with_filesystem_policy_and_network( agent_image: &str, sandbox_memory: Option<&str>, sandbox_cpus: Option<&str>, + sandbox_isolated_paths: &[String], ) -> Result { let current_dir = env::current_dir()?; @@ -140,6 +142,19 @@ pub async fn run_command_with_filesystem_policy_and_network( let network = docker_network.context("Docker sandbox backend selected without a per-run network")?; let args = codex_args_forcing_full_access(command, args); + let cwd = current_dir.to_string_lossy(); + for path in sandbox_isolated_paths { + let created = + super::docker_sandbox::ensure_isolated_path_volume(&cwd, path, agent_image) + .map_err(|error| { + anyhow::anyhow!("failed to prepare isolated path '{path}': {error}") + })?; + if created { + eprintln!( + "Isolated path '{path}' is new and empty for this repo — run your install (e.g. `npm ci`) inside the sandbox first." + ); + } + } let (program, launcher_args) = super::docker_sandbox::docker_run_command( command, network, @@ -150,6 +165,7 @@ pub async fn run_command_with_filesystem_policy_and_network( agent_image, sandbox_memory, sandbox_cpus, + sandbox_isolated_paths, ) .map_err(|error| anyhow::anyhow!("failed to build Docker sandbox invocation: {error}"))?; return run_built_command( diff --git a/src/models/agent.rs b/src/models/agent.rs index 6f31ec46..7b05b3a5 100644 --- a/src/models/agent.rs +++ b/src/models/agent.rs @@ -135,6 +135,13 @@ pub struct AgentSandboxProfile { /// No cap by default, for the same reason as `memory`. #[serde(default)] pub cpus: Option, + /// Docker backend only: directories (relative to the working directory, + /// e.g. "node_modules") that get their own per-repo Docker volume + /// inside the container instead of the host's copy — so a macOS host + /// and the Linux container never overwrite each other's + /// platform-specific installs. + #[serde(default)] + pub isolated_paths: Vec, } /// Project/environment-backed secret bindings. Personal credentials deliberately