feat(extension-host): run the Linux host under bubblewrap, say why when it cannot, gate start and RSS

TS program Phase 1 (CURRENT_DECISIONS §26: "Linux/Windows host sandbox,
measured startup/RSS gates"). The extension host was OS-sandboxed only on
macOS: `plan_launch` built a `SandboxManager::new()` whose bwrap preference
is off, so on Linux `prepare` yielded `SandboxType::None` and the host ran
with the user's permissions.

Linux: the host now launches through the existing bwrap builder
(`SandboxManager::with_bwrap_preference(true)` → `build_bwrap_command`, no
second builder) with the macOS policy: no network, writable only its data
dir and the temp dirs, the same read deny-list. Because bwrap may be
installed yet unable to create its namespaces (Ubuntu 24.04's
kernel.apparmor_restrict_unprivileged_userns), every launch first runs the
finished wrapper around `<runtime> --version` (10 s cap). If bwrap is
missing or the probe fails, the host still starts as before, but the
sandbox is now a typed `HostSandbox::{Wrapped, Unsandboxed(reason)}` that
`HostLaunch`, `HostStatus::Ready`, `/plugin`, the start diagnostic and a new
doctor line all carry, e.g. `UNSANDBOXED (bwrap unavailable (bwrap: setting
up uid map: Permission denied; unprivileged user namespaces look blocked
...)): host code runs with your user permissions`. No silent fallback.
Windows stays unsandboxed exactly as before and now says why.

bwrap can mask only what exists, and the macOS test denies stores created
after launch by name. So under bwrap each Codewhale home is masked whole
and its readable entries (extension-host, plugins, builtin-plugins) are
bound again after the masks, with the data dir writable again and any
denied path inside an exception masked again
(`sandbox::bwrap_exception_args`, reached only when exceptions are set, so
the shell's bwrap argv is unchanged). Known limits are written in the
supervisor module docs: late-created default credential stores stay
readable under bwrap; the host's ppid watchdog cannot fire inside bwrap's
PID namespace, so `--die-with-parent` (already in the builder) is what ends
the host with the core; the reported pid is bwrap's.

macOS launch argv is unchanged: `with_bwrap_preference(false)` is
`new()`, `host_denied_read_paths(home, false)` returns the old list, and
the exception list is empty.

Gates: host.test.mjs now asserts the cold-start ready time <= 1500 ms and
idle RSS <= 160 MiB (measured here: Node 26 54 ms / 56 MiB, Bun 1.4
46 ms / 36 MiB); the Rust end-to-end DSH test asserts spawn+handshake+
activation <= 20 s and tree RSS (bwrap included) <= 160 MiB, skipped with
a printed reason where there is no `ps` (Windows). The isolation test runs
on Linux when bwrap works and skips only after an independent minimal bwrap
run also fails, printing the host's reason.

Evidence (macOS arm64, governed cargo wrapper):
- cargo test -p codewhale-tui --lib -- extension_host:: sandbox::
  test result: ok. 139 passed; 0 failed (CODEWHALE_EXT_HOST_TESTS=1,
  CODEWHALE_EXT_HOST_BUN_TESTS=1)
- npm test: tests 64, pass 63, fail 0, skipped 1; npm run test:bun:
  64 pass, 0 fail
- Linux-only bwrap.rs + helpers type-checked and clippy-clean for
  aarch64-unknown-linux-gnu with rustc/clippy-driver --emit=metadata
  (non-test and --test); the Linux runtime path itself has not run here.
- check-blocking-calls-budget: within budget (sandbox/mod.rs std_fs 2 -> 4:
  the deny-mask metadata call moved from bwrap.rs, which drops 2 -> 1, plus
  one canonicalize; all sync, reached through SandboxManager::prepare, which
  the host calls under spawn_blocking).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: CodeWhale Bot <[email protected]>
This commit is contained in:
CodeWhale BotandClaude Opus 5.5 committed 2026-09-30 10:49:35 -07:00
1 parent ce1ecc8dc2
commit b6a2c4cbac
10 files changed
+909 -135

No files matched your search

+27 -2
View File
@@ -5,11 +5,30 @@ import { mkdirSync, mkdtempSync, writeFileSync, rmSync } from 'node:fs'
import { createServer } from 'node:http'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { spawn } from 'node:child_process'
import { execFileSync, spawn } from 'node:child_process'
import { fileURLToPath } from 'node:url'
import { BUNDLE, HOST_ARGS, IS_BUN, activate, sha256File, startHost } from './harness.mjs'
import { encodeFrame } from '../dist/protocol.mjs'
// Budgets for a cold host start (the first host this file starts), gated in
// the first test. Measured 2026-09-30 on macOS arm64, 5 starts each: Node 26
// ready in 50-54 ms at 56 MiB resident, Bun 1.4 in 23-24 ms at 36 MiB; a
// Linux container measured 34-67 MB idle (`supervisor::HOST_MEMORY_CAP`).
// CI runs these suites on shared ubuntu-latest runners with test files in
// parallel, so each budget leaves room for load: 1500 ms is ~30x the Node
// start and ~15x the slowest runtime start measured for the host (Node SEA,
// 98 ms p50, CURRENT_DECISIONS D1); 160 MiB is 2.4x the largest idle host.
// The Rust integration test gates the core-side start and a tree RSS.
const READY_BUDGET_MS = 1500
const IDLE_RSS_BUDGET_MIB = 160
/** Resident MiB of `pid` from `ps`; `undefined` where there is no `ps`. */
function residentMiB(pid) {
if (process.platform === 'win32') return undefined
const kib = Number(execFileSync('ps', ['-o', 'rss=', '-p', String(pid)], { encoding: 'utf8' }).trim())
return Number.isFinite(kib) && kib > 0 ? kib / 1024 : undefined
}
function tempPlugin(source) {
const dir = mkdtempSync(join(tmpdir(), 'cw-ext-host-'))
const entry = join(dir, 'index.mjs')
@@ -27,7 +46,13 @@ test('handshake reports protocol 1 and the digest of the running bundle', async
assert.equal(host.hello.node_version, undefined)
// No limit was asked for, so none is reported.
assert.equal(host.hello.memory_limit_mib, undefined)
t.diagnostic(`spawn → host/ready: ${host.readyMs.toFixed(1)} ms`)
const rss = residentMiB(host.child.pid)
t.diagnostic(
`spawn → host/ready: ${host.readyMs.toFixed(1)} ms (budget ${READY_BUDGET_MS} ms); ` +
`idle RSS ${rss === undefined ? '?' : rss.toFixed(1)} MiB (budget ${IDLE_RSS_BUDGET_MIB} MiB)`,
)
assert.ok(host.readyMs <= READY_BUDGET_MS, `cold start took ${host.readyMs.toFixed(1)} ms, over ${READY_BUDGET_MS} ms`)
if (rss !== undefined) assert.ok(rss <= IDLE_RSS_BUDGET_MIB, `idle host is ${rss.toFixed(1)} MiB resident, over ${IDLE_RSS_BUDGET_MIB} MiB`)
})
test('heartbeat answers after initialization without an owner or tool call', async (t) => {
+35 -24
View File
@@ -41,14 +41,17 @@
//! (`supervisor::HostProcess::call`). Cancellation is a request: a plugin
//! that ignores its abort signal keeps running in the host until the host
//! is torn down, though the core has already failed the call.
//! * One host per engine process and one trust tier. On macOS (Seatbelt) the
//! host has no direct network, and cannot read the Codewhale home (except
//! the bundle, its data dir and plugin code), the Codex and DSH credential
//! homes, or the default credential stores (`supervisor::plan_launch`).
//! Other files the user can read — including project `.env` files — stay
//! readable, and Mach services are not restricted. On Linux and Windows it
//! runs unsandboxed with the user's permissions. Either way the flag is
//! Experimental.
//! * One host per engine process and one trust tier. Under its OS sandbox
//! (Seatbelt on macOS; bubblewrap on Linux when a launch-time probe shows it
//! works) the host has no direct network, and cannot read the Codewhale
//! home (except the bundle, its data dir and plugin code), the Codex and
//! DSH credential homes, or the default credential stores
//! (`supervisor::plan_launch`, whose module docs list what bubblewrap does
//! not cover). Other files the user can read — including project `.env`
//! files — stay readable, and Mach services are not restricted. On Windows,
//! and on Linux where bwrap is missing or cannot start, it runs unsandboxed
//! with the user's permissions, and `/plugin`, doctor and the start
//! diagnostic say why. Either way the flag is Experimental.
//! * The runtime (`[extension_host] runtime`) defaults to Node. Bun is an
//! opt-in (`bun`, or `auto`, which prefers a Bun >= 1.4.0 and uses Node
//! when none is found) and is qualified on macOS only. The runtime is
@@ -66,7 +69,7 @@
//! (`extension-host/src/runtime.ts`), for the entry points found so far; a
//! native-code entry point a newer runtime adds is not covered until it is
//! added there. A process a plugin starts is outside that policy and runs
//! under the same OS sandbox (none on Linux and Windows).
//! under the same OS sandbox (none on Windows, or where bwrap fails).
//! * The owner token is a bug/staleness guard, not a boundary between
//! plugins that share the process: one plugin can alter another's
//! behaviour, which the approval card discloses.
@@ -328,8 +331,7 @@ pub enum HostStatus {
runtime: &'static str,
/// As reported by the host (`host/hello`).
runtime_version: String,
/// `seatbelt` / `bwrap`, or `None` when the host runs unsandboxed.
sandbox: Option<String>,
sandbox: supervisor::HostSandbox,
/// How the memory cap is enforced for this process.
memory: supervisor::MemoryEnforcement,
},
@@ -495,16 +497,31 @@ fn prepare_launch(
(runtime, Some(format!("{}{note}", resolution.summary())))
}
};
let root = match options.root.clone() {
Some(root) => root,
None => codewhale_config::codewhale_home()
.map_err(|error| format!("Codewhale home unavailable: {error}"))?,
};
let root = host_root(options)?;
let bundle = materialize_bundle(&root)?;
let launch = supervisor::plan_launch(&runtime, &bundle, &root, options.supervision.memory_cap)?;
Ok((launch, summary))
}
/// The Codewhale home the host lives under.
fn host_root(options: &ExtensionHostOptions) -> Result<PathBuf, String> {
match options.root.clone() {
Some(root) => Ok(root),
None => codewhale_config::codewhale_home()
.map_err(|error| format!("Codewhale home unavailable: {error}")),
}
}
/// The OS sandbox a host started now on `runtime` would run under, planned —
/// and on Linux probed — exactly as a launch does (for doctor). Blocking;
/// creates the host's data dir, as a launch would.
pub(crate) fn planned_sandbox(
options: &ExtensionHostOptions,
runtime: &crate::dependencies::HostRuntime,
) -> Result<supervisor::HostSandbox, String> {
supervisor::planned_sandbox(runtime, &host_root(options)?)
}
impl ManagerShared {
async fn deactivate_owner(&self, host: &Arc<HostProcess>, owner: &OwnerRef) {
let diagnostic = match host
@@ -1525,7 +1542,7 @@ impl ExtensionHostManager {
.map_or_else(|| "?".to_string(), |pid| pid.to_string()),
host.runtime.kind.name(),
host.runtime_version.get().map_or("?", String::as_str),
host.sandbox.as_deref().unwrap_or("none")
host.sandbox.label()
));
Ok(host)
}
@@ -1605,14 +1622,8 @@ pub(crate) fn render_status(manager: &ExtensionHostManager) -> String {
} => {
let _ = write!(
out,
"running · pid {} · {runtime} {runtime_version} · {}",
"running · pid {} · {runtime} {runtime_version} · {sandbox}",
pid.map_or_else(|| "?".to_string(), |pid| pid.to_string()),
match sandbox {
Some(sandbox) => format!(
"{sandbox} sandbox (no direct network; the Codewhale home except plugin code, the Codex and DSH credential homes and the default credential stores are unreadable; other files you can read, such as project .env files, are not protected)"
),
None => "UNSANDBOXED: host code runs with your user permissions".to_string(),
}
);
}
HostStatus::Failed { stderr_tail, .. } => {
+440 -52
View File
@@ -4,18 +4,43 @@
//! The existing Rust manager owns heartbeat, crash budget, generation changes,
//! and receipt-checked replay; this channel never replays a tool call.
//!
//! **OS sandbox.** Where Codewhale's default command sandbox is available
//! (Seatbelt on macOS; bubblewrap stays opt-in for shell commands and is not
//! used here) the host runs under a workspace-write profile rooted at
//! **OS sandbox** ([`HostSandbox`]: the one value `/plugin`, doctor and the
//! start diagnostic report). The host runs under Codewhale's command sandbox
//! with a workspace-write policy rooted at
//! `$CODEWHALE_HOME/extension-host/data`: no direct network, writes only
//! there and in the temp dirs, and **no reads** of the Codewhale homes
//! (everything but the bundle, its data dir and plugin code), the Codex and
//! DSH credential homes, and the credential-store default deny-list
//! (`sandbox::read_guard`). Other user-readable files stay readable —
//! including `.env` files, whose filename rule has no Seatbelt subpath form —
//! and Mach services are not restricted, so this is defense-in-depth, not a
//! containment boundary. Elsewhere (Linux, Windows) the host runs unsandboxed
//! with the user's permissions, and `/plugin` says so.
//! including `.env` files, whose filename rule has no Seatbelt subpath or
//! bubblewrap mount form — so this is defense-in-depth, not a containment
//! boundary.
//! * macOS: Seatbelt. Mach services are not restricted.
//! * Linux: bubblewrap (`/usr/bin/bwrap`, the shell's builder), used without
//! the shell's `prefer_bwrap` opt-in. Every launch first runs the finished
//! wrapper around `<runtime> --version` ([`probe_bwrap`]). When bwrap is
//! missing or cannot start — e.g. unprivileged user namespaces blocked by
//! Ubuntu 24.04's `kernel.apparmor_restrict_unprivileged_userns` — the host
//! starts unsandboxed and every surface says so with bwrap's own error;
//! never a silent downgrade. bwrap can mask only what exists, so each
//! Codewhale home is masked whole and its readable entries are bound again
//! (`sandbox::bwrap_exception_args`): an entry created after launch is
//! denied, as on macOS.
//! * Windows: unsandboxed (the Job Object contains the process tree; that is
//! not isolation), and `/plugin` says so.
//!
//! Known limits under bubblewrap: a default-deny-list credential store
//! created after launch stays readable (Seatbelt denies it by name); a
//! Codewhale home, or a readable entry such as `plugins/`, that does not exist
//! at launch is not masked, or not visible, until the host restarts; bwrap
//! anywhere but `/usr/bin/bwrap` is not used; the probe costs one extra
//! runtime start per launch. The reported pid is bwrap's. The host runs in
//! bwrap's PID namespace, where its parent is bwrap's init and never
//! changes, so the host's own parent watchdog (`extension-host/src/main.ts`)
//! cannot fire; `--die-with-parent` is what ends it with the core. That is
//! a parent-death signal tied to the thread that spawned bwrap, a Tokio
//! worker that lives as long as the runtime. Plugin child processes end with
//! the namespace when bwrap's init loses the host.
//!
//! **Runtime.** Node (the default) or Bun (opt-in: `runtime = "bun"`, or
//! `"auto"`, which prefers a supported Bun), chosen once per manager
@@ -116,8 +141,7 @@ pub(crate) struct HostLaunch {
pub program: PathBuf,
pub args: Vec<String>,
pub cwd: PathBuf,
/// `seatbelt` / `bwrap`, or `None` when the host runs unsandboxed.
pub sandbox: Option<String>,
pub sandbox: HostSandbox,
/// Environment the sandbox wrapper adds (`CODEWHALE_SANDBOX`, …).
pub sandbox_env: Vec<(String, String)>,
/// The runtime inside the wrapper; `host/hello` must report the same one.
@@ -131,6 +155,44 @@ pub(crate) struct HostLaunch {
pub memory: MemoryEnforcement,
}
/// Whether the host runs under an OS sandbox, and why not when it does not
/// (module docs). Settled by [`plan_launch`]; never inferred afterwards.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HostSandbox {
/// Under this wrapper, named as `sandbox::SandboxType` names it
/// (`macos-seatbelt`, `linux-bwrap`).
Wrapped(String),
/// With the user's permissions, for this reason.
Unsandboxed(String),
}
impl HostSandbox {
/// The wrapper's name, or `none: <reason>`, for one-line diagnostics.
#[must_use]
pub fn label(&self) -> String {
match self {
Self::Wrapped(name) => name.clone(),
Self::Unsandboxed(reason) => format!("none: {reason}"),
}
}
}
/// What `/plugin` and doctor say about the sandbox.
impl std::fmt::Display for HostSandbox {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Wrapped(name) => write!(
f,
"{name} sandbox (no direct network; the Codewhale home except plugin code, the Codex and DSH credential homes and the default credential stores are unreadable; other files you can read, such as project .env files, are not protected)"
),
Self::Unsandboxed(reason) => write!(
f,
"UNSANDBOXED ({reason}): host code runs with your user permissions"
),
}
}
}
/// Environment variable carrying the jetsam limit a macOS Bun host applies to
/// itself, in MiB (`extension-host/src/runtime.ts`, `applyMemoryLimit`).
pub(crate) const MEMORY_LIMIT_REQUEST_ENV: &str = "CODEWHALE_HOST_MEMORY_LIMIT_MIB";
@@ -299,12 +361,22 @@ const HOST_DENIED_HOME_ENTRIES: &[&str] = &[
];
/// Paths the host process must never read, even though the sandbox otherwise
/// grants full-disk read: the curated credential-store defaults; every entry
/// of Codewhale's homes (the runtime home, the ambient `~/.codewhale`, and the
/// legacy `~/.deepseek`) except [`HOST_READABLE_HOME_ENTRIES`]; and the Codex
/// and DSH homes whose credential files Codewhale itself reads. Blocking.
pub(crate) fn host_denied_read_paths(home: &Path) -> Vec<PathBuf> {
/// grants full-disk read, and the exceptions inside them: the curated
/// credential-store defaults; Codewhale's homes (the runtime home, the ambient
/// `~/.codewhale`, and the legacy `~/.deepseek`) except
/// [`HOST_READABLE_HOME_ENTRIES`]; and the Codex and DSH homes whose
/// credential files Codewhale itself reads. Blocking.
///
/// With `whole_homes` (bubblewrap, which can mask only what exists) each home
/// is denied whole and its readable entries come back as exceptions to bind
/// again. Without it (Seatbelt, which matches paths that do not exist yet)
/// every other entry is denied by name and there are no exceptions.
pub(crate) fn host_denied_read_paths(
home: &Path,
whole_homes: bool,
) -> (Vec<PathBuf>, Vec<PathBuf>) {
let mut paths = crate::sandbox::read_guard::ReadDenylist::build(true, &[], &[]).subtree_paths();
let mut exceptions: Vec<PathBuf> = Vec::new();
let mut push = |path: PathBuf| {
if !paths.contains(&path) {
paths.push(path);
@@ -318,6 +390,16 @@ pub(crate) fn host_denied_read_paths(home: &Path) -> Vec<PathBuf> {
roots.push(user.join(".deepseek"));
}
for root in roots {
if whole_homes {
for entry in HOST_READABLE_HOME_ENTRIES {
let readable = root.join(entry);
if !exceptions.contains(&readable) {
exceptions.push(readable);
}
}
push(root);
continue;
}
let mut names: Vec<std::ffi::OsString> = HOST_DENIED_HOME_ENTRIES
.iter()
.chain(std::iter::once(&codewhale_config::CONFIG_FILE_NAME))
@@ -358,66 +440,239 @@ pub(crate) fn host_denied_read_paths(home: &Path) -> Vec<PathBuf> {
if let Some(dsh_home) = codewhale_config::default_dsh_credentials_path().parent() {
push(dsh_home.to_path_buf());
}
paths
(paths, exceptions)
}
/// Plan the host launch. Blocking (creates the data dir, canonicalizes the
/// deny-list); call from `spawn_blocking`.
/// deny-list, and on Linux runs the bwrap probe); call from `spawn_blocking`.
pub(crate) fn plan_launch(
runtime: &HostRuntime,
bundle: &Path,
home: &Path,
memory_cap: u64,
) -> Result<HostLaunch, String> {
use crate::sandbox::{CommandSpec, SandboxManager, SandboxPolicy, SandboxType};
let data = host_data_dir(home)?;
let mut args = runtime_args(runtime);
args.push(bundle.to_string_lossy().into_owned());
let wrapped = wrap_host(&runtime.path, &args, &data, home);
host_launch(runtime, args, data, memory_cap, wrapped)
}
/// The sandbox a host started now would get, planned (and on Linux probed)
/// exactly as [`plan_launch`] does, for doctor. Blocking; creates the data
/// dir, as a launch would.
pub(crate) fn planned_sandbox(runtime: &HostRuntime, home: &Path) -> Result<HostSandbox, String> {
let data = host_data_dir(home)?;
Ok(
match wrap_host(&runtime.path, &runtime_args(runtime), &data, home) {
Ok(wrapped) => HostSandbox::Wrapped(wrapped.name),
Err(reason) => HostSandbox::Unsandboxed(reason),
},
)
}
fn host_data_dir(home: &Path) -> Result<PathBuf, String> {
let data = home.join("extension-host").join("data");
std::fs::create_dir_all(&data)
.map_err(|error| format!("cannot create {}: {error}", data.display()))?;
let mut args = runtime_args(runtime);
args.push(bundle.to_string_lossy().into_owned());
let unsandboxed = HostLaunch {
program: runtime.path.clone(),
args: args.clone(),
cwd: data.clone(),
sandbox: None,
sandbox_env: Vec::new(),
runtime: runtime.clone(),
runtime_env: runtime_env(runtime.kind),
memory_cap,
memory: MemoryEnforcement::planned(runtime.kind),
};
Ok(data)
}
/// A host command wrapped in an OS sandbox ([`wrap_host`]).
#[derive(Debug)]
struct Wrapped {
/// `sandbox::SandboxType`'s name for the wrapper.
name: String,
/// The wrapper's argv, ending with the runtime's.
command: Vec<String>,
/// Environment the wrapper adds (`CODEWHALE_SANDBOX`, …).
env: Vec<(String, String)>,
}
/// Why the host runs on Windows without an OS sandbox.
const WINDOWS_UNSANDBOXED: &str = "Windows has no host sandbox yet; its Job Object contains the process tree, which is not isolation";
/// Wrap `program args` in the host's OS sandbox (module docs), or say why it
/// has none here. Blocking.
fn wrap_host(program: &Path, args: &[String], data: &Path, home: &Path) -> Result<Wrapped, String> {
use crate::sandbox::{CommandSpec, SandboxManager, SandboxPolicy, SandboxType};
if cfg!(windows) {
// The Windows helper is process containment only; ProcessTree already
// provides that, and it must not be reported as isolation.
return Ok(unsandboxed);
return Err(WINDOWS_UNSANDBOXED.to_string());
}
let spec = CommandSpec::program(&runtime.path.to_string_lossy(), args, data, Duration::ZERO)
let spec = |args: Vec<String>| {
CommandSpec::program(
&program.to_string_lossy(),
args,
data.to_path_buf(),
Duration::ZERO,
)
.with_policy(SandboxPolicy::WorkspaceWrite {
writable_roots: Vec::new(),
network_access: false,
exclude_tmpdir: false,
exclude_slash_tmp: false,
});
let mut manager = SandboxManager::new();
manager.set_denied_read_subpaths(host_denied_read_paths(home));
let env = manager.prepare(&spec);
})
};
let bwrap = cfg!(all(target_os = "linux", not(target_env = "ohos")));
let mut manager = SandboxManager::with_bwrap_preference(bwrap);
let (denied, exceptions) = host_denied_read_paths(home, bwrap);
manager.set_denied_read_subpaths(denied);
manager.set_denied_read_exceptions(exceptions);
let env = manager.prepare(&spec(args.to_vec()));
if matches!(env.sandbox_type, SandboxType::None) {
return Ok(unsandboxed);
return Err(no_wrapper_reason());
}
let mut command = env.command.into_iter();
let program = command
.next()
.ok_or("sandbox wrapper produced an empty command")?;
Ok(HostLaunch {
program: PathBuf::from(program),
args: command.collect(),
cwd: env.cwd,
sandbox: Some(env.sandbox_type.to_string()),
sandbox_env: env.env.into_iter().collect(),
..unsandboxed
#[cfg(all(target_os = "linux", not(target_env = "ohos")))]
if matches!(env.sandbox_type, SandboxType::LinuxBubblewrap) {
probe_bwrap(
&manager
.prepare(&spec(vec!["--version".to_string()]))
.command,
data,
)?;
}
Ok(Wrapped {
name: env.sandbox_type.to_string(),
command: env.command,
env: env.env.into_iter().collect(),
})
}
/// The launch for a [`wrap_host`] outcome: the wrapper's argv, or the
/// runtime itself, unsandboxed, carrying the reason `/plugin` shows. Pure.
fn host_launch(
runtime: &HostRuntime,
args: Vec<String>,
data: PathBuf,
memory_cap: u64,
wrapped: Result<Wrapped, String>,
) -> Result<HostLaunch, String> {
let (program, args, sandbox, sandbox_env) = match wrapped {
Ok(wrapped) => {
let mut command = wrapped.command.into_iter();
let program = command
.next()
.ok_or("sandbox wrapper produced an empty command")?;
(
PathBuf::from(program),
command.collect(),
HostSandbox::Wrapped(wrapped.name),
wrapped.env,
)
}
Err(reason) => (
runtime.path.clone(),
args,
HostSandbox::Unsandboxed(reason),
Vec::new(),
),
};
Ok(HostLaunch {
program,
args,
cwd: data,
sandbox,
sandbox_env,
runtime: runtime.clone(),
runtime_env: runtime_env(runtime.kind),
memory_cap,
memory: MemoryEnforcement::planned(runtime.kind),
})
}
/// Why [`wrap_host`] found no wrapper on this platform.
#[cfg(all(target_os = "linux", not(target_env = "ohos")))]
fn no_wrapper_reason() -> String {
format!(
"bwrap unavailable: {} is not an executable file (install bubblewrap)",
crate::sandbox::bwrap::BWRAP_PATH
)
}
#[cfg(target_os = "macos")]
fn no_wrapper_reason() -> String {
"Seatbelt (sandbox-exec) is unavailable".to_string()
}
#[cfg(not(any(
target_os = "macos",
all(target_os = "linux", not(target_env = "ohos"))
)))]
fn no_wrapper_reason() -> String {
"no OS sandbox for the host on this platform".to_string()
}
/// How long the bwrap probe may take. A working bwrap runs
/// `<runtime> --version` in well under a second, even on a loaded CI runner.
const BWRAP_PROBE_DEADLINE: Duration = Duration::from_secs(10);
/// Run the finished bwrap wrapper around `<runtime> --version` (`command`),
/// with no environment, to learn whether bwrap works on this host: it may be
/// installed yet unable to create its namespaces. Blocking.
#[cfg_attr(
not(all(target_os = "linux", not(target_env = "ohos"))),
allow(dead_code)
)]
fn probe_bwrap(command: &[String], cwd: &Path) -> Result<(), String> {
use std::io::Read as _;
use wait_timeout::ChildExt as _;
let (program, args) = command
.split_first()
.ok_or("the sandbox wrapper produced an empty command")?;
let mut child = std::process::Command::new(program)
.args(args)
.current_dir(cwd)
.env_clear()
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.map_err(|error| format!("bwrap unavailable: {program} does not start ({error})"))?;
let status = match child.wait_timeout(BWRAP_PROBE_DEADLINE) {
Ok(Some(status)) => status,
outcome => {
let _ = child.kill();
let _ = child.wait();
return Err(match outcome {
Err(error) => format!("bwrap unavailable: waiting for the probe failed ({error})"),
_ => format!(
"bwrap unavailable: the probe did not finish within {BWRAP_PROBE_DEADLINE:?}"
),
});
}
};
let mut stderr = String::new();
if let Some(mut pipe) = child.stderr.take() {
let _ = pipe.read_to_string(&mut stderr);
}
bwrap_probe_verdict(status.success(), &status.to_string(), &stderr)
}
/// What a wrapped `<runtime> --version` run says about bwrap here: `Ok` when
/// it ran, otherwise the reason `/plugin` and doctor show — bwrap's first
/// line of stderr (or the exit status), and a hint when that line is about
/// the user namespace bwrap could not create. Pure.
#[cfg_attr(
not(all(target_os = "linux", not(target_env = "ohos"))),
allow(dead_code)
)]
fn bwrap_probe_verdict(succeeded: bool, status: &str, stderr: &str) -> Result<(), String> {
if succeeded {
return Ok(());
}
let first = stderr.lines().map(str::trim).find(|line| !line.is_empty());
let mut detail: String = first.map_or_else(
|| status.to_string(),
|line| line.chars().take(240).collect(),
);
if first.is_some_and(|line| line.contains("namespace") || line.contains("uid map")) {
detail.push_str(
"; unprivileged user namespaces look blocked here (on Ubuntu 24.04 and later: the kernel.apparmor_restrict_unprivileged_userns sysctl)",
);
}
Err(format!("bwrap unavailable ({detail})"))
}
/// The kernel-enforced memory cap on Linux: `RLIMIT_DATA`, applied in the
/// child between fork and exec, so only the host (and what it starts) is
/// limited. Soft and hard limit are both set, so plugin code cannot raise
@@ -541,8 +796,7 @@ pub(crate) struct HostProcess {
pub memory_cap: u64,
/// How the cap is enforced for this process, settled at the handshake.
memory: Arc<std::sync::OnceLock<MemoryEnforcement>>,
/// `seatbelt` / `bwrap`, or `None` when unsandboxed.
pub sandbox: Option<String>,
pub sandbox: HostSandbox,
tree: Arc<crate::process_tree::ProcessTree>,
outbound: mpsc::Sender<Vec<u8>>,
pending: Arc<Mutex<HashMap<u64, PendingCall>>>,
@@ -1214,3 +1468,137 @@ fn handle_host_message(
pub fn bundle_dir(root: &Path, sha256: &str) -> PathBuf {
root.join("extension-host").join(sha256)
}
#[cfg(test)]
mod tests {
use super::*;
fn node() -> HostRuntime {
HostRuntime {
kind: HostRuntimeKind::Node,
path: PathBuf::from("/opt/node/bin/node"),
version: (22, 19, 0),
native_code_flags: Vec::new(),
}
}
/// The Linux launch decision, exercised on every platform: a bwrap that
/// is installed but cannot start (the probe's stderr as Ubuntu 24.04
/// prints it) launches the runtime itself, labelled unsandboxed with
/// bwrap's own error wherever the sandbox is reported — never
/// `linux-bwrap` — while a probe that ran keeps the wrapper's argv.
#[test]
fn a_bwrap_that_cannot_start_leaves_the_host_unsandboxed_and_says_why() {
let runtime = node();
let data = PathBuf::from("/home/u/.codewhale/extension-host/data");
let mut args = runtime_args(&runtime);
args.push("/home/u/.codewhale/extension-host/abc/host.mjs".to_string());
let refused = bwrap_probe_verdict(
false,
"exit status: 1",
"\nbwrap: setting up uid map: Permission denied\n",
)
.unwrap_err();
assert!(
refused.starts_with("bwrap unavailable (bwrap: setting up uid map: Permission denied;"),
"{refused}"
);
assert!(
refused.contains("kernel.apparmor_restrict_unprivileged_userns"),
"{refused}"
);
let launch = host_launch(
&runtime,
args.clone(),
data.clone(),
HOST_MEMORY_CAP,
Err(refused.clone()),
)
.unwrap();
assert_eq!(launch.program, runtime.path);
assert_eq!(launch.args, args);
assert!(launch.sandbox_env.is_empty());
assert_eq!(launch.sandbox, HostSandbox::Unsandboxed(refused.clone()));
assert_eq!(launch.sandbox.label(), format!("none: {refused}"));
assert!(
launch
.sandbox
.to_string()
.starts_with(&format!("UNSANDBOXED ({refused}): ")),
"{}",
launch.sandbox
);
// No stderr: the exit status is the reason, with no namespace hint.
assert_eq!(
bwrap_probe_verdict(false, "signal: 9 (SIGKILL)", ""),
Err("bwrap unavailable (signal: 9 (SIGKILL))".to_string())
);
// A probe that ran keeps the wrapper.
assert_eq!(bwrap_probe_verdict(true, "exit status: 0", ""), Ok(()));
let command: Vec<String> = ["/usr/bin/bwrap", "--unshare-all", "--die-with-parent", "--"]
.iter()
.map(ToString::to_string)
.chain(std::iter::once(runtime.path.to_string_lossy().into_owned()))
.chain(args.iter().cloned())
.collect();
let launch = host_launch(
&runtime,
args.clone(),
data.clone(),
HOST_MEMORY_CAP,
Ok(Wrapped {
name: "linux-bwrap".to_string(),
command: command.clone(),
env: vec![("CODEWHALE_SANDBOX".to_string(), "bwrap".to_string())],
}),
)
.unwrap();
assert_eq!(launch.program, PathBuf::from("/usr/bin/bwrap"));
assert_eq!(launch.args, command[1..].to_vec());
assert_eq!(launch.cwd, data);
assert_eq!(
launch.sandbox,
HostSandbox::Wrapped("linux-bwrap".to_string())
);
assert!(
launch
.sandbox
.to_string()
.starts_with("linux-bwrap sandbox (no direct network;"),
"{}",
launch.sandbox
);
}
/// bubblewrap can mask only what exists, so under it each Codewhale home
/// is denied whole (an entry created later is then denied too) and its
/// readable entries come back as exceptions; Seatbelt's form is unchanged.
#[test]
fn under_bubblewrap_a_codewhale_home_is_denied_whole_but_its_readable_entries() {
let temp = tempfile::tempdir().unwrap();
let home = temp.path().join("home");
std::fs::create_dir_all(home.join("extension-host")).unwrap();
std::fs::write(home.join("config.toml.bak-1"), "").unwrap();
let (seatbelt, none) = host_denied_read_paths(&home, false);
assert!(none.is_empty());
assert!(seatbelt.contains(&home.join("config.toml.bak-1")));
assert!(
seatbelt.contains(&home.join("secrets")),
"named before it exists"
);
assert!(!seatbelt.contains(&home));
assert!(!seatbelt.contains(&home.join("extension-host")));
let (bwrap, exceptions) = host_denied_read_paths(&home, true);
assert!(bwrap.contains(&home));
assert!(!bwrap.contains(&home.join("config.toml.bak-1")));
for entry in HOST_READABLE_HOME_ENTRIES {
assert!(exceptions.contains(&home.join(entry)), "{entry}");
assert!(!bwrap.contains(&home.join(entry)), "{entry}");
}
}
}
+106 -14
View File
@@ -418,12 +418,62 @@ pub(crate) fn host_tool(
registry.get(name).unwrap()
}
fn rss_kib(pid: u32) -> Option<u64> {
/// Start budget for `dsh_plugin_runs_end_to_end_behind_the_approval_gate`:
/// bundle materialization, the runtime probe, the launch plan (on Linux, the
/// bwrap probe), spawn, handshake and one plugin's activation, under
/// nextest's full-core load on every CI OS. The host alone is ready in
/// ~50 ms (the JS suite's `READY_BUDGET_MS` gates that), but Windows CI
/// under load has taken over 5 s to hand-shake (`HANDSHAKE_DEADLINE`).
/// 20 s fails a start drifting toward that 30 s deadline, whose miss
/// disables every extension for the session, without failing on CI load.
const HOST_START_BUDGET: Duration = Duration::from_secs(20);
/// Resident-size budget for a host with one plugin active, summed over its
/// process tree (bwrap's two processes included on Linux). 160 MiB is 2.4x
/// the largest idle host measured (67 MB, Node in a Linux container:
/// `supervisor::HOST_MEMORY_CAP`; 57 MiB Node 26 and 41 MiB Bun 1.4 with this
/// plugin on macOS arm64, 2026-09-30) and far below the 1 GiB cap. Not
/// checked where there is no `ps` (Windows).
const HOST_RSS_BUDGET_MIB: u64 = 160;
/// Resident size in KiB of `pid` and every process under it — under bwrap,
/// `pid` is bwrap's and the runtime two levels down — from `ps`; `None` where
/// `ps` is missing or does not list `pid`.
fn tree_rss_kib(pid: u32) -> Option<u64> {
let output = std::process::Command::new("ps")
.args(["-o", "rss=", "-p", &pid.to_string()])
.args(["-A", "-o", "pid=,ppid=,rss="])
.output()
.ok()?;
String::from_utf8_lossy(&output.stdout).trim().parse().ok()
let rows: Vec<[u64; 3]> = String::from_utf8_lossy(&output.stdout)
.lines()
.filter_map(|line| {
let fields: Vec<u64> = line
.split_whitespace()
.filter_map(|field| field.parse().ok())
.collect();
<[u64; 3]>::try_from(fields).ok()
})
.collect();
let root = u64::from(pid);
if !rows.iter().any(|row| row[0] == root) {
return None;
}
let mut members = vec![root];
let mut next = 0;
while let Some(&parent) = members.get(next) {
members.extend(
rows.iter()
.filter(|row| row[1] == parent && row[0] != parent)
.map(|row| row[0]),
);
next += 1;
}
Some(
rows.iter()
.filter(|row| members.contains(&row[0]))
.map(|row| row[2])
.sum(),
)
}
#[tokio::test]
@@ -445,11 +495,23 @@ async fn dsh_plugin_runs_end_to_end_behind_the_approval_gate() {
engine.sync().await.unwrap();
let elapsed = started.elapsed();
let pid = manager.host_pid().expect("host running");
let rss = tree_rss_kib(pid);
eprintln!(
"extension host: spawn + handshake + activation {:.1} ms; RSS {} KiB",
"extension host: spawn + handshake + activation {:.1} ms (budget {HOST_START_BUDGET:?}); RSS {} KiB (budget {HOST_RSS_BUDGET_MIB} MiB)",
elapsed.as_secs_f64() * 1000.0,
rss_kib(pid).map_or_else(|| "?".to_string(), |kib| kib.to_string())
rss.map_or_else(|| "?".to_string(), |kib| kib.to_string())
);
assert!(
elapsed <= HOST_START_BUDGET,
"the extension host took {elapsed:?} to start, over its {HOST_START_BUDGET:?} budget"
);
match rss {
Some(kib) => assert!(
kib <= HOST_RSS_BUDGET_MIB * 1024,
"the extension host is {kib} KiB resident, over its {HOST_RSS_BUDGET_MIB} MiB budget"
),
None => eprintln!("extension host RSS budget not checked: no `ps` listing here"),
}
assert_eq!(
manager.live_tool_names(),
vec!["load_workspace_dependencies"]
@@ -791,20 +853,50 @@ async fn sandboxed_host_cannot_read_codewhale_secrets_or_write_outside_its_data_
let readable = fixture.workspace().join("readable.txt");
std::fs::write(&readable, "plain").unwrap();
let manager = fixture.manager(node);
let manager = fixture.manager(node.clone());
let engine = manager.attach(fixture.registry());
engine.sync().await.unwrap();
let HostStatus::Ready { sandbox, .. } = manager.status() else {
panic!("host not ready: {:?}", manager.status());
};
let Some(sandbox) = sandbox else {
assert!(
cfg!(windows) || crate::sandbox::get_platform_sandbox().is_none(),
"an OS sandbox is available here, so the host must run under it"
);
eprintln!("skipping sandbox assertions: no OS sandbox for the host on this platform");
manager.shutdown().await;
return;
let sandbox = match sandbox {
super::supervisor::HostSandbox::Wrapped(name) => name,
super::supervisor::HostSandbox::Unsandboxed(reason) => {
// Only where no wrapper works, checked independently of the
// launch's own probe, and the reason is what `/plugin` shows.
#[cfg(target_os = "linux")]
{
let minimal = std::process::Command::new("/usr/bin/bwrap")
.args(["--unshare-all", "--die-with-parent", "--ro-bind", "/", "/"])
.args(["--dev", "/dev", "--proc", "/proc", "--"])
.arg(&node)
.arg("--version")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.is_ok_and(|status| status.success());
assert!(
!minimal,
"bwrap runs here, so the host must run under it: {reason}"
);
assert!(reason.starts_with("bwrap unavailable"), "{reason}");
}
#[cfg(target_os = "macos")]
assert!(
crate::sandbox::get_platform_sandbox().is_none(),
"Seatbelt is available here, so the host must run under it: {reason}"
);
#[cfg(windows)]
assert!(reason.starts_with("Windows"), "{reason}");
assert!(
super::render_status(&manager).contains(&format!("UNSANDBOXED ({reason})")),
"{}",
super::render_status(&manager)
);
eprintln!("skipping sandbox assertions: the host runs unsandboxed here ({reason})");
manager.shutdown().await;
return;
}
};
assert!(super::render_status(&manager).contains(&format!("{sandbox} sandbox")));
+28 -13
View File
@@ -5577,24 +5577,32 @@ async fn run_doctor(
{
// The runtime the TypeScript extension host would use, resolved the
// same way the host launcher does (`[extension_host] runtime`). The
// resolver runs each candidate runtime, so it stays off the async
// runtime. Known limit: the probes have no timeout, so a runtime
// binary that hangs on `--version` stalls doctor here.
// same way the host launcher does (`[extension_host] runtime`), and
// with the host on, the OS sandbox it would get, planned (on Linux,
// bwrap-probed) the way a launch plans it. Both run child processes,
// so they stay off the async runtime. Known limit: the runtime probes
// have no timeout, so a runtime binary that hangs on `--version`
// stalls doctor here.
let options = crate::extension_host::ExtensionHostOptions::from_config(
config.extension_host.as_ref(),
);
let resolution = tokio::task::spawn_blocking(move || {
crate::dependencies::resolve_extension_host_runtime(
options.runtime,
options.node_override.as_deref(),
options.bun_override.as_deref(),
)
})
.await;
let enabled = config
.features()
.enabled(crate::features::Feature::ExtensionHost);
let resolution = tokio::task::spawn_blocking(move || {
let resolution = crate::dependencies::resolve_extension_host_runtime(
options.runtime,
options.node_override.as_deref(),
options.bun_override.as_deref(),
);
let sandbox = resolution
.selected
.as_ref()
.filter(|_| enabled)
.map(|runtime| crate::extension_host::planned_sandbox(&options, runtime));
(resolution, sandbox)
})
.await;
let state = if enabled {
""
} else {
@@ -5606,7 +5614,7 @@ async fn run_doctor(
"·".dimmed()
};
match resolution {
Ok(resolution) => match &resolution.selected {
Ok((resolution, sandbox)) => match &resolution.selected {
Some(runtime) => {
println!(
" {} Extension host runtime: {}{state}",
@@ -5618,6 +5626,13 @@ async fn run_doctor(
crate::extension_host::supervisor::MemoryEnforcement::planned(runtime.kind)
.describe(crate::extension_host::supervisor::HOST_MEMORY_CAP)
);
match sandbox {
Some(Ok(sandbox)) => println!(" {sandbox}"),
Some(Err(error)) => {
println!(" host sandbox not determined: {error}")
}
None => {}
}
}
None => println!(
" {failed} Extension host runtime: {}{state}",
+76 -15
View File
@@ -27,6 +27,13 @@
//! access add `--share-net` after `--unshare-all`. A private `/dev` and
//! `/proc` plus a tmpfs `/tmp` keep standard toolchain expectations working
//! (#5410); user-configured extra roots and device nodes append after them.
//! The read deny-list masks go last; exceptions inside a masked directory
//! (`SandboxManager::set_denied_read_exceptions`) are bound again after them.
//!
//! The extension host (`extension_host::supervisor`) launches through this
//! builder whenever bubblewrap works, without the `prefer_bwrap` opt-in; it
//! probes the finished command first and reports the host as unsandboxed,
//! with bwrap's own error, when it does not run.
//!
//! # Important
//!
@@ -107,6 +114,7 @@ pub fn build_bwrap_command(
network_access: bool,
extensions: &crate::sandbox::BwrapMountExtensions,
denied_read_subpaths: &[std::path::PathBuf],
denied_read_exceptions: &[std::path::PathBuf],
) -> Vec<String> {
let (writable_mounts, read_only_mounts) = safe_mounts(writable_roots);
let (extra_read_only, device_mounts) = extensions.resolve();
@@ -151,7 +159,7 @@ pub fn build_bwrap_command(
cmd.push(device);
}
for root in writable_mounts {
for root in &writable_mounts {
let root = root.to_string_lossy().into_owned();
cmd.push("--bind".to_string());
cmd.push(root.clone());
@@ -182,20 +190,18 @@ pub fn build_bwrap_command(
// tmpfs, an existing file with a bind of /dev/null. Non-existent paths
// are skipped — there is nothing to deny.
for denied in denied_read_subpaths {
let Ok(meta) = std::fs::metadata(denied) else {
continue;
};
let denied_str = denied.to_string_lossy().into_owned();
if meta.is_dir() {
cmd.push("--tmpfs".to_string());
cmd.push(denied_str);
} else {
cmd.push("--ro-bind".to_string());
cmd.push("/dev/null".to_string());
cmd.push(denied_str);
}
cmd.extend(super::bwrap_mask_args(denied));
}
// Exceptions inside a masked directory (the extension host's own files
// and plugin code under a Codewhale home it denies whole) are bound again
// after the masks, then any denied path inside them is masked again.
cmd.extend(super::bwrap_exception_args(
denied_read_subpaths,
denied_read_exceptions,
&writable_mounts,
));
// Change to the working directory inside the container.
let cwd_str = cwd.to_string_lossy().to_string();
cmd.push("--chdir".to_string());
@@ -287,6 +293,7 @@ mod tests {
false,
&crate::sandbox::BwrapMountExtensions::default(),
&[],
&[],
);
// Should start with bwrap
@@ -329,6 +336,7 @@ mod tests {
false,
&crate::sandbox::BwrapMountExtensions::default(),
&[],
&[],
);
assert!(!cmd.iter().any(|arg| arg == "--bind"));
@@ -363,6 +371,7 @@ mod tests {
true,
&crate::sandbox::BwrapMountExtensions::default(),
&[],
&[],
);
for root in [&workspace, &extra] {
@@ -410,7 +419,7 @@ mod tests {
PathBuf::from("/dev/does-not-exist"),
],
};
let cmd = build_bwrap_command(cwd, "true", &[], &[], false, &extensions, &[]);
let cmd = build_bwrap_command(cwd, "true", &[], &[], false, &extensions, &[], &[]);
assert!(has_mount(
&cmd,
@@ -446,7 +455,16 @@ mod tests {
read_only_roots: vec![narrowed.clone()],
device_roots: vec![],
};
let cmd = build_bwrap_command(&workspace, "true", &[], &roots, false, &extensions, &[]);
let cmd = build_bwrap_command(
&workspace,
"true",
&[],
&roots,
false,
&extensions,
&[],
&[],
);
let writable_pos = cmd
.windows(3)
@@ -468,6 +486,49 @@ mod tests {
);
}
/// The extension host's form of the deny-list: its Codewhale home masked
/// whole, then its own files bound again read-only and its data dir
/// writable again, all after the mask; a missing exception is skipped.
#[test]
#[cfg(target_os = "linux")]
fn a_home_denied_whole_keeps_its_exceptions_after_the_mask() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::create_dir_all(dir.path().join("home/extension-host/data")).expect("data dir");
std::fs::create_dir_all(dir.path().join("home/secrets")).expect("secrets dir");
let home = dir
.path()
.join("home")
.canonicalize()
.expect("canonical home");
let bundle = home.join("extension-host");
let data = bundle.join("data");
let cmd = build_bwrap_command(
&data,
"true",
&[],
&[WritableRoot::new(data.clone())],
false,
&crate::sandbox::BwrapMountExtensions::default(),
std::slice::from_ref(&home),
&[bundle.clone(), home.join("plugins")],
);
let at = |flag: &str, path: &Path| -> Vec<usize> {
cmd.windows(3)
.enumerate()
.filter(|(_, args)| args[0] == flag && args[1] == path.to_string_lossy())
.map(|(index, _)| index)
.collect()
};
let mask = at("--tmpfs", &home);
let exception = at("--ro-bind", &bundle);
let data_binds = at("--bind", &data);
assert_eq!(mask.len(), 1, "{cmd:?}");
assert_eq!(exception.len(), 1, "{cmd:?}");
assert!(mask[0] < exception[0], "{cmd:?}");
assert!(data_binds.last() > Some(&exception[0]), "{cmd:?}");
assert!(!cmd.iter().any(|arg| arg.ends_with("/plugins")), "{cmd:?}");
}
#[cfg(target_os = "linux")]
fn has_mount(command: &[String], flag: &str, path: &Path) -> bool {
let path = path.to_string_lossy();
+171 -1
View File
@@ -11,7 +11,9 @@
//! - **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.
//! therefore is not advertised. The extension host is the exception to the
//! opt-in: it uses bubblewrap whenever a probe shows it works
//! (`extension_host::supervisor::plan_launch`).
//! - **OpenHarmony**: No local Linux sandbox is advertised. Bubblewrap,
//! seccomp, and Linux `prctl` hardening are gated out under `target_env =
//! "ohos"`.
@@ -458,6 +460,89 @@ fn expand_home_prefix(path: PathBuf) -> PathBuf {
path
}
/// The bubblewrap arguments that hide `path` from a sandboxed command: an
/// empty tmpfs over an existing directory, `/dev/null` bound over an existing
/// file, nothing for a path that does not exist (there is nothing to deny).
/// Blocking. Platform-neutral so it is exercised on every host; only the
/// Linux bwrap builder emits it.
fn bwrap_mask_args(path: &std::path::Path) -> Vec<String> {
let Ok(meta) = std::fs::metadata(path) else {
return Vec::new();
};
let path = path.to_string_lossy().into_owned();
if meta.is_dir() {
vec!["--tmpfs".to_string(), path]
} else {
vec!["--ro-bind".to_string(), "/dev/null".to_string(), path]
}
}
/// Arguments appended after the read deny-list masks so `exceptions` inside
/// a masked directory stay visible. Only a denied directory that holds an
/// existing exception opens up again: each such exception is bound read-only
/// again, then each (canonical) writable root strictly inside such a
/// directory is bound writable again, then every denied path strictly inside
/// what was bound again is masked again, so an exception never re-exposes a
/// denied path. Any other denied directory stays masked over whatever lies
/// inside it, writable roots included. Empty, without touching the
/// filesystem, when there are no exceptions (every caller but the extension
/// host). Blocking (resolves paths).
///
/// This is how the extension host denies a Codewhale home whole — including
/// entries created after it started, which a per-entry mask cannot cover —
/// while its bundle, data dir and plugin code stay reachable.
///
/// Known limits: an exception or writable root that does not exist when the
/// command starts stays hidden for its lifetime; a protected descendant
/// (`.codewhale` inside a writable root) or an extra read-only root inside a
/// masked directory is not re-applied. Only the extension host sets
/// exceptions, and its data dir has neither.
fn bwrap_exception_args(
denied: &[PathBuf],
exceptions: &[PathBuf],
writable_roots: &[PathBuf],
) -> Vec<String> {
if exceptions.is_empty() {
return Vec::new();
}
let canonical = |path: &PathBuf| std::fs::canonicalize(path).ok();
let strictly_inside = |path: &std::path::Path, roots: &[PathBuf]| {
roots
.iter()
.any(|root| path != root.as_path() && path.starts_with(root))
};
let exceptions: Vec<PathBuf> = exceptions.iter().filter_map(canonical).collect();
let opened: Vec<PathBuf> = denied
.iter()
.filter(|path| path.is_dir())
.filter_map(canonical)
.filter(|dir| {
exceptions
.iter()
.any(|exception| strictly_inside(exception, std::slice::from_ref(dir)))
})
.collect();
let mut rebound: Vec<PathBuf> = Vec::new();
let mut args = Vec::new();
let candidates = exceptions
.iter()
.map(|path| ("--ro-bind", path))
.chain(writable_roots.iter().map(|path| ("--bind", path)));
for (flag, path) in candidates {
if strictly_inside(path, &opened) && !rebound.contains(path) {
let spelled = path.to_string_lossy().into_owned();
args.extend([flag.to_string(), spelled.clone(), spelled]);
rebound.push(path.clone());
}
}
for path in denied {
if canonical(path).is_some_and(|resolved| strictly_inside(&resolved, &rebound)) {
args.extend(bwrap_mask_args(path));
}
}
args
}
/// The `SandboxManager` is responsible for:
/// - Detecting available sandbox technologies
/// - Transforming `CommandSpecs` into sandboxed `ExecEnvs`
@@ -483,6 +568,11 @@ pub struct SandboxManager {
/// read (Seatbelt appends last-match-wins deny rules; bubblewrap masks
/// each path). Empty by default — today's behavior unchanged.
denied_read_subpaths: Vec<PathBuf>,
/// Paths inside a denied directory that stay visible (bubblewrap only,
/// [`bwrap_exception_args`]; Seatbelt ignores this list). Empty by
/// default.
denied_read_exceptions: Vec<PathBuf>,
}
impl SandboxManager {
@@ -536,6 +626,14 @@ impl SandboxManager {
self.denied_read_subpaths = resolved;
}
/// Set the paths inside a denied directory that stay visible under
/// bubblewrap ([`bwrap_exception_args`]). The extension host uses it to
/// deny a Codewhale home whole while keeping its own files and plugin
/// code readable.
pub fn set_denied_read_exceptions(&mut self, paths: Vec<PathBuf>) {
self.denied_read_exceptions = paths;
}
/// Test-only view of the resolved deny-list (post home-expansion and
/// canonicalization).
#[cfg(test)]
@@ -667,6 +765,7 @@ impl SandboxManager {
spec.sandbox_policy.has_network_access(),
&self.bwrap_extensions,
&self.denied_read_subpaths,
&self.denied_read_exceptions,
);
let mut env = spec.env.clone();
@@ -810,6 +909,77 @@ impl SandboxManager {
mod tests {
use super::*;
/// The extension host's deny-list form, platform-neutral so it runs on
/// every host: an exception and a writable root inside a masked
/// directory are bound again, a missing exception and the masked
/// directory itself are not, and a denied path inside an exception is
/// masked again.
#[test]
fn bwrap_exceptions_rebind_inside_masks_and_never_reexpose_a_denied_path() {
let dir = tempfile::tempdir().unwrap();
let root = std::fs::canonicalize(dir.path()).unwrap();
let home = root.join("home");
std::fs::create_dir_all(home.join("extension-host/data")).unwrap();
std::fs::create_dir_all(home.join("extension-host/secret")).unwrap();
std::fs::write(home.join("token"), "s3cret").unwrap();
std::fs::create_dir_all(root.join("other/writable")).unwrap();
let bundle = home.join("extension-host");
let data = bundle.join("data");
let nested = bundle.join("secret");
let token = home.join("token");
let spell = |path: &std::path::Path| path.to_string_lossy().into_owned();
let (bundle_s, data_s, nested_s, home_s, token_s) = (
spell(&bundle),
spell(&data),
spell(&nested),
spell(&home),
spell(&token),
);
// `other` is denied without an exception: the writable root inside
// it stays masked, as it is for every caller that sets none.
let args = bwrap_exception_args(
&[
home.clone(),
token.clone(),
nested.clone(),
root.join("other"),
],
&[bundle.clone(), home.join("plugins"), home.clone()],
&[data.clone(), root.clone(), root.join("other/writable")],
);
assert_eq!(
args,
[
"--ro-bind",
bundle_s.as_str(),
bundle_s.as_str(),
"--bind",
data_s.as_str(),
data_s.as_str(),
"--tmpfs",
nested_s.as_str(),
]
);
// No exceptions, or none inside a masked directory: nothing to add.
assert!(
bwrap_exception_args(
std::slice::from_ref(&home),
&[],
std::slice::from_ref(&data)
)
.is_empty()
);
assert!(bwrap_exception_args(std::slice::from_ref(&token), &[bundle], &[data]).is_empty());
assert_eq!(bwrap_mask_args(&home), ["--tmpfs", home_s.as_str()]);
assert_eq!(
bwrap_mask_args(&token),
["--ro-bind", "/dev/null", token_s.as_str()]
);
assert!(bwrap_mask_args(&home.join("missing")).is_empty());
}
#[test]
fn test_command_spec_shell() {
let spec = CommandSpec::shell("echo hello", PathBuf::from("/tmp"), Duration::from_secs(30));
+15 -8
View File
@@ -97,8 +97,8 @@ unavailable too; under Node, `node:sqlite` and `node:ffi` are switched off
when one of these restrictions does not hold on the installed runtime. Known
limit: these are the native-code entry points found so far (Bun 1.4, Node 22
and 26); one a newer runtime adds is not covered until it is added. A
process an extension starts is outside this policy; on macOS it runs under the
same sandbox as the host.
process an extension starts is outside this policy; on macOS and Linux it runs
under the same sandbox as the host.
Include local imports in the bundle; trust stages reviewed content, and the
entry is rehashed before import. Changes to reviewed bytes or capabilities
require another review. See [bundle rules](PLUGIN_BUNDLES.md).
@@ -170,12 +170,19 @@ Dispose within 2 seconds and avoid leaving background work behind.
## Sandbox
Trust is not a complete security boundary. Plugins share one process and can
interfere with each other. On macOS, the existing Seatbelt profile denies direct
network access, writes outside host data/temp paths and reads of the protected
credential locations. Other user-readable files, including project `.env`
files, remain readable. Linux and Windows currently run this host with the
user's permissions. Review the [current design limits](design/TS_EXTENSION_HOST.md)
before enabling third-party code.
interfere with each other. On macOS the existing Seatbelt profile, and on Linux
bubblewrap (`/usr/bin/bwrap`), deny direct network access, writes outside host
data/temp paths and reads of the protected credential locations. Other
user-readable files, including project `.env` files, remain readable. On Linux
each start first checks that bwrap actually runs; where it is missing or cannot
create its namespaces (for example Ubuntu 24.04's
`kernel.apparmor_restrict_unprivileged_userns`), the host runs with the user's
permissions and `/plugin`, `codewhale doctor` and the start diagnostic say
`UNSANDBOXED` with bwrap's own error. Under bubblewrap a default credential
store created after the host started stays readable until it restarts.
Windows runs this host with the user's permissions. Review the
[current design limits](design/TS_EXTENSION_HOST.md) before enabling
third-party code.
## Limits
+9 -4
View File
@@ -132,8 +132,8 @@ the diagnosed fallback. The same embedded bundle runs on either runtime.
Not done: the bundled single-executable host (`bun build --compile`, D1) is
not built, signed or shipped; the host still runs on a user-installed Bun or
Node. DSH's own loader, HMR and inspector bridge stay Node-only (spike §2); the
Codewhale host does not use them. Bun on Linux and Windows is unsandboxed
exactly as Node is. The Windows Job Object limit and the Linux `RLIMIT_DATA`
Codewhale host does not use them. Bun on Linux runs under the same bwrap
wrapper as Node, and on Windows is unsandboxed exactly as Node is. The Windows Job Object limit and the Linux `RLIMIT_DATA`
path were not run on this machine; CI runs the memory-cap tests there. The
Rust CI job still runs the Rust integration tests on Node only; the Bun ones
skip there unless `CODEWHALE_EXT_HOST_BUN_TESTS` is set. So the Bun default
@@ -211,8 +211,13 @@ differences from the text below:
**What it does not do:** other user-readable files, including project `.env`
files, stay readable (the `.env` filename rule has no Seatbelt subpath form),
and Mach services and `exec` are not restricted, so this is defense in depth,
not the §4.5 containment. Linux and Windows run the host unsandboxed, and
`/plugin` says so. §4.5 remains the plan for real containment.
not the §4.5 containment. On Linux the same policy runs under bubblewrap
when a launch-time probe shows bwrap works; each Codewhale home is masked
whole and its readable entries bound again, since bwrap cannot deny a path
that does not exist yet (`extension_host::supervisor` lists what that does
not cover). Where bwrap is missing or cannot start, and on Windows, the host
runs unsandboxed, and `/plugin` and doctor say why. §4.5 remains the plan
for real containment.
- **Extension tool names that the approval path keys by name are refused.**
Approval keys (`approval_cache`), approval-card summaries and the approval /
auto-review category are derived from the tool name. A plugin tool named
+2 -2
View File
@@ -363,11 +363,11 @@
},
"crates/tui/src/sandbox/bwrap.rs": {
"path_canonicalize": 1,
"std_fs": 2
"std_fs": 1
},
"crates/tui/src/sandbox/mod.rs": {
"path_canonicalize": 1,
"std_fs": 2
"std_fs": 4
},
"crates/tui/src/sandbox/policy.rs": {
"path_canonicalize": 8