Sandboxing
WorkspaceSandbox six-step pipeline, platform-native enforcement (macOS / Linux / Windows), [sandbox.command_policy] hardline command filter
Overview
The Sandbox trait (src/sandbox/mod.rs) is the single seam between
exec-class tools and the operating system:
#[async_trait]
pub trait Sandbox: Send + Sync + 'static {
async fn execute(&self, command: SandboxCommand)
-> Result<SandboxOutput, SandboxError>;
}Production boots an Arc<dyn Sandbox> pointing at WorkspaceSandbox
(src/sandbox/workspace.rs), which owns three concerns:
- Workspace provisioning — materialize
~/.aleph/workspaces/{hash(session_id)}/on first exec, keep it alive for the session, reuse on subsequent calls. - Capability enforcement — classify every
SandboxCommandagainst the session's baselineSandboxCapabilities, escalate out-of-baseline requests throughApprovalGate, cache per-session grants. - OS isolation — delegate actual subprocess launch to an
OsSandboxDriverTraitimplementation (macOS:sandbox-exec+ Seatbelt; Linux:bwrap+ Landlock + seccomp-bpf + cgroup v2; Windows:AppContainer/RestrictedToken/JobObject).
exec-class tool (bash_exec, code_exec, …)
│ Arc<dyn Sandbox>
▼
WorkspaceSandbox ──► ApprovalGate (capability elevation)
│
│ OsSandboxDriverTrait
▼
OsSandboxDriver ──► macOS sandbox-exec / Linux bwrap / Windows 3-tier soft-degradeSource locations:
- Trait + re-exports + test helper:
src/sandbox/mod.rs - Six-step pipeline:
src/sandbox/workspace.rs - Command-policy layer:
src/sandbox/command_policy/(rules.rs,normalize.rs,hooks.rs) - Capabilities:
src/sandbox/capabilities.rs(SandboxCapabilities+NetworkPolicy+is_within) - OS driver:
src/sandbox/driver.rs(trait) + platform implementations - Factory:
src/sandbox/factory.rs - Config:
src/sandbox/config.rs - Task-local
SESSION_ID:src/sandbox/context.rs - Managed proxy:
src/sandbox/proxy/(HTTP CONNECT + SOCKS5) - Linux / macOS / Windows adapters:
src/sandbox/platforms/
Lifecycle — per-session workspace
WorkspaceSandbox keeps a HashMap<SessionId, Arc<SessionWorkspace>>
behind an RwLock. for_session(&sid) is the entry point:
- Fast path:
read().await→ cache hit → return the existingArc<SessionWorkspace>. - Slow path:
write().await→ double-check (another task may have created it) →tokio::fs::create_dir_all(cwd)→ insert into the map.
The on-disk path is deterministic:
workspace_root / session_key_to_filename(session_id)session_key_to_filename (src/sandbox/workspace.rs:114) SHA-256s the
JSON-serialized SessionId and truncates to 16 bytes (32 hex chars) —
short and safe across every SessionKey variant regardless of the
characters those variants may carry.
Each SessionWorkspace carries:
cwd: PathBuf— the materialized directorybaseline: SandboxCapabilities— policy ceiling (today:::strict())granted_elevations: RwLock<HashSet<SandboxCapabilities>>— per-session cache of approvals the user has already granted
The six-step execute pipeline
WorkspaceSandbox::execute (src/sandbox/workspace.rs:124) implements
the spec §8 pipeline:
- Session resolve —
self.for_session(&cmd.session_id)→ lazy dir creation on first call; cachedArc<SessionWorkspace>otherwise. - cwd validate —
cmd.cwdis eitherNone(defaults to workspace root) or mustcanonicalize + starts_with(&ws.cwd)(BUG-3 hardening: a symlink cannot escape). Anything else returnsSandboxError::CapabilityDenied { reason: "cwd outside workspace root" }. - Capability check —
cmd.capabilities.is_within(&ws.baseline)is the fast path (no approval). Otherwise consultgranted_elevations; if the request is within a prior grant, pass. Otherwise askApprovalGate::request_approval_for_tool.ApprovalOutcome::Approved→ insertcmd.capabilitiesintogranted_elevations(future same-or-narrower requests are cached).ApprovalOutcome::Denied|Timeout→SandboxError::CapabilityDenied.
- Profile generate —
os_driver.profile_for(&caps, &cwd)returns an opaqueOsSandboxProfile(on macOS, SBPL profile text). - Run —
os_driver.run(program, args, env, stdin, cwd, profile, timeout, max_output_bytes). Default 60 s timeout and 1 MiB output budget (split stdout + stderr). Both overrideable onWorkspaceSandboxviawith_timeout/with_max_output_bytes. Precedence:cmd.timeout>capabilities.timeout_secs> configured default. - Audit —
tracing::info!(target: "capability_ledger", …)record carryingsession_id,program,caps,exit_code,signal,duration_ms. Downstream tracing subscribers can sink it to any store.
Inside the pipeline, SandboxCapabilities::is_within
(src/sandbox/capabilities.rs:36) enforces four monotonic checks:
fs_read ⊆ (prefix), fs_write ⊆ (prefix), network (None ⊆ AllowHosts ⊆ AllowAll), spawn_subprocess (false ⊆ any).
SandboxCapabilities
pub struct SandboxCapabilities {
pub fs_read: Vec<PathBuf>,
pub fs_write: Vec<PathBuf>,
pub network: NetworkPolicy,
pub spawn_subprocess: bool,
}
pub enum NetworkPolicy {
None,
AllowAll,
AllowHosts { hosts: Vec<String> },
}::strict() (equivalent to ::default()) is the workspace baseline: no
fs access outside the cwd (which the OS driver auto-grants via the
seatbelt profile), no network, no subprocess spawn. Any command that
needs more must escalate via ApprovalGate.
Task-local SESSION_ID
Exec-class tools don't know the current session id — their
AlephTool::execute signature doesn't carry it. The sandbox subsystem
uses a tokio task_local! to thread the id without touching every tool
trait:
crate::sandbox::context::SESSION_ID— declared insrc/sandbox/context.rscurrent_session() -> Option<SessionId>— the read helper- Writer:
crate::session::invoke_with_session_trace(src/session/tool_trace.rs:17) wrapstool_svc.execute(...)in aSESSION_ID.scope(session_id.clone(), async move { ... }).
This gives tools a single, narrow mechanism:
crate::sandbox::context::current_session() returns Some(sid) inside
the scope and None outside.
SandboxConfig
pub struct SandboxConfig {
pub workspace_root: PathBuf, // default: ~/.aleph/workspaces
pub enabled: bool, // default: true
pub default_timeout_seconds: u64, // default: 60
pub max_output_bytes: usize, // default: 1 MiB
}Serde reads [sandbox] TOML with defaults. Tests / CI can set
enabled = false to disable the subsystem; build_sandbox then hands
back a NoopSandbox whose execute always errors with
SandboxError::Other("sandbox disabled: …") — a deliberate fail-fast,
not a silent bypass.
Platform implementations
macOS — sandbox-exec + Seatbelt
src/sandbox/platforms/macos/seatbelt.rs ships the complete codex
restricted_read_only_platform_defaults.sbpl (mach-lookups to logd /
trustd / runningboard / analyticsd, IOSurface, system-mac-syscall,
firmlink ancestors, terminal / PTY / dev handles, /tmp scratch space,
opt-homebrew lib).
NetworkPolicy::AllowHosts flows through the managed proxy (Cycle 6):
WorkspaceSandbox::maybe_spawn_proxyspawns an in-process HTTP CONNECT + SOCKS5 proxy on127.0.0.1:0;cmd.capabilities.networkis collapsed toAllowHosts(["127.0.0.1"]);- standard
HTTP_PROXY/HTTPS_PROXY/ALL_PROXYenv vars are injected; - the seatbelt profile collapses to "allow loopback only";
- the proxy enforces hostname allowlists internally (exact names,
*.suffixwildcards, IP literals).
Linux — bwrap + Landlock + seccomp-bpf + cgroup v2
src/sandbox/platforms/linux/bwrap.rs (#[cfg(target_os = "linux")]-gated).
Defense-in-depth (SP-2): bwrap's namespace isolation sits underneath
two additional LSM mechanisms, applied by a hidden aleph-server sandbox-init subcommand that bwrap launches inside its mount namespace:
- Landlock (kernel ≥ 5.13): in-process FS ACL inside the mounts
bwrap already gave the child.
READ_FILE | READ_DIR | EXECUTEonSYSTEM_READ_PATHS(/usr,/lib,/lib64,/bin,/sbin,/etc)SandboxCapabilities.fs_read; full RW + Exec onSandboxCapabilities.fs_write+ the session cwd.
- seccomp-bpf (kernel ≥ 3.5, universal in practice): syscall
denylist returning
EPERMfor filesystem manipulation (mount/umount/pivot_root/chroot), kernel reload (kexec_*), module loading (*_module), eBPF, perf,ptrace, cross-process memory (process_vm_readv/process_vm_writev— a read/write primitive independent ofptrace, critical in theallow_fork=trueshared-PID-ns path), kernel keyring,userfaultfd, io_uring,mknodat, swap, syslog, reboot, namespace switching,clone/unsharewithCLONE_NEWUSER, and the filesystem-handle escape primitivesopen_by_handle_at/name_to_handle_at. - seccomp socket gate (
SeccompNetworkMode):Unrestricted(AllowAll/ rawAllowHosts) — no socket-family filtering.UnixOnly(None) — allow onlyAF_UNIX, denyconnect, and deny the rest of the socket-operation surface.ProxyRouted(loopback-collapsedAllowHostsbehind the netns→UDS→loopback bridge) — allow onlyAF_INET/AF_INET6to reach the local bridge, denyAF_UNIXsocketpairs.
cgroup v2 resource limits (SP-5): when delegated to the user,
BubblewrapDriver::run creates a per-execution sub-cgroup under the
aleph-server process's own cgroup and applies:
memory.maxfromSandboxCapabilities.max_memory_mb. RSS-based (sommap(PROT_NONE)tricks that bypassRLIMIT_ASare caught).memory.swap.max = 0always.cpu.maxfromLinuxSandboxConfig.cpu_quota_percent.None→ unlimited;Some(50)→ 50 % of one core.pids.maxfromLinuxSandboxConfig.max_pids(defaultSome(200)).
SandboxCapabilities.max_memory_mb caps the sandboxed process's
virtual address space on all three OSes:
- macOS / Linux:
setrlimit(RLIMIT_AS)viapre_execon the sandbox helper. - Windows:
JOBOBJECT_EXTENDED_LIMIT_INFORMATION.ProcessMemoryLimit.
SandboxCapabilities.timeout_secs is the per-call override of
SandboxConfig.default_timeout_seconds, enforced as the
WorkspaceSandbox::execute wall-clock timeout. Precedence,
most-specific first: SandboxCommand.timeout > capabilities.timeout_secs
configured default.
capabilities.timeout_secsis the only timeout carrier.
Windows — AppContainer (preferred) → RestrictedToken → JobObject
src/sandbox/platforms/windows/.
The SP-6 three-tier soft-degrade chain (sandbox-init-windows):
- AppContainer (SP-6): per-execution unique profile via
CreateAppContainerProfile; capability SIDs derived fromSandboxCapabilities.network(AllowAll⇒internetClient/privateNetworkClientServer;None⇒ nothing);CreateProcessWwithEXTENDED_STARTUPINFO_PRESENT+PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES. Target runs at a trust level below Low IL with capability-gated resource access. Profile isDeleteAppContainerProfile-d afterwait. On any AppContainer setup failure, soft-degrades to tier 2. - Restricted token + Low IL (SP-3a):
CreateRestrictedToken(self, DISABLE_MAX_PRIVILEGE)→SetTokenInformation(TokenIntegrityLevel = S-1-16-4096)→CreateProcessAsUserW(target). Target runs with no privileges at Low IL. OnERROR_PRIVILEGE_NOT_HELD, soft-degrades to tier 3. CreateProcessWbaseline: host token, Medium IL, inside JobObject only. Last-resort tier — JobObject containment from cycle 1 always applies regardless of which tier launches the target.
JobObject (cycle 1): active-process limit (fork-bomb defense), kill-on-close, die-on-unhandled-exception, virtual-memory ceiling, UI restrictions.
Cycles 3 / 5 tightened DACL on {.git, .aleph, .codex, .agents} —
existing paths get DENY_ACCESS ACEs, missing paths get a stub directory
- ACE (NTFS ACLs cannot deny "create a child named
.git" by name). Cycle 5 tightened symlink escapes (push_metadata_protection_argsrunsprotected_paths::first_writable_symlink_component).
Windows AllowHosts still hard-fails, with a readable rejection
message that includes the exact pre-resolved IPs that would be allowed.
WFP (SP-3b) needs admin and is deferred indefinitely.
R1 carve-out — process-isolation FFI stays in-core
The sandbox is the one place src/ calls platform FFI directly
(windows-sys under src/sandbox/platforms/windows/*,
src/sandbox/windows_init/*), and this is a deliberate R1 carve-out,
not a violation. Restricted-token / job-object / AppContainer /
integrity-level / SID·ACL calls must be made by the parent at spawn
time — you cannot sandbox a process from a separate helper, and routing
a spawn-time restricted token through an IPC round-trip would weaken the
security model. The local PID-liveness probe in
src/builtin_tools/desktop/session_lock.rs (OpenProcess /
GetExitCodeProcess) is in-core for the same reason.
R1 targets the desktop UI / screen / Vision limbs, which still go
through the Swift bridge — proven by src/ carrying zero direct
cocoa / objc / core-graphics use.
[sandbox.command_policy] — hardline command filter
src/sandbox/command_policy/ adds a command-content hard-filter
in front of the OS sandbox, modelled on clawshell's DLP [[patterns]]
engine but specialised for shell commands and evaluated in a single pass
via regex::RegexSet (Aho-Corasick-backed). It is an R7-sanctioned
hard-filter, not an intent classifier.
- Wiring: implemented as a
SandboxBeforeHook(sibling ofRateLimitHook) and installed bybuild_sandboxfirst in the hook chain. Zero changes to theexecutepipeline — it reuses the existinghooks.run_before()→Denypath. - Ruleset (
rules.rs):Block= fork bomb,rm --no-preserve-root, bare-rootrm -rf //rm -rf ////.(cycle 7 tightened the multi-slash / dot bypass),dd of=/dev/<disk>,mkfs /dev/…, redirect-to-block-device,wipefs/blkdiscard/shredof a device.Warn(audit-only) =rm -rfof an absolute system path,curl|wget … | sh,chmod 777of a system path, writes to/etc/{passwd,shadow,sudoers},/dev/tcp/reverse shells, host shutdown / reboot,sudo -S/--stdin/--askpass/-s, writes into~/.ssh/authorized_keys. - Windows shapes (added 2026-06-15, deepened 2026-07-27):
Block=format <drive:>/Format-Volume; drive / hive-root recursive delete; shadow-copy destruction; backup-catalog destruction; raw-disk destruction;bcdedit /delete/bcdedit /set … recoveryenabled No/bootstatuspolicy ignoreallfailures; whole-hivereg delete HKLM /f.Warn= Windows system-location recursive delete,powershell -EncodedCommand, download-execute cradles, disabling Defender, disabling the firewall, weakening the execution policy, AMSI tampering, event-log clearing, local-account backdoors, autostart persistence, ACL takeover. - Normalisation (
normalize.rs) folds the evasions the shell would execute verbatim: invisible characters, cmd^and PowerShell`escapes, empty quote pairs, three Windows-specific readings (two views of\, path-prefix canonicalisation,-EncodedCommandexpansion). Because this happens in the normaliser, ahead of the tier split,enforcement = "off"cannot restore the blind spot. - Config (
[sandbox.command_policy]):enabled,enforcement(block/warn/off),use_default_rules,custom_rules[]({name, regex, action, description}). A malformed custom regex fails safe — boot logs the offending rule by name and falls back to the curated defaults rather than running with no filter. - Scan target:
program+ space-joinedargs+ any UTF-8stdinpayload (thebash -slarge-script path), bounded to 256 KiB. - Audit: matches log to the
command_policytracing target. - Non-breaking: defaults block only patterns with essentially no
legitimate workspace use; relative-path
rm -rf build/and ordinary commands are unaffected. The OS sandbox remains the real enforcer.
Testing pattern
The sandbox stack is testable at every seam without real subprocesses or OS sandboxing:
- Fake OS driver — implement
OsSandboxDriverTrait, countruncalls, return cannedSandboxOutput. Used bysrc/sandbox/workspace.rsunit tests and integration tests. - Fake approval requester — implement
ApprovalRequesterand hand the resulting box toApprovalGate::new(cfg, Some(Box::new(requester))). Unit tests use this to drive every branch of step 3. MockSandbox(src/sandbox/mod.rs:50,#[cfg(test)]) — records everySandboxCommandand returns cannedSandboxOutput. Used by exec-class tools (bash_exec,code_exec) to assert they route through the sandbox seam.- Integration —
tests/sandbox_capability_approval.rsdrives the full pipeline via the public surface (build_sandbox,Arc<dyn Sandbox>,SandboxCommand) with the fake driver + fake requester wiring.
Tests that don't touch exec at all can skip the subsystem by
constructing SandboxConfig { enabled: false, .. } — build_sandbox
returns NoopSandbox.
Desktop Bridge boundary
The Swift AlephBridge helper process runs outside the Rust sandbox.
Hard rules that must not be violated by any bridge handler:
- The bridge process must not open any TCP or Unix domain socket. Only the inherited stdio pipes are used for IPC.
- The bridge must not read or write
~/.aleph/data/, the.shared_tokenfile, or any other vault path. Vault access is exclusive to the Rust core. - Permission status is owned by macOS TCC; the bridge merely reflects
it via
perm.checkand returnsPermissionGuidein-32001errors. - Any new bridge handler added in the future must include a comment
justifying why it does not touch
~/.aleph/. Bridge code review checks this invariant.
See also
- Security Overview — trust boundary + tier
- Execution Approval — action-aware gate
- Pairing — device trust establishment