fix(sandbox): default Linux workspace-write to bubblewrap, probed and disclosed

The workspace-write posture on Linux silently ran unrestricted: prefer_bwrap
defaulted to false, so select_sandbox resolved SandboxType::None and the
confined command became an unconfined one. Reproduced as an
outside-workspace write succeeding under a workspace-write policy.

Three parts, mirroring the extension host's proven pattern:

- Config::prefers_bwrap() resolves the key as on-by-default; every consumer
  (engine config, exec agent, TUI init/frame, runtime threads, doctor/setup
  status) migrates to the accessor. prefer_bwrap = false remains a working
  opt-out and danger-full-access is untouched.
- bwrap::is_available() now runs a real wrapped /bin/true once (built by
  build_bwrap_command itself, cached in a OnceLock). The exec bit alone lied
  on hosts that restrict user namespaces (Ubuntu 24.04
  apparmor_restrict_unprivileged_userns), where every wrapped command would
  fail.
- codewhale exec prints a stderr warning when the resolved policy wants a
  sandbox but no enforcing wrapper exists, so policy-only never goes silent
  for the human. The model already saw the same posture via turn_meta.

Real Linux proof, not a marker check: new test
bwrap_workspace_write_blocks_outside_write_allows_inside runs the same
outside-workspace write unwrapped (lands on the host) and wrapped (EROFS,
file absent), plus a normal inside-workspace write that succeeds under the
wrapper. Evidence: cargo test -p codewhale-tui --lib --locked -- bwrap
prefers_bwrap linux_ parity_manager -> 26 passed; 0 failed.

Docs aligned across SANDBOX.md (en/zh), CONFIGURATION.md (en/zh),
config.example.toml (also drops the stale Landlock comment), the FAQ page
and dictionaries, features.toml, and the public-surface facts both sides of
the drift gate.
This commit is contained in:
Hunter Bown committed 2026-10-06 00:50:49 +00:00
1 parent 3dd34faf14
commit b69eccaacb
24 files changed
+311 -111

No files matched your search

+8 -8
View File
@@ -285,21 +285,21 @@ sandbox_mode = "workspace-write" # read-only | workspace-write | danger-full-acc
# TTY modes are not supported with external backends — all commands run
# synchronously via HTTP.
# ─────────────────────────────────────────────────────────────────────────────────
# Bubblewrap (Linux only, additional filesystem isolation)
# Bubblewrap (Linux only, OS-level filesystem isolation)
# ─────────────────────────────────────────────────────────────────────────────────
# When set to true and `/usr/bin/bwrap` is present, exec_shell commands are
# routed through bubblewrap instead of relying solely on Landlock. Bubblewrap
# creates a read-only view of the root filesystem with write access limited to
# the working directory. Install separately:
# On by default: when `/usr/bin/bwrap` is installed and a probe shows it can
# create its namespaces on this host, sandboxed exec_shell commands are routed
# through it. Bubblewrap creates a read-only view of the root filesystem with
# write access limited to the sandbox mode's writable roots. Install separately:
#
# Ubuntu/Debian: apt install bubblewrap
# Fedora: dnf install bubblewrap
# Arch: pacman -S bubblewrap
#
# prefer_bwrap = false # default — use Landlock only
# prefer_bwrap = false # opt out — Linux commands run unwrapped
#
# Env override: CODEWHALE_PREFER_BWRAP=true
# Legacy alias (deprecated until 0.10.0): DEEPSEEK_PREFER_BWRAP=true
# Env override: CODEWHALE_PREFER_BWRAP=true|false
# Legacy alias (deprecated until 0.10.0): DEEPSEEK_PREFER_BWRAP=true|false
#
# With prefer_bwrap = true, the sandbox gets a private /dev and /proc plus a
# writable isolated /tmp by default, so toolchains work without widening the
+19 -4
View File
@@ -2349,10 +2349,14 @@ pub struct Config {
/// Optional API key for the external sandbox backend (sent as Bearer token).
#[serde(alias = "sandboxApiKey")]
pub sandbox_api_key: Option<String>,
/// When true and `/usr/bin/bwrap` is executable on Linux, route exec_shell
/// through bubblewrap (#2184).
/// Defaults to false. Requires the `bubblewrap` package to be installed
/// separately — we do NOT vendor bwrap.
/// When true and bubblewrap actually works on this Linux host, route
/// sandboxed exec_shell commands through it (#2184).
/// Defaults to true — an unset key means sandboxed commands run under
/// bwrap whenever `/usr/bin/bwrap` is installed and can create its
/// namespaces. An explicit `prefer_bwrap = false` opts out and leaves
/// Linux commands unwrapped (the posture then reports policy-only).
/// Requires the `bubblewrap` package to be installed separately — we do
/// NOT vendor bwrap.
#[serde(alias = "preferBwrap")]
pub prefer_bwrap: Option<bool>,
/// Additional host paths to bind read-only inside the bubblewrap sandbox
@@ -3685,6 +3689,17 @@ impl Config {
self.read_denylist().subtree_paths()
}
/// Whether Linux shell commands prefer bubblewrap confinement.
///
/// On by default: an unset `prefer_bwrap` means sandboxed commands use
/// the OS wrapper whenever `/usr/bin/bwrap` works on this host, matching
/// the Seatbelt behavior macOS already has. An explicit `false` opts out
/// and leaves Linux commands unwrapped.
#[must_use]
pub fn prefers_bwrap(&self) -> bool {
self.prefer_bwrap.unwrap_or(true)
}
#[must_use]
pub fn stop_words(&self) -> Vec<String> {
self.stop_words.clone().unwrap_or_else(default_stop_words)
+19
View File
@@ -2685,6 +2685,25 @@ fn legacy_prefer_bwrap_env_remains_a_compatible_alias() {
assert_eq!(config.prefer_bwrap, Some(true));
}
#[test]
fn prefers_bwrap_defaults_on_and_honors_explicit_opt_out() {
assert!(Config::default().prefers_bwrap());
assert!(
Config {
prefer_bwrap: Some(true),
..Config::default()
}
.prefers_bwrap()
);
assert!(
!Config {
prefer_bwrap: Some(false),
..Config::default()
}
.prefers_bwrap()
);
}
struct EnvGuard {
// Seal path overrides through EnvVarGuard so default_config_path honors
// this fixture instead of the isolated test root (#5355, #5359).
+2 -1
View File
@@ -662,7 +662,8 @@ impl Default for EngineConfig {
user_input_limits: crate::tools::user_input::UserInputLimits::default(),
user_input_timeout: None,
goal_max_steps: None,
prefer_bwrap: false,
// Mirrors `Config::prefers_bwrap`: on unless explicitly opted out.
prefer_bwrap: true,
bwrap_extensions: crate::sandbox::BwrapMountExtensions::default(),
// Fail-closed (F7): `Engine::new` unconditionally installs this
// list process-wide via `read_guard::set_active`, so a default
+43 -10
View File
@@ -617,7 +617,7 @@ pub(crate) async fn run_exec_agent(
subagent_heartbeat_timeout: std::time::Duration::from_secs(
execution_config.subagent_heartbeat_timeout_secs_for_provider(&effective_identity),
),
prefer_bwrap: execution_config.prefer_bwrap.unwrap_or(false),
prefer_bwrap: execution_config.prefers_bwrap(),
bwrap_extensions: crate::sandbox::BwrapMountExtensions {
read_only_roots: execution_config.bwrap_ro_roots.clone(),
device_roots: execution_config.bwrap_dev_roots.clone(),
@@ -691,6 +691,47 @@ pub(crate) async fn run_exec_agent(
// The Full Access posture travels in the op's auto_approve/approval_mode
// fields; modes no longer carry permission.
let mode = AppMode::Agent;
let turn_approval_mode = if auto_approve {
ApprovalMode::Bypass
} else {
execution_config
.approval_policy
.as_deref()
.and_then(ApprovalMode::from_config_value)
.unwrap_or_default()
};
// A restricted posture that resolves to no enforcing sandbox must reach
// the person running exec, not only the model's posture line: policy-only
// is accepted (read-only already fails closed in the shell tool), but it
// is never silent. Mirrors the enforcement detection in `Engine::new`.
let turn_sandbox_policy = crate::core::authority::sandbox_policy_for_turn(
mode,
turn_approval_mode,
execution_config.sandbox_mode.as_deref(),
&workspace,
crate::core::authority::SandboxNetworkAccess::from_config(
execution_config.sandbox_network_access,
),
);
let enforcement_unavailable = crate::sandbox::backend::SandboxKind::parse(
execution_config
.sandbox_backend
.as_deref()
.unwrap_or("none"),
)
.is_none()
&& crate::sandbox::get_platform_sandbox_with_bwrap_preference(
execution_config.prefers_bwrap(),
)
.is_none();
if enforcement_unavailable && turn_sandbox_policy.should_sandbox() {
eprintln!(
"warning: {} — shell commands will run unrestricted",
turn_sandbox_policy.posture_label_with_enforcement(
crate::sandbox::policy::SandboxEnforcement::Unavailable,
)
);
}
let resuming_session = resume_session.is_some();
let mut loaded_session_id = None;
@@ -762,15 +803,7 @@ pub(crate) async fn run_exec_agent(
trust_mode,
auto_approve,
translation_enabled: false,
approval_mode: if auto_approve {
ApprovalMode::Bypass
} else {
execution_config
.approval_policy
.as_deref()
.and_then(ApprovalMode::from_config_value)
.unwrap_or_default()
},
approval_mode: turn_approval_mode,
verbosity: execution_config.verbosity.clone(),
provenance: crate::core::ops::UserInputProvenance::ExternalUser,
// Headless exec does not correlate submissions.
+7 -10
View File
@@ -4200,9 +4200,8 @@ fn run_setup(
println!(" Next: run `/plugin validate`, review `example`, then trust and enable it.");
}
let sandbox = crate::sandbox::get_platform_sandbox_with_bwrap_preference(
config.prefer_bwrap.unwrap_or(false),
);
let sandbox =
crate::sandbox::get_platform_sandbox_with_bwrap_preference(config.prefers_bwrap());
if let Some(kind) = sandbox {
println!(" ✓ Sandbox available: {kind}");
} else {
@@ -4592,9 +4591,8 @@ fn run_setup_status(
crate::utils::display_path(&plugins_dir)
);
let sandbox = crate::sandbox::get_platform_sandbox_with_bwrap_preference(
config.prefer_bwrap.unwrap_or(false),
);
let sandbox =
crate::sandbox::get_platform_sandbox_with_bwrap_preference(config.prefers_bwrap());
match sandbox {
Some(kind) => println!(
" {} sandbox: {kind}",
@@ -5884,9 +5882,8 @@ async fn run_doctor(
println!(" OS: {}", std::env::consts::OS);
println!(" Arch: {}", std::env::consts::ARCH);
let sandbox = crate::sandbox::get_platform_sandbox_with_bwrap_preference(
config.prefer_bwrap.unwrap_or(false),
);
let sandbox =
crate::sandbox::get_platform_sandbox_with_bwrap_preference(config.prefers_bwrap());
if let Some(kind) = sandbox {
println!(
" {} sandbox available: {}",
@@ -8052,7 +8049,7 @@ fn run_doctor_json(
},
},
"sandbox": match crate::sandbox::get_platform_sandbox_with_bwrap_preference(
config.prefer_bwrap.unwrap_or(false),
config.prefers_bwrap(),
) {
Some(kind) => json!({"available": true, "kind": kind.to_string()}),
None => json!({"available": false, "kind": null}),
+1 -1
View File
@@ -15206,7 +15206,7 @@ impl RuntimeThreadManager {
subagent_heartbeat_timeout: std::time::Duration::from_secs(
cfg.subagent_heartbeat_timeout_secs_for_provider(&route_identity),
),
prefer_bwrap: cfg.prefer_bwrap.unwrap_or(false),
prefer_bwrap: cfg.prefers_bwrap(),
bwrap_extensions: crate::sandbox::BwrapMountExtensions {
read_only_roots: cfg.bwrap_ro_roots.clone(),
device_roots: cfg.bwrap_dev_roots.clone(),
+62 -8
View File
@@ -6,9 +6,11 @@
//!
//! # How it works
//!
//! When `/usr/bin/bwrap` is executable AND the top-level config key
//! `prefer_bwrap` is set to `true`, exec_shell commands are routed through
//! bwrap. The bwrap invocation looks like:
//! When `/usr/bin/bwrap` actually works — it is executable AND it can create
//! its namespaces on this host — sandboxed exec_shell commands are routed
//! through it by default. `prefer_bwrap = false` in the config opts out and
//! leaves Linux commands unwrapped; the posture then reports policy-only.
//! The bwrap invocation looks like:
//!
//! ```text
//! bwrap \
@@ -43,9 +45,10 @@
//! - Fedora: `dnf install bubblewrap`
//! - Arch: `pacman -S bubblewrap`
//!
//! If bwrap is not executable, Codewhale reports no Linux OS sandbox and runs
//! the command without an OS wrapper. It never labels that fallback as
//! sandboxed.
//! If bwrap is missing or cannot run (e.g. user namespaces are restricted,
//! as on Ubuntu 24.04 with `kernel.apparmor_restrict_unprivileged_userns`),
//! Codewhale reports no Linux OS sandbox and runs the command without an OS
//! wrapper. It never labels that fallback as sandboxed.
#[cfg(target_os = "linux")]
use super::policy::WritableRoot;
@@ -66,10 +69,61 @@ pub(crate) fn existing_directory_shim(path: &Path) -> Option<PathBuf> {
#[cfg(target_os = "linux")]
pub const BWRAP_PATH: &str = "/usr/bin/bwrap";
/// Check if bubblewrap is installed and executable.
/// How long the availability probe may take. A working bwrap runs a wrapped
/// `/bin/true` in well under a second, even on a loaded host.
#[cfg(target_os = "linux")]
const PROBE_DEADLINE: std::time::Duration = std::time::Duration::from_secs(10);
/// Whether bwrap is executable AND can actually confine a child here.
///
/// The exec bit alone lies on hosts where user namespaces are restricted:
/// bwrap starts but cannot create the sandbox, so every wrapped command
/// would fail instead of running unsandboxed. The check therefore runs a
/// real wrapped `/bin/true` once, built by [`build_bwrap_command`] itself so
/// the probe covers the same argument shape the real path uses, and caches
/// the verdict for the process.
#[cfg(target_os = "linux")]
pub fn is_available() -> bool {
is_executable(std::path::Path::new(BWRAP_PATH))
fn probe() -> bool {
use wait_timeout::ChildExt as _;
if !is_executable(std::path::Path::new(BWRAP_PATH)) {
return false;
}
let command = build_bwrap_command(
std::path::Path::new("/"),
"/bin/true",
&[],
&[],
false,
&crate::sandbox::BwrapMountExtensions::default(),
&[],
&[],
);
let Some((program, args)) = command.split_first() else {
return false;
};
let mut child = match std::process::Command::new(program)
.args(args)
.env_clear()
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
{
Ok(child) => child,
Err(_) => return false,
};
match child.wait_timeout(PROBE_DEADLINE) {
Ok(Some(status)) => status.success(),
_ => {
let _ = child.kill();
let _ = child.wait();
false
}
}
}
static VERDICT: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*VERDICT.get_or_init(probe)
}
#[cfg(target_os = "linux")]
+76 -7
View File
@@ -9,10 +9,11 @@
//! # Platform Support
//!
//! - **macOS**: Uses Seatbelt (`sandbox-exec`) when the runtime probe succeeds
//! - **Linux**: Uses bubblewrap only when the user opts in and `/usr/bin/bwrap`
//! is executable. The seccomp helper is not wired into child execution and
//! therefore is not advertised. The extension host is the exception to the
//! opt-in: it uses bubblewrap whenever a probe shows it works
//! - **Linux**: Uses bubblewrap by default whenever `/usr/bin/bwrap` is
//! installed and a probe shows it can create its namespaces; `prefer_bwrap
//! = false` opts out. The seccomp helper is not wired into child execution
//! and therefore is not advertised. The extension host uses bubblewrap on
//! the same condition, probed per launch
//! (`extension_host::supervisor::plan_launch`).
//! - **OpenHarmony**: No local Linux sandbox is advertised. Bubblewrap,
//! seccomp, and Linux `prctl` hardening are gated out under `target_env =
@@ -67,7 +68,7 @@ pub use policy::SandboxPolicy;
// renaming it silently breaks that gate.
pub const PUBLIC_SANDBOX_BACKENDS: &[&str] = &[
"seatbelt (macOS, when available)",
"bubblewrap (Linux, opt-in when installed)",
"bubblewrap (Linux, default when installed and working)",
];
/// Specification for a command to be executed, potentially within a sandbox.
@@ -344,8 +345,11 @@ pub fn get_platform_sandbox() -> Option<SandboxType> {
/// Detect the sandbox wrapper the configured command path can actually use.
///
/// Linux bubblewrap is deliberately opt-in. Source-only sandbox prototypes do
/// not make commands sandboxed unless the child launch path applies them.
/// Linux bubblewrap is on by default via `Config::prefers_bwrap` (an
/// explicit `prefer_bwrap = false` opts out) and only selected when a real
/// wrapped probe run proves it works on this host. Source-only sandbox
/// prototypes do not make commands sandboxed unless the child launch path
/// applies them.
pub fn get_platform_sandbox_with_bwrap_preference(prefer_bwrap: bool) -> Option<SandboxType> {
#[cfg(target_os = "macos")]
{
@@ -1309,6 +1313,71 @@ mod tests {
let _ = env;
}
/// Real Linux enforcement proof, not a marker check: the same
/// outside-workspace write succeeds unsandboxed and fails under
/// bubblewrap, and a normal workspace write succeeds inside the wrapper.
/// Skips on hosts where the functional probe says bwrap cannot run.
#[test]
#[cfg(all(target_os = "linux", not(target_env = "ohos")))]
fn bwrap_workspace_write_blocks_outside_write_allows_inside() {
if !bwrap::is_available() {
return;
}
let workspace = tempfile::tempdir().expect("workspace tempdir");
let home = std::env::var_os("HOME").map(PathBuf::from).unwrap();
let outside = tempfile::Builder::new()
.prefix("cw_bwrap_outside")
.tempdir_in(&home)
.expect("outside-workspace tempdir under HOME");
let outside_target = outside.path().join("must_not_persist");
let inside_target = workspace.path().join("allowed_write");
let run = |manager: &SandboxManager, command: &str| -> (SandboxType, bool) {
let spec = CommandSpec::shell(
command,
workspace.path().to_path_buf(),
Duration::from_secs(15),
)
.with_policy(SandboxPolicy::default());
let env = manager.prepare(&spec);
let (program, args) = env.command.split_first().unwrap();
let status = std::process::Command::new(program)
.args(args)
.current_dir(&env.cwd)
.envs(&env.env)
.status()
.expect("spawn prepared command");
(env.sandbox_type, status.success())
};
// Control: without the wrapper the outside write lands on the host —
// this proves the command itself is permitted and only the sandbox
// confines it.
let unwrapped = SandboxManager::default();
let (kind, ok) = run(
&unwrapped,
&format!("echo x > {}", outside_target.display()),
);
assert_eq!(kind, SandboxType::None);
assert!(ok);
assert!(outside_target.exists());
std::fs::remove_file(&outside_target).unwrap();
let wrapped = SandboxManager::with_bwrap_preference(true);
let (kind, ok) = run(&wrapped, &format!("echo x > {}", outside_target.display()));
assert_eq!(kind, SandboxType::LinuxBubblewrap);
assert!(!ok);
assert!(!outside_target.exists());
let (kind, ok) = run(&wrapped, &format!("echo ok > {}", inside_target.display()));
assert_eq!(kind, SandboxType::LinuxBubblewrap);
assert!(ok);
assert_eq!(
std::fs::read_to_string(&inside_target).unwrap().trim(),
"ok"
);
}
#[test]
#[cfg(all(target_os = "linux", not(target_env = "ohos")))]
fn bwrap_read_only_policy_keeps_the_working_directory_read_only() {
+3 -2
View File
@@ -1780,8 +1780,9 @@ pub struct App {
pub configured_sandbox_network: Option<bool>,
/// The sandbox backend this platform+config can actually enforce with,
/// resolved once at startup. `None` means there is NO enforcement
/// available (default Linux without `prefer_bwrap`, and all Windows), so
/// surfaces must not claim the session is sandboxed (2026-08-04 audit).
/// available (Linux with `prefer_bwrap = false` or no working bwrap, and
/// all Windows), so surfaces must not claim the session is sandboxed
/// (2026-08-04 audit).
pub sandbox_backend: Option<crate::sandbox::SandboxType>,
/// Off-event-loop worker for durable Lane control writes. `/lane interrupt`
/// submits here instead of tearing down a Runtime on the composer thread
+1 -1
View File
@@ -874,7 +874,7 @@ impl App {
configured_sandbox_mode: config.sandbox_mode.clone(),
configured_sandbox_network: config.sandbox_network_access,
sandbox_backend: crate::sandbox::get_platform_sandbox_with_bwrap_preference(
config.prefer_bwrap.unwrap_or(false),
config.prefers_bwrap(),
),
// #4022: the worker thread is spawned lazily on first submit, so
// constructing an App never costs a thread.
+1 -1
View File
@@ -1039,7 +1039,7 @@ pub(crate) fn build_engine_config(app: &App, config: &Config) -> EngineConfig {
|| config.subagent_heartbeat_timeout_secs(),
|identity| config.subagent_heartbeat_timeout_secs_for_provider(identity),
)),
prefer_bwrap: config.prefer_bwrap.unwrap_or(false),
prefer_bwrap: config.prefers_bwrap(),
bwrap_extensions: crate::sandbox::BwrapMountExtensions {
read_only_roots: config.bwrap_ro_roots.clone(),
device_roots: config.bwrap_dev_roots.clone(),
+3 -3
View File
@@ -2190,9 +2190,9 @@ reasoning contract, and all four membership ids omit generic sampling fields.
```
- `sandbox_mode` (string, optional): `read-only`, `workspace-write`, `danger-full-access`, `external-sandbox`.
Platform support is not identical. macOS uses Seatbelt when its runtime
probe succeeds. Linux uses bubblewrap only when `prefer_bwrap = true` and
`/usr/bin/bwrap` is executable; without that opt-in it reports no OS command
sandbox. Windows does not currently advertise an OS sandbox; its planned helper contract starts
probe succeeds. Linux uses bubblewrap by default whenever `/usr/bin/bwrap`
is installed and a probe proves it can confine a child; `prefer_bwrap =
false` opts out and reports no OS command sandbox. Windows does not currently advertise an OS sandbox; its planned helper contract starts
with process-tree containment only and must not be described as read-only
filesystem isolation, workspace-write enforcement, network blocking,
registry isolation, or AppContainer isolation until those are implemented.
+21 -14
View File
@@ -16,8 +16,8 @@ run before execution reaches this boundary.
| Mechanism | Platform | Selection | What Codewhale reports |
|---|---|---|---|
| Seatbelt (`sandbox-exec`) | macOS | Automatic when the runtime probe succeeds | `macos-seatbelt` |
| Bubblewrap (`/usr/bin/bwrap`) | Linux | `prefer_bwrap = true` and the file is executable | `linux-bwrap` |
| No OS wrapper | Linux without usable opt-in bwrap | Default | `none` |
| Bubblewrap (`/usr/bin/bwrap`) | Linux | Default when installed and a wrapped probe run succeeds; `prefer_bwrap = false` opts out | `linux-bwrap` |
| No OS wrapper | Linux without usable bwrap, or opted out | `prefer_bwrap = false`, or bwrap absent/unusable | `none` |
| No OS wrapper | Windows | Current implementation | `none` |
| OpenSandbox-compatible service | Any supported host | `sandbox_backend = "opensandbox"` | External execution path |
@@ -43,16 +43,21 @@ If the probe fails or `sandbox-exec` is unavailable, Codewhale reports no OS
sandbox and launches the command without a Seatbelt wrapper. It does not set a
Seatbelt marker on that fallback.
## Linux: opt-in bubblewrap
## Linux: default bubblewrap
Linux command sandboxing is opt-in. Set the top-level configuration key:
Linux command sandboxing is on by default whenever bubblewrap works. Set the
top-level configuration key to opt out:
```toml
prefer_bwrap = true
prefer_bwrap = false
```
Codewhale selects bubblewrap only when `/usr/bin/bwrap` is a regular executable
file. The wrapper derives its mounts and network namespace from the resolved
Codewhale selects bubblewrap only when `/usr/bin/bwrap` is a regular
executable file AND a wrapped probe run proves it can create its namespaces
on this host — the exec bit alone lies where user namespaces are restricted
(e.g. Ubuntu 24.04 with `kernel.apparmor_restrict_unprivileged_userns`),
where every wrapped command would fail rather than run unsandboxed. The
wrapper derives its mounts and network namespace from the resolved
`SandboxPolicy`:
```text
@@ -91,11 +96,12 @@ by default. Codewhale adds `--share-net` only when the policy's
`network_access` is true. `danger-full-access` and `external-sandbox` bypass the
local wrapper entirely.
If the user does not opt in, or `/usr/bin/bwrap` is missing or non-executable,
Codewhale reports `none` and launches the command without a Linux OS wrapper.
There is no marker-only fallback to a different Linux sandbox.
If the user opts out, or `/usr/bin/bwrap` is missing, non-executable, or
cannot actually confine a child, Codewhale reports `none` and launches the
command without a Linux OS wrapper. There is no marker-only fallback to a
different Linux sandbox.
Install bubblewrap separately when this opt-in fits the workflow:
Install bubblewrap separately to get enforcement on Linux:
- Ubuntu/Debian: `apt install bubblewrap`
- Fedora: `dnf install bubblewrap`
@@ -180,8 +186,9 @@ backend:
- `CODEWHALE_SANDBOX_URL`
- `CODEWHALE_SANDBOX_API_KEY`
There is no `CODEWHALE_PREFER_BWRAP` environment override; use the top-level
`prefer_bwrap` config key.
`CODEWHALE_PREFER_BWRAP` (legacy alias `DEEPSEEK_PREFER_BWRAP`) overrides the
preference explicitly; the top-level `prefer_bwrap` config key is the durable
setting.
## Diagnostics and failure attribution
@@ -219,7 +226,7 @@ Denial attribution is intentionally conservative:
- `crates/tui/src/sandbox/mod.rs` — truthful selection and public capability markers
- `crates/tui/src/sandbox/seatbelt.rs` — macOS wrapper and availability probe
- `crates/tui/src/sandbox/bwrap.rs` — Linux opt-in wrapper
- `crates/tui/src/sandbox/bwrap.rs` — Linux wrapper and functional availability probe
- `crates/tui/src/sandbox/process_hardening.rs` — Linux parent-process hardening
- `crates/tui/src/sandbox/backend.rs` — external backend selection
- `crates/tui/src/tools/diagnostics.rs` — machine-readable diagnostics
+1 -1
View File
@@ -277,7 +277,7 @@ owner = "crates/execpolicy/src/approval_mode.rs"
[[feature]]
id = "sandbox"
name = "Command sandbox"
summary = "Wraps commands in Seatbelt on macOS; bubblewrap on Linux is opt-in."
summary = "Wraps commands in Seatbelt on macOS; bubblewrap on Linux is on by default once installed and verified."
status = "stable"
surfaces = { tui = "stable" }
docs = "docs/SANDBOX.md"
+2 -2
View File
@@ -25,7 +25,7 @@
"toolCount": 80,
"sandboxBackends": [
"seatbelt (macOS, when available)",
"bubblewrap (Linux, opt-in when installed)"
"bubblewrap (Linux, default when installed and working)"
],
"sources": [
"Cargo.toml",
@@ -185,7 +185,7 @@
"telemetry": "Codewhale 0.10.1 counts anonymous usage by default and discloses it at first launch (policy notice version 5, naming Codewhale and PostHog as processors), while the earlier 0.9.11 release asked first; turning it off is a saved choice that later versions keep, an opt-out recorded under the earlier opt-in policy stays off, and showing the notice never records any acceptance on the user's behalf; Codewhale does not collect conversations, code, prompts, files, file/repo/branch names, model content, credentials, or per-turn/per-tool timelines; while on, sessions post aggregate counts and closed enums to the first-party endpoint https://telemetry.codewhale.net/v1/telemetry, whose storage has no IP, country, or geo column and a fixed three-month retention; optional PostHog forwarding requires separate operator configuration and verified IP-safe egress, and its retention is a separate project setting; telemetry_endpoint = \"\" writes only to a local dry-run file; no mandatory hosted relay",
"account": "no account required for the local runtime",
"plan": "Plan centrally refuses file mutation and shell execution; policy-allowed research may contact external services and local session state can still be persisted.",
"sandbox": "Seatbelt is used on macOS when available; Linux bubblewrap is opt-in and must be installed; Windows currently reports no OS sandbox",
"sandbox": "Seatbelt is used on macOS when available; Linux bubblewrap is on by default once installed and verified working, and prefer_bwrap = false opts out; Windows currently reports no OS sandbox",
"audit": "sensitive events append best-effort to $CODEWHALE_HOME/audit.log (default ~/.codewhale/audit.log); write failures are logged",
"usage": "provider token and cache usage is shown locally when available",
"sources": [
+1 -1
View File
@@ -1194,7 +1194,7 @@ DeepSeek V4 前缀缓存让 token 标签变得重要。这些数量保持分离
timeout_seconds = 300
```
- `sandbox_mode`(字符串,可选):`read-only`、`workspace-write`、`danger-full-access`、`external-sandbox`。各平台的支持并不相同。macOS 在其运行时探测成功时使用 Seatbelt。Linux 只在 `prefer_bwrap = true` 且 `/usr/bin/bwrap` 可执行时使用 bubblewrap;没有这一选择加入时,它会报告没有 OS 命令沙箱。Windows 目前不宣称有 OS 沙箱;其规划中的辅助程序契约只从进程树隔离开始,在只读文件系统隔离、workspace-write 强制、网络阻断、注册表隔离或 AppContainer 隔离真正实现之前,不得被描述成这些能力。
- `sandbox_mode`(字符串,可选):`read-only`、`workspace-write`、`danger-full-access`、`external-sandbox`。各平台的支持并不相同。macOS 在其运行时探测成功时使用 Seatbelt。Linux 默认在 `/usr/bin/bwrap` 已安装且探测证明它能限制子进程时使用 bubblewrap;`prefer_bwrap = false` 退出并报告没有 OS 命令沙箱。Windows 目前不宣称有 OS 沙箱;其规划中的辅助程序契约只从进程树隔离开始,在只读文件系统隔离、workspace-write 强制、网络阻断、注册表隔离或 AppContainer 隔离真正实现之前,不得被描述成这些能力。
- 模式准入、hooks、已注册工具的要求、类型化规则、Auto-Review、仓库保护规则、人工审批和执行沙箱之间的跨层关系,定义在[授权顺序](../AUTHORIZATION_ORDER.md)中。
- **读取拒绝列表。** 每一种沙箱档位——包括 `read-only`——都授予对整个文件系统的读取权限;这些档位的区别在于它们可以*写入*什么,以及能否访问网络。读取拒绝列表会收窄这一点:
- `sandbox_read_denylist_defaults`(bool,默认 `true`):应用内置的凭据存储集合——`~/.ssh`、`~/.gnupg`、云凭据目录(`~/.aws`、`~/.config/gcloud`、`~/.azure`、`~/.kube` 等)、`~/.netrc`、`~/.npmrc`、`~/.git-credentials`、macOS 钥匙串、浏览器配置文件、Codewhale 自己的机密存储,以及 `.env` 文件(但不包括 `.env.example` 之类)。普通源码、`Cargo.toml`、`~/.gitconfig`、`~/.cargo` 和 `~/.npm` 保持可读,因此构建和测试仍能工作。设为 `false` 可恢复 0.9.12 之前的整盘读取行为。
+15 -11
View File
@@ -15,8 +15,8 @@ Codewhale 可以执行由模型提出的 shell 命令。审批策略、感知工
| 机制 | 平台 | 选择方式 | Codewhale 报告的结果 |
|---|---|---|---|
| Seatbelt(`sandbox-exec`) | macOS | 运行时探测成功时自动启用 | `macos-seatbelt` |
| Bubblewrap(`/usr/bin/bwrap`) | Linux | `prefer_bwrap = true` 且该文件可执行 | `linux-bwrap` |
| 无操作系统包装器 | Linux,没有可用且已启用的 bwrap | 默认 | `none` |
| Bubblewrap(`/usr/bin/bwrap`) | Linux | 默认启用,前提是已安装且探测包装运行成功;`prefer_bwrap = false` 可退出 | `linux-bwrap` |
| 无操作系统包装器 | Linux,bwrap 不可用或已退出 | `prefer_bwrap = false`,或 bwrap 不存在/不可用 | `none` |
| 无操作系统包装器 | Windows | 当前实现 | `none` |
| 兼容 OpenSandbox 的服务 | 任何受支持的主机 | `sandbox_backend = "opensandbox"` | 外部执行路径 |
@@ -39,15 +39,18 @@ Seatbelt profile。
探测失败,或 `sandbox-exec` 不可用时,Codewhale 会报告未启用操作系统沙箱,
直接启动命令,不套 Seatbelt 包装器。这条回退路径上也不会打任何 Seatbelt 标记。
## Linux:需要主动启用的 bubblewrap
## Linux:默认启用的 bubblewrap
Linux 下的命令沙箱需要主动启用。设置顶层配置项:
只要 bubblewrap 可用,Linux 下的命令沙箱默认启用。退出使用顶层配置项:
```toml
prefer_bwrap = true
prefer_bwrap = false
```
只有当 `/usr/bin/bwrap` 是普通的可执行文件时,Codewhale 才会选用 bubblewrap。
只有当 `/usr/bin/bwrap` 是普通的可执行文件,且一次实际的包装探测运行证明
它能在这台主机上创建命名空间时,Codewhale 才会选用 bubblewrap —— 仅有可执行
位会骗人:在限制用户命名空间的主机上(例如开启了 `kernel.apparmor_restrict_unprivileged_userns`
的 Ubuntu 24.04),每条被包装的命令都会失败,而不是以未沙箱化方式运行。
包装器根据解析后的 `SandboxPolicy` 推导自己的挂载点和网络命名空间:
```text
@@ -84,11 +87,11 @@ prefer_bwrap = true
时,Codewhale 才补上 `--share-net`。`danger-full-access` 和 `external-sandbox`
完全绕过本地包装器。
如果用户没有主动启用,或者 `/usr/bin/bwrap` 不存在、不可执行,Codewhale 会
报告 `none`,直接启动命令,不带任何 Linux 操作系统包装器。这里没有回退做法:
不会只打个标记,就把它当成另一种 Linux 沙箱。
如果用户选择退出,或者 `/usr/bin/bwrap` 不存在、不可执行、或实际上无法
限制子进程,Codewhale 会报告 `none`,直接启动命令,不带任何 Linux 操作
系统包装器。这里没有回退做法:不会只打个标记,就把它当成另一种 Linux 沙箱。
如果这套主动启用的方案适合你的工作流,请另行安装 bubblewrap:
在 Linux 上获得强制力,请另行安装 bubblewrap:
- Ubuntu/Debian:`apt install bubblewrap`
- Fedora:`dnf install bubblewrap`
@@ -165,7 +168,8 @@ sandbox_mode = "workspace-write" # read-only | workspace-write | danger-full-acc
- `CODEWHALE_SANDBOX_URL`
- `CODEWHALE_SANDBOX_API_KEY`
不存在 `CODEWHALE_PREFER_BWRAP` 环境变量覆盖;请使用顶层的 `prefer_bwrap` 配置项。
`CODEWHALE_PREFER_BWRAP`(旧别名 `DEEPSEEK_PREFER_BWRAP`)可显式覆盖此偏好;
顶层 `prefer_bwrap` 配置项是持久设置。
## 诊断与失败归因
+2 -2
View File
@@ -257,7 +257,7 @@ codewhale --provider openrouter --model deepseek/deepseek-v4-pro
provider you select receives the prompt, project context, tool definitions,
and tool results required for that turn. Use a loopback local-model route to
keep model inference local.
OS command sandboxing is platform-specific: Codewhale uses <strong>Seatbelt</strong> on macOS when available. On Linux it uses <strong>bubblewrap</strong> only when <code className="inline">prefer_bwrap = true</code> and <code className="inline">/usr/bin/bwrap</code> is executable; otherwise commands have no Codewhale OS wrapper. Windows currently reports no OS sandbox.
OS command sandboxing is platform-specific: Codewhale uses <strong>Seatbelt</strong> on macOS when available. On Linux it uses <strong>bubblewrap</strong> by default whenever <code className="inline">/usr/bin/bwrap</code> is installed and a probe shows it works; <code className="inline">prefer_bwrap = false</code> opts out. Otherwise commands have no Codewhale OS wrapper. Windows currently reports no OS sandbox.
Workspace boundaries default to <code className="inline">--workspace</code>. <code className="inline">/trust</code> lifts them.
Permission posture is configurable per session.
</>
@@ -618,7 +618,7 @@ codewhale --provider openrouter --model deepseek/deepseek-v4-pro
可用 <code className="inline">codewhale config set telemetry false</code> 或
<code className="inline">CODEWHALE_TELEMETRY=0</code> 关闭)。也不要求经过托管中继。你选择的托管 provider 会收到本轮所需的
prompt、项目上下文、工具定义与工具结果。若要让模型推理也保持本地,请使用回环地址上的本地模型路由。
OS 命令沙箱因平台而异:macOS 在可用时使用 <strong>Seatbelt</strong>。Linux 仅在 <code className="inline">prefer_bwrap = true</code> 且 <code className="inline">/usr/bin/bwrap</code> 可执行时使用 <strong>bubblewrap</strong>;否则命令没有 Codewhale OS 包装器。Windows 当前报告无 OS 沙箱。
OS 命令沙箱因平台而异:macOS 在可用时使用 <strong>Seatbelt</strong>。Linux 默认在 <code className="inline">/usr/bin/bwrap</code> 已安装且探测可用时使用 <strong>bubblewrap</strong>;<code className="inline">prefer_bwrap = false</code> 退出。否则命令没有 Codewhale OS 包装器。Windows 当前报告无 OS 沙箱。
工作区边界默认为 <code className="inline">--workspace</code>。<code className="inline">/trust</code> 可解除边界。
权限姿态可按会话配置。
</>
+5 -5
View File
@@ -1206,7 +1206,7 @@
],
[
"Linux",
"Bubblewrap, but only if you turn it on (below). Without it, commands run with no OS sandbox."
"Bubblewrap, automatically, once it is installed and verified working (opt out below). Without it, commands run with no OS sandbox."
],
[
"Windows",
@@ -1232,17 +1232,17 @@
},
{
"id": "linux",
"title": "Turn on the Linux sandbox",
"title": "Enable (or turn off) the Linux sandbox",
"blocks": [
{
"p": "Install bubblewrap, then opt in with one line in `~/.codewhale/config.toml`:"
"p": "Install bubblewrap — Codewhale uses it by default. To run Linux commands unwrapped instead, opt out with one line in `~/.codewhale/config.toml`:"
},
{
"code": "sudo apt install bubblewrap # Fedora: dnf install bubblewrap · Arch: pacman -S bubblewrap\n\n# ~/.codewhale/config.toml\nprefer_bwrap = true",
"code": "sudo apt install bubblewrap # Fedora: dnf install bubblewrap · Arch: pacman -S bubblewrap\n\n# ~/.codewhale/config.toml — optional opt-out\nprefer_bwrap = false",
"lang": "Terminal / config.toml"
},
{
"p": "Codewhale uses `/usr/bin/bwrap` only when that file exists and is executable. Commands then see a read-only view of the system, write only where the sandbox mode allows, and have no network unless the mode enables it."
"p": "Codewhale uses `/usr/bin/bwrap` only when that file exists, is executable, and a probe shows it can create its sandbox namespaces — an installed-but-blocked bwrap is treated as absent, not used. Commands then see a read-only view of the system, write only where the sandbox mode allows, and have no network unless the mode enables it."
}
]
},
+5 -5
View File
@@ -1206,7 +1206,7 @@
],
[
"Linux",
"bubblewrap,但需要你手动开启(见下文)。不开启时,命令在没有操作系统沙箱的情况下运行。"
"bubblewrap,安装并验证可用后自动启用(退出见下文)。不可用时,命令在没有操作系统沙箱的情况下运行。"
],
[
"Windows",
@@ -1232,17 +1232,17 @@
},
{
"id": "linux",
"title": "开启 Linux 沙箱",
"title": "启用(或关闭)Linux 沙箱",
"blocks": [
{
"p": "先安装 bubblewrap,再在 `~/.codewhale/config.toml` 中加一行来启用:"
"p": "安装 bubblewrap 后 Codewhale 默认使用它。想让 Linux 命令不被包装,在 `~/.codewhale/config.toml` 中加一行退出:"
},
{
"code": "sudo apt install bubblewrap # Fedora: dnf install bubblewrap · Arch: pacman -S bubblewrap\n\n# ~/.codewhale/config.toml\nprefer_bwrap = true",
"code": "sudo apt install bubblewrap # Fedora: dnf install bubblewrap · Arch: pacman -S bubblewrap\n\n# ~/.codewhale/config.toml — 可选退出\nprefer_bwrap = false",
"lang": "终端 / config.toml"
},
{
"p": "只有当 `/usr/bin/bwrap` 存在且可执行时,Codewhale 才会使用它。此后,命令看到的是只读的系统视图,只能写入沙箱模式允许的位置,除非模式允许,否则无法联网。"
"p": "只有当 `/usr/bin/bwrap` 存在、可执行,且探测证明它能创建沙箱命名空间时,Codewhale 才会使用它——装了但被系统拦住的 bwrap 按不存在处理。此后,命令看到的是只读的系统视图,只能写入沙箱模式允许的位置,除非模式允许,否则无法联网。"
}
]
},
+2 -2
View File
@@ -37,7 +37,7 @@ export interface RepoFacts {
}
export const FACTS: RepoFacts = {
"generatedAt": "2026-10-02T18:06:32.682Z",
"generatedAt": "2026-10-06T00:44:34.685Z",
"sourceRevision": null,
"sourceCommittedAt": null,
"version": "0.10.1",
@@ -73,7 +73,7 @@ export const FACTS: RepoFacts = {
],
"sandboxBackends": [
"seatbelt (macOS, when available)",
"bubblewrap (Linux, opt-in when installed)"
"bubblewrap (Linux, default when installed and working)"
],
"providers": [
{
+6 -6
View File
@@ -22,7 +22,7 @@ export const docsSandbox: DocsSandboxDict = {
{
rows: [
["macOS", "Seatbelt, automatically, when its startup check succeeds. Commands get broad read access, writes limited by the sandbox mode, and network only when the mode allows it."],
["Linux", "Bubblewrap, but only if you turn it on (below). Without it, commands run with no OS sandbox."],
["Linux", "Bubblewrap, automatically, once it is installed and verified working (opt out below). Without it, commands run with no OS sandbox."],
["Windows", "No OS sandbox today. Your approval setting and Windows permissions still apply."],
["External service", "With `sandbox_backend = \"opensandbox\"`, shell commands run on an OpenSandbox-compatible service you configure; its isolation is that service's to guarantee."],
],
@@ -36,18 +36,18 @@ export const docsSandbox: DocsSandboxDict = {
},
{
id: "linux",
title: "Turn on the Linux sandbox",
title: "Enable (or turn off) the Linux sandbox",
blocks: [
{ p: "Install bubblewrap, then opt in with one line in `~/.codewhale/config.toml`:" },
{ p: "Install bubblewrap — Codewhale uses it by default. To run Linux commands unwrapped instead, opt out with one line in `~/.codewhale/config.toml`:" },
{
code: `sudo apt install bubblewrap # Fedora: dnf install bubblewrap · Arch: pacman -S bubblewrap
# ~/.codewhale/config.toml
prefer_bwrap = true`,
# ~/.codewhale/config.toml — optional opt-out
prefer_bwrap = false`,
lang: "Terminal / config.toml",
},
{
p: "Codewhale uses `/usr/bin/bwrap` only when that file exists and is executable. Commands then see a read-only view of the system, write only where the sandbox mode allows, and have no network unless the mode enables it.",
p: "Codewhale uses `/usr/bin/bwrap` only when that file exists, is executable, and a probe shows it can create its sandbox namespaces — an installed-but-blocked bwrap is treated as absent, not used. Commands then see a read-only view of the system, write only where the sandbox mode allows, and have no network unless the mode enables it.",
},
],
},
+6 -6
View File
@@ -17,7 +17,7 @@ export const docsSandbox: DocsSandboxDict = {
{
rows: [
["macOS", "Seatbelt,启动检查通过后自动启用。命令可以广泛读取,写入范围由沙箱模式限定,只有模式允许时才能联网。"],
["Linux", "bubblewrap,但需要你手动开启(见下文)。不开启时,命令在没有操作系统沙箱的情况下运行。"],
["Linux", "bubblewrap,安装并验证可用后自动启用(退出见下文)。不可用时,命令在没有操作系统沙箱的情况下运行。"],
["Windows", "目前没有操作系统沙箱。你的审批设置和 Windows 自身的权限仍然有效。"],
["外部服务", "设置 `sandbox_backend = \"opensandbox\"` 后,shell 命令会在你配置的 OpenSandbox 兼容服务上运行;隔离效果由该服务负责保证。"],
],
@@ -31,18 +31,18 @@ export const docsSandbox: DocsSandboxDict = {
},
{
id: "linux",
title: "开启 Linux 沙箱",
title: "启用(或关闭)Linux 沙箱",
blocks: [
{ p: "先安装 bubblewrap,再在 `~/.codewhale/config.toml` 中加一行来启用:" },
{ p: "安装 bubblewrap 后 Codewhale 默认使用它。想让 Linux 命令不被包装,在 `~/.codewhale/config.toml` 中加一行退出:" },
{
code: `sudo apt install bubblewrap # Fedora: dnf install bubblewrap · Arch: pacman -S bubblewrap
# ~/.codewhale/config.toml
prefer_bwrap = true`,
# ~/.codewhale/config.toml — 可选退出
prefer_bwrap = false`,
lang: "终端 / config.toml",
},
{
p: "只有当 `/usr/bin/bwrap` 存在且可执行时,Codewhale 才会使用它。此后,命令看到的是只读的系统视图,只能写入沙箱模式允许的位置,除非模式允许,否则无法联网。",
p: "只有当 `/usr/bin/bwrap` 存在、可执行,且探测证明它能创建沙箱命名空间时,Codewhale 才会使用它——装了但被系统拦住的 bwrap 按不存在处理。此后,命令看到的是只读的系统视图,只能写入沙箱模式允许的位置,除非模式允许,否则无法联网。",
},
],
},