supercode_harness/sandbox.rs
1//! P5-10 (COMPOSABLE-HARNESS-DESIGN.md §2 module 12 `permissions.sandbox`,
2//! ~row 462): the OS-level enforcement BACKSTOP `permissions.rules`'
3//! rule-layer floor and the file-tool [`crate::tools::SandboxPolicy`] both
4//! defer to for full coverage (`crate::permissions` module doc: "complete
5//! OS-level write confinement of arbitrary bash… is `capabilities.
6//! permissions.sandbox`'s job (P5 module 10, a later unit), not this
7//! one's" — this IS that unit).
8//!
9//! # What this module adds
10//! - **Real Linux fs enforcement via Landlock** ([`landlock_available`],
11//! `apply_linux_confinement`): the spawned `bash`/`shell` subprocess (and
12//! its own children) is kernel-confined to the configured tier's writable
13//! set — a genuine `EPERM` from the kernel on a disallowed write, not a
14//! path string comparison. Applied via a `pre_exec` closure that runs in
15//! the FORKED CHILD after `fork()`, before `exec()` — [`crate::agent::
16//! Agent`]/supercode itself is never confined, only the subprocess tree
17//! the tool spawns.
18//! - **Coarse network cut-off** ([`netns_available`],
19//! `apply_linux_confinement`): when `network.enabled` is set with no
20//! domain allow/deny lists, the subprocess is placed in a fresh, isolated
21//! network namespace (`unshare(CLONE_NEWUSER|CLONE_NEWNET)`, self-mapped
22//! so file-permission checks are unaffected) — a real kernel-level
23//! all-network cutoff. Domain-level allow/deny is OUT OF REACH on this
24//! kernel class (that needs the out-of-scope TLS-MITM proxy, or Landlock
25//! ABI v4 network scoping, kernel ≥6.7) and is surfaced as an honest gap,
26//! never silently dropped.
27//! - **Fail-closed, never silently-unsandboxed** ([`decide_fs`]): a
28//! confining tier this platform/kernel genuinely cannot enforce refuses to
29//! run the subprocess at all (`escalation = "deny"`, the default), unless
30//! `escalation` explicitly says otherwise (`"ask"` routes through
31//! `crate::permissions::PermissionsApprovalHandler`; `"allow"` runs
32//! unconfined with a loud, one-time warning). The worst defect class named
33//! for this unit — "a tier claiming enforcement but silently running
34//! unconfined" — is structurally impossible here: [`decide_fs`] only ever
35//! returns [`FsDecision::Confine`] when the caller already told it
36//! enforcement IS available; every other input funnels through the
37//! escalation gate.
38//! - **`env_policy`** ([`apply_env_policy`]): `inherit` (today's behavior,
39//! byte-identical), `filtered` (strip a sensitive-var denylist),
40//! `none` (bare `PATH` + a couple of universally-needed variables).
41//!
42//! # Pure decision, real effect
43//! [`decide_fs`]/[`decide_net`] are pure functions — every availability/
44//! approval input is a PARAMETER, never an internal `cfg!`/probe call — so
45//! the fail-closed/ask/allow/monotonic-tightening branches are all unit-
46//! testable without touching a real kernel or spawning a process. The real
47//! call sites (`crate::tools::builtins::BashTool::execute` et al.) supply
48//! real inputs via [`landlock_available`]/[`netns_available`] (cached,
49//! real-kernel probes) and the installed
50//! [`crate::permissions::PermissionsApprovalHandler`].
51
52#[cfg(target_os = "linux")]
53use std::path::{Path, PathBuf};
54use std::sync::{Arc, Mutex, OnceLock};
55
56use crate::permissions::{ApprovalOutcome, ApprovalRequest, PermissionsApprovalHandler};
57use crate::tools::SandboxPolicy;
58
59/// A thin, `Clone` + `Debug` wrapper around
60/// `Arc<dyn PermissionsApprovalHandler>` so
61/// [`crate::tools::ToolContext`] (which derives both) can carry one as an
62/// ambient field — mirroring the existing `write_observer: Option<Arc<dyn
63/// WriteObserver>>` precedent, except [`PermissionsApprovalHandler`] (a
64/// pre-existing P5-1 public trait) doesn't itself require `Debug` as a
65/// supertrait, so this newtype supplies a placeholder `Debug` impl instead
66/// of widening that trait's contract for every existing implementor.
67#[derive(Clone)]
68pub struct SandboxApprovalHandler(pub Arc<dyn PermissionsApprovalHandler>);
69
70impl std::fmt::Debug for SandboxApprovalHandler {
71 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72 f.write_str("SandboxApprovalHandler(..)")
73 }
74}
75
76impl std::ops::Deref for SandboxApprovalHandler {
77 type Target = dyn PermissionsApprovalHandler;
78 fn deref(&self) -> &Self::Target {
79 &*self.0
80 }
81}
82
83// ---------------------------------------------------------------------------
84// Config-facing enums (§3.1 `capabilities.permissions.sandbox.escalation` /
85// `.env_policy`) — both carry a strictness RANK so the project-overlay
86// monotonic-tightening clamp (`crate::configfile::clamp_project_permissions`)
87// can compare a project's requested value against the trusted layer's,
88// exactly like `sandbox_rank`/`approval_rank` already do for `tier`/
89// `approval`.
90// ---------------------------------------------------------------------------
91
92/// `capabilities.permissions.sandbox.escalation` (§3.1): what happens when a
93/// confining fs tier is requested but this platform/kernel cannot actually
94/// enforce it. `Deny` (the default) refuses to run the subprocess at all —
95/// the cardinal "never silently unsandboxed" rule. `Ask` routes the decision
96/// through `crate::permissions::PermissionsApprovalHandler` (P5-1's
97/// `permissions.approvals` seam, wired here per this module's build brief).
98/// `Allow` auto-permits an unconfined run with a loud, one-time warning.
99#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
100pub enum SandboxEscalation {
101 /// Refuse to run the subprocess when confinement can't be established
102 /// (fail-closed; the default).
103 #[default]
104 Deny,
105 /// Consult the installed [`PermissionsApprovalHandler`] for a per-call
106 /// decision; no handler installed denies (fail-closed, same posture
107 /// `PermissionsApprovalHandler`'s own doc comment already documents).
108 Ask,
109 /// Auto-permit an unconfined run, with a loud one-time warning.
110 Allow,
111}
112
113impl SandboxEscalation {
114 /// Strictness rank — LOWER is stricter, same convention as
115 /// `configfile::sandbox_rank`/`approval_rank` (`Deny` prompts nothing
116 /// through, the strictest floor; `Allow` is the loosest, an unconfined
117 /// run with only a warning).
118 pub fn rank(self) -> u8 {
119 match self {
120 SandboxEscalation::Deny => 0,
121 SandboxEscalation::Ask => 1,
122 SandboxEscalation::Allow => 2,
123 }
124 }
125
126 /// Parse the config string (`"deny" | "ask" | "allow"`), `_`/`-`/case
127 /// normalized like every other sandbox-adjacent string parser in this
128 /// crate (`configfile::parse_sandbox_str`/`parse_approval_str`).
129 pub fn parse(s: &str) -> Option<Self> {
130 match s.replace('_', "-").to_ascii_lowercase().as_str() {
131 "deny" => Some(SandboxEscalation::Deny),
132 "ask" => Some(SandboxEscalation::Ask),
133 "allow" => Some(SandboxEscalation::Allow),
134 _ => None,
135 }
136 }
137}
138
139/// `capabilities.permissions.sandbox.env_policy` (§3.1): child-process
140/// environment sanitization for the spawned `bash`/`shell` subprocess.
141/// `Inherit` (the default) is today's behavior — the parent's environment
142/// (plus `core.shell_env_snapshot`, if configured) passes through
143/// unchanged. `Filtered` strips a sensitive-variable denylist (tokens,
144/// keys, cloud credentials). `None` keeps only `PATH` and a couple of
145/// universally-needed variables (`HOME`, `TERM`, `LANG`) — nearest to a
146/// bare-metal shell with nothing extra.
147#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
148pub enum SandboxEnvPolicy {
149 /// Full environment passthrough — today's behavior (byte-identical).
150 #[default]
151 Inherit,
152 /// Strip a sensitive-variable denylist; everything else passes through.
153 Filtered,
154 /// Bare minimum: `PATH`/`HOME`/`TERM`/`LANG` only.
155 None,
156}
157
158impl SandboxEnvPolicy {
159 /// Strictness rank — LOWER is stricter (`None` exposes the least,
160 /// `Inherit` the most), same convention as [`SandboxEscalation::rank`].
161 pub fn rank(self) -> u8 {
162 match self {
163 SandboxEnvPolicy::None => 0,
164 SandboxEnvPolicy::Filtered => 1,
165 SandboxEnvPolicy::Inherit => 2,
166 }
167 }
168
169 /// Parse the config string (`"inherit" | "filtered" | "none"`).
170 pub fn parse(s: &str) -> Option<Self> {
171 match s.replace('_', "-").to_ascii_lowercase().as_str() {
172 "inherit" => Some(SandboxEnvPolicy::Inherit),
173 "filtered" => Some(SandboxEnvPolicy::Filtered),
174 "none" => Some(SandboxEnvPolicy::None),
175 _ => None,
176 }
177 }
178}
179
180/// Environment variable NAMES (exact match, case-sensitive — POSIX env
181/// var convention) stripped under [`SandboxEnvPolicy::Filtered`]: API
182/// keys/tokens this crate itself resolves credentials from
183/// (`crate::config`'s `api_key_env`/provider-default env vars), common
184/// cloud-provider credential variables, and generic secret-shaped names.
185/// Deliberately a denylist rather than an allowlist (`Filtered` is the
186/// MIDDLE tier — "strip the obviously sensitive ones", not "start from
187/// nothing", which is what [`SandboxEnvPolicy::None`] is for).
188const FILTERED_ENV_DENYLIST_PREFIXES: &[&str] = &[
189 "OPENROUTER_API_KEY",
190 "OPENAI_API_KEY",
191 "ANTHROPIC_API_KEY",
192 "AWS_ACCESS_KEY_ID",
193 "AWS_SECRET_ACCESS_KEY",
194 "AWS_SESSION_TOKEN",
195 "GITHUB_TOKEN",
196 "GH_TOKEN",
197 "GITLAB_TOKEN",
198 "NPM_TOKEN",
199 "DOCKER_PASSWORD",
200 "GOOGLE_APPLICATION_CREDENTIALS",
201 "AZURE_CLIENT_SECRET",
202 "SSH_AUTH_SOCK",
203 "SUPERCODE_",
204];
205
206/// Whether `key` should be stripped under [`SandboxEnvPolicy::Filtered`]:
207/// an exact match against [`FILTERED_ENV_DENYLIST_PREFIXES`], OR a
208/// case-insensitive substring match on `TOKEN`/`SECRET`/`PASSWORD`/`_KEY`/
209/// `CREDENTIAL` — the generic "this looks like a secret" heuristic every
210/// credential-scanning tool uses, applied here as a denylist (a false
211/// positive just costs the child a var it didn't need; a false negative
212/// under `Filtered` is the actually dangerous direction, so the heuristic
213/// is deliberately broad).
214fn is_filtered_env_key(key: &str) -> bool {
215 let upper = key.to_ascii_uppercase();
216 if FILTERED_ENV_DENYLIST_PREFIXES
217 .iter()
218 .any(|p| upper == *p || upper.starts_with(p))
219 {
220 return true;
221 }
222 [
223 "TOKEN",
224 "SECRET",
225 "PASSWORD",
226 "_KEY",
227 "CREDENTIAL",
228 "APIKEY",
229 ]
230 .iter()
231 .any(|needle| upper.contains(needle))
232}
233
234/// Environment variables kept under [`SandboxEnvPolicy::None`] — the bare
235/// minimum a POSIX shell needs to do anything useful at all.
236const MINIMAL_ENV_KEEP: &[&str] = &["PATH", "HOME", "TERM", "LANG", "LC_ALL", "TMPDIR"];
237
238/// Build the environment the subprocess should see, starting from `base`
239/// (the process's own inherited environment, or `ctx.shell_env`'s snapshot
240/// when one is configured — the caller decides `base`, this function only
241/// applies the POLICY on top of it). `Inherit` returns `base` unchanged
242/// (byte-identical to pre-P5-10 behavior — the common case, since
243/// `env_policy` defaults to `Inherit`).
244pub fn apply_env_policy<I, K, V>(policy: SandboxEnvPolicy, base: I) -> Vec<(String, String)>
245where
246 I: IntoIterator<Item = (K, V)>,
247 K: Into<String>,
248 V: Into<String>,
249{
250 let base: Vec<(String, String)> = base
251 .into_iter()
252 .map(|(k, v)| (k.into(), v.into()))
253 .collect();
254 match policy {
255 SandboxEnvPolicy::Inherit => base,
256 SandboxEnvPolicy::Filtered => base
257 .into_iter()
258 .filter(|(k, _)| !is_filtered_env_key(k))
259 .collect(),
260 SandboxEnvPolicy::None => base
261 .into_iter()
262 .filter(|(k, _)| MINIMAL_ENV_KEEP.contains(&k.as_str()))
263 .collect(),
264 }
265}
266
267// ---------------------------------------------------------------------------
268// The pure decision layer — no I/O, fully unit-testable.
269// ---------------------------------------------------------------------------
270
271/// What to do about filesystem confinement for one subprocess spawn.
272#[derive(Debug, Clone, PartialEq, Eq)]
273pub enum FsDecision {
274 /// The tier is `DangerFullAccess`, or the OS backstop isn't active for
275 /// this call (`enabled` resolves to `false`) — no confinement is even
276 /// attempted. Byte-identical to pre-P5-10 behavior.
277 NotRequested,
278 /// Confinement IS available on this platform/kernel — apply it for
279 /// real (the caller installs the `pre_exec` closure).
280 Confine,
281 /// Confinement was requested but this platform/kernel cannot provide
282 /// it, and `escalation` says to proceed anyway (`allow`, or `ask` with
283 /// an approving handler) — run UNCONFINED, with `reason` surfaced as a
284 /// loud, one-time warning.
285 RunUnconfinedWithWarning {
286 /// Human-readable reason, fed to the one-time warning + the tool
287 /// error message (on the `Refuse` sibling) so the honest gap is
288 /// always named, never silent.
289 reason: String,
290 },
291 /// Confinement was requested, this platform/kernel cannot provide it,
292 /// and `escalation` says to refuse (`deny`, the default; or `ask` with
293 /// no handler installed / a denying handler). The subprocess is NOT
294 /// spawned at all.
295 Refuse {
296 /// Human-readable reason, returned to the model as the tool error.
297 reason: String,
298 },
299}
300
301/// What to do about network confinement for one subprocess spawn. Unlike
302/// [`FsDecision`], this never refuses the whole call — network.enabled is
303/// an independent, best-effort axis (§build brief item 3): "surface the
304/// gap, never claim enforcement you lack", not a hard fs-style gate. A
305/// caller that ALSO has an [`FsDecision::Refuse`] for the same call still
306/// refuses (that decision wins), but a network-only gap never blocks a
307/// call that has no fs confinement problem.
308#[derive(Debug, Clone, PartialEq, Eq)]
309pub enum NetDecision {
310 /// `network.enabled` is `false` (the default) — nothing to do,
311 /// byte-identical to pre-P5-10 behavior.
312 NotRequested,
313 /// A coarse (no domain granularity) network cut-off is available and
314 /// requested — apply it for real.
315 Confine,
316 /// `network.enabled` is `true` but this platform/kernel can't deliver
317 /// what was asked (domain allow/deny lists — needs the out-of-scope
318 /// TLS-MITM proxy — or coarse cut-off itself is unavailable). Never
319 /// silently dropped: surfaced as a one-time warning, the subprocess
320 /// still runs (network-UNCONFINED, everything else about the call is
321 /// unaffected).
322 GapWarn {
323 /// Human-readable reason, fed to the one-time warning.
324 reason: String,
325 },
326}
327
328/// Whether the OS-level backstop is ACTIVE for `tier`/`os_enabled` at all —
329/// the "turn the sandbox ENUM into an enabled/disabled OS engagement" half
330/// of §3.1's table. `DangerFullAccess` is an absolute opt-out (never
331/// confine, regardless of `os_enabled` — §3.1 item 1: "no confinement
332/// (opt-out)"). Otherwise: an EXPLICIT `os_enabled` value wins; `None`
333/// (never set — the bare `sandbox = "<tier>"` shorthand, or a CLI
334/// `--sandbox` flag, neither of which touch the table's `enabled` key at
335/// all) preserves the PRE-P5-10 trigger this crate already shipped
336/// (`tools/builtins.rs`'s macOS seatbelt firing off `ctx.sandbox` alone,
337/// no separate gate) — so an existing CLI user or the `cx-parity` preset
338/// (bare `sandbox = "workspace_write"`, no `enabled` key) keeps its
339/// current confining behavior byte-for-byte, while `cc-parity`'s explicit
340/// table-form `enabled = false` (§3.1's own "OS sandbox OFF… opt-in"
341/// comment) is honored as a real, independent off-switch.
342pub fn os_sandbox_active(tier: SandboxPolicy, os_enabled: Option<bool>) -> bool {
343 match tier {
344 SandboxPolicy::DangerFullAccess => false,
345 _ => os_enabled.unwrap_or(true),
346 }
347}
348
349/// Decide what to do about FILESYSTEM confinement for one subprocess spawn.
350/// Pure — `fs_available` is the caller's REAL probe result
351/// ([`landlock_available`] on Linux, `true` on macOS via the existing
352/// seatbelt path which this function is not consulted for — see
353/// `tools::builtins::build_sandboxed_sh`'s doc comment), never computed
354/// internally, so every branch (including the platform-can't-enforce ones)
355/// is directly testable without touching a kernel.
356#[allow(clippy::too_many_arguments)]
357pub fn decide_fs(
358 tier: SandboxPolicy,
359 os_enabled: Option<bool>,
360 fs_available: bool,
361 escalation: SandboxEscalation,
362 approval: Option<&dyn PermissionsApprovalHandler>,
363 subject: &str,
364) -> FsDecision {
365 if !os_sandbox_active(tier, os_enabled) {
366 return FsDecision::NotRequested;
367 }
368 if fs_available {
369 return FsDecision::Confine;
370 }
371 let reason = format!(
372 "sandbox: filesystem confinement ({tier:?}) was requested but is unavailable on this \
373 platform/kernel (no Landlock support) for `{subject}`"
374 );
375 resolve_escalation(escalation, approval, "bash", subject, reason)
376}
377
378/// Decide what to do about NETWORK confinement for one subprocess spawn.
379/// Pure — `net_available` is the caller's real probe result
380/// ([`netns_available`] on Linux). Never gates on `escalation` (see
381/// [`NetDecision`]'s doc comment) — a network gap is always a warn, never a
382/// refuse, so this needs no approval handler at all.
383pub fn decide_net(
384 network_enabled: bool,
385 has_domain_rules: bool,
386 net_available: bool,
387) -> NetDecision {
388 if !network_enabled {
389 return NetDecision::NotRequested;
390 }
391 if has_domain_rules {
392 return NetDecision::GapWarn {
393 reason: "sandbox: capabilities.permissions.sandbox.network.allow_domains/\
394 deny_domains was set, but domain-level network filtering has no OS \
395 primitive on this platform — that needs an out-of-scope TLS-MITM proxy \
396 (COMPOSABLE-HARNESS-DESIGN.md gap honesty note). Network was NOT \
397 confined for this call."
398 .to_string(),
399 };
400 }
401 if net_available {
402 return NetDecision::Confine;
403 }
404 NetDecision::GapWarn {
405 reason: "sandbox: capabilities.permissions.sandbox.network.enabled was set, but a \
406 coarse network cut-off is unavailable on this platform/kernel (no \
407 unprivileged network-namespace support). Network was NOT confined for this \
408 call."
409 .to_string(),
410 }
411}
412
413/// Shared `deny`/`ask`/`allow` resolution for an unenforceable FS request —
414/// factored out of [`decide_fs`] so a future confining axis (were one ever
415/// added) reuses the exact same escalation semantics rather than a second,
416/// possibly-drifting copy.
417fn resolve_escalation(
418 escalation: SandboxEscalation,
419 approval: Option<&dyn PermissionsApprovalHandler>,
420 tool: &str,
421 subject: &str,
422 reason: String,
423) -> FsDecision {
424 match escalation {
425 SandboxEscalation::Deny => FsDecision::Refuse { reason },
426 SandboxEscalation::Allow => FsDecision::RunUnconfinedWithWarning { reason },
427 SandboxEscalation::Ask => match approval {
428 Some(handler) => {
429 let raw_args = serde_json::Value::Null;
430 let req = ApprovalRequest {
431 tool,
432 subject: Some(subject),
433 raw_args: &raw_args,
434 };
435 match handler.ask(&req) {
436 ApprovalOutcome::Deny => FsDecision::Refuse { reason },
437 ApprovalOutcome::Allow | ApprovalOutcome::AllowForSession => {
438 FsDecision::RunUnconfinedWithWarning { reason }
439 }
440 }
441 }
442 // No handler installed: fail-closed, same posture
443 // `PermissionsApprovalHandler`'s own doc comment documents for
444 // the P5-1 rule engine's `Ask` tier.
445 None => FsDecision::Refuse { reason },
446 },
447 }
448}
449
450/// Print `reason` to stderr ONCE per distinct reason string, for the
451/// lifetime of this process — the "loud, one-time persistent warning" the
452/// build brief calls for on an `escalation = "allow"`/approved-`ask` run,
453/// and on a network gap. Deduped by exact text (not a blanket
454/// once-per-process `Once`) so a DIFFERENT gap later in the same run still
455/// gets its own warning — only an EXACT repeat is suppressed.
456pub fn warn_once(reason: &str) {
457 static WARNED: OnceLock<Mutex<std::collections::HashSet<String>>> = OnceLock::new();
458 let set = WARNED.get_or_init(|| Mutex::new(std::collections::HashSet::new()));
459 if let Ok(mut set) = set.lock() {
460 if set.insert(reason.to_string()) {
461 eprintln!("\x1b[33mwarning: {reason}\x1b[0m");
462 }
463 }
464}
465
466// ---------------------------------------------------------------------------
467// Linux: real Landlock fs enforcement + coarse network-namespace cut-off.
468// ---------------------------------------------------------------------------
469
470/// Whether real Landlock filesystem confinement is available on THIS
471/// process's kernel — a genuine, side-effect-free (beyond dropping one
472/// ruleset file descriptor) PARENT-PROCESS probe: it builds a
473/// `CompatLevel::HardRequirement` ruleset requiring exactly the write-access
474/// rights `apply_linux_confinement` would later request and checks
475/// whether `Ruleset::create()` succeeds — it deliberately never calls
476/// `restrict_self()` (that confines the CALLING process/thread permanently
477/// and every future child of it — calling it here would confine supercode
478/// ITSELF, exactly the "confinement targets the child, not supercode"
479/// invariant this module must never violate). Cached for the process
480/// lifetime (the kernel's Landlock support can't change at runtime).
481#[cfg(target_os = "linux")]
482pub fn landlock_available() -> bool {
483 static AVAILABLE: OnceLock<bool> = OnceLock::new();
484 *AVAILABLE.get_or_init(|| {
485 use landlock::{AccessFs, CompatLevel, Compatible, Ruleset, RulesetAttr, ABI};
486 Ruleset::default()
487 .set_compatibility(CompatLevel::HardRequirement)
488 .handle_access(AccessFs::from_write(ABI::V1))
489 .and_then(|r| r.create())
490 .is_ok()
491 })
492}
493
494/// Non-Linux: Landlock never exists — always unavailable. Kept as a real
495/// function (not `cfg!`-inlined at call sites) so callers stay identical
496/// across platforms.
497#[cfg(not(target_os = "linux"))]
498pub fn landlock_available() -> bool {
499 false
500}
501
502/// Whether an unprivileged, self-contained network namespace cut-off is
503/// available on THIS process's kernel — a real probe, but one that (unlike
504/// [`landlock_available`]) genuinely can't be done risk-free in the calling
505/// process itself (`unshare(2)` acts on the CALLING process/thread, so
506/// probing it directly would isolate supercode's own network, not just
507/// check availability). Instead this forks a disposable, single-purpose
508/// child that does nothing but attempt the unshare and immediately
509/// `_exit()` with the result — never touches the allocator, locks, or any
510/// other state the parent might hold mid-fork (the standard safe shape for
511/// a post-fork child that never execs), so it carries none of `pre_exec`'s
512/// usual multi-threaded-fork hazards. Cached for the process lifetime.
513#[cfg(target_os = "linux")]
514pub fn netns_available() -> bool {
515 static AVAILABLE: OnceLock<bool> = OnceLock::new();
516 *AVAILABLE.get_or_init(probe_netns_fork)
517}
518
519#[cfg(not(target_os = "linux"))]
520/// Non-Linux platforms cannot create the Linux network namespace used for
521/// coarse network isolation, so the capability is always unavailable.
522pub fn netns_available() -> bool {
523 false
524}
525
526#[cfg(target_os = "linux")]
527fn probe_netns_fork() -> bool {
528 // SAFETY: the child touches nothing but raw syscalls (`unshare`,
529 // `_exit`) between `fork()` and exit — no allocation, no locks, no
530 // library calls that could be mid-acquired in another thread at fork
531 // time. This is the textbook safe post-fork-no-exec shape.
532 unsafe {
533 let pid = libc::fork();
534 if pid == 0 {
535 let rc = libc::unshare(libc::CLONE_NEWUSER | libc::CLONE_NEWNET);
536 libc::_exit(i32::from(rc != 0));
537 } else if pid > 0 {
538 let mut status: libc::c_int = 0;
539 if libc::waitpid(pid, &mut status, 0) != pid {
540 return false;
541 }
542 libc::WIFEXITED(status) && libc::WEXITSTATUS(status) == 0
543 } else {
544 false
545 }
546 }
547}
548
549/// Real fs/net confinement, installed on `cmd` as a `pre_exec` closure that
550/// runs in the FORKED CHILD (after `fork()`, before `exec()` —
551/// [`crate::agent::Agent`]/supercode itself is never touched; only the
552/// spawned subprocess tree is). `cwd`/`extra_write_dirs` MUST already be
553/// resolved via [`crate::safe_path::resolve_real`] (real, symlink-resolved
554/// paths) — Landlock rules operate on directory file descriptors opened
555/// from these exact paths, so the same dual lexical+resolved discipline
556/// every other containment check in this crate uses applies here too (a
557/// symlink'd `cwd` must grant the REAL target directory, not the symlink's
558/// lexical location).
559#[cfg(target_os = "linux")]
560pub fn apply_linux_confinement(
561 cmd: &mut tokio::process::Command,
562 confine_fs: bool,
563 fs_allow_writes: bool,
564 cwd: PathBuf,
565 extra_write_dirs: Vec<PathBuf>,
566 confine_net: bool,
567) {
568 if !confine_fs && !confine_net {
569 return;
570 }
571 // Captured by value into the closure — no shared/borrowed state crosses
572 // the fork boundary.
573 let uid = unsafe { libc::getuid() };
574 let gid = unsafe { libc::getgid() };
575 // SAFETY: see the closure body's own comments — every operation is a
576 // raw syscall (or a `/proc/self/*` write via raw fd ops), no
577 // allocation-heavy std IO beyond what the `landlock` crate itself does
578 // (small, bounded `Vec`s over a handful of paths), matching this
579 // module's build brief ("a pre_exec closure calling the landlock
580 // crate's restrict_self() in the child after fork, before exec").
581 unsafe {
582 cmd.pre_exec(move || {
583 if confine_net {
584 netns_isolate_self(uid, gid)
585 .map_err(|e| std::io::Error::other(format!("sandbox netns: {e}")))?;
586 }
587 if confine_fs {
588 landlock_restrict_self(&cwd, &extra_write_dirs, fs_allow_writes)
589 .map_err(|e| std::io::Error::other(format!("sandbox landlock: {e}")))?;
590 }
591 Ok(())
592 });
593 }
594}
595
596/// Isolate the CALLING process (the forked child, pre-exec) into a fresh,
597/// unprivileged user+network namespace with NO network interfaces beyond
598/// loopback — a real kernel-level all-network cutoff (coarse: no domain
599/// granularity, see [`decide_net`]'s doc comment for why that's out of
600/// reach here). `uid`/`gid` (captured in the PARENT before `fork()`) are
601/// mapped identity-onto-self inside the new user namespace
602/// (`/proc/self/uid_map`/`gid_map`, the same `unshare(1) --map-root-user`
603/// technique) so file-permission checks against the workspace are
604/// UNAFFECTED — without this mapping the process would run as the
605/// namespace's unmapped "overflow" uid and lose access to its own files.
606#[cfg(target_os = "linux")]
607fn netns_isolate_self(uid: libc::uid_t, gid: libc::gid_t) -> Result<(), String> {
608 unsafe {
609 if libc::unshare(libc::CLONE_NEWUSER | libc::CLONE_NEWNET) != 0 {
610 return Err(format!(
611 "unshare(CLONE_NEWUSER|CLONE_NEWNET): errno {}",
612 *libc::__errno_location()
613 ));
614 }
615 }
616 write_proc_self_raw("setgroups", b"deny")?;
617 write_proc_self_raw("uid_map", format!("0 {uid} 1\n").as_bytes())?;
618 write_proc_self_raw("gid_map", format!("0 {gid} 1\n").as_bytes())?;
619 Ok(())
620}
621
622/// Write `contents` to `/proc/self/<name>` using raw `open`/`write`/`close`
623/// syscalls (not `std::fs`) — deliberately minimal post-fork-pre-exec code,
624/// consistent with `apply_linux_confinement`'s safety comment.
625#[cfg(target_os = "linux")]
626fn write_proc_self_raw(name: &str, contents: &[u8]) -> Result<(), String> {
627 let path = format!("/proc/self/{name}\0");
628 unsafe {
629 let fd = libc::open(path.as_ptr() as *const libc::c_char, libc::O_WRONLY);
630 if fd < 0 {
631 return Err(format!(
632 "open(/proc/self/{name}): errno {}",
633 *libc::__errno_location()
634 ));
635 }
636 let n = libc::write(fd, contents.as_ptr() as *const libc::c_void, contents.len());
637 let write_errno = *libc::__errno_location();
638 libc::close(fd);
639 if n != contents.len() as isize {
640 return Err(format!("write(/proc/self/{name}): errno {write_errno}"));
641 }
642 }
643 Ok(())
644}
645
646/// Real Landlock ruleset construction + `restrict_self()` — runs ONLY
647/// inside the forked child's `pre_exec` closure (see
648/// `apply_linux_confinement`). Restricts WRITE-family access rights
649/// crate-wide (`AccessFs::from_write`, ABI V1 — the conservative baseline
650/// every Landlock-supporting kernel honors; read/execute are never
651/// "handled" by this ruleset at all, so they stay exactly as unrestricted
652/// as [`crate::tools::SandboxPolicy`]'s own doc comment already promises:
653/// "reads broad" for `workspace_write`, "reads allowed" for `read_only` —
654/// this module only ever tightens WRITES). `fs_allow_writes` (true for
655/// `WorkspaceWrite`, false for `ReadOnly`) gates whether ANY path gets a
656/// write-grant rule at all; when true, `cwd` + `extra_write_dirs` (system
657/// temp) are the only writable roots. Fails (never silently degrades) if
658/// the resulting status isn't `RulesetStatus::FullyEnforced` — a
659/// `PartiallyEnforced`/`NotEnforced` status would mean this function
660/// claimed confinement it didn't actually get.
661#[cfg(target_os = "linux")]
662fn landlock_restrict_self(
663 cwd: &Path,
664 extra_write_dirs: &[PathBuf],
665 fs_allow_writes: bool,
666) -> Result<(), String> {
667 use landlock::{
668 path_beneath_rules, AccessFs, CompatLevel, Compatible, Ruleset, RulesetAttr,
669 RulesetCreatedAttr, RulesetStatus, ABI,
670 };
671 let write_access = AccessFs::from_write(ABI::V1);
672 let created = Ruleset::default()
673 .set_compatibility(CompatLevel::HardRequirement)
674 .handle_access(write_access)
675 .map_err(|e| e.to_string())?
676 .create()
677 .map_err(|e| e.to_string())?
678 .set_compatibility(CompatLevel::HardRequirement);
679 let created = if fs_allow_writes {
680 let mut dirs = Vec::with_capacity(1 + extra_write_dirs.len());
681 dirs.push(cwd.to_path_buf());
682 dirs.extend(extra_write_dirs.iter().cloned());
683 created
684 .add_rules(path_beneath_rules(&dirs, write_access))
685 .map_err(|e| e.to_string())?
686 } else {
687 created
688 };
689 let status = created.restrict_self().map_err(|e| e.to_string())?;
690 if status.ruleset != RulesetStatus::FullyEnforced {
691 return Err(format!(
692 "ruleset not fully enforced ({:?}) — refusing to claim confinement it doesn't have",
693 status.ruleset
694 ));
695 }
696 Ok(())
697}
698
699#[cfg(test)]
700mod tests {
701 use super::*;
702 use crate::permissions::ApprovalOutcome;
703
704 struct FakeApproval(ApprovalOutcome);
705 impl PermissionsApprovalHandler for FakeApproval {
706 fn ask(&self, _req: &ApprovalRequest) -> ApprovalOutcome {
707 self.0
708 }
709 }
710
711 // ---- os_sandbox_active ----
712
713 #[test]
714 fn danger_full_access_is_always_inactive() {
715 assert!(!os_sandbox_active(
716 SandboxPolicy::DangerFullAccess,
717 Some(true)
718 ));
719 assert!(!os_sandbox_active(
720 SandboxPolicy::DangerFullAccess,
721 Some(false)
722 ));
723 assert!(!os_sandbox_active(SandboxPolicy::DangerFullAccess, None));
724 }
725
726 #[test]
727 fn confining_tier_defaults_active_when_enabled_unset() {
728 // Legacy trigger preserved: a CLI `--sandbox workspace-write` (or
729 // the bare `sandbox = "<tier>"` shorthand) never touches `enabled`
730 // at all, so it must keep confining, exactly like the pre-P5-10
731 // macOS seatbelt already did off `ctx.sandbox` alone.
732 assert!(os_sandbox_active(SandboxPolicy::WorkspaceWrite, None));
733 assert!(os_sandbox_active(SandboxPolicy::ReadOnly, None));
734 }
735
736 #[test]
737 fn explicit_enabled_false_overrides_confining_tier() {
738 // cc-parity: `[capabilities.permissions.sandbox] enabled = false,
739 // tier = "danger_full_access"` — tier is already off, but this also
740 // proves the table's `enabled` key is real, independent wiring.
741 assert!(!os_sandbox_active(
742 SandboxPolicy::WorkspaceWrite,
743 Some(false)
744 ));
745 }
746
747 // ---- decide_fs: default-off byte-identity ----
748
749 #[test]
750 fn danger_full_access_never_requests_fs_confinement() {
751 let d = decide_fs(
752 SandboxPolicy::DangerFullAccess,
753 None,
754 false, // fs_available irrelevant
755 SandboxEscalation::Deny,
756 None,
757 "echo hi",
758 );
759 assert_eq!(d, FsDecision::NotRequested);
760 }
761
762 #[test]
763 fn explicit_enabled_false_never_requests_fs_confinement() {
764 let d = decide_fs(
765 SandboxPolicy::WorkspaceWrite,
766 Some(false),
767 false,
768 SandboxEscalation::Deny,
769 None,
770 "echo hi",
771 );
772 assert_eq!(d, FsDecision::NotRequested);
773 }
774
775 // ---- decide_fs: real availability -> real confinement ----
776
777 #[test]
778 fn available_confining_tier_confines() {
779 let d = decide_fs(
780 SandboxPolicy::WorkspaceWrite,
781 None,
782 true,
783 SandboxEscalation::Deny,
784 None,
785 "echo hi",
786 );
787 assert_eq!(d, FsDecision::Confine);
788 }
789
790 // ---- decide_fs: unavailable + escalation matrix (the cardinal rule) ----
791
792 #[test]
793 fn unavailable_plus_deny_refuses() {
794 let d = decide_fs(
795 SandboxPolicy::WorkspaceWrite,
796 None,
797 false,
798 SandboxEscalation::Deny,
799 None,
800 "echo hi",
801 );
802 assert!(matches!(d, FsDecision::Refuse { .. }), "got {d:?}");
803 }
804
805 #[test]
806 fn unavailable_plus_ask_no_handler_fails_closed() {
807 let d = decide_fs(
808 SandboxPolicy::WorkspaceWrite,
809 None,
810 false,
811 SandboxEscalation::Ask,
812 None,
813 "echo hi",
814 );
815 assert!(matches!(d, FsDecision::Refuse { .. }), "got {d:?}");
816 }
817
818 #[test]
819 fn unavailable_plus_ask_denying_handler_refuses() {
820 let handler = FakeApproval(ApprovalOutcome::Deny);
821 let d = decide_fs(
822 SandboxPolicy::WorkspaceWrite,
823 None,
824 false,
825 SandboxEscalation::Ask,
826 Some(&handler),
827 "echo hi",
828 );
829 assert!(matches!(d, FsDecision::Refuse { .. }), "got {d:?}");
830 }
831
832 #[test]
833 fn unavailable_plus_ask_approving_handler_runs_unconfined_with_warning() {
834 let handler = FakeApproval(ApprovalOutcome::Allow);
835 let d = decide_fs(
836 SandboxPolicy::WorkspaceWrite,
837 None,
838 false,
839 SandboxEscalation::Ask,
840 Some(&handler),
841 "echo hi",
842 );
843 assert!(
844 matches!(d, FsDecision::RunUnconfinedWithWarning { .. }),
845 "got {d:?}"
846 );
847 }
848
849 #[test]
850 fn unavailable_plus_allow_runs_unconfined_with_warning_no_handler_needed() {
851 let d = decide_fs(
852 SandboxPolicy::WorkspaceWrite,
853 None,
854 false,
855 SandboxEscalation::Allow,
856 None,
857 "echo hi",
858 );
859 assert!(
860 matches!(d, FsDecision::RunUnconfinedWithWarning { .. }),
861 "got {d:?}"
862 );
863 }
864
865 #[test]
866 fn ask_never_bypasses_when_denied_even_with_allow_for_session_semantics_elsewhere() {
867 // A refused escalation stays denied — no partial/implicit grant.
868 let handler = FakeApproval(ApprovalOutcome::Deny);
869 let d = decide_fs(
870 SandboxPolicy::ReadOnly,
871 None,
872 false,
873 SandboxEscalation::Ask,
874 Some(&handler),
875 "cat /etc/shadow",
876 );
877 assert_eq!(
878 d,
879 FsDecision::Refuse {
880 reason: "sandbox: filesystem confinement (ReadOnly) was requested but is \
881 unavailable on this platform/kernel (no Landlock support) for `cat \
882 /etc/shadow`"
883 .to_string()
884 }
885 );
886 }
887
888 // ---- decide_net ----
889
890 #[test]
891 fn network_not_requested_is_a_pure_noop() {
892 assert_eq!(decide_net(false, false, true), NetDecision::NotRequested);
893 assert_eq!(decide_net(false, true, true), NetDecision::NotRequested);
894 }
895
896 #[test]
897 fn network_requested_and_available_confines() {
898 assert_eq!(decide_net(true, false, true), NetDecision::Confine);
899 }
900
901 #[test]
902 fn network_requested_but_unavailable_gap_warns_never_refuses() {
903 let d = decide_net(true, false, false);
904 assert!(matches!(d, NetDecision::GapWarn { .. }), "got {d:?}");
905 }
906
907 #[test]
908 fn network_domain_rules_always_gap_warn_even_when_netns_available() {
909 // Domain-level filtering is out of reach regardless of coarse netns
910 // support — never silently downgrade to a coarse block the user
911 // didn't ask for, and never silently drop the domain policy.
912 let d = decide_net(true, true, true);
913 assert!(matches!(d, NetDecision::GapWarn { .. }), "got {d:?}");
914 }
915
916 // ---- SandboxEscalation / SandboxEnvPolicy parsing + ranks ----
917
918 #[test]
919 fn escalation_parse_and_rank_order() {
920 assert_eq!(
921 SandboxEscalation::parse("deny"),
922 Some(SandboxEscalation::Deny)
923 );
924 assert_eq!(
925 SandboxEscalation::parse("ASK"),
926 Some(SandboxEscalation::Ask)
927 );
928 assert_eq!(
929 SandboxEscalation::parse("allow"),
930 Some(SandboxEscalation::Allow)
931 );
932 assert_eq!(SandboxEscalation::parse("bogus"), None);
933 assert!(SandboxEscalation::Deny.rank() < SandboxEscalation::Ask.rank());
934 assert!(SandboxEscalation::Ask.rank() < SandboxEscalation::Allow.rank());
935 }
936
937 #[test]
938 fn env_policy_parse_and_rank_order() {
939 assert_eq!(
940 SandboxEnvPolicy::parse("inherit"),
941 Some(SandboxEnvPolicy::Inherit)
942 );
943 assert_eq!(
944 SandboxEnvPolicy::parse("filtered"),
945 Some(SandboxEnvPolicy::Filtered)
946 );
947 assert_eq!(
948 SandboxEnvPolicy::parse("none"),
949 Some(SandboxEnvPolicy::None)
950 );
951 assert_eq!(SandboxEnvPolicy::parse("bogus"), None);
952 assert!(SandboxEnvPolicy::None.rank() < SandboxEnvPolicy::Filtered.rank());
953 assert!(SandboxEnvPolicy::Filtered.rank() < SandboxEnvPolicy::Inherit.rank());
954 }
955
956 // ---- apply_env_policy ----
957
958 fn sample_env() -> Vec<(String, String)> {
959 vec![
960 ("PATH".to_string(), "/usr/bin".to_string()),
961 ("HOME".to_string(), "/home/u".to_string()),
962 ("OPENROUTER_API_KEY".to_string(), "sk-secret".to_string()),
963 ("MY_APP_TOKEN".to_string(), "t-secret".to_string()),
964 ("HARMLESS_VAR".to_string(), "ok".to_string()),
965 ]
966 }
967
968 #[test]
969 fn env_inherit_is_byte_identical_passthrough() {
970 let out = apply_env_policy(SandboxEnvPolicy::Inherit, sample_env());
971 assert_eq!(out, sample_env());
972 }
973
974 #[test]
975 fn env_filtered_strips_secrets_keeps_the_rest() {
976 let out = apply_env_policy(SandboxEnvPolicy::Filtered, sample_env());
977 let keys: Vec<&str> = out.iter().map(|(k, _)| k.as_str()).collect();
978 assert!(keys.contains(&"PATH"));
979 assert!(keys.contains(&"HOME"));
980 assert!(keys.contains(&"HARMLESS_VAR"));
981 assert!(!keys.contains(&"OPENROUTER_API_KEY"));
982 assert!(!keys.contains(&"MY_APP_TOKEN"));
983 }
984
985 #[test]
986 fn env_none_keeps_only_the_minimal_set() {
987 let out = apply_env_policy(SandboxEnvPolicy::None, sample_env());
988 let keys: Vec<&str> = out.iter().map(|(k, _)| k.as_str()).collect();
989 assert_eq!(keys, vec!["PATH", "HOME"]);
990 }
991
992 // ---- warn_once dedup ----
993
994 #[test]
995 fn warn_once_dedupes_exact_text() {
996 // Not much to assert without capturing stderr; this just proves it
997 // doesn't panic on repeated/differing input and the dedup set
998 // grows as expected via a second, distinguishable call path
999 // (covered indirectly by the integration test's stderr scrape).
1000 warn_once("sandbox test warning A");
1001 warn_once("sandbox test warning A");
1002 warn_once("sandbox test warning B");
1003 }
1004}