Skip to content

Platform Isolation

An AI coding agent has write access to the filesystem and can execute shell commands. If the model is compromised or produces malicious code, the agent process is the attack surface. OS-level sandboxing constrains what the agent can do even if the model output is adversarial: a sandbox that allows writes only to the project directory cannot exfiltrate secrets from ~/.ssh/ regardless of what the model requests.

There are two categories of isolation:

Filesystem isolation restricts which paths the agent process can read or write. The goal is that even if the model generates a tool call writing to /etc/passwd, the write is rejected at the kernel level before the file is touched.

Network isolation restricts outbound connections. An agent that can only reach localhost:8080 (a local proxy) cannot exfiltrate data to an external server even if the model requests it.

Codex implements both on all three major platforms. Aider implements neither. OpenCode implements UI-level approval gates with no OS-level enforcement.


Aider has no OS-level sandboxing. There is no landlock, seccomp, sandbox-exec, or AppContainer in the codebase. File writes are unrestricted; network is unrestricted.

This is a documented limitation rather than an oversight. Aider’s security model is that the user reviews each proposed edit before it is applied and can /undo after the fact. The threat model assumes a non-adversarial model that might make mistakes but is not attempting exfiltration.

For users running Aider in sensitive environments, the recommended mitigation is to run Aider inside a container (Docker, distrobox) with bind mounts limited to the project directory.


Commit: 4ab44e2c5 (codex-rs workspace)

Codex implements a full three-platform sandboxing system. Each platform uses the native isolation primitive most appropriate for that OS.

Linux sandboxing is implemented in codex-rs/linux-sandbox/. It is a two-layer approach:

Layer 1 — Filesystem isolation via Bubblewrap (codex-rs/linux-sandbox/src/bwrap.rs):

Bubblewrap (bwrap) creates a new mount namespace and establishes a controlled view of the filesystem using bind mounts. The agent process sees the same directory tree but with modified permissions:

Mount order (codex-rs/linux-sandbox/src/bwrap.rs:134–205):
1. --ro-bind / /
Bind-mount the entire real filesystem read-only into the new namespace.
2. --bind <writable_root> <writable_root>
Re-bind each permitted root as read-write.
3. --ro-bind <protected_subpath> <protected_subpath>
Re-apply read-only protection to subpaths within writable roots.
(.git and .codex are always protected)
4. --dev-bind /dev/null /dev/null
Ensure /dev/null is writable (required by many programs).

The mount order is critical. Step 1 establishes the baseline (everything read-only). Step 2 pokes holes for permitted write paths. Step 3 closes holes for protected subpaths within those write paths. The .git and .codex directories are automatically listed as read_only_subpaths — the model can write to the project directory but cannot corrupt the git history or the agent’s own configuration.

Symlink attack mitigation (bwrap.rs:252–284):

If a writable root contains a symlink (e.g., .codex -> /home/user/.secrets), a naive bind mount would follow the symlink and expose the target. Bubblewrap’s find_symlink_in_path() detects this and mounts /dev/null over the symlink itself:

// If .codex is a symlink inside the writable root, mount /dev/null on it.
// This blocks the agent from reading or writing through the symlink.
cmd.args(["--bind", "/dev/null", symlink_path])

Missing path prevention (bwrap.rs:290–315):

A protected path that does not yet exist (e.g., .codex/config.toml on first run) cannot be mounted. find_first_non_existent_component() finds the deepest existing ancestor and mounts /dev/null on that instead, preventing the agent from creating the protected path hierarchy.

Layer 2 — Network isolation via Seccomp (codex-rs/linux-sandbox/src/landlock.rs:143–202):

Seccomp (Secure Computing mode) installs a BPF filter that intercepts system calls before they reach the kernel. Codex uses an allowlist-with-exceptions approach: the default action is Allow, but specific network syscalls return EPERM:

Blocked SyscallPurpose
SYS_connectNo outbound TCP/UDP
SYS_accept, SYS_accept4No inbound connections
SYS_bind, SYS_listenNo port binding
SYS_sendto, SYS_sendmmsgNo packet sending
SYS_recvmmsgNo multi-message receive
SYS_getsockopt, SYS_setsockoptNo socket options
SYS_ptraceNo tracing (prevents seccomp bypass)
SYS_io_uring_setup/enter/registerNo async I/O bypass

SYS_socket (socket creation) is handled with a conditional rule (landlock.rs:173–182): AF_UNIX domain sockets are permitted (needed for IPC, dbus, named pipes), but all other domains (AF_INET, AF_INET6, AF_NETLINK) are denied:

SeccompRule::new(vec![
Cmp::new(0, CmpOp::Ne, AF_UNIX as u64, None) // arg0 != AF_UNIX → EPERM
])

The filter targets both x86_64 and aarch64. It is implemented with the seccompiler crate and compiled to a BPF program applied via prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, ...).

PR_SET_NO_NEW_PRIVS (landlock.rs:92–99):

fn set_no_new_privs() -> Result<()> {
unsafe { libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) };
Ok(())
}

This flag prevents the sandboxed process from gaining elevated privileges via setuid binaries or capability-granting file attributes. It must be set before installing the seccomp filter. It is irreversible within the process lifetime.

Landlock (Legacy) (landlock.rs:110–136):

Landlock is an in-kernel, unprivileged filesystem sandboxing mechanism (available since Linux 5.13). Codex includes a Landlock implementation as a backup for environments where bubblewrap is unavailable:

Ruleset::default()
.set_compatibility(CompatLevel::BestEffort) // degrade gracefully on older kernels
.handle_access(access_rw)?
.create()?
.add_rules(path_beneath_rules(&["/"], access_ro))? // entire FS: read-only
.add_rules(path_beneath_rules(&["/dev/null"], access_rw))? // /dev/null: rw
.add_rules(path_beneath_rules(&writable_roots, access_rw))? // project: rw

CompatLevel::BestEffort means Landlock degrades silently on kernels older than 5.13 rather than aborting — the agent runs with reduced isolation rather than failing to start.

Two-stage execution flow (linux-sandbox/src/linux_run_main.rs:63–218):

Bubblewrap must be invoked before PR_SET_NO_NEW_PRIVS because setuid programs fail after that flag is set. Codex uses a two-stage pipeline:

  1. The sandbox binary invokes bwrap to establish the namespace
  2. Inside the bwrap environment, the sandbox binary re-enters itself with --inner flag
  3. The inner invocation sets PR_SET_NO_NEW_PRIVS, installs the seccomp filter, then replaces itself with the user’s command via execve()

A proc mount fallback handles restrictive container environments (lines 143–218): first attempts --proc /proc for PID namespace isolation; if that fails with “Invalid argument”, retries with --no-proc.

Seatbelt is macOS’s kernel-level sandbox, accessed via /usr/bin/sandbox-exec. Codex drives it from codex-rs/core/src/seatbelt.rs.

Policy delivery (seatbelt.rs:24–25):

Two policy files are embedded in the binary at compile time:

const MACOS_SEATBELT_BASE_POLICY: &str = include_str!("seatbelt_base_policy.sbpl");
const MACOS_SEATBELT_NETWORK_POLICY: &str = include_str!("seatbelt_network_policy.sbpl");

The full policy is assembled dynamically at runtime: base policy + computed filesystem rules + computed network rules. It is passed to the OS via the -p flag (inline policy string) plus -D flags for path variable substitution.

Base policy (seatbelt_base_policy.sbpl):

(version 1)
(deny default) ; closed-by-default: deny everything not explicitly allowed
(allow process-exec) ; spawn child processes
(allow process-fork) ; fork()
(allow signal (target same-sandbox))
; /dev/null writes (required by many programs):
(allow file-write-data
(require-all (path "/dev/null") (vnode-type CHARACTER-DEVICE)))
; PTY support (terminal rendering):
(allow pseudo-tty)
(allow file-read* file-write* file-ioctl (literal "/dev/ptmx"))
; sysctl allowlist: CPU info, memory info, kernel version
; Mach lookup: user directory service, power management
(allow ipc-posix-sem) ; Python multiprocessing semaphores
(allow mach-lookup
(global-name "com.apple.system.opendirectoryd.libinfo")
(global-name "com.apple.PowerManagement.control"))

Dynamic filesystem rules (seatbelt.rs:203–294):

Write access is computed per-writable-root. For a project at /Users/user/project with .git and .codex protected:

(allow file-write*
(require-all
(subpath (param "WRITABLE_ROOT_0"))
(require-not (subpath (param "WRITABLE_ROOT_0_RO_0")))
(require-not (subpath (param "WRITABLE_ROOT_0_RO_1")))))

Where -DWRITABLE_ROOT_0=/Users/user/project, -DWRITABLE_ROOT_0_RO_0=/Users/user/project/.git, -DWRITABLE_ROOT_0_RO_1=/Users/user/project/.codex.

Paths are canonicalized before injection (seatbelt.rs:217–222) to resolve macOS’s /var/private/var symlink. A rule for /var/folders/... will silently fail to match /private/var/folders/... if not canonicalized.

Dynamic network rules (seatbelt_network_policy.sbpl + seatbelt.rs:134–176):

The network policy file enables Mach lookups needed for TLS (networkd, trustd, SecurityServer) and writes to the Darwin user cache directory for TLS session caching:

(allow system-socket
(require-all (socket-domain AF_SYSTEM) (socket-protocol 2)))
(allow mach-lookup
(global-name "com.apple.networkd")
(global-name "com.apple.ocspd")
(global-name "com.apple.trustd.agent")
(global-name "com.apple.SecurityServer"))
(allow file-write* (subpath (param "DARWIN_USER_CACHE_DIR")))

When a proxy is configured, outbound is restricted to specific loopback ports:

(allow network-outbound (remote ip "localhost:8080"))

Fail-closed behavior (seatbelt.rs:156–166):

If network isolation is required but no valid proxy ports are resolved, the dynamic policy returns an empty string. With (deny default) as the base, all network calls then return EPERM. This ensures misconfiguration results in denied network rather than accidentally permitted network.

Full command construction (seatbelt.rs:317–332):

/usr/bin/sandbox-exec \
-p "(version 1)(deny default)..." \
-DWRITABLE_ROOT_0=/Users/user/project \
-DWRITABLE_ROOT_0_RO_0=/Users/user/project/.git \
-DDARWIN_USER_CACHE_DIR=/Users/user/Library/Caches \
-- /bin/bash <agent-command>

Windows sandboxing is in codex-rs/windows-sandbox-rs/. It uses two mechanisms: a restricted access token and ACL-based filesystem restrictions.

RestrictedToken (windows-sandbox-rs/src/token.rs):

// CreateRestrictedToken with these flags:
DISABLE_MAX_PRIVILEGE // 0x01 — strips all elevated privileges from the token
LUA_TOKEN // 0x04 — limited user account semantics
WRITE_RESTRICTED // 0x08 — all writes require explicit DACL grants

A SetDefaultDacl() call (token.rs:52–106) adds GENERIC_ALL for specific SIDs — required for PowerShell and subprocess pipe creation, which internally create kernel objects that need the process’s DACL.

ACL evaluation (windows-sandbox-rs/src/acl.rs):

fn dacl_mask_allows(p_dacl, sids, desired_mask, require_all_bits) -> bool {
// Iterate DACL ACEs; skip INHERIT_ONLY entries
// For ACEs whose SID matches the token's SID list:
// apply GENERIC_MAPPING to expand generic rights
// check if access_mask grants desired_mask
}

GetSecurityInfo() retrieves the DACL from a file handle, and SetNamedSecurityInfoW() applies deny ACEs to restrict paths. The dacl_mask_allows() predicate determines at policy-evaluation time whether a given path is writable under the restricted token.

Sandbox modes (core/src/windows_sandbox.rs:19–44):

pub enum WindowsSandboxLevel {
Elevated, // Job Objects (stronger isolation, requires admin setup)
RestrictedToken, // Token-based only (always available, default for non-admin)
Disabled,
}

Elevated mode adds a Job Object with process limits and no-breakaway restrictions for stronger containment but requires an elevated parent process to configure the Job Object before the agent starts.


OpenCode has no OS-level sandboxing. The permission system is entirely a UI-layer approval mechanism.

Architecture (packages/opencode/src/permission/next.ts):

export const ask = async (input: AskInput) => {
const ruleset = await currentRuleset()
for (const pattern of input.patterns ?? []) {
const rule = evaluate(input.permission, pattern, ruleset, approved)
if (rule.action === "deny")
throw new DeniedError(...)
if (rule.action === "ask")
return new Promise((resolve, reject) => {
pending[id] = { info, resolve, reject }
Bus.publish(Event.Asked, info) // TUI shows approval prompt
})
if (rule.action === "allow")
continue
}
}

Rules are matched by permission type ("fs.write") and path pattern ("~/projects/*"). Three actions: allow (proceed silently), ask (TUI prompt), deny (throw immediately).

No enforcement: there is no kernel-level mechanism backing these rules. A tool that calls ctx.ask() honors the policy; a tool implementation that bypasses the call faces no OS-level enforcement. The system provides visibility and consent, not containment.

Persistent approvals (next.ts:196–214): when a user selects “Always allow”, the approval is stored in the SQLite database scoped to the project and replayed on subsequent runs without prompting.


Bubblewrap Requires Unprivileged User Namespaces

Section titled “Bubblewrap Requires Unprivileged User Namespaces”

bwrap needs CONFIG_USER_NS=y and sysctl kernel.unprivileged_userns_clone=1. Some hardened kernels (Debian stable, RHEL with certain sysctl settings) disable this. Codex checks for bwrap availability at runtime and falls back to Landlock when unavailable.

The seccomp BPF program must be compiled for the target CPU architecture. A filter compiled for x86_64 silently fails on ARM64. Codex compiles two variants gated on cfg(target_arch). If the architecture is unrecognized, the filter is not applied — this is a silent degradation and should be logged as a warning.

Seatbelt Path Canonicalization Is Mandatory on macOS

Section titled “Seatbelt Path Canonicalization Is Mandatory on macOS”

macOS’s /var is a symlink to /private/var. A Seatbelt rule for /var/folders/... will not match the real path /private/var/folders/.... Failing to canonicalize paths before injecting them as -D parameters produces rules that appear correct in logs but silently fail to match at runtime.

Seatbelt Fail-Closed Network Creates Confusing Errors

Section titled “Seatbelt Fail-Closed Network Creates Confusing Errors”

When the proxy address resolution fails silently (environment variable unset, DNS unavailable), the agent runs with full filesystem isolation but no network access. Model API calls fail with EPERM — a syscall error with no indication that sandboxing is the cause. Error messages should explicitly check for EPERM on connect() and surface “sandbox: network blocked” as a distinct error code.

Windows RestrictedToken and Subprocess Pipes

Section titled “Windows RestrictedToken and Subprocess Pipes”

PowerShell and cmd.exe spawn subprocesses that inherit the restricted token. Pipe creation for stdin/stdout uses kernel objects that require the process’s DACL. Without the SetDefaultDacl() step, pipe creation fails with ERROR_ACCESS_DENIED. This failure mode is not obvious from the error message alone.

OpenCode: Permission Gates Are a UX Feature, Not a Security Boundary

Section titled “OpenCode: Permission Gates Are a UX Feature, Not a Security Boundary”

Running OpenCode inside a container or VM is necessary for any meaningful security guarantee. The permission system communicates intent and gives the user visibility into what the model wants to do, but it does not prevent a tool from writing to arbitrary paths if the tool implementation bypasses ctx.ask().


OpenOxide should implement all three platform sandboxes as first-class features, vendoring Codex’s Linux sandbox crate where possible.

[target.'cfg(target_os = "linux")'.dependencies]
landlock = "0.4" # Landlock filesystem rules (kernel 5.13+)
seccompiler = "0.4" # BPF seccomp filter compilation
libc = "0.2" # prctl(), seccomp install
# For bubblewrap: invoke as a subprocess (no crate needed)
# Vendor codex-rs/linux-sandbox directly
[target.'cfg(target_os = "macos")'.dependencies]
# No additional crates: drive /usr/bin/sandbox-exec as a subprocess
# Embed .sbpl policy files with include_str!()
[target.'cfg(target_os = "windows")'.dependencies]
windows = { version = "0.58", features = [
"Win32_Security",
"Win32_System_Threading",
"Win32_Foundation",
]}
pub struct SandboxPolicy {
pub writable_roots: Vec<WritableRoot>,
pub network: NetworkPolicy,
}
pub struct WritableRoot {
pub path: PathBuf,
pub read_only_subpaths: Vec<PathBuf>, // always includes .git, .openoxide
}
pub enum NetworkPolicy {
Full, // development mode only
None, // full block
Proxy { host: String, ports: Vec<u16> }, // loopback proxy
}
pub trait PlatformSandbox: Send + Sync {
fn wrap_command(&self, policy: &SandboxPolicy, cmd: Command) -> Result<Command, SandboxError>;
}
pub fn default_policy(root: &Path) -> SandboxPolicy {
SandboxPolicy {
writable_roots: vec![WritableRoot {
path: root.canonicalize().unwrap_or_else(|_| root.to_owned()),
read_only_subpaths: vec![
root.join(".git"),
root.join(".openoxide"),
],
}],
network: NetworkPolicy::Proxy {
host: "localhost".into(),
ports: vec![8080],
},
}
}
  1. Detect bwrap at startup via which bwrap
  2. If present: bubblewrap for filesystem + seccomp for network (full isolation)
  3. If absent: Landlock + seccomp (reduced filesystem isolation, no namespace)
  4. If neither: log warning, continue without filesystem sandbox (network-only via seccomp)
  1. Check for /usr/bin/sandbox-exec (always present on macOS 10.5+)
  2. Build policy string from SandboxPolicy — use same template structure as Codex
  3. Canonicalize all paths before injection
  4. Pass policy inline via -p (avoids temp file creation and cleanup)
openoxide --sandbox=strict # default: writable_roots + proxy only
openoxide --sandbox=relaxed # full filesystem, proxy only
openoxide --sandbox=off # no sandboxing (development/CI)

Surface active sandbox mode in the TUI status bar so users know their isolation level at a glance.