Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions architecture/sandbox.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
59 changes: 52 additions & 7 deletions crates/openshell-supervisor-process/src/bwrap_launcher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -398,6 +399,7 @@ fn prepare_full_proc() -> Result<()> {
}

struct ShellRequest {
version: u64,
command: String,
root: PathBuf,
socket: PathBuf,
Expand All @@ -421,12 +423,16 @@ fn parse_shell_request(request: &Value) -> Result<ShellRequest> {
];
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()
Expand All @@ -451,14 +457,25 @@ fn parse_shell_request(request: &Value) -> Result<ShellRequest> {
for folder in [
"workspace",
"agent",
"skills",
"memory",
"run",
"run/turns",
"run/scratch",
] {
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
Expand Down Expand Up @@ -514,6 +531,7 @@ fn parse_shell_request(request: &Value) -> Result<ShellRequest> {
.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,
Expand Down Expand Up @@ -598,10 +616,15 @@ fn run_shell(request: ShellRequest, stream: &UnixStream) -> Result<Value> {
] {
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),
Expand Down Expand Up @@ -667,7 +690,7 @@ fn run_shell(request: ShellRequest, stream: &UnixStream) -> Result<Value> {
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,
Expand All @@ -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",
Expand Down Expand Up @@ -733,7 +760,10 @@ fn run_shell_child(command: &str) -> Result<()> {
fn handle_request(stream: &UnixStream) -> Result<Value> {
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)
Expand Down Expand Up @@ -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);
Expand Down
6 changes: 6 additions & 0 deletions docs/security/best-practices.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions e2e/fixtures/computer-jail/README.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
22 changes: 20 additions & 2 deletions e2e/fixtures/computer-jail/probe.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@
"workspace",
"agent",
"skills",
"platform",
"platform/public",
"platform/public/skills",
"platform/private",
"memory",
"runtime",
"run",
Expand All @@ -23,16 +27,18 @@
(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))
os.chmod(turn_socket, 0o600)
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),
Expand All @@ -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'
)
Expand Down
Loading