mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-09 01:49:49 -10:00
feat(extension-host): sandbox the host, share the hooks' process tree
Finishes the phase-1 items the first pass left as deviations from the
design (TS-EXTENSION-HOST-DESIGN.md §8), behind [features] extension_host
(off by default):
- Process tree: HookProcessTree / WindowsHookJob move out of
hooks/executor.rs into crate::process_tree (a move, not a copy). Hooks
keep their taskkill fallback in a small terminate_tree wrapper; the host
now contains its child with the same ProcessTree (Unix process group,
Windows kill-on-close Job Object) instead of its own killpg helper.
- OS sandbox: the host launches through SandboxManager with a
workspace-write profile rooted at $CODEWHALE_HOME/extension-host/data:
no network, writes only there and in the temp dirs, and no reads of the
read_guard credential defaults plus Codewhale's secrets, credentials,
config.toml, mcp.json, sessions and session-archives (runtime home and
ambient ~/.codewhale). Seatbelt on macOS only; Linux (bwrap stays opt-in)
and Windows run unsandboxed and /plugin says UNSANDBOXED.
- Review text: with the flag on, native is shown as "host code:
JavaScript" and is no longer described as inactive.
- Tests: new secret-probe fixture proves a trusted host plugin cannot read
<home>/secrets/token or write outside its data dir (verified failing with
the home deny entries removed: {"ok":true,"text":"s3cret-value"}).
Mid-call disable now measures cancellation from the moment the registry
drops the handle (watcher thread) and asserts < 500 ms (observed 2.0 ms);
the kill test uses taskkill on Windows.
The earlier "wip:" commit (dropping a double #[must_use] on an iterator
accessor) is kept: it is correct and clippy-clean.
Evidence (local, macOS arm64, Node 22.20.0 via the candidate ladder; the
first node on PATH is a broken Homebrew build):
- cargo test -p codewhale-tui --lib (filters: extension_host,
extension_tool_is_deferred, hooks::executor, render):
test result: ok. 455 passed; 0 failed; 1 ignored
- cargo check -p codewhale-tui --locked (lib and --tests): clean
- cargo clippy -p codewhale-tui --all-targets --all-features (CI flags): clean
- npm test (root): workspaces 67/67 + 14/14, extension host 46/46,
web 502/502 (55 files); npm run check:web: exit 0
- extension-host npm run build: no dist/ drift
- scripts/check-blocking-calls-budget.py: within budget (+1 supervisor.rs,
plan_launch runs only under spawn_blocking)
- Windows target not compiled locally (ring needs MSVC headers); CI owns it.
Measured (node v22.20.0, 12 runs, harness without sandbox): spawn ->
host/ready p50 33.3 ms (cold 34.1); ext/activate of the DSH fixture p50
4.8 ms; RSS idle p50 47.8 MB, after activation 49.3 MB; bundle 169 KB.
Rust path with Seatbelt, materialize and activation: 64-79 ms, RSS ~52 MB.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01R9rJEoMuUSRWznU6QjkE7h
This commit is contained in:
1 parent
ff74907086
commit
31f55b824b
11 files changed
+577
-210
No files matched your search
+5
-2
@@ -1240,8 +1240,11 @@ exec_policy = true
|
||||
# extension_host = false # EXPERIMENTAL. Run reviewed plugins' `native` host code
|
||||
# (TypeScript/JavaScript, Cordis/DSH plugin model) in a
|
||||
# Node.js sidecar. Tools only in this phase; every
|
||||
# extension tool always asks for approval. Host code
|
||||
# runs with your user permissions (no sandbox yet).
|
||||
# extension tool always asks for approval. On macOS
|
||||
# the host runs under Seatbelt (no network, no reads of
|
||||
# ~/.codewhale secrets/credentials/config/sessions,
|
||||
# writes only to its data dir and temp dirs); on Linux
|
||||
# and Windows it runs with your user permissions.
|
||||
# Toggling this changes the plugin activation policy,
|
||||
# so every plugin must be re-reviewed afterwards.
|
||||
|
||||
|
||||
@@ -28,18 +28,19 @@ pub(super) fn render_bundle_detail(
|
||||
let unsupported = if detail.unsupported_labels.is_empty() {
|
||||
"none".to_string()
|
||||
} else {
|
||||
detail.unsupported_labels.join(", ")
|
||||
display_component_labels(&detail.unsupported_labels)
|
||||
};
|
||||
let active_components = if detail.active {
|
||||
let labels = &detail.supported_labels;
|
||||
if labels.is_empty() {
|
||||
"none".to_string()
|
||||
} else {
|
||||
labels.join(", ")
|
||||
display_component_labels(labels)
|
||||
}
|
||||
} else {
|
||||
"none".to_string()
|
||||
};
|
||||
let extension_host = crate::plugins::activation::extension_host_policy_enabled();
|
||||
let (content_hash, capability_hash) = if include_hashes {
|
||||
(
|
||||
detail.content_hash.as_str(),
|
||||
@@ -76,18 +77,36 @@ pub(super) fn render_bundle_detail(
|
||||
.collect::<Vec<_>>();
|
||||
let _ = write!(
|
||||
output,
|
||||
"\nCompatibility: {}\nActive components: [{active_components}]\nInactive components: [{unsupported}]\nQualified skills: [{}]\nActivation boundary: trust stages the exact reviewed content but does not activate it; enable rebuilds this workspace's Skills, MCP, Commands, Agents, and Hooks immediately. Every plugin command dispatch, Agent spawn, Hook process start, Skill use, and MCP call rechecks current authority. LSP, native, filesystem-roots, and lifecycle-mutation stay inventoried and inactive.",
|
||||
"\nCompatibility: {}\nActive components: [{active_components}]\nInactive components: [{unsupported}]\nQualified skills: [{}]\nActivation boundary: trust stages the exact reviewed content but does not activate it; enable rebuilds this workspace's Skills, MCP, Commands, Agents, and Hooks immediately. Every plugin command dispatch, Agent spawn, Hook process start, Skill use, and MCP call rechecks current authority. {}",
|
||||
detail.compatibility,
|
||||
if skills.is_empty() {
|
||||
"none".to_string()
|
||||
} else {
|
||||
skills.join(", ")
|
||||
},
|
||||
if extension_host {
|
||||
"Native host code (JavaScript) runs in the experimental extension host with your user permissions (sandboxed where the OS sandbox is available); every extension tool call asks for approval. LSP, filesystem-roots, and lifecycle-mutation stay inventoried and inactive."
|
||||
} else {
|
||||
"LSP, native, filesystem-roots, and lifecycle-mutation stay inventoried and inactive."
|
||||
}
|
||||
);
|
||||
append_diagnostics(presentation, &mut output, &detail.diagnostics);
|
||||
output
|
||||
}
|
||||
|
||||
/// Component labels for review text. `native` is shown by what it is; the
|
||||
/// hashed label itself (`PluginActivationCapability::as_str`) never changes.
|
||||
fn display_component_labels(labels: &[String]) -> String {
|
||||
labels
|
||||
.iter()
|
||||
.map(|label| match label.as_str() {
|
||||
"native" => "native (host code: JavaScript)",
|
||||
other => other,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
}
|
||||
|
||||
fn render_permissions(detail: &PluginDetail) -> String {
|
||||
let filesystem = if detail.filesystem_roots.is_empty() {
|
||||
"none".to_string()
|
||||
|
||||
@@ -20,12 +20,17 @@
|
||||
//! * No heartbeat and no auto-restart. A dead host fails in-flight calls with
|
||||
//! a typed error and stays failed until the next session or until a plugin
|
||||
//! the session has not seen before becomes desired.
|
||||
//! * One host per engine process and one trust tier; host plugins run
|
||||
//! unsandboxed with the user's permissions, so the flag is Experimental.
|
||||
//! * One host per engine process and one trust tier. On macOS (Seatbelt) the
|
||||
//! host has no network and cannot read Codewhale's secret/credential/config
|
||||
//! stores (`supervisor::plan_launch`); on Linux and Windows it runs
|
||||
//! unsandboxed with the user's permissions. Either way the flag is
|
||||
//! Experimental.
|
||||
//! * The owner token is a bug/staleness guard, not a boundary between
|
||||
//! plugins that share the process.
|
||||
//! * Windows: the host is killed as a single process (`kill_on_drop`); the
|
||||
//! hooks' job-object tree was not moved into a shared module yet.
|
||||
//! * The host's process tree (Unix process group / Windows Job Object,
|
||||
//! shared with hooks via `crate::process_tree`) is killed as a whole. On
|
||||
//! Windows the host is assigned to its job just after spawn, not created
|
||||
//! suspended as hooks are.
|
||||
|
||||
pub(crate) mod protocol;
|
||||
pub(crate) mod registry;
|
||||
@@ -134,6 +139,8 @@ pub enum HostStatus {
|
||||
Ready {
|
||||
pid: Option<u32>,
|
||||
node_version: String,
|
||||
/// `seatbelt` / `bwrap`, or `None` when the host runs unsandboxed.
|
||||
sandbox: Option<String>,
|
||||
},
|
||||
Failed {
|
||||
reason: String,
|
||||
@@ -328,6 +335,7 @@ impl ExtensionHostManager {
|
||||
HostSlot::Ready(host) => HostStatus::Ready {
|
||||
pid: host.pid,
|
||||
node_version: host.node_version.get().cloned().unwrap_or_default(),
|
||||
sandbox: host.sandbox.clone(),
|
||||
},
|
||||
HostSlot::Failed {
|
||||
reason,
|
||||
@@ -663,7 +671,7 @@ impl ExtensionHostManager {
|
||||
}
|
||||
shared.spawn_attempts.fetch_add(1, Ordering::SeqCst);
|
||||
let options = shared.options.clone();
|
||||
let prepared = tokio::task::spawn_blocking(move || -> Result<(PathBuf, PathBuf), String> {
|
||||
let prepared = tokio::task::spawn_blocking(move || -> Result<supervisor::HostLaunch, String> {
|
||||
let resolution =
|
||||
crate::dependencies::resolve_node_for_extension_host(options.node_override.as_deref());
|
||||
let Some((node, _)) = resolution.selected else {
|
||||
@@ -677,16 +685,17 @@ impl ExtensionHostManager {
|
||||
None => codewhale_config::codewhale_home()
|
||||
.map_err(|error| format!("Codewhale home unavailable: {error}"))?,
|
||||
};
|
||||
Ok((node, materialize_bundle(&root)?))
|
||||
let bundle = materialize_bundle(&root)?;
|
||||
supervisor::plan_launch(&node, &bundle, &root)
|
||||
})
|
||||
.await
|
||||
.map_err(|error| format!("extension host preparation failed: {error}"))
|
||||
.and_then(|result| result);
|
||||
let spawned = match prepared {
|
||||
Ok((node, bundle)) => {
|
||||
Ok(launch) => {
|
||||
let generation = shared.host_generation.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
let events: Arc<dyn HostEvents> = Arc::new(Events(Arc::downgrade(shared)));
|
||||
HostProcess::spawn(generation, &node, &bundle, bundle_sha256(), events).await
|
||||
HostProcess::spawn(generation, &launch, bundle_sha256(), events).await
|
||||
}
|
||||
Err(error) => Err(error),
|
||||
};
|
||||
@@ -696,10 +705,11 @@ impl ExtensionHostManager {
|
||||
*slot = HostSlot::Ready(Arc::clone(&host));
|
||||
drop(slot);
|
||||
shared.diagnostic(format!(
|
||||
"extension host started (pid {}, node {})",
|
||||
"extension host started (pid {}, node {}, sandbox {})",
|
||||
host.pid
|
||||
.map_or_else(|| "?".to_string(), |pid| pid.to_string()),
|
||||
host.node_version.get().map_or("?", String::as_str)
|
||||
host.node_version.get().map_or("?", String::as_str),
|
||||
host.sandbox.as_deref().unwrap_or("none")
|
||||
));
|
||||
Ok(host)
|
||||
}
|
||||
@@ -757,11 +767,21 @@ pub(crate) fn render_status(manager: &ExtensionHostManager) -> String {
|
||||
"not running (starts in the background when a reviewed plugin with host code is enabled)",
|
||||
),
|
||||
HostStatus::Starting => out.push_str("starting"),
|
||||
HostStatus::Ready { pid, node_version } => {
|
||||
HostStatus::Ready {
|
||||
pid,
|
||||
node_version,
|
||||
sandbox,
|
||||
} => {
|
||||
let _ = write!(
|
||||
out,
|
||||
"running · pid {} · node {node_version}",
|
||||
pid.map_or_else(|| "?".to_string(), |pid| pid.to_string())
|
||||
"running · pid {} · node {node_version} · {}",
|
||||
pid.map_or_else(|| "?".to_string(), |pid| pid.to_string()),
|
||||
match sandbox {
|
||||
Some(sandbox) => format!(
|
||||
"{sandbox} sandbox (no network; Codewhale secrets and credential stores unreadable)"
|
||||
),
|
||||
None => "UNSANDBOXED: host code runs with your user permissions".to_string(),
|
||||
}
|
||||
);
|
||||
}
|
||||
HostStatus::Failed {
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
//! One extension-host process: spawn, handshake, channel, exit.
|
||||
//! One extension-host process: launch plan, spawn, handshake, channel, exit.
|
||||
//!
|
||||
//! Phase 1 has no heartbeat and no auto-restart. When the process exits for
|
||||
//! any reason, every in-flight call fails with a typed error, the owner
|
||||
//! registry is revoked wholesale, and the host is marked failed with its
|
||||
//! stderr tail. Nothing respawns until the next session or a newly enabled
|
||||
//! plugin.
|
||||
//!
|
||||
//! **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
|
||||
//! `$CODEWHALE_HOME/extension-host/data`: no network, writes only there and
|
||||
//! in the temp dirs, and **no reads** of the credential-store default
|
||||
//! deny-list (`sandbox::read_guard`) plus Codewhale's own secret, credential,
|
||||
//! config, MCP and session stores. Elsewhere (Linux, Windows) the host runs
|
||||
//! unsandboxed with the user's permissions, and
|
||||
//! `/plugin` says so. This is defense-in-depth, not the phase-5 sandbox:
|
||||
//! exec is not restricted beyond what the profile allows.
|
||||
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -47,6 +58,102 @@ pub enum HostCallError {
|
||||
Busy,
|
||||
}
|
||||
|
||||
/// How the host is started: the argv (wrapped by the OS sandbox when one is
|
||||
/// available), its working directory, and which sandbox applies.
|
||||
#[derive(Debug, Clone)]
|
||||
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>,
|
||||
/// Environment the sandbox wrapper adds (`CODEWHALE_SANDBOX`, …).
|
||||
pub sandbox_env: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
/// Paths the host process must never read, even though the sandbox otherwise
|
||||
/// grants full-disk read: the curated credential-store defaults, plus
|
||||
/// Codewhale's own stores under both the runtime home and the ambient
|
||||
/// `~/.codewhale` (a relocated home is not a licence to read the real one).
|
||||
pub(crate) fn host_denied_read_paths(home: &Path) -> Vec<PathBuf> {
|
||||
let mut paths = crate::sandbox::read_guard::ReadDenylist::build(true, &[], &[]).subtree_paths();
|
||||
let mut roots = vec![home.to_path_buf()];
|
||||
roots.extend(codewhale_config::codewhale_home().ok());
|
||||
roots.extend(
|
||||
codewhale_paths::user_home().map(|user| user.join(codewhale_config::CODEWHALE_APP_DIR)),
|
||||
);
|
||||
for root in roots {
|
||||
for store in [
|
||||
"secrets",
|
||||
"credentials",
|
||||
codewhale_config::CONFIG_FILE_NAME,
|
||||
"mcp.json",
|
||||
"sessions",
|
||||
"session-archives",
|
||||
] {
|
||||
let path = root.join(store);
|
||||
if !paths.contains(&path) {
|
||||
paths.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
paths
|
||||
}
|
||||
|
||||
/// Plan the host launch. Blocking (creates the data dir, canonicalizes the
|
||||
/// deny-list); call from `spawn_blocking`.
|
||||
pub(crate) fn plan_launch(node: &Path, bundle: &Path, home: &Path) -> Result<HostLaunch, String> {
|
||||
use crate::sandbox::{CommandSpec, SandboxManager, SandboxPolicy, SandboxType};
|
||||
let data = home.join("extension-host").join("data");
|
||||
std::fs::create_dir_all(&data)
|
||||
.map_err(|error| format!("cannot create {}: {error}", data.display()))?;
|
||||
let args: Vec<String> = [
|
||||
"--max-old-space-size=256",
|
||||
"--disable-proto=throw",
|
||||
"--no-addons",
|
||||
]
|
||||
.into_iter()
|
||||
.map(str::to_string)
|
||||
.chain(std::iter::once(bundle.to_string_lossy().into_owned()))
|
||||
.collect();
|
||||
let unsandboxed = HostLaunch {
|
||||
program: node.to_path_buf(),
|
||||
args: args.clone(),
|
||||
cwd: data.clone(),
|
||||
sandbox: None,
|
||||
sandbox_env: Vec::new(),
|
||||
};
|
||||
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);
|
||||
}
|
||||
let spec = CommandSpec::program(&node.to_string_lossy(), args, data, 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);
|
||||
if matches!(env.sandbox_type, SandboxType::None) {
|
||||
return Ok(unsandboxed);
|
||||
}
|
||||
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(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Callbacks from the channel into the manager.
|
||||
pub(crate) trait HostEvents: Send + Sync + 'static {
|
||||
fn register(&self, params: &protocol::RegisterParams) -> RegisterResult;
|
||||
@@ -74,6 +181,9 @@ struct Handshake {
|
||||
pub(crate) struct HostProcess {
|
||||
pub pid: Option<u32>,
|
||||
pub node_version: std::sync::OnceLock<String>,
|
||||
/// `seatbelt` / `bwrap`, or `None` when unsandboxed.
|
||||
pub sandbox: Option<String>,
|
||||
tree: Arc<crate::process_tree::ProcessTree>,
|
||||
outbound: mpsc::Sender<Vec<u8>>,
|
||||
pending: Arc<Mutex<HashMap<u64, PendingCall>>>,
|
||||
next_id: AtomicU64,
|
||||
@@ -102,18 +212,15 @@ impl HostProcess {
|
||||
/// anti-substitution: the control is that Rust chooses what to exec).
|
||||
pub(crate) async fn spawn(
|
||||
generation: u64,
|
||||
node: &Path,
|
||||
bundle: &Path,
|
||||
launch: &HostLaunch,
|
||||
expected_sha256: &str,
|
||||
events: Arc<dyn HostEvents>,
|
||||
) -> Result<Arc<Self>, String> {
|
||||
let mut command = tokio::process::Command::new(node);
|
||||
let mut command = tokio::process::Command::new(&launch.program);
|
||||
crate::utils::suppress_tokio_console_window(&mut command);
|
||||
command
|
||||
.arg("--max-old-space-size=256")
|
||||
.arg("--disable-proto=throw")
|
||||
.arg("--no-addons")
|
||||
.arg(bundle)
|
||||
.args(&launch.args)
|
||||
.current_dir(&launch.cwd)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
@@ -121,10 +228,14 @@ impl HostProcess {
|
||||
// Scrubbed environment: no credentials, no ambient proxy URLs.
|
||||
command.env_clear();
|
||||
let parent_pid = std::process::id().to_string();
|
||||
for (key, value) in crate::child_env::sanitized_plugin_mcp_env_from(
|
||||
std::env::vars_os(),
|
||||
[("CODEWHALE_HOST_PARENT_PID", parent_pid.as_str())],
|
||||
) {
|
||||
let overrides = launch
|
||||
.sandbox_env
|
||||
.iter()
|
||||
.map(|(key, value)| (key.as_str(), value.as_str()))
|
||||
.chain([("CODEWHALE_HOST_PARENT_PID", parent_pid.as_str())]);
|
||||
for (key, value) in
|
||||
crate::child_env::sanitized_plugin_mcp_env_from(std::env::vars_os(), overrides)
|
||||
{
|
||||
command.env(key, value);
|
||||
}
|
||||
#[cfg(unix)]
|
||||
@@ -132,8 +243,15 @@ impl HostProcess {
|
||||
|
||||
let mut child = command
|
||||
.spawn()
|
||||
.map_err(|error| format!("failed to start {}: {error}", node.display()))?;
|
||||
.map_err(|error| format!("failed to start {}: {error}", launch.program.display()))?;
|
||||
let pid = child.id();
|
||||
let tree = match crate::process_tree::ProcessTree::attach_tokio(&child) {
|
||||
Ok(tree) => Arc::new(tree),
|
||||
Err(error) => {
|
||||
let _ = child.start_kill();
|
||||
return Err(format!("failed to contain the extension host: {error}"));
|
||||
}
|
||||
};
|
||||
let stdin = child.stdin.take().ok_or("host stdin unavailable")?;
|
||||
let stdout = child.stdout.take().ok_or("host stdout unavailable")?;
|
||||
let stderr = child.stderr.take().ok_or("host stderr unavailable")?;
|
||||
@@ -211,6 +329,7 @@ impl HostProcess {
|
||||
let pending = Arc::clone(&pending);
|
||||
let tail = Arc::clone(&stderr_tail);
|
||||
let events = Arc::clone(&events);
|
||||
let tree = Arc::clone(&tree);
|
||||
tokio::spawn(async move {
|
||||
let reason = tokio::select! {
|
||||
status = child.wait() => match status {
|
||||
@@ -218,11 +337,13 @@ impl HostProcess {
|
||||
Err(error) => format!("wait failed: {error}"),
|
||||
},
|
||||
Some(reason) = kill_rx.recv() => {
|
||||
kill_process_tree(pid);
|
||||
let _ = tree.kill();
|
||||
let _ = child.kill().await;
|
||||
reason
|
||||
}
|
||||
};
|
||||
// The leader is gone; take anything it left behind with it.
|
||||
let _ = tree.kill();
|
||||
let drained: Vec<PendingCall> = pending
|
||||
.lock()
|
||||
.expect("pending lock")
|
||||
@@ -242,6 +363,8 @@ impl HostProcess {
|
||||
let host = Arc::new(Self {
|
||||
pid,
|
||||
node_version: std::sync::OnceLock::new(),
|
||||
sandbox: launch.sandbox.clone(),
|
||||
tree,
|
||||
outbound,
|
||||
pending,
|
||||
next_id: AtomicU64::new(1),
|
||||
@@ -449,38 +572,21 @@ impl HostProcess {
|
||||
})
|
||||
.await;
|
||||
if waited.is_err() {
|
||||
kill_process_tree(self.pid);
|
||||
let _ = self.tree.kill();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for HostProcess {
|
||||
fn drop(&mut self) {
|
||||
// The exit watcher also holds the tree; kill explicitly so dropping
|
||||
// the last handle to a live host never leaves it running.
|
||||
if !self.has_exited() {
|
||||
kill_process_tree(self.pid);
|
||||
let _ = self.tree.kill();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Kill the host's process group (Unix) or the process (elsewhere).
|
||||
///
|
||||
/// Phase-1 deviation from the design: the hooks' `HookProcessTree` /
|
||||
/// `WindowsHookJob` were not moved into a shared module; the host is its
|
||||
/// own process group on Unix and `kill_on_drop` covers the immediate child
|
||||
/// elsewhere. Phase-1 hosts spawn no brokered children.
|
||||
pub(crate) fn kill_process_tree(pid: Option<u32>) {
|
||||
#[cfg(unix)]
|
||||
if let Some(pid) = pid {
|
||||
// SAFETY: kill(2) dereferences no pointers; a negative pid names the
|
||||
// process group created with `process_group(0)` at spawn.
|
||||
unsafe {
|
||||
let _ = libc::kill(-(pid as libc::pid_t), libc::SIGKILL);
|
||||
}
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
let _ = pid;
|
||||
}
|
||||
|
||||
fn handle_host_message(
|
||||
message: HostMessage,
|
||||
pending: &Mutex<HashMap<u64, PendingCall>>,
|
||||
|
||||
@@ -467,7 +467,26 @@ async fn disabling_mid_call_revokes_at_once_and_teardown_waits_for_async_dispose
|
||||
tokio::time::sleep(Duration::from_millis(150)).await;
|
||||
|
||||
let disabled = fixture.disable("slow-tool");
|
||||
let revoked_at = Instant::now();
|
||||
// The revocation scan (a full re-hash of the staged tree) runs first;
|
||||
// the clock for the 500 ms bound starts when the registry drops the
|
||||
// handle, which is the moment revocation takes effect. A plain thread
|
||||
// watches for it, because this runtime is single-threaded (the policy
|
||||
// override is thread-local) and would only look when `sync` yields.
|
||||
let watcher = {
|
||||
let manager = Arc::clone(&manager);
|
||||
std::thread::spawn(move || {
|
||||
let started = Instant::now();
|
||||
while !manager.live_tool_names().is_empty() {
|
||||
assert!(
|
||||
started.elapsed() < Duration::from_secs(10),
|
||||
"revocation never happened"
|
||||
);
|
||||
std::thread::yield_now();
|
||||
}
|
||||
Instant::now()
|
||||
})
|
||||
};
|
||||
let sync_started = Instant::now();
|
||||
let sync = {
|
||||
let manager = Arc::clone(&manager);
|
||||
tokio::spawn(async move { manager.sync(disabled).await })
|
||||
@@ -476,23 +495,23 @@ async fn disabling_mid_call_revokes_at_once_and_teardown_waits_for_async_dispose
|
||||
.await
|
||||
.expect("call resolves")
|
||||
.unwrap();
|
||||
let call_resolved = revoked_at.elapsed();
|
||||
let resolved_at = Instant::now();
|
||||
let revoked_at = watcher.join().unwrap();
|
||||
let call_resolved = resolved_at.saturating_duration_since(revoked_at);
|
||||
assert!(
|
||||
matches!(outcome, Err(ToolError::Cancelled { .. })),
|
||||
"{outcome:?}"
|
||||
);
|
||||
// The revocation scan (a full re-hash of the staged tree) runs first; the
|
||||
// 500 ms bound is on the cancellation itself.
|
||||
eprintln!(
|
||||
"extension host: in-flight call resolved as cancelled {:.1} ms after revocation",
|
||||
call_resolved.as_secs_f64() * 1000.0
|
||||
);
|
||||
assert!(
|
||||
call_resolved < Duration::from_millis(1500),
|
||||
call_resolved < Duration::from_millis(500),
|
||||
"{call_resolved:?}"
|
||||
);
|
||||
assert!(
|
||||
manager.live_tool_names().is_empty(),
|
||||
"revoked synchronously"
|
||||
);
|
||||
sync.await.unwrap().unwrap();
|
||||
let teardown = revoked_at.elapsed();
|
||||
let teardown = sync_started.elapsed();
|
||||
assert!(
|
||||
teardown >= Duration::from_millis(300),
|
||||
"ack must wait for the 300 ms async disposer (got {teardown:?})"
|
||||
@@ -520,10 +539,16 @@ async fn killed_host_fails_calls_with_a_typed_error_and_does_not_respawn() {
|
||||
let context = ToolContext::new(fixture.workspace());
|
||||
let call = tokio::spawn(async move { tool.execute(json!({}), &context).await });
|
||||
tokio::time::sleep(Duration::from_millis(150)).await;
|
||||
#[cfg(unix)]
|
||||
let status = std::process::Command::new("kill")
|
||||
.args(["-9", &pid.to_string()])
|
||||
.status()
|
||||
.unwrap();
|
||||
#[cfg(windows)]
|
||||
let status = std::process::Command::new("taskkill")
|
||||
.args(["/F", "/PID", &pid.to_string()])
|
||||
.status()
|
||||
.unwrap();
|
||||
assert!(status.success());
|
||||
let outcome = tokio::time::timeout(Duration::from_secs(2), call)
|
||||
.await
|
||||
@@ -657,3 +682,78 @@ async fn with_no_native_plugin_the_host_is_never_spawned() {
|
||||
assert_eq!(manager.status(), HostStatus::Idle);
|
||||
assert!(!temp.path().join("home").exists(), "nothing materialized");
|
||||
}
|
||||
|
||||
async fn probe(tool: &Arc<dyn ToolSpec>, path: &Path, context: &ToolContext) -> Value {
|
||||
let result = tool
|
||||
.execute(json!({"path": path.to_string_lossy()}), context)
|
||||
.await
|
||||
.unwrap();
|
||||
serde_json::from_str(&result.content).unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sandboxed_host_cannot_read_codewhale_secrets_or_write_outside_its_data_dir() {
|
||||
let Some(node) = node_for_tests("sandboxed_host") else {
|
||||
return;
|
||||
};
|
||||
let _policy = TestPolicyGuard::extension_host(true);
|
||||
let fixture = FixturePlugins::new(&["secret-probe"]);
|
||||
// Created before launch: the deny-list records the canonical spelling of
|
||||
// paths that exist (macOS `/var` → `/private/var`).
|
||||
let secrets = fixture.root.join("secrets");
|
||||
std::fs::create_dir_all(&secrets).unwrap();
|
||||
let token = secrets.join("token");
|
||||
std::fs::write(&token, "s3cret-value").unwrap();
|
||||
let readable = fixture.root.join("readable.txt");
|
||||
std::fs::write(&readable, "plain").unwrap();
|
||||
|
||||
let manager = fixture.manager(node);
|
||||
manager.sync(fixture.registry()).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;
|
||||
};
|
||||
assert!(super::render_status(&manager).contains(&format!("{sandbox} sandbox")));
|
||||
|
||||
let context = ToolContext::new(fixture.workspace());
|
||||
let read = host_tool(&manager, fixture.workspace(), "probe_read");
|
||||
let write = host_tool(&manager, fixture.workspace(), "probe_write");
|
||||
|
||||
let plain = probe(&read, &readable, &context).await;
|
||||
assert_eq!(
|
||||
plain,
|
||||
json!({"ok": true, "text": "plain"}),
|
||||
"ordinary reads work"
|
||||
);
|
||||
let secret = probe(&read, &token, &context).await;
|
||||
assert_eq!(secret["ok"], false, "{secret}");
|
||||
assert!(!secret.to_string().contains("s3cret"), "{secret}");
|
||||
|
||||
let data = fixture.root.join("extension-host/data/probe.txt");
|
||||
assert_eq!(probe(&write, &data, &context).await["ok"], true);
|
||||
// Outside the data dir and the temp dirs (the fixture itself lives under
|
||||
// TMPDIR, which the profile leaves writable): the crate's source dir,
|
||||
// unless the checkout itself sits in a temp dir.
|
||||
let crate_dir = std::fs::canonicalize(env!("CARGO_MANIFEST_DIR")).unwrap();
|
||||
let in_temp = [std::env::temp_dir(), PathBuf::from("/tmp")]
|
||||
.iter()
|
||||
.filter_map(|dir| std::fs::canonicalize(dir).ok())
|
||||
.any(|dir| crate_dir.starts_with(dir));
|
||||
if !in_temp {
|
||||
let escape = crate_dir.join(format!(".ext-host-probe-{}", uuid::Uuid::new_v4().simple()));
|
||||
let escaped = probe(&write, &escape, &context).await;
|
||||
let leaked = escape.exists();
|
||||
let _ = std::fs::remove_file(&escape);
|
||||
assert_eq!(escaped["ok"], false, "{escaped}");
|
||||
assert!(!leaked);
|
||||
}
|
||||
manager.shutdown().await;
|
||||
}
|
||||
@@ -12,24 +12,17 @@ use std::thread::JoinHandle;
|
||||
use std::time::{Duration, Instant};
|
||||
use wait_timeout::ChildExt;
|
||||
|
||||
use crate::process_tree::ProcessTree;
|
||||
#[cfg(windows)]
|
||||
use std::os::windows::io::AsRawHandle;
|
||||
use crate::process_tree::windows_io_error;
|
||||
#[cfg(windows)]
|
||||
use windows::Win32::Foundation::{CloseHandle, HANDLE};
|
||||
use windows::Win32::Foundation::CloseHandle;
|
||||
#[cfg(windows)]
|
||||
use windows::Win32::System::Diagnostics::ToolHelp::{
|
||||
CreateToolhelp32Snapshot, TH32CS_SNAPTHREAD, THREADENTRY32, Thread32First, Thread32Next,
|
||||
};
|
||||
#[cfg(windows)]
|
||||
use windows::Win32::System::JobObjects::{
|
||||
AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
|
||||
JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation,
|
||||
SetInformationJobObject, TerminateJobObject,
|
||||
};
|
||||
#[cfg(windows)]
|
||||
use windows::Win32::System::Threading::{OpenThread, ResumeThread, THREAD_SUSPEND_RESUME};
|
||||
#[cfg(windows)]
|
||||
use windows::core::PCWSTR;
|
||||
|
||||
/// Context passed to hooks via environment variables
|
||||
#[derive(Debug, Clone, Default)]
|
||||
@@ -898,141 +891,21 @@ pub struct TurnEndPayloadInput<'a> {
|
||||
pub queued_message_count: usize,
|
||||
}
|
||||
|
||||
/// Owns the process tree created for one hook invocation.
|
||||
///
|
||||
/// Hooks run through a shell, so killing only the immediate `sh`/`cmd.exe`
|
||||
/// child can leave the actual hook runtime alive. Unix hooks get their own
|
||||
/// process group and Windows hooks are attached to a kill-on-close Job Object.
|
||||
/// Dropping this guard after the shell exits also closes inherited stdout and
|
||||
/// stderr pipes held by any lingering descendants.
|
||||
struct HookProcessTree {
|
||||
#[cfg(unix)]
|
||||
pgid: libc::pid_t,
|
||||
/// Kill a hook's whole process tree (see [`crate::process_tree`]): hooks run
|
||||
/// through a shell, so killing only the immediate `sh`/`cmd.exe` child can
|
||||
/// leave the actual hook runtime alive. Falls back to `taskkill /T` on Windows
|
||||
/// and to the immediate child everywhere.
|
||||
fn terminate_tree(process_tree: &ProcessTree, child: &mut Child) {
|
||||
let result = process_tree.kill();
|
||||
#[cfg(windows)]
|
||||
job: WindowsHookJob,
|
||||
}
|
||||
|
||||
impl HookProcessTree {
|
||||
fn attach(child: &Child) -> std::io::Result<Self> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
Ok(Self {
|
||||
pgid: child.id() as libc::pid_t,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
Ok(Self {
|
||||
job: WindowsHookJob::attach(child)?,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
{
|
||||
Ok(Self {})
|
||||
}
|
||||
let result = result.or_else(|_| kill_windows_process_tree(child.id()));
|
||||
if let Err(error) = result {
|
||||
tracing::warn!(
|
||||
?error,
|
||||
"failed to terminate hook process tree; killing immediate child"
|
||||
);
|
||||
let _ = child.kill();
|
||||
}
|
||||
|
||||
fn terminate(&self, child: &mut Child) {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
// SAFETY: kill(2) dereferences no pointers.
|
||||
let result = unsafe { libc::kill(-self.pgid, libc::SIGKILL) };
|
||||
if result != 0 {
|
||||
let error = std::io::Error::last_os_error();
|
||||
if error.raw_os_error() != Some(libc::ESRCH) {
|
||||
tracing::warn!(?error, "failed to terminate hook process group");
|
||||
let _ = child.kill();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let result = self
|
||||
.job
|
||||
.terminate()
|
||||
.or_else(|_| kill_windows_process_tree(child.id()));
|
||||
if let Err(error) = result {
|
||||
tracing::warn!(
|
||||
?error,
|
||||
"failed to terminate hook process tree; killing immediate child"
|
||||
);
|
||||
let _ = child.kill();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
{
|
||||
let _ = child.kill();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for HookProcessTree {
|
||||
fn drop(&mut self) {
|
||||
#[cfg(unix)]
|
||||
// SAFETY: kill(2) dereferences no pointers.
|
||||
unsafe {
|
||||
// The shell may have exited while one of its descendants still
|
||||
// holds a captured pipe. Reaping the process group keeps hook
|
||||
// lifetimes bounded and lets the reader threads finish.
|
||||
let _ = libc::kill(-self.pgid, libc::SIGKILL);
|
||||
}
|
||||
// On Windows, dropping WindowsHookJob closes a Job Object configured
|
||||
// with JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE.
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
struct WindowsHookJob {
|
||||
handle: HANDLE,
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
impl WindowsHookJob {
|
||||
fn attach(child: &Child) -> std::io::Result<Self> {
|
||||
// SAFETY: returned handle is owned by the new wrapper.
|
||||
let handle = unsafe { CreateJobObjectW(None, PCWSTR::null()).map_err(windows_io_error)? };
|
||||
let job = Self { handle };
|
||||
let mut limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default();
|
||||
limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
|
||||
|
||||
// SAFETY: `limits` is live with matching size; both handles are live.
|
||||
unsafe {
|
||||
SetInformationJobObject(
|
||||
job.handle,
|
||||
JobObjectExtendedLimitInformation,
|
||||
&limits as *const _ as *const core::ffi::c_void,
|
||||
std::mem::size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
|
||||
)
|
||||
.map_err(windows_io_error)?;
|
||||
AssignProcessToJobObject(job.handle, HANDLE(child.as_raw_handle()))
|
||||
.map_err(windows_io_error)?;
|
||||
}
|
||||
Ok(job)
|
||||
}
|
||||
|
||||
fn terminate(&self) -> std::io::Result<()> {
|
||||
// SAFETY: `self.handle` is a live owned job handle.
|
||||
unsafe { TerminateJobObject(self.handle, 1).map_err(windows_io_error) }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
impl Drop for WindowsHookJob {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: `self.handle` is owned here; Drop runs once.
|
||||
unsafe {
|
||||
let _ = CloseHandle(self.handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn windows_io_error(error: windows::core::Error) -> std::io::Error {
|
||||
std::io::Error::other(error)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
@@ -1130,9 +1003,9 @@ fn kill_and_reap_immediate_child(child: &mut Child, timeout: Duration) -> bool {
|
||||
/// resolved interpreter path, and the OS message: the caller turns them into a
|
||||
/// user-visible "hook could not answer" receipt, and on Windows a raw spawn
|
||||
/// error echoes the whole command line back. The detail is logged instead.
|
||||
fn spawn_hook_child(command: &mut Command) -> std::io::Result<(Child, HookProcessTree)> {
|
||||
fn spawn_hook_child(command: &mut Command) -> std::io::Result<(Child, ProcessTree)> {
|
||||
let mut child = command.spawn()?;
|
||||
let process_tree = match HookProcessTree::attach(&child) {
|
||||
let process_tree = match ProcessTree::attach(&child) {
|
||||
Ok(process_tree) => process_tree,
|
||||
Err(error) => {
|
||||
// Windows hooks are created suspended, so a containment failure
|
||||
@@ -2612,9 +2485,9 @@ const WINDOWS_TASKKILL_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
fn terminate_and_reap(
|
||||
hook_name: Option<&str>,
|
||||
child: &mut Child,
|
||||
process_tree: HookProcessTree,
|
||||
process_tree: ProcessTree,
|
||||
) -> bool {
|
||||
process_tree.terminate(child);
|
||||
terminate_tree(&process_tree, child);
|
||||
// Drop before the wait, not after: on Windows this closes the Job Object
|
||||
// and is itself a kill, and on Unix it re-signals the group. Waiting first
|
||||
// would delay the very thing meant to make the wait short.
|
||||
|
||||
@@ -83,6 +83,7 @@ mod oauth;
|
||||
mod operate;
|
||||
mod plugins;
|
||||
mod pricing;
|
||||
mod process_tree;
|
||||
mod project_context;
|
||||
mod project_context_cache;
|
||||
mod prompt_zones;
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
//! Process-tree containment for a spawned child and everything it starts.
|
||||
//!
|
||||
//! Moved out of `hooks/executor.rs` (where it was `HookProcessTree` /
|
||||
//! `WindowsHookJob`) so the hook runner and the extension host share one
|
||||
//! implementation. Killing only the immediate child can leave the real work
|
||||
//! alive (a hook's shell runtime, a host plugin's `child_process`), so:
|
||||
//!
|
||||
//! * **Unix:** the child must be spawned as the leader of its own process
|
||||
//! group (`process_group(0)`); the tree is that group, and it is SIGKILLed
|
||||
//! as a whole.
|
||||
//! * **Windows:** the child is assigned to a Job Object configured with
|
||||
//! `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`, so closing the job kills every
|
||||
//! process in it. Hooks are created suspended and resumed only after the
|
||||
//! assignment, so no descendant can escape; the extension host is not
|
||||
//! (tokio's `Command` does not expose a suspended spawn), which leaves a
|
||||
//! window of microseconds before assignment in which Node has not yet run
|
||||
//! any plugin code.
|
||||
//!
|
||||
//! Dropping the guard kills the tree. That is deliberate: the guard's lifetime
|
||||
//! is the tree's lifetime.
|
||||
|
||||
#[cfg(windows)]
|
||||
use windows::Win32::Foundation::{CloseHandle, HANDLE};
|
||||
#[cfg(windows)]
|
||||
use windows::Win32::System::JobObjects::{
|
||||
AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
|
||||
JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation,
|
||||
SetInformationJobObject, TerminateJobObject,
|
||||
};
|
||||
#[cfg(windows)]
|
||||
use windows::core::PCWSTR;
|
||||
|
||||
/// Owns the process tree rooted at one spawned child.
|
||||
pub(crate) struct ProcessTree {
|
||||
#[cfg(unix)]
|
||||
pgid: libc::pid_t,
|
||||
#[cfg(windows)]
|
||||
job: WindowsJob,
|
||||
}
|
||||
|
||||
// SAFETY (Windows): the job handle is an owned kernel handle; it is only
|
||||
// used through `&self` calls that the OS serializes, and closed once in Drop.
|
||||
#[cfg(windows)]
|
||||
unsafe impl Send for ProcessTree {}
|
||||
#[cfg(windows)]
|
||||
unsafe impl Sync for ProcessTree {}
|
||||
|
||||
impl ProcessTree {
|
||||
/// Contain a `std::process::Child` (spawned with `process_group(0)` on Unix).
|
||||
pub(crate) fn attach(child: &std::process::Child) -> std::io::Result<Self> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::io::AsRawHandle;
|
||||
Self::attach_parts(child.id(), child.as_raw_handle())
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
Self::attach_parts(child.id())
|
||||
}
|
||||
}
|
||||
|
||||
/// Contain a `tokio::process::Child` (spawned with `process_group(0)` on
|
||||
/// Unix). Fails if the child has already been reaped.
|
||||
pub(crate) fn attach_tokio(child: &tokio::process::Child) -> std::io::Result<Self> {
|
||||
let pid = child
|
||||
.id()
|
||||
.ok_or_else(|| std::io::Error::other("child already exited"))?;
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let handle = child
|
||||
.raw_handle()
|
||||
.ok_or_else(|| std::io::Error::other("child already exited"))?;
|
||||
Self::attach_parts(pid, handle)
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
Self::attach_parts(pid)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn attach_parts(_pid: u32, handle: std::os::windows::io::RawHandle) -> std::io::Result<Self> {
|
||||
Ok(Self {
|
||||
job: WindowsJob::attach(handle)?,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
#[allow(clippy::unnecessary_wraps)]
|
||||
fn attach_parts(pid: u32) -> std::io::Result<Self> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
Ok(Self {
|
||||
pgid: pid as libc::pid_t,
|
||||
})
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
let _ = pid;
|
||||
Ok(Self {})
|
||||
}
|
||||
}
|
||||
|
||||
/// Kill every process in the tree. A tree that is already gone is not an
|
||||
/// error. On failure the caller should fall back to killing the child.
|
||||
pub(crate) fn kill(&self) -> std::io::Result<()> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
// SAFETY: kill(2) dereferences no pointers; a negative pid names
|
||||
// the process group.
|
||||
let result = unsafe { libc::kill(-self.pgid, libc::SIGKILL) };
|
||||
if result != 0 {
|
||||
let error = std::io::Error::last_os_error();
|
||||
if error.raw_os_error() != Some(libc::ESRCH) {
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
self.job.terminate()
|
||||
}
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
{
|
||||
Err(std::io::Error::other(
|
||||
"process-tree containment is not supported on this platform",
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ProcessTree {
|
||||
fn drop(&mut self) {
|
||||
#[cfg(unix)]
|
||||
// SAFETY: kill(2) dereferences no pointers.
|
||||
unsafe {
|
||||
// The leader may have exited while a descendant still holds an
|
||||
// inherited pipe. Reaping the group keeps lifetimes bounded.
|
||||
let _ = libc::kill(-self.pgid, libc::SIGKILL);
|
||||
}
|
||||
// On Windows, dropping `WindowsJob` closes a Job Object configured
|
||||
// with JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE.
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
struct WindowsJob {
|
||||
handle: HANDLE,
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
impl WindowsJob {
|
||||
fn attach(child: std::os::windows::io::RawHandle) -> std::io::Result<Self> {
|
||||
// SAFETY: returned handle is owned by the new wrapper.
|
||||
let handle = unsafe { CreateJobObjectW(None, PCWSTR::null()).map_err(windows_io_error)? };
|
||||
let job = Self { handle };
|
||||
let mut limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default();
|
||||
limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
|
||||
|
||||
// SAFETY: `limits` is live with matching size; both handles are live.
|
||||
unsafe {
|
||||
SetInformationJobObject(
|
||||
job.handle,
|
||||
JobObjectExtendedLimitInformation,
|
||||
&limits as *const _ as *const core::ffi::c_void,
|
||||
std::mem::size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
|
||||
)
|
||||
.map_err(windows_io_error)?;
|
||||
AssignProcessToJobObject(job.handle, HANDLE(child)).map_err(windows_io_error)?;
|
||||
}
|
||||
Ok(job)
|
||||
}
|
||||
|
||||
fn terminate(&self) -> std::io::Result<()> {
|
||||
// SAFETY: `self.handle` is a live owned job handle.
|
||||
unsafe { TerminateJobObject(self.handle, 1).map_err(windows_io_error) }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
impl Drop for WindowsJob {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: `self.handle` is owned here; Drop runs once.
|
||||
unsafe {
|
||||
let _ = CloseHandle(self.handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
pub(crate) fn windows_io_error(error: windows::core::Error) -> std::io::Error {
|
||||
std::io::Error::other(error)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// Ambient Node file access from inside a host plugin. Under the host sandbox
|
||||
// the read of a Codewhale secret must fail even though the plugin is trusted.
|
||||
import { readFile, writeFile } from 'node:fs/promises'
|
||||
|
||||
export const name = 'secret-probe'
|
||||
export const inject = ['tools']
|
||||
|
||||
export function apply(ctx) {
|
||||
ctx.tools.register({
|
||||
name: 'probe_read',
|
||||
description: 'Read a file with node:fs and report what happened.',
|
||||
parameters: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] },
|
||||
async execute(args) {
|
||||
try {
|
||||
return { ok: true, text: await readFile(args.path, 'utf8') }
|
||||
} catch (error) {
|
||||
return { ok: false, code: error.code ?? String(error) }
|
||||
}
|
||||
},
|
||||
})
|
||||
ctx.tools.register({
|
||||
name: 'probe_write',
|
||||
description: 'Write a file with node:fs and report what happened.',
|
||||
parameters: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] },
|
||||
async execute(args) {
|
||||
try {
|
||||
await writeFile(args.path, 'probe')
|
||||
return { ok: true }
|
||||
} catch (error) {
|
||||
return { ok: false, code: error.code ?? String(error) }
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"$schema": "https://agent-plugins.org/schemas/plugin.json",
|
||||
"name": "secret-probe",
|
||||
"version": "0.1.0",
|
||||
"description": "Test fixture: a tool that tries to read a file and write one, to prove the host sandbox.",
|
||||
"license": "MIT",
|
||||
"extensions": {
|
||||
"net.codewhale": {
|
||||
"native": {
|
||||
"path": "index.mjs"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -181,6 +181,9 @@
|
||||
"crates/tui/src/extension_host/mod.rs": {
|
||||
"std_fs": 7
|
||||
},
|
||||
"crates/tui/src/extension_host/supervisor.rs": {
|
||||
"std_fs": 1
|
||||
},
|
||||
"crates/tui/src/external_credentials.rs": {
|
||||
"std_fs": 2
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user