From eefd8361d6ceb81b6079063142d729480ee20fbe Mon Sep 17 00:00:00 2001 From: Nave Cohen Date: Thu, 8 Oct 2026 17:35:39 +0300 Subject: [PATCH] feat(sandbox): add read-only platform mount in protocol 4 (task 33.05.01) Signed-off-by: Nave Cohen --- architecture/sandbox.md | 12 ++++ .../src/bwrap_launcher.rs | 59 ++++++++++++++++--- docs/security/best-practices.mdx | 6 ++ e2e/fixtures/computer-jail/README.md | 5 +- e2e/fixtures/computer-jail/probe.py | 22 ++++++- 5 files changed, 93 insertions(+), 11 deletions(-) diff --git a/architecture/sandbox.md b/architecture/sandbox.md index fde042b6af..a77339acda 100644 --- a/architecture/sandbox.md +++ b/architecture/sandbox.md @@ -532,3 +532,15 @@ engine with a gateway policy revision. reports its normalized exit code before shutdown. The gateway persists the code on sandbox status, records `MainProcessExited`, and makes the sandbox terminal `Error`; runtime restart policies must not replace the process. + +Protocol 4 uses the same closed shell request and isolation boundaries as protocol 3, +but binds only `agent_root/platform/public/` read-only at `/platform` instead of +`skills/` at `/skills`. Both platform directories must be owned directories without +symlink traversal. Runtime-only prompts and indexes belong outside `public/`; neither +the enclosing platform root nor the database is mounted. Protocol 3 retains its old +layout. Replies echo the requested version; unknown versions fail explicitly. + +The personal-assistant platform bundle requires a launcher implementing protocol 4 +in both Docker and hosted environments. An earlier launcher rejects that request; +runtimes must not fall back to protocol 3. This protocol addition does not publish a +fork release or update deployment pins. diff --git a/crates/openshell-supervisor-process/src/bwrap_launcher.rs b/crates/openshell-supervisor-process/src/bwrap_launcher.rs index d6916113a4..0373720208 100644 --- a/crates/openshell-supervisor-process/src/bwrap_launcher.rs +++ b/crates/openshell-supervisor-process/src/bwrap_launcher.rs @@ -40,6 +40,7 @@ const ENABLE_ENV: &str = "OPENSHELL_EXPERIMENTAL_BWRAP_LAUNCHER"; const PROTOCOL_VERSION: u64 = 1; const FILES_PROTOCOL_VERSION: u64 = 2; const SHELL_PROTOCOL_VERSION: u64 = 3; +const PLATFORM_PROTOCOL_VERSION: u64 = 4; const SHELL_TIMEOUT_SECONDS: u64 = 900; const MAX_CONNECTIONS: usize = 4; pub const BWRAP_LAUNCHER_SUBCOMMAND: &str = "experimental-bwrap-launcher"; @@ -398,6 +399,7 @@ fn prepare_full_proc() -> Result<()> { } struct ShellRequest { + version: u64, command: String, root: PathBuf, socket: PathBuf, @@ -421,12 +423,16 @@ fn parse_shell_request(request: &Value) -> Result { ]; if fields.len() != keys.len() || !keys.iter().all(|key| fields.contains_key(*key)) - || request["protocol_version"].as_u64() != Some(SHELL_PROTOCOL_VERSION) + || !matches!( + request["protocol_version"].as_u64(), + Some(SHELL_PROTOCOL_VERSION | PLATFORM_PROTOCOL_VERSION) + ) { return Err(miette::miette!( - "protocol 3 requires only command, agent_root, turn_socket, cwd and timeout_seconds" + "protocol 3/4 requires only command, agent_root, turn_socket, cwd and timeout_seconds" )); } + let version = request["protocol_version"].as_u64().unwrap(); let string = |key: &str| -> Result<&str> { request[key] .as_str() @@ -451,7 +457,6 @@ fn parse_shell_request(request: &Value) -> Result { for folder in [ "workspace", "agent", - "skills", "memory", "run", "run/turns", @@ -459,6 +464,18 @@ fn parse_shell_request(request: &Value) -> Result { ] { check_owned_directory(&root.join(folder))?; } + let content = if version == PLATFORM_PROTOCOL_VERSION { + check_owned_directory(&root.join("platform"))?; + "platform/public" + } else { + "skills" + }; + check_owned_directory(&root.join(content))?; + if fs::canonicalize(root.join(content)).into_diagnostic()? != root.join(content) { + return Err(miette::miette!( + "platform content must not traverse a symlink" + )); + } let socket = PathBuf::from(string("turn_socket")?); if socket.parent() != Some(root.join("run/turns").as_path()) || fs::canonicalize(&socket).into_diagnostic()? != socket @@ -514,6 +531,7 @@ fn parse_shell_request(request: &Value) -> Result { .filter(|value| *value > 0) .ok_or_else(|| miette::miette!("timeout_seconds must be a positive integer"))?; Ok(ShellRequest { + version, command: string("command")?.to_owned(), root, socket, @@ -598,10 +616,15 @@ fn run_shell(request: ShellRequest, stream: &UnixStream) -> Result { ] { command.args(["--ro-bind-try", path, path]); } + let (content, content_target) = if request.version == PLATFORM_PROTOCOL_VERSION { + ("platform/public", "/platform") + } else { + ("skills", "/skills") + }; for (source, target, writable) in [ (request.root.join("workspace"), "/files", true), (request.root.join("agent"), "/agent", true), - (request.root.join("skills"), "/skills", false), + (request.root.join(content), content_target, false), (request.root.join("memory"), "/memory", false), (request.scratch, "/tmp", true), (request.socket, "/run/tools.sock", true), @@ -667,7 +690,7 @@ fn run_shell(request: ShellRequest, stream: &UnixStream) -> Result { let (stderr, stderr_truncated) = stderr .join() .map_err(|_| miette::miette!("stderr reader failed"))??; - Ok(json!({"protocol_version": SHELL_PROTOCOL_VERSION, + Ok(json!({"protocol_version": request.version, "status": if timed_out { "timed_out" } else if status.success() { "succeeded" } else { "failed" }, "exit_code": status.code(), "stdout": stdout, "stderr": stderr, "stdout_truncated": stdout_truncated, "stderr_truncated": stderr_truncated, @@ -691,7 +714,11 @@ fn run_shell_child(command: &str) -> Result<()> { read_only: [ "/usr", "/etc", - "/skills", + if Path::new("/platform").exists() { + "/platform" + } else { + "/skills" + }, "/memory", "/proc", "/dev/urandom", @@ -733,7 +760,10 @@ fn run_shell_child(command: &str) -> Result<()> { fn handle_request(stream: &UnixStream) -> Result { let request = read_request(stream)?; let request: Value = serde_json::from_slice(&request).into_diagnostic()?; - if request.get("protocol_version").and_then(Value::as_u64) == Some(SHELL_PROTOCOL_VERSION) { + if matches!( + request.get("protocol_version").and_then(Value::as_u64), + Some(SHELL_PROTOCOL_VERSION | PLATFORM_PROTOCOL_VERSION) + ) { return run_shell(parse_shell_request(&request)?, stream); } run_code(request) @@ -1014,6 +1044,21 @@ mod tests { let parsed = parse_shell_request(&request).unwrap(); assert_eq!(parsed.timeout, Duration::from_secs(900)); assert_eq!(parsed.cwd, Path::new("/files")); + request["protocol_version"] = json!(4); + assert!(parse_shell_request(&request).is_err()); + fs::create_dir(root.path().join("platform")).unwrap(); + std::os::unix::fs::symlink( + root.path().join("skills"), + root.path().join("platform/public"), + ) + .unwrap(); + assert!(parse_shell_request(&request).is_err()); + fs::remove_file(root.path().join("platform/public")).unwrap(); + fs::create_dir(root.path().join("platform/public")).unwrap(); + assert_eq!(parse_shell_request(&request).unwrap().version, 4); + request["protocol_version"] = json!(5); + assert!(parse_shell_request(&request).is_err()); + request["protocol_version"] = json!(3); request["timeout_seconds"] = json!(0); assert!(parse_shell_request(&request).is_err()); request["timeout_seconds"] = json!(30); diff --git a/docs/security/best-practices.mdx b/docs/security/best-practices.mdx index 88c4602db2..db6859b2db 100644 --- a/docs/security/best-practices.mdx +++ b/docs/security/best-practices.mdx @@ -68,6 +68,12 @@ blocks internet sockets and further user namespaces, sets 4096 open files and root setup with `SYS_ADMIN` to prepare the parent procfs before its normal privilege drop. Full-shell setup fails closed if a fresh child procfs is denied. +Protocol 4 retains those limits and replaces the legacy `/skills` mount with +read-only `/platform`, sourced only from the agent root's `platform/public/`. +Keep private role prompts and indexes outside that projection. Protocol 3 clients +retain their original layout. A runtime using the new layout requires a protocol-4 +launcher and must reject incompatible replies instead of falling back. + This integration supports runc. Its namespaces and security filters share the host kernel; they do not provide a virtual-machine boundary. Keep the host kernel, container runtime, Bubblewrap, and supervisor patched, and retain the diff --git a/e2e/fixtures/computer-jail/README.md b/e2e/fixtures/computer-jail/README.md index 6c247e3350..c6576254cf 100644 --- a/e2e/fixtures/computer-jail/README.md +++ b/e2e/fixtures/computer-jail/README.md @@ -1,9 +1,10 @@ # Computer jail acceptance (33.03.03) -Run this fixture as the supervisor's ordinary process entrypoint, with protocol v3 +Run this fixture as the supervisor's ordinary process entrypoint, with protocol v3/v4 launcher enabled and the policy in this folder. It proves a command longer than five seconds, child processes, Python/Node/git, asyncio, the private filesystem -boundary, read-only skills, internet socket denial, output caps, timeout kills, and cleanup when a turn disconnects. +boundary, read-only legacy skills and platform projection, excluded private assets, +unknown-version rejection, internet socket denial, output caps, timeout kills, and cleanup when a turn disconnects. The companion computer-spike fixture proves Office/browser feasibility separately. The container must start the supervisor as root with SYS_ADMIN and an outer diff --git a/e2e/fixtures/computer-jail/probe.py b/e2e/fixtures/computer-jail/probe.py index eaa9a20cb4..70b38fa087 100644 --- a/e2e/fixtures/computer-jail/probe.py +++ b/e2e/fixtures/computer-jail/probe.py @@ -13,6 +13,10 @@ "workspace", "agent", "skills", + "platform", + "platform/public", + "platform/public/skills", + "platform/private", "memory", "runtime", "run", @@ -23,6 +27,8 @@ (root / folder).mkdir(mode=0o700) (root / "runtime/data.db").write_text("private database") (root / "skills/test.md").write_text("read-only skill") +(root / "platform/public/skills/test.md").write_text("platform skill") +(root / "platform/private/main.md").write_text("runtime-only prompt") turn_socket = root / "run/turns/turn.sock" listener = socket.socket(socket.AF_UNIX) listener.bind(str(turn_socket)) @@ -30,9 +36,9 @@ listener.listen() -def run(command, timeout=30): +def run(command, timeout=30, version=3): request = { - "protocol_version": 3, + "protocol_version": version, "command": command, "agent_root": str(root), "turn_socket": str(turn_socket), @@ -52,6 +58,18 @@ def run(command, timeout=30): return json.loads(response) +result = run("cat /platform/skills/test.md; test ! -e /platform/private && test ! -e /runtime && test ! -e /skills; echo boundary:$?; echo edit >> /platform/skills/test.md", version=4) +assert result["protocol_version"] == 4 and "platform skill" in result["stdout"] and "boundary:0" in result["stdout"] and result["exit_code"] != 0, result +assert run("true", version=5)["status"] == "launcher_error" +# A client cannot redirect the fixed public source through a symlink. +public = root / "platform/public" +public.rename(root / "platform/saved") +public.symlink_to(root / "runtime", target_is_directory=True) +assert run("true", version=4)["status"] == "launcher_error" +public.unlink() +assert run("true", version=4)["status"] == "launcher_error" +(root / "platform/saved").rename(public) + result = run( 'sleep 6; git --version; python --version; node --version; python -c "import asyncio; asyncio.run(asyncio.sleep(0))"; echo computer-ready' )