use super::super::content_spill::{self, SpillStore};
use super::super::display::ToolPresentation;
use super::super::permissions::{
DenialKind, PermissionDecision, PermissionGate, PermissionRequest,
};
use super::live_output::{LiveOutputRelay, LiveOutputSession};
use super::output_budget::{
self, cap_model_output, cap_model_output_with_handle, max_output_tokens, output_head_tokens,
};
use super::{
denial_context, denial_recovery_hint, full_access_requested, ocap_disabled,
permission_granted_result, requests_are_replay_safe, resolve_tool_alias, AliasOutcome,
};
use crate::ExecOutcome;
pub fn venv_cmd_prefix() -> Option<String> {
let venv = std::env::var("NEWT_VENV")
.or_else(|_| std::env::var("VIRTUAL_ENV"))
.ok();
let exec_paths = std::env::var("NEWT_EXEC_PATHS").ok();
if venv.is_none() && exec_paths.is_none() {
return None;
}
let q = |s: &str| format!("'{}'", s.replace('\'', r"'\''"));
let mut path_dirs: Vec<String> = Vec::new();
let mut prefix = String::new();
if let Some(ref venv) = venv {
let venv_bin = format!("{venv}/bin");
prefix.push_str(&format!("export VIRTUAL_ENV={}; ", q(venv)));
path_dirs.push(venv_bin);
}
if let Some(ref paths) = exec_paths {
for dir in paths.split(':') {
if !dir.is_empty() {
path_dirs.push(dir.to_string());
}
}
}
if !path_dirs.is_empty() {
let quoted: Vec<String> = path_dirs.iter().map(|d| q(d)).collect();
prefix.push_str(&format!("export PATH={}:\"$PATH\"; ", quoted.join(":")));
}
if prefix.is_empty() {
None
} else {
Some(prefix)
}
}
pub(super) fn venv_env_map() -> std::collections::BTreeMap<String, String> {
let mut map = std::collections::BTreeMap::new();
for var in shell_env_passthrough() {
if let Ok(val) = std::env::var(&var) {
map.insert(var, val);
}
}
if let Some(config_path) = crate::Config::user_config_path() {
map.extend(crate::shell_env::from_config_dir(&config_path));
}
if let Ok(tmp) = std::env::var("NEWT_CHILD_TMPDIR") {
if !tmp.trim().is_empty() {
map.insert("TMPDIR".to_string(), tmp);
}
}
let identity = crate::AgentIdentity::resolve().unwrap_or_default();
let (name, email) = identity.sandbox_author();
map.extend(crate::git_hardening::sandbox_git_env(
(&name, &email),
&identity.git.config,
));
map.insert("SHELL".to_string(), shell_engine().as_str().to_string());
let venv = std::env::var("NEWT_VENV")
.or_else(|_| std::env::var("VIRTUAL_ENV"))
.ok();
let exec_paths = std::env::var("NEWT_EXEC_PATHS").ok();
let mut path_dirs: Vec<String> = Vec::new();
if let Some(ref venv) = venv {
map.insert("VIRTUAL_ENV".to_string(), venv.clone());
path_dirs.push(format!("{venv}/bin"));
}
if let Some(ref paths) = exec_paths {
for dir in paths.split(':') {
if !dir.is_empty() {
path_dirs.push(dir.to_string());
}
}
}
#[cfg(target_os = "macos")]
if let Some(developer) = crate::confined_exec::selected_developer_directory() {
path_dirs.push(format!("{developer}/usr/bin"));
}
if !path_dirs.is_empty() {
let prepend = path_dirs.join(":");
let path = match std::env::var("PATH") {
Ok(inherited) if !inherited.is_empty() => format!("{prepend}:{inherited}"),
_ => prepend,
};
map.insert("PATH".to_string(), path);
}
map
}
fn shell_env_passthrough() -> Vec<String> {
match std::env::var("NEWT_SHELL_ENV_PASSTHROUGH") {
Ok(s) if !s.trim().is_empty() => s
.split(':')
.filter(|v| !v.is_empty())
.map(str::to_string)
.collect(),
_ => crate::config::shell_env_passthrough_default(),
}
}
pub(super) fn command_cwd_key(name: &str) -> Option<&'static str> {
let name = match resolve_tool_alias(name) {
Some(AliasOutcome::Rewrite(canonical)) => canonical,
_ => name,
};
match name {
"run_command" | "build_exec" => Some("cwd"),
"lifecycle" => Some("dir"),
_ => None,
}
}
pub(super) fn command_args_with_default_cwd<'a>(
name: &str,
args: &'a serde_json::Value,
workspace: &str,
default_cwd: Option<&std::path::Path>,
session: Option<&crate::worktree_adoption::WorktreeSession>,
) -> Result<std::borrow::Cow<'a, serde_json::Value>, &'static str> {
let Some(key) = command_cwd_key(name) else {
return Ok(std::borrow::Cow::Borrowed(args));
};
if args.get(key).is_some() || !args.is_object() {
return Ok(std::borrow::Cow::Borrowed(args));
}
let task_root = session.and_then(|session| session.task_root(std::path::Path::new(workspace)));
let is_shell = name == "run_command"
|| matches!(
resolve_tool_alias(name),
Some(AliasOutcome::Rewrite("run_command"))
);
let default_cwd = task_root
.as_deref()
.or_else(|| is_shell.then_some(default_cwd).flatten());
let Some(default_cwd) = default_cwd.filter(|cwd| *cwd != std::path::Path::new(workspace))
else {
return Ok(std::borrow::Cow::Borrowed(args));
};
let cwd = dunce::simplified(default_cwd).to_str().ok_or(
"error: the command working directory is not valid UTF-8; select a UTF-8 directory",
)?;
let mut projected = args.clone();
projected[key] = serde_json::Value::String(cwd.to_owned());
Ok(std::borrow::Cow::Owned(projected))
}
pub(super) fn resolve_exec_cwd(workspace: &str, cwd: Option<&str>) -> String {
match cwd.filter(|c| !c.trim().is_empty()) {
None => workspace.to_string(),
Some(c) if std::path::Path::new(c).is_absolute() => c.to_string(),
Some(c) => std::path::Path::new(workspace)
.join(c)
.to_string_lossy()
.into_owned(),
}
}
pub(super) fn existing_exec_cwd(
cwd: &std::path::Path,
) -> Result<std::path::PathBuf, (String, ExecOutcome)> {
let unavailable = |reason: String| {
(
format!(
"error: working directory {} {reason}; no command ran",
crate::worktree_adoption::task_path_literal(cwd)
),
ExecOutcome::Unavailable,
)
};
let resolved = cwd.canonicalize().map_err(|error| {
unavailable(if error.kind() == std::io::ErrorKind::NotFound {
"does not exist".into()
} else {
format!("cannot be resolved: {error}")
})
})?;
if !resolved.is_dir() {
return Err(unavailable("is not a directory".into()));
}
Ok(resolved)
}
pub(super) fn split_leading_cd(cmd: &str) -> (Option<String>, String) {
let rest = match cmd.trim_start().strip_prefix("cd") {
Some(r) if r.starts_with(char::is_whitespace) => r.trim_start(),
_ => return (None, cmd.to_string()),
};
let (path, after) = parse_cd_path(rest);
if path.is_empty() {
return (None, cmd.to_string());
}
let after = after.trim_start();
let remainder = if let Some(r) = after.strip_prefix("&&") {
r.trim_start().to_string()
} else if let Some(r) = after.strip_prefix(';') {
r.trim_start().to_string()
} else if after.is_empty() {
String::new()
} else {
return (None, cmd.to_string());
};
(Some(path), remainder)
}
fn parse_cd_path(s: &str) -> (String, &str) {
let first = s.chars().next();
if let Some(q @ ('"' | '\'')) = first {
if let Some(end) = s[1..].find(q) {
return (s[1..=end].to_string(), &s[end + 2..]);
}
}
match s.find(char::is_whitespace) {
Some(i) => (s[..i].to_string(), &s[i..]),
None => (s.to_string(), ""),
}
}
pub(super) fn same_file_redirect_refusal(cmd: &str, cwd: &str) -> Option<String> {
let tokens = tokenize(cmd);
let mut start = 0usize;
for (idx, tok) in tokens.iter().enumerate() {
if matches!(tok, RedirectToken::Sep) {
if let Some(msg) = check_pipeline_redirects(&tokens[start..idx], cmd, cwd) {
return Some(msg);
}
start = idx + 1;
}
}
check_pipeline_redirects(&tokens[start..], cmd, cwd)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum RedirectOp {
In,
Out,
OutAppend,
OutClobber,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum RedirectToken {
Word(String, bool),
Redirect(RedirectOp),
Pipe,
Sep,
}
const AMBIGUOUS_CHARS: [char; 6] = ['$', '`', '*', '?', '[', '~'];
fn tokenize(cmd: &str) -> Vec<RedirectToken> {
let chars: Vec<char> = cmd.chars().collect();
let mut out = Vec::new();
let mut i = 0;
while i < chars.len() {
let c = chars[i];
if c.is_whitespace() {
i += 1;
continue;
}
if c == '&' && chars.get(i + 1) == Some(&'&') {
out.push(RedirectToken::Sep);
i += 2;
continue;
}
if c == '|' && chars.get(i + 1) == Some(&'|') {
out.push(RedirectToken::Sep);
i += 2;
continue;
}
if c == ';' {
out.push(RedirectToken::Sep);
i += 1;
continue;
}
if c == '|' {
out.push(RedirectToken::Pipe);
i += 1;
continue;
}
if c == '>' && chars.get(i + 1) == Some(&'>') {
out.push(RedirectToken::Redirect(RedirectOp::OutAppend));
i += 2;
continue;
}
if c == '>' && chars.get(i + 1) == Some(&'|') {
out.push(RedirectToken::Redirect(RedirectOp::OutClobber));
i += 2;
continue;
}
if c == '>' {
out.push(RedirectToken::Redirect(RedirectOp::Out));
i += 1;
continue;
}
if c == '<' {
out.push(RedirectToken::Redirect(RedirectOp::In));
i += 1;
continue;
}
let (word, opaque, consumed) = read_word(&chars[i..]);
out.push(RedirectToken::Word(word, opaque));
i += consumed.max(1);
}
out
}
fn read_word(chars: &[char]) -> (String, bool, usize) {
let mut text = String::new();
let mut opaque = false;
let mut i = 0;
while i < chars.len() {
let c = chars[i];
if c.is_whitespace() || matches!(c, '>' | '<' | '|' | '&' | ';') {
break;
}
match c {
'\'' | '"' => {
let quote = c;
let mut j = i + 1;
let mut closed = false;
while j < chars.len() {
if chars[j] == quote {
closed = true;
break;
}
j += 1;
}
if !closed {
opaque = true;
text.extend(&chars[i..]);
i = chars.len();
break;
}
text.extend(&chars[i + 1..j]);
i = j + 1;
}
_ if AMBIGUOUS_CHARS.contains(&c) => {
opaque = true;
text.push(c);
i += 1;
}
_ => {
text.push(c);
i += 1;
}
}
}
(text, opaque, i)
}
const NON_READING_COMMANDS: [&str; 5] = ["echo", "printf", "true", "false", ":"];
const SUBCOMMAND_DISPATCHERS: [&str; 6] = ["cargo", "git", "just", "make", "npm", "go"];
fn check_pipeline_redirects(tokens: &[RedirectToken], cmd: &str, cwd: &str) -> Option<String> {
let mut reads: Vec<String> = Vec::new();
let mut writes: Vec<String> = Vec::new();
let mut at_stage_start = true;
let mut stage_command: Option<&str> = None;
let mut operand_index = 0usize;
let mut i = 0;
while i < tokens.len() {
match &tokens[i] {
RedirectToken::Pipe => {
at_stage_start = true;
stage_command = None;
operand_index = 0;
i += 1;
}
RedirectToken::Sep => unreachable!("pipelines are pre-split on Sep"),
RedirectToken::Redirect(op) => {
at_stage_start = false;
if let Some(RedirectToken::Word(text, false)) = tokens.get(i + 1) {
match op {
RedirectOp::In => reads.push(text.clone()),
RedirectOp::Out | RedirectOp::OutAppend | RedirectOp::OutClobber => {
writes.push(text.clone());
}
}
i += 2;
} else {
i += 1;
}
}
RedirectToken::Word(text, opaque) => {
if at_stage_start {
stage_command = Some(text.as_str());
at_stage_start = false;
} else if stage_command == Some("tee") {
if !opaque && !text.starts_with('-') {
writes.push(text.clone());
}
operand_index += 1;
} else {
let non_reading =
stage_command.is_some_and(|c| NON_READING_COMMANDS.contains(&c));
let subcommand_slot = operand_index == 0
&& stage_command.is_some_and(|c| SUBCOMMAND_DISPATCHERS.contains(&c));
if !opaque && !non_reading && !subcommand_slot {
reads.push(text.clone());
}
operand_index += 1;
}
i += 1;
}
}
}
let normalized =
|token: &str| crate::caveats::lexically_normalize(&resolve_exec_cwd(cwd, Some(token)));
writes
.iter()
.filter(|path| !path.is_empty())
.find_map(|write| {
let write_path = normalized(write);
reads
.iter()
.any(|read| !read.is_empty() && normalized(read) == write_path)
.then(|| {
format!(
"error: refusing to run this command — it reads '{write}' and \
also redirects output to '{write}' in the same pipeline. The \
shell truncates '{write}' before the command finishes reading \
it, destroying the input. Write to a new name and move it into \
place instead (e.g. `awk … f > f.tmp && mv f.tmp f`).\n\
Refused command: {cmd}"
)
})
})
}
pub(super) fn confined_dispatch_args(cmd: &str, cwd: &str) -> serde_json::Value {
let env = venv_env_map();
serde_json::json!({
"cmd": cmd,
"cwd": cwd,
"env": env,
})
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum ShellRoute {
Cmd,
AmbientBrush,
BashSh,
Bridled(crate::ShellEngine),
}
#[allow(clippy::too_many_arguments)]
pub(super) fn select_shell_route(
bypass_enabled: bool,
has_broker: bool,
has_lease: bool,
floor_permits: bool,
windows: bool,
windows_cmd: bool,
runner_available: bool,
engine: crate::ShellEngine,
) -> ShellRoute {
if bypass_enabled && !has_broker && !has_lease && floor_permits {
if windows {
if windows_cmd || !runner_available {
ShellRoute::Cmd
} else {
ShellRoute::AmbientBrush
}
} else {
ShellRoute::BashSh
}
} else {
ShellRoute::Bridled(if has_broker {
crate::ShellEngine::Brush
} else {
engine
})
}
}
fn missing_runner_notice(
route: ShellRoute,
opted_out: bool,
shown: &std::sync::atomic::AtomicBool,
) -> Option<&'static str> {
(route == ShellRoute::Cmd
&& !opted_out
&& !shown.swap(true, std::sync::atomic::Ordering::Relaxed))
.then_some("carried brush not installed; using cmd")
}
impl ShellRoute {
pub(super) fn sentence(self) -> &'static str {
match self {
Self::Cmd => "Commands run under cmd.exe /C: chain with &&; POSIX ; and $? are not supported. run_command already returns the exit status — do not echo it.",
Self::AmbientBrush | Self::BashSh | Self::Bridled(_) => "Commands use POSIX shell syntax; the selected engine may restrict shell constructs.",
}
}
}
pub fn run_command_dialect_sentence() -> &'static str {
select_shell_route(
ocap_disabled(),
false,
false,
true,
cfg!(windows),
crate::config::windows_cmd_enabled(),
crate::ambient_brush::installed(),
shell_engine(),
)
.sentence()
}
pub fn shell_dialect_sentence(host_bypass: bool, windows: bool) -> &'static str {
select_shell_route(
host_bypass,
false,
false,
true,
windows,
false,
true,
crate::ShellEngine::SafeSubset,
)
.sentence()
}
pub(super) fn shell_engine() -> crate::ShellEngine {
if let Some(engine) = std::env::var("NEWT_SHELL_ENGINE")
.ok()
.and_then(|s| s.parse::<crate::ShellEngine>().ok())
{
return engine;
}
if full_access_requested() {
return crate::full_access_default_engine();
}
crate::confined_default_engine(crate::ocap_l3_backend().1)
}
fn b1_run_command_sandbox_policy() -> agent_bridle::SandboxPolicy {
agent_bridle::SandboxPolicy {
child_network: agent_bridle::ChildNetworkPolicy::DenyDirect,
..crate::confined_exec::runtime_sandbox_policy()
}
}
fn bridle_registry(
engine: crate::ShellEngine,
live: Option<std::sync::Arc<LiveOutputRelay>>,
wall: std::time::Duration,
held_read_roots: Vec<agent_bridle::HeldReadRoot>,
execution_lease: Option<std::sync::Arc<dyn Send + Sync>>,
command_broker: Option<std::sync::Arc<dyn agent_bridle_tool_shell::CommandBroker>>,
) -> agent_bridle::Registry {
use std::sync::Arc;
#[cfg(test)]
let _ = &command_broker;
let shell: Arc<dyn agent_bridle::Tool> = match engine {
crate::ShellEngine::SafeSubset => {
let mut tool = agent_bridle::ShellTool::with_config(shell_limits(wall))
.with_sandbox_policy(b1_run_command_sandbox_policy())
.with_held_read_roots(held_read_roots.clone());
if let Some(lease) = execution_lease {
tool = tool.with_execution_lease(lease);
}
if let Some(observer) = live.clone() {
tool = tool.with_output_observer(observer);
}
Arc::new(tool)
}
crate::ShellEngine::Host => {
let mut tool = agent_bridle::HostShellTool::new()
.with_timeout(wall)
.sandbox_policy(Arc::new(b1_run_command_sandbox_policy()))
.with_held_read_roots(held_read_roots.clone());
if let Some(lease) = execution_lease {
tool = tool.with_execution_lease(lease);
}
if let Some(observer) = live.clone() {
tool = tool.with_output_observer(observer);
}
Arc::new(tool)
}
crate::ShellEngine::Brush => {
#[cfg(test)]
let shell = {
let mut tool = agent_bridle::ShellTool::with_config(shell_limits(wall))
.with_sandbox_policy(b1_run_command_sandbox_policy())
.with_held_read_roots(held_read_roots.clone());
if let Some(lease) = execution_lease {
tool = tool.with_execution_lease(lease);
}
if let Some(observer) = live {
tool = tool.with_output_observer(observer);
}
Arc::new(tool) as Arc<dyn agent_bridle::Tool>
};
#[cfg(not(test))]
let shell = {
let mut tool = agent_bridle::BrushShellTool::new()
.with_max_output_bytes(shell_limits(wall).max_output_bytes)
.expect("shared shell output limit fits Brush protocol bounds")
.with_timeout(wall)
.with_sandbox_policy(Arc::new(b1_run_command_sandbox_policy()))
.with_held_read_roots(held_read_roots.clone());
if let Some(lease) = execution_lease {
tool = tool.with_execution_lease(lease);
}
if let Some(broker) = command_broker {
tool = tool.with_command_broker(broker);
}
if let Some(observer) = live {
tool = tool.with_output_observer(observer);
}
Arc::new(tool) as Arc<dyn agent_bridle::Tool>
};
shell
}
};
agent_bridle::Registry::builder()
.tool(shell)
.tool(Arc::new(agent_bridle::WebFetchTool::new()))
.build()
}
pub(super) fn shadow_records(host_bypass: bool, full_access: bool) -> bool {
host_bypass || full_access
}
pub(super) fn exec_floor_permits(floor: Option<&crate::caveats::Scope<String>>, cmd: &str) -> bool {
use crate::caveats::ScopeExt as _;
let Some(scope) = floor else {
return true; };
const SHELL_META: &[char] = &['&', '|', ';', '`', '$', '\n', '>', '<', '(', ')'];
if cmd.contains(SHELL_META) {
return false;
}
match cmd.split_ascii_whitespace().next() {
None => true,
Some(prog) => scope.permits(&prog.to_string()),
}
}
#[cfg(all(test, any(not(windows), feature = "windows-appcontainer")))]
pub(super) async fn dispatch_bridled_shell(
args: serde_json::Value,
caveats: &crate::caveats::Caveats,
sink: Option<std::sync::Arc<dyn crate::agentic::LiveToolOutput>>,
) -> agent_bridle::ToolResult<serde_json::Value> {
dispatch_bridled_shell_with_floor(
args,
caveats,
sink,
None,
None,
None,
Vec::new(),
Default::default(),
)
.await
}
#[allow(clippy::too_many_arguments)]
pub(super) async fn dispatch_bridled_build_shell(
args: serde_json::Value,
caveats: &crate::caveats::Caveats,
sink: Option<std::sync::Arc<dyn crate::agentic::LiveToolOutput>>,
execution_lease: std::sync::Arc<dyn Send + Sync>,
command_broker: Option<std::sync::Arc<dyn agent_bridle_tool_shell::CommandBroker>>,
held_read_roots: Vec<agent_bridle::HeldReadRoot>,
command_budget: crate::RunCommandBudget,
) -> agent_bridle::ToolResult<serde_json::Value> {
dispatch_bridled_shell_with_floor(
args,
caveats,
sink,
Some(agent_bridle::AxisEnforcement::Kernel),
Some(execution_lease),
command_broker,
held_read_roots,
command_budget,
)
.await
}
#[allow(clippy::too_many_arguments)]
async fn dispatch_bridled_shell_with_floor(
mut args: serde_json::Value,
caveats: &crate::caveats::Caveats,
sink: Option<std::sync::Arc<dyn crate::agentic::LiveToolOutput>>,
strength_floor: Option<agent_bridle::AxisEnforcement>,
execution_lease: Option<std::sync::Arc<dyn Send + Sync>>,
command_broker: Option<std::sync::Arc<dyn agent_bridle_tool_shell::CommandBroker>>,
held_read_roots: Vec<agent_bridle::HeldReadRoot>,
command_budget: crate::RunCommandBudget,
) -> agent_bridle::ToolResult<serde_json::Value> {
let mut live = LiveOutputSession::start(sink);
let cmd = args.get("cmd").and_then(serde_json::Value::as_str);
let wall = command_wall(
cmd.unwrap_or_default(),
args.get("timeout_secs").and_then(serde_json::Value::as_u64),
command_budget,
);
let dispatch_caveats = if strength_floor.is_some() {
caveats.clone()
} else {
dispatch_caveats_for_command(cmd.unwrap_or(""), caveats)
};
let ShellRoute::Bridled(engine) = select_shell_route(
false,
command_broker.is_some(),
execution_lease.is_some(),
true,
cfg!(windows),
crate::config::windows_cmd_enabled(),
crate::ambient_brush::installed(),
shell_engine(),
) else {
unreachable!("bridled dispatch cannot bypass confinement")
};
let registry = bridle_registry(
engine,
live.as_ref().map(LiveOutputSession::relay),
wall,
held_read_roots,
execution_lease.clone(),
command_broker,
);
let grant = registry.mint_grant(dispatch_caveats);
if let Some(args) = args.as_object_mut() {
args.remove("timeout_secs");
}
let result = match strength_floor {
Some(floor) => {
registry
.dispatch_with_strength_floor("shell", args, &grant, floor)
.await
}
None => registry.dispatch("shell", args, &grant).await,
};
if let Some(live) = live.as_mut() {
let ordinary_completion = result
.as_ref()
.ok()
.and_then(|envelope| envelope.get("timed_out"))
.and_then(serde_json::Value::as_bool)
!= Some(true);
if result.is_ok() && ordinary_completion {
live.finish_after_observer();
} else {
live.finish();
}
}
result.map(|mut envelope| {
envelope["timeout_secs"] = wall.as_secs().into();
envelope
})
}
pub(super) const UNGRANTED_FS_AUTHORITY_DENIAL: &str =
"capability denied: declared filesystem authority was not granted for this command";
pub(super) fn declared_filesystem_requests(
args: &serde_json::Value,
cmd: &str,
cwd: &str,
) -> Result<Vec<PermissionRequest>, String> {
let mut requests = Vec::new();
for (field, kind) in [
("fs_read", DenialKind::FsRead),
("fs_write", DenialKind::FsWrite),
] {
let Some(value) = args.get(field) else {
continue;
};
let invalid = || {
format!(
"error: run_command {field} must be an array of nonempty absolute paths without NUL bytes"
)
};
for value in value.as_array().ok_or_else(invalid)? {
let target = value.as_str().ok_or_else(invalid)?;
if target.is_empty()
|| target.contains('\0')
|| !std::path::Path::new(target).is_absolute()
{
return Err(invalid());
}
if !requests
.iter()
.any(|request: &PermissionRequest| request.kind == kind && request.target == target)
{
requests.push(PermissionRequest {
tool: "run_command".into(),
kind,
target: target.into(),
reason: format!("declared {field} for command {cmd:?} in {cwd:?}"),
harness_bound: false,
});
}
}
}
Ok(requests)
}
pub(super) fn permits_filesystem_request(
caveats: &crate::caveats::Caveats,
request: &PermissionRequest,
) -> bool {
let scope = match request.kind {
DenialKind::FsRead => &caveats.fs_read,
DenialKind::FsWrite => &caveats.fs_write,
_ => return false,
};
crate::caveats::permits_path(scope, &request.target)
}
#[allow(clippy::too_many_arguments)]
pub(super) async fn exec_confined_command(
cmd: &str,
cwd: &str,
workspace: &str,
color: bool,
tool_output_lines: usize,
caveats: &crate::caveats::Caveats,
filesystem_requests: &[PermissionRequest],
exec_floor: Option<&crate::caveats::Scope<String>>,
permission_gate: &mut Option<&mut dyn PermissionGate>,
tool_offload: bool,
spill_store: Option<&dyn SpillStore>,
live_tool_output: Option<std::sync::Arc<dyn crate::agentic::LiveToolOutput>>,
presentation: &mut dyn ToolPresentation,
command_budget: crate::RunCommandBudget,
) -> (String, ExecOutcome) {
exec_confined_command_with_broker(
cmd,
cwd,
workspace,
color,
tool_output_lines,
caveats,
filesystem_requests,
exec_floor,
permission_gate,
tool_offload,
spill_store,
live_tool_output,
presentation,
None,
None,
&mut None,
None,
None,
None,
command_budget,
)
.await
}
#[allow(clippy::too_many_arguments)]
pub(super) async fn exec_confined_command_with_broker(
cmd: &str,
cwd: &str,
workspace: &str,
color: bool,
tool_output_lines: usize,
caveats: &crate::caveats::Caveats,
filesystem_requests: &[PermissionRequest],
exec_floor: Option<&crate::caveats::Scope<String>>,
permission_gate: &mut Option<&mut dyn PermissionGate>,
tool_offload: bool,
spill_store: Option<&dyn SpillStore>,
live_tool_output: Option<std::sync::Arc<dyn crate::agentic::LiveToolOutput>>,
presentation: &mut dyn ToolPresentation,
command_broker: Option<std::sync::Arc<dyn agent_bridle_tool_shell::CommandBroker>>,
execution_lease: Option<agent_bridle_tool_shell::ExecutionLease>,
pre_exec_missing: &mut Option<Vec<PermissionRequest>>,
retry_authorized: Option<&std::sync::OnceLock<()>>,
timeout_secs: Option<u64>,
adopted: Option<&crate::worktree_adoption::AdoptedWorktree>,
command_budget: crate::RunCommandBudget,
) -> (String, ExecOutcome) {
presentation.execution_command(cmd, cwd);
if let Some(policy) = adopted {
if filesystem_requests.iter().any(|request| {
request.kind == DenialKind::FsWrite
&& policy.blocked(std::path::Path::new(&request.target))
}) {
return (policy.notice(), ExecOutcome::Denied);
}
}
if let Some(refusal) = same_file_redirect_refusal(cmd, cwd) {
return with_denial_context((refusal, ExecOutcome::Denied), workspace, cwd, None);
}
let cmd_with_venv = match venv_cmd_prefix() {
Some(prefix) => format!("{prefix}{cmd}"),
None => cmd.to_string(),
};
let route = select_shell_route(
ocap_disabled(),
command_broker.is_some(),
execution_lease.is_some(),
exec_floor_permits(exec_floor, cmd),
cfg!(windows),
crate::config::windows_cmd_enabled(),
crate::ambient_brush::installed(),
shell_engine(),
);
let host_bypass = !matches!(route, ShellRoute::Bridled(_));
if shadow_records(host_bypass, full_access_requested()) {
crate::flight_recorder::log_unconfined(cmd);
}
if host_bypass {
static FALLBACK_SHOWN: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
if let Some(notice) =
missing_runner_notice(route, crate::config::windows_cmd_enabled(), &FALLBACK_SHOWN)
{
presentation.preview(notice, 1);
}
let mut live = LiveOutputSession::start(live_tool_output);
let run = host_shell_dispatch(
route,
if route == ShellRoute::BashSh {
&cmd_with_venv
} else {
cmd
},
cwd,
live.as_ref().map(LiveOutputSession::relay),
timeout_secs,
command_budget,
)
.await;
if let Some(live) = live.as_mut() {
live.finish();
}
return match run {
Ok(envelope) => host_result(&envelope, |envelope| {
let mut text = shell_envelope_output(
envelope,
tool_output_lines,
color,
tool_offload,
spill_store,
Some(&mut *presentation),
);
if let Some(hint) = super::worktree::creation_failure_hint(cmd, envelope) {
text.push_str(hint);
}
text
}),
Err(e) => (format!("error: {e}"), ExecOutcome::Unavailable),
};
}
let refreshed = match permission_gate.as_deref_mut() {
Some(gate) => match gate.refresh_caveats(caveats) {
PermissionDecision::Allow(current) => Some(current),
PermissionDecision::Deny => {
return with_denial_context(
(
"capability denied: current permission authority was refused".to_string(),
ExecOutcome::Denied,
),
workspace,
cwd,
None,
);
}
},
None => None,
};
let caveats = refreshed.as_ref().unwrap_or(caveats);
let bound = permission_gate.as_deref_mut().map(|gate| {
let mut current = gate.apply_command_retry(cmd, cwd, caveats);
let Ok(inspection) = agent_bridle::inspect_shell(cmd) else {
return current;
};
let commands = inspection.commands;
for program in commands.iter().filter_map(|c| c.program.as_deref()) {
current = gate.apply_pending_once(DenialKind::Exec, program, ¤t);
}
current
});
let caveats = bound.as_ref().unwrap_or(caveats);
let missing: Vec<_> = filesystem_requests
.iter()
.filter(|request| !permits_filesystem_request(caveats, request))
.cloned()
.collect();
let admitted = if missing.is_empty() {
None
} else {
let decision = permission_gate
.as_deref_mut()
.map(|gate| gate.ask_with_caveats(caveats, &missing));
match decision {
Some(PermissionDecision::Allow(allowed))
if filesystem_requests
.iter()
.all(|request| permits_filesystem_request(&allowed, request)) =>
{
Some(allowed)
}
_ => {
*pre_exec_missing = Some(missing);
return with_denial_context(
(UNGRANTED_FS_AUTHORITY_DENIAL.into(), ExecOutcome::Denied),
workspace,
cwd,
Some(caveats),
);
}
}
};
let caveats = admitted.as_ref().unwrap_or(caveats);
if let Some(refusal) = super::native_git::windows_appcontainer_native_git_refusal(cmd, caveats)
{
return (format!("error: {refusal}"), ExecOutcome::Unavailable);
}
let mut dispatch_args = confined_dispatch_args(cmd, cwd);
dispatch_args["timeout_secs"] = serde_json::json!(timeout_secs);
let result = match dispatch_bridled_shell_with_floor(
dispatch_args.clone(),
caveats,
live_tool_output.clone(),
None,
execution_lease.clone(),
command_broker.clone(),
Vec::new(),
command_budget,
)
.await
{
Ok(envelope) => {
if envelope_denied(&envelope) {
crate::denial_journal::record_envelope(
cmd,
cwd,
crate::denial_journal::DenialStage::Initial,
&envelope,
);
if let Some(notice) = worktree_denial_notice(adopted, &envelope, cwd) {
return (notice, ExecOutcome::Denied);
}
if let Some(gate) = permission_gate.as_deref_mut() {
if let Some(requests) =
exec_denial_requests(&envelope).or_else(|| net_denial_requests(&envelope))
{
if let PermissionDecision::Allow(widened) =
gate.ask_with_caveats(caveats, &requests)
{
use crate::caveats::CaveatsExt as _;
if !requests.iter().all(|request| match request.kind {
DenialKind::Exec => widened.permits_exec(&request.target),
DenialKind::Net => widened.permits_net(&request.target),
_ => false,
}) {
return with_denial_context(
("capability denied: returned authority does not cover the requested targets".into(), ExecOutcome::Denied),
workspace,
cwd,
Some(caveats),
);
}
if !requests_are_replay_safe(&requests, cmd) {
if requests
.iter()
.all(|request| request.kind == DenialKind::Exec)
{
gate.queue_command_retry(cmd, cwd, &requests);
if let Some(ready) = retry_authorized {
let _ = ready.set(());
}
} else {
for request in &requests {
gate.queue_pending_once(request.kind, &request.target);
}
}
let granted = requests
.iter()
.map(|request| {
permission_granted_result(
request.kind.as_str(),
&request.target,
)
})
.collect::<Vec<_>>()
.join("\n");
return with_denial_context(
(granted, ExecOutcome::Denied),
workspace,
cwd,
Some(&widened),
);
}
if !filesystem_requests
.iter()
.all(|request| permits_filesystem_request(&widened, request))
{
return with_denial_context(
("capability denied: declared filesystem authority was not retained for this command".into(), ExecOutcome::Denied),
workspace,
cwd,
None,
);
}
if let Some(refusal) =
super::native_git::windows_appcontainer_native_git_refusal(
cmd, &widened,
)
{
return (format!("error: {refusal}"), ExecOutcome::Unavailable);
}
let retried = match dispatch_bridled_shell_with_floor(
dispatch_args,
&widened,
live_tool_output,
None,
execution_lease.clone(),
command_broker,
Vec::new(),
command_budget,
)
.await
{
Ok(env2) if envelope_denied(&env2) => {
crate::denial_journal::record_envelope(
cmd,
cwd,
crate::denial_journal::DenialStage::AfterGrant,
&env2,
);
(
worktree_denial_notice(adopted, &env2, cwd).unwrap_or_else(
|| denied_run_command_result(&env2, color),
),
ExecOutcome::Denied,
)
}
Ok(env2) => confined_result(
cmd,
&env2,
&widened,
color,
|envelope| {
shell_envelope_output(
envelope,
tool_output_lines,
color,
tool_offload,
spill_store,
Some(&mut *presentation),
)
},
command_budget,
),
Err(e) => dispatch_error_result(e),
};
return with_denial_context(retried, workspace, cwd, Some(&widened));
}
}
}
if let Some(requests) = exec_denial_requests(&envelope) {
*pre_exec_missing = Some(requests);
}
}
confined_result(
cmd,
&envelope,
caveats,
color,
|envelope| {
shell_envelope_output(
envelope,
tool_output_lines,
color,
tool_offload,
spill_store,
Some(&mut *presentation),
)
},
command_budget,
)
}
Err(e) => dispatch_error_result(e),
};
with_denial_context(result, workspace, cwd, Some(caveats))
}
pub(super) fn dispatch_error_result(error: agent_bridle::ToolError) -> (String, ExecOutcome) {
let outcome = if matches!(error, agent_bridle::ToolError::Denied { .. }) {
ExecOutcome::Denied
} else {
ExecOutcome::Unavailable
};
(format!("error: {error}"), outcome)
}
fn with_denial_context(
(mut text, outcome): (String, ExecOutcome),
workspace: &str,
cwd: &str,
caveats: Option<&crate::caveats::Caveats>,
) -> (String, ExecOutcome) {
if outcome == ExecOutcome::Denied {
text.push('\n');
text.push_str(&denial_context(workspace, Some(cwd), caveats));
}
(text, outcome)
}
pub(super) fn envelope_outcome(envelope: &serde_json::Value) -> ExecOutcome {
if envelope_denied(envelope) {
ExecOutcome::Denied
} else if envelope
.get("timed_out")
.and_then(serde_json::Value::as_bool)
== Some(true)
{
ExecOutcome::TimedOut
} else if envelope
.get("exit_code")
.and_then(serde_json::Value::as_i64)
== Some(0)
{
ExecOutcome::Passed
} else {
ExecOutcome::Failed
}
}
pub(super) fn confined_result(
cmd: &str,
envelope: &serde_json::Value,
caveats: &crate::caveats::Caveats,
color: bool,
render: impl FnOnce(&serde_json::Value) -> String,
command_budget: crate::RunCommandBudget,
) -> (String, ExecOutcome) {
if envelope_denied(envelope) {
return (
denied_run_command_result(envelope, color),
ExecOutcome::Denied,
);
}
if let Some(refusal) = absent_binary_refusal(envelope, &caveats.exec) {
return (refusal, ExecOutcome::Unavailable);
}
let absent = absent_program_note(envelope, &caveats.exec);
let unstructured_126 = unstructured_exit_126_note(envelope);
let outcome = envelope_outcome(envelope);
let mut text = render(envelope);
if let Some(hint) = super::worktree::creation_failure_hint(cmd, envelope) {
text.push_str(hint);
}
if let Some(note) = absent {
text.push('\n');
text.push_str(¬e);
}
if let Some(note) = unstructured_126 {
text.push_str(note);
}
if outcome == ExecOutcome::TimedOut {
text.push_str(&timed_out_note(
envelope
.get("timeout_secs")
.and_then(serde_json::Value::as_u64)
.map(std::time::Duration::from_secs)
.unwrap_or_else(|| dispatch_wall(cmd, command_budget)),
command_budget,
));
}
(text, outcome)
}
pub(super) const LIFECYCLE_BUILD_TIMEOUT: std::time::Duration =
std::time::Duration::from_secs(30 * 60);
const LIFECYCLE_BUILD_CALL: &str = r#"{"action":"build","phase":"test"}"#;
pub(super) const RUN_COMMAND_MAX_SECS: u64 = crate::RunCommandBudget::MAX_SECS;
#[cfg(test)]
pub(super) fn command_budget_seconds(configured: Option<&str>, requested: Option<u64>) -> u64 {
crate::RunCommandBudget::from_configured(configured).seconds(requested)
}
pub(super) fn shell_limits(wall: std::time::Duration) -> agent_bridle::LimitsPolicy {
let default = agent_bridle::LimitsPolicy::default();
agent_bridle::LimitsPolicy {
default_timeout_secs: wall.as_secs(),
max_timeout_secs: wall.as_secs().max(default.max_timeout_secs),
..default
}
}
pub(super) fn command_wall(
cmd: &str,
requested: Option<u64>,
command_budget: crate::RunCommandBudget,
) -> std::time::Duration {
if super::build_shell::has_build_work(cmd) {
requested
.filter(|secs| *secs > 0)
.map(|secs| std::time::Duration::from_secs(command_budget.seconds(Some(secs))))
.unwrap_or(LIFECYCLE_BUILD_TIMEOUT)
} else {
std::time::Duration::from_secs(command_budget.seconds(requested))
}
}
pub(super) fn dispatch_wall(
cmd: &str,
command_budget: crate::RunCommandBudget,
) -> std::time::Duration {
command_wall(cmd, None, command_budget)
}
pub(super) fn run_command_limit_sentence(command_budget: crate::RunCommandBudget) -> String {
format!(
"Ordinary calls are killed after {} seconds (wall clock); classified builds get 30 minutes. Override with timeout_secs up to \
{} seconds; the result carries the exit code. For an offline build use \
`lifecycle` action=build, i.e. call the tool with {LIFECYCLE_BUILD_CALL} (`phase` is \
never `build`), or narrow the command (one test filter, one crate).",
command_budget.seconds(None),
RUN_COMMAND_MAX_SECS
)
}
pub(super) fn timed_out_note(
wall: std::time::Duration,
command_budget: crate::RunCommandBudget,
) -> String {
let default = std::time::Duration::from_secs(command_budget.seconds(None));
let wall_note = if wall == default {
format!("{}s wall", wall.as_secs())
} else if wall == LIFECYCLE_BUILD_TIMEOUT {
format!(
"{}s build-lane wall (not the default {}s)",
wall.as_secs(),
default.as_secs()
)
} else {
format!(
"{}s wall (per-call budget; default {}s)",
wall.as_secs(),
default.as_secs()
)
};
if wall == LIFECYCLE_BUILD_TIMEOUT {
return format!(
"\n(the command hit the {wall_note} and was killed; the output above is partial. \
Narrow the command: one crate, one test filter.)"
);
}
format!(
"\n(the command hit the {wall_note} and was killed; the output above is partial. For a \
build or full test run use `lifecycle` action=build, i.e. call the tool with \
{LIFECYCLE_BUILD_CALL} ({}-minute limit, confined, offline; `phase` is the phase to run, not `build`), or \
narrow the command.)",
LIFECYCLE_BUILD_TIMEOUT.as_secs() / 60
)
}
pub(super) fn host_result(
envelope: &serde_json::Value,
render: impl FnOnce(&serde_json::Value) -> String,
) -> (String, ExecOutcome) {
let outcome = match envelope_outcome(envelope) {
ExecOutcome::Failed
if envelope
.get("exit_code")
.and_then(serde_json::Value::as_i64)
== Some(127)
&& host_named_missing(envelope)
.is_some_and(|prog| host_path_lookup(prog).is_none()) =>
{
ExecOutcome::Unavailable
}
outcome => outcome,
};
(render(envelope), outcome)
}
const HOST_NOT_FOUND: [(&str, &str); 2] =
[("bash: ", ": command not found"), ("sh: ", ": not found")];
fn host_named_missing(envelope: &serde_json::Value) -> Option<&str> {
let stderr = envelope.get("stderr")?.as_str()?;
stderr.lines().rev().find_map(|line| {
HOST_NOT_FOUND.iter().find_map(|(prefix, suffix)| {
line.strip_prefix(prefix)?
.strip_suffix(suffix)?
.rsplit(": ")
.next()
})
})
}
pub(super) async fn host_shell_dispatch(
route: ShellRoute,
cmd: &str,
cwd: &str,
live: Option<std::sync::Arc<LiveOutputRelay>>,
timeout_secs: Option<u64>,
command_budget: crate::RunCommandBudget,
) -> std::io::Result<serde_json::Value> {
let wall = command_wall(cmd, timeout_secs, command_budget);
let run = if timeout_secs.is_none() && wall != LIFECYCLE_BUILD_TIMEOUT {
host_shell_output(route, cmd, cwd, live, command_budget).await?
} else {
host_shell_output_with_timeout(route, cmd, cwd, live, wall).await?
};
Ok(serde_json::json!({
"exit_code": run.exit_code,
"timeout_secs": wall.as_secs(),
"stdout": decode_shell_stream(&run.stdout),
"stderr": decode_shell_stream(&run.stderr),
"timed_out": run.timed_out,
"sandbox_kind": "none",
}))
}
pub(super) struct HostShellRun {
pub(super) exit_code: i64,
pub(super) stdout: Vec<u8>,
stderr: Vec<u8>,
pub(super) timed_out: bool,
}
fn host_exec_timeout(command_budget: crate::RunCommandBudget) -> std::time::Duration {
std::time::Duration::from_secs(command_budget.seconds(None))
}
pub(super) fn decode_shell_stream(bytes: &[u8]) -> String {
match std::str::from_utf8(bytes) {
Ok(text) => text.to_string(),
Err(_) => repair_bsd_cat_v_utf8(bytes)
.unwrap_or_else(|| String::from_utf8_lossy(bytes).into_owned()),
}
}
fn repair_bsd_cat_v_utf8(bytes: &[u8]) -> Option<String> {
let mut repaired = Vec::with_capacity(bytes.len());
let mut changed = false;
let mut i = 0;
while i < bytes.len() {
let lead = bytes[i];
let Some(cont_count) = utf8_continuation_count(lead) else {
repaired.push(lead);
i += 1;
continue;
};
let mut seq = Vec::with_capacity(cont_count + 1);
seq.push(lead);
let mut j = i + 1;
let mut ok = true;
for _ in 0..cont_count {
match parse_cat_v_meta_byte(bytes, j) {
Some((cont, next)) if (0x80..=0xbf).contains(&cont) => {
seq.push(cont);
j = next;
}
_ => {
ok = false;
break;
}
}
}
if ok && std::str::from_utf8(&seq).is_ok() {
repaired.extend_from_slice(&seq);
changed = true;
i = j;
} else {
repaired.push(lead);
i += 1;
}
}
changed.then(|| String::from_utf8(repaired).ok()).flatten()
}
fn utf8_continuation_count(lead: u8) -> Option<usize> {
match lead {
0xc2..=0xdf => Some(1),
0xe0..=0xef => Some(2),
0xf0..=0xf4 => Some(3),
_ => None,
}
}
fn parse_cat_v_meta_byte(bytes: &[u8], start: usize) -> Option<(u8, usize)> {
if start + 2 > bytes.len() || &bytes[start..start + 2] != b"M-" {
return None;
}
let pos = start + 2;
match bytes.get(pos).copied()? {
b'^' => {
let c = bytes.get(pos + 1).copied()?;
let low = if c == b'?' {
0x7f
} else if (b'@'..=b'_').contains(&c) {
c - b'@'
} else {
return None;
};
Some((low | 0x80, pos + 2))
}
c if (0x20..=0x7e).contains(&c) => Some((c | 0x80, pos + 1)),
_ => None,
}
}
async fn drain_host_pipe<R: tokio::io::AsyncRead + Unpin>(
mut reader: R,
live: Option<std::sync::Arc<LiveOutputRelay>>,
stream: crate::agentic::ToolOutputStream,
) -> std::io::Result<Vec<u8>> {
use tokio::io::AsyncReadExt as _;
let mut output = Vec::new();
let mut chunk = [0u8; 8 * 1024];
loop {
let read = reader.read(&mut chunk).await?;
if read == 0 {
return Ok(output);
}
if let Some(live) = live.as_ref() {
live.write(stream, &chunk[..read]);
}
output.extend_from_slice(&chunk[..read]);
}
}
#[cfg(not(windows))]
pub(super) async fn host_shell_output(
route: ShellRoute,
cmd: &str,
cwd: &str,
live: Option<std::sync::Arc<LiveOutputRelay>>,
command_budget: crate::RunCommandBudget,
) -> std::io::Result<HostShellRun> {
host_shell_output_with_timeout(route, cmd, cwd, live, host_exec_timeout(command_budget)).await
}
pub(super) const CHILD_STRIPPED_AUTHORITY_ENV: &[&str] = &[
"NEWT_DISABLE_OCAP",
"NEWT_FULL_ACCESS",
"NEWT_UNSAFE_HOST_EXEC",
"NEWT_BENCH_OCAP",
"NEWT_SHELL_ENGINE",
"NEWT_WINDOWS_CMD",
"NEWT_SHELL_ENV_PASSTHROUGH",
"NEWT_WRITE_PATHS",
"NEWT_READ_PATHS",
"NEWT_EXEC_PATHS",
"NEWT_VENV",
"NEWT_NO_ROUTE",
"NEWT_AGENT_KEY",
"NEWT_OPERATOR_KEY",
"NEWT_TOKEN_PASSPHRASE",
];
fn strip_child_authority_env(c: &mut tokio::process::Command) {
for key in CHILD_STRIPPED_AUTHORITY_ENV {
c.env_remove(key);
}
}
pub(super) fn host_shell_command(
route: ShellRoute,
fallback: bool,
cmd: &str,
cwd: &str,
) -> std::io::Result<tokio::process::Command> {
let executable = if route == ShellRoute::AmbientBrush {
std::env::current_exe()?
} else {
std::path::PathBuf::new()
};
host_shell_command_at(route, fallback, cmd, cwd, &executable)
}
fn host_shell_command_at(
route: ShellRoute,
fallback: bool,
cmd: &str,
cwd: &str,
executable: &std::path::Path,
) -> std::io::Result<tokio::process::Command> {
use std::ffi::OsStr;
use std::process::Stdio;
let runner = crate::ambient_brush::runner_path(executable);
let (program, flag): (&OsStr, &str) = match route {
ShellRoute::Cmd => (OsStr::new("cmd"), "/C"),
ShellRoute::AmbientBrush => (runner.as_os_str(), ""),
ShellRoute::BashSh => (OsStr::new(if fallback { "sh" } else { "bash" }), "-c"),
ShellRoute::Bridled(_) => {
return Err(std::io::Error::other(
"bridled route cannot construct an ambient child",
))
}
};
let mut c = tokio::process::Command::new(program);
if route != ShellRoute::AmbientBrush {
c.arg(flag);
}
if route == ShellRoute::AmbientBrush {
let tools = dunce::simplified(executable)
.parent()
.ok_or_else(|| std::io::Error::other("executable has no parent"))?
.join("tools");
let mut paths = vec![tools];
if let Some(venv) =
std::env::var_os("NEWT_VENV").or_else(|| std::env::var_os("VIRTUAL_ENV"))
{
paths.push(std::path::Path::new(&venv).join(if cfg!(windows) {
"Scripts"
} else {
"bin"
}));
c.env("VIRTUAL_ENV", venv);
}
if let Some(path) = std::env::var_os("PATH") {
paths.extend(std::env::split_paths(&path));
}
let path = std::env::join_paths(paths).map_err(std::io::Error::other)?;
c.env("PATH", path);
} else if route == ShellRoute::BashSh {
c.arg(agent_bridle_tool_shell::with_posix_command_discovery(cmd));
} else {
c.arg(cmd);
}
c.current_dir(cwd)
.stdin(if route == ShellRoute::AmbientBrush {
Stdio::piped()
} else {
Stdio::null()
})
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
#[cfg(not(windows))]
c.process_group(0);
strip_child_authority_env(&mut c);
Ok(c)
}
#[cfg(not(windows))]
pub(super) async fn host_shell_output_with_timeout(
route: ShellRoute,
cmd: &str,
cwd: &str,
live: Option<std::sync::Arc<LiveOutputRelay>>,
timeout: std::time::Duration,
) -> std::io::Result<HostShellRun> {
async fn run_one(
mut child: tokio::process::Child,
live: Option<std::sync::Arc<LiveOutputRelay>>,
timeout: std::time::Duration,
) -> std::io::Result<HostShellRun> {
let stdout = child
.stdout
.take()
.ok_or_else(|| std::io::Error::other("host shell stdout was not piped"))?;
let stderr = child
.stderr
.take()
.ok_or_else(|| std::io::Error::other("host shell stderr was not piped"))?;
let completed = async {
let (status, stdout, stderr) = tokio::try_join!(
child.wait(),
drain_host_pipe(
stdout,
live.clone(),
crate::agentic::ToolOutputStream::Stdout
),
drain_host_pipe(stderr, live, crate::agentic::ToolOutputStream::Stderr),
)?;
Ok::<_, std::io::Error>((status, stdout, stderr))
};
match tokio::time::timeout(timeout, completed).await {
Ok(Ok((status, stdout, stderr))) => Ok(HostShellRun {
exit_code: status.code().unwrap_or(-1) as i64,
stdout,
stderr,
timed_out: false,
}),
Ok(Err(e)) => Err(e),
Err(_elapsed) => Ok(HostShellRun {
exit_code: 124,
stdout: Vec::new(),
stderr: format!(
"command exceeded {}s host-shell timeout and was killed\n",
timeout.as_secs()
)
.into_bytes(),
timed_out: true,
}),
}
}
match host_shell_command(route, false, cmd, cwd)?.spawn() {
Ok(child) => run_one(child, live, timeout).await,
Err(e) if route == ShellRoute::BashSh && e.kind() == std::io::ErrorKind::NotFound => {
run_one(
host_shell_command(route, true, cmd, cwd)?.spawn()?,
live,
timeout,
)
.await
}
Err(e) => Err(e),
}
}
#[cfg(windows)]
pub(super) async fn host_shell_output(
route: ShellRoute,
cmd: &str,
cwd: &str,
live: Option<std::sync::Arc<LiveOutputRelay>>,
command_budget: crate::RunCommandBudget,
) -> std::io::Result<HostShellRun> {
host_shell_output_with_timeout(route, cmd, cwd, live, host_exec_timeout(command_budget)).await
}
#[cfg(windows)]
pub(super) async fn host_shell_output_with_timeout(
route: ShellRoute,
cmd: &str,
cwd: &str,
live: Option<std::sync::Arc<LiveOutputRelay>>,
timeout: std::time::Duration,
) -> std::io::Result<HostShellRun> {
let cmd_builder = host_shell_command(route, false, cmd, cwd)?;
supervise_windows_shell(
cmd_builder,
(route == ShellRoute::AmbientBrush).then_some(cmd),
live,
timeout,
)
.await
}
#[cfg(windows)]
async fn supervise_windows_shell(
mut cmd_builder: tokio::process::Command,
script: Option<&str>,
live: Option<std::sync::Arc<LiveOutputRelay>>,
timeout: std::time::Duration,
) -> std::io::Result<HostShellRun> {
use tokio::io::AsyncWriteExt;
let mut child = cmd_builder.spawn()?;
let _job = if script.is_some() {
Some(
crate::confined_exec::WindowsJob::for_process(
child
.raw_handle()
.ok_or_else(|| std::io::Error::other("missing child handle"))?,
)
.ok_or_else(|| std::io::Error::other("cannot supervise ambient Brush process tree"))?,
)
} else {
None
};
let mut stdin = child.stdin.take();
let stdout = child
.stdout
.take()
.ok_or_else(|| std::io::Error::other("host shell stdout was not piped"))?;
let stderr = child
.stderr
.take()
.ok_or_else(|| std::io::Error::other("host shell stderr was not piped"))?;
let completed = async {
if let Some(script) = script {
let mut input = stdin
.take()
.ok_or_else(|| std::io::Error::other("Brush stdin was not piped"))?;
let encoded = serde_json::to_vec(script).map_err(std::io::Error::other)?;
input.write_all(&encoded).await?;
input.shutdown().await?;
drop(input);
}
let (status, stdout, stderr) = tokio::try_join!(
child.wait(),
drain_host_pipe(
stdout,
live.clone(),
crate::agentic::ToolOutputStream::Stdout
),
drain_host_pipe(stderr, live, crate::agentic::ToolOutputStream::Stderr),
)?;
Ok::<_, std::io::Error>((status, stdout, stderr))
};
match tokio::time::timeout(timeout, completed).await {
Ok(Ok((status, stdout, stderr))) => Ok(HostShellRun {
exit_code: status.code().unwrap_or(-1) as i64,
stdout,
stderr,
timed_out: false,
}),
Ok(Err(e)) => Err(e),
Err(_elapsed) => Ok(HostShellRun {
exit_code: 124,
stdout: Vec::new(),
stderr: format!(
"command exceeded {}s host-shell timeout and was killed\r\n",
timeout.as_secs()
)
.into_bytes(),
timed_out: true,
}),
}
}
pub(super) fn envelope_denied(envelope: &serde_json::Value) -> bool {
envelope
.get("denied")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false)
}
pub(super) fn envelope_denial_reason(envelope: &serde_json::Value) -> String {
let reasons: Vec<String> = envelope
.get("denials")
.and_then(serde_json::Value::as_array)
.map(|arr| {
arr.iter()
.filter_map(|d| d.get("reason").and_then(serde_json::Value::as_str))
.map(str::to_string)
.collect()
})
.unwrap_or_default();
if reasons.is_empty() {
"denied: the capability leash refused an operation".to_string()
} else {
reasons.join("; ")
}
}
pub(super) fn denied_run_command_result(envelope: &serde_json::Value, _color: bool) -> String {
let recovery = denial_recovery_hints(envelope)
.map(|hints| hints.join(" "))
.unwrap_or_else(|| {
"No actionable capability grant is identified. Take a different approach within your current authority.".into()
});
format!(
"capability denied: {}. {}",
envelope_denial_reason(envelope),
recovery
)
}
pub(super) fn worktree_denial_notice(
adopted: Option<&crate::worktree_adoption::AdoptedWorktree>,
envelope: &serde_json::Value,
cwd: &str,
) -> Option<String> {
let policy = adopted?;
if !envelope_denied(envelope) {
return None;
}
envelope
.get("denials")?
.as_array()?
.iter()
.any(|denial| {
let write = match denial.get("kind").and_then(serde_json::Value::as_str) {
Some("fs_write") => true,
Some("open") => {
denial
.get("reason")
.and_then(serde_json::Value::as_str)
.and_then(open_denial_axis)
== Some("fs_write")
}
_ => false,
};
write
&& denial
.get("target")
.and_then(serde_json::Value::as_str)
.is_some_and(|path| {
!path.is_empty()
&& !std::path::Path::new(path).components().any(|component| {
matches!(component, std::path::Component::Normal(part)
if part.to_string_lossy().contains(['*', '?', '[']))
})
&& policy.blocked(&std::path::Path::new(cwd).join(path))
})
})
.then(|| policy.notice())
}
pub(super) fn denial_recovery_hints(envelope: &serde_json::Value) -> Option<Vec<String>> {
let denials = envelope
.get("denials")?
.as_array()
.filter(|rows| !rows.is_empty())?;
denials
.iter()
.map(|denial| {
let kind = denial.get("kind")?.as_str()?;
let reason = denial.get("reason").and_then(serde_json::Value::as_str);
if reason.is_some_and(is_structural_refusal) {
return None;
}
let target = denial
.get("target")?
.as_str()
.filter(|target| !target.trim().is_empty())?;
let axis = match kind {
"exec" | "fs_read" | "fs_write" | "net" => kind,
"open"
if std::path::Path::new(target).is_absolute()
&& !target.contains(['*', '?', '[']) =>
{
open_denial_axis(reason?)?
}
_ => return None,
};
Some(denial_recovery_hint(axis, target))
})
.collect()
}
fn open_denial_axis(reason: &str) -> Option<&'static str> {
let reason = reason.strip_prefix("denied: ").unwrap_or(reason);
let reason = reason.trim_end_matches('.');
let (axis, other) = if reason.starts_with("write of ") {
("fs_write", "fs_read")
} else if reason.starts_with("read of ") {
("fs_read", "fs_write")
} else {
return None;
};
(!reason.ends_with(&format!("is not within the granted {other} scope"))).then_some(axis)
}
pub(crate) fn absent_binary_refusal(
envelope: &serde_json::Value,
exec: &crate::caveats::Scope<String>,
) -> Option<String> {
if envelope
.get("exit_code")
.and_then(serde_json::Value::as_i64)
!= Some(127)
{
return None;
}
absent_program_note(envelope, exec)
}
fn absent_program_note(
envelope: &serde_json::Value,
exec: &crate::caveats::Scope<String>,
) -> Option<String> {
if structured_denials_present(envelope) {
return None;
}
let prog = named_program(envelope, "command not found: ")?.to_string();
let granted = granted_host_binaries(exec);
Some(if host_path_lookup(&prog).is_some() {
format!(
"error: {prog}: {ABSENT_BINARY_MARKER}.\n \
granted host binaries: {granted}\n \
For project compiler/test validation, if lifecycle is advertised, prefer lifecycle action=build \
for the appropriate project phase: it requests explicit offline build authority.\n \
The shell's own report named this program; that text is not trusted evidence of what the \
confined child actually ran, so no specific path is named here. If direct execution would \
genuinely help, ask the operator via request_permissions rather than guessing a target; do \
not retry a declined grant."
)
} else {
format!(
"error: {prog}: {ABSENT_BINARY_MARKER}, and {NOT_ON_HOST_MARKER}.\n \
granted host binaries: {granted}\n \
no grant can supply it - install it on the host, or use a carried tool."
)
})
}
pub(crate) const ABSENT_BINARY_MARKER: &str = "not in this profile's carried userland";
pub(crate) const NOT_ON_HOST_MARKER: &str = "not installed on this host";
fn structured_denials_present(envelope: &serde_json::Value) -> bool {
envelope_denied(envelope)
|| envelope
.get("denials")
.and_then(serde_json::Value::as_array)
.is_some_and(|d| !d.is_empty())
}
pub(super) fn unstructured_exit_126_note(envelope: &serde_json::Value) -> Option<&'static str> {
(envelope
.get("exit_code")
.and_then(serde_json::Value::as_i64)
== Some(126)
&& !structured_denials_present(envelope))
.then_some(UNSTRUCTURED_EXIT_126_NOTE)
}
pub(crate) const UNSTRUCTURED_EXIT_126_NOTE: &str = "\n(exit 126: the shell found a command but \
could not execute it. No structured denial was recorded, so whether the sandbox refused it is \
unknown, and so are the path and the axis it would have refused on; a missing execute bit or \
a script without a shebang exits 126 as well. Nothing here names a grant to ask for: do not \
infer one from the text above. Check the file's mode and interpreter line first; if both are \
right, report exactly what was run to the operator rather than guessing a grant.)";
fn named_program<'a>(envelope: &'a serde_json::Value, marker: &str) -> Option<&'a str> {
envelope
.get("stderr")
.and_then(serde_json::Value::as_str)
.and_then(|stderr| stderr.rfind(marker).map(|at| &stderr[at + marker.len()..]))
.and_then(|rest| rest.split(['\'', '\n']).next())
.map(str::trim)
.filter(|name| !name.is_empty())
}
pub(super) fn dispatch_caveats_for_git_shell(
cmd: &str,
workspace: &str,
caveats: &crate::caveats::Caveats,
) -> crate::caveats::Caveats {
if leading_program(cmd) != Some("git")
|| !crate::caveats::permits_path(&caveats.fs_write, workspace)
{
return caveats.clone();
}
let write = crate::git_hardening::own_gitdir_shell_write_grant(std::path::Path::new(workspace));
if write.is_empty() {
return caveats.clone();
}
let mut widened = caveats.clone();
widened.fs_write = match &widened.fs_write {
crate::caveats::Scope::All => crate::caveats::Scope::All,
crate::caveats::Scope::Only(set) => {
crate::caveats::Scope::only(set.iter().cloned().chain(write))
}
};
widened.fs_read = match &widened.fs_read {
crate::caveats::Scope::All => crate::caveats::Scope::All,
crate::caveats::Scope::Only(set) => crate::caveats::Scope::only(
set.iter()
.cloned()
.chain(std::iter::once("/etc/gitconfig".to_string())),
),
};
widened
}
pub(super) fn leading_program(cmd: &str) -> Option<&str> {
cmd.split_ascii_whitespace().find(|tok| !tok.contains('='))
}
fn dispatch_caveats_for_command(
cmd: &str,
caveats: &crate::caveats::Caveats,
) -> crate::caveats::Caveats {
let mut widened = caveats.clone();
widened.net = crate::caveats::spawn_net_scope(&widened.net);
if let crate::caveats::Scope::Only(exec) = &mut widened.exec {
let twins = crate::confined_exec::developer_exec_twins(exec.iter());
exec.extend(twins);
}
if leading_program(cmd).is_some_and(crate::confined_exec::is_build_tool_exec) {
if let crate::caveats::Scope::Only(reads) = &mut widened.fs_read {
reads.extend(crate::confined_exec::toolchain_read_roots());
}
}
widened
}
fn granted_host_binaries(exec: &crate::caveats::Scope<String>) -> String {
match exec {
crate::caveats::Scope::All => "(unrestricted)".to_string(),
crate::caveats::Scope::Only(set) if set.is_empty() => "(none)".to_string(),
crate::caveats::Scope::Only(set) => set
.iter()
.map(String::as_str)
.collect::<Vec<_>>()
.join(", "),
}
}
fn host_path_lookup(prog: &str) -> Option<String> {
fn executable(p: &std::path::Path) -> bool {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
std::fs::metadata(p).is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
}
#[cfg(not(unix))]
{
p.is_file()
}
}
if prog.contains('/') || prog.contains('\\') {
return executable(std::path::Path::new(prog)).then(|| prog.to_string());
}
std::env::split_paths(&std::env::var_os("PATH")?)
.map(|dir| dir.join(prog))
.find(|cand| executable(cand))
.map(|p| p.display().to_string())
}
pub(super) fn shell_envelope_output(
envelope: &serde_json::Value,
tool_output_lines: usize,
color: bool,
tool_offload: bool,
spill_store: Option<&dyn SpillStore>,
presentation: Option<&mut dyn ToolPresentation>,
) -> String {
shell_envelope_output_with_view(
envelope,
None,
tool_output_lines,
color,
tool_offload,
spill_store,
presentation,
)
}
pub(super) fn shell_envelope_output_with_view(
envelope: &serde_json::Value,
view: Option<&str>,
_tool_output_lines: usize,
_color: bool,
tool_offload: bool,
spill_store: Option<&dyn SpillStore>,
presentation: Option<&mut dyn ToolPresentation>,
) -> String {
let mut presentation = presentation;
if let Some(presentation) = presentation.as_deref_mut() {
presentation.execution_result(envelope);
}
let stdout = envelope
.get("stdout")
.and_then(serde_json::Value::as_str)
.unwrap_or("");
let stderr = envelope
.get("stderr")
.and_then(serde_json::Value::as_str)
.unwrap_or("");
let truncated_streams = match (
envelope
.get("stdout_truncated")
.and_then(serde_json::Value::as_bool)
== Some(true),
envelope
.get("stderr_truncated")
.and_then(serde_json::Value::as_bool)
== Some(true),
) {
(true, true) => Some("stdout and stderr"),
(true, false) => Some("stdout"),
(false, true) => Some("stderr"),
(false, false) => None,
};
let capture_notice = truncated_streams
.map(|streams| format!("[{streams} capture truncated; omitted bytes are unavailable]"));
let annotate_capture = |text: &str| match &capture_notice {
Some(notice) => format!("{notice}\n{text}"),
None => text.to_owned(),
};
let out = annotate_capture(&format!("{stdout}{stderr}"));
let selected = view.map(annotate_capture);
let view = selected.as_deref().unwrap_or(&out);
let selected_view = view != out;
let code = envelope
.get("exit_code")
.and_then(serde_json::Value::as_i64)
.unwrap_or(-1);
let mark_failure = |payload: String| {
if code == 0 {
payload
} else {
format!("error: command exited {code}\n{payload}")
}
};
if out.trim().is_empty() {
return if code == 0 {
format!("(exit {code})")
} else {
format!("error: command exited {code}")
};
}
{
if let Some(presentation) = presentation {
presentation.override_result(view.to_owned());
}
let max_tokens = max_output_tokens();
let est = output_budget::cap_estimator();
let should_spill = (tool_offload && selected_view)
|| output_budget::should_spill_full_output(
out.len(),
out.chars().count(),
max_tokens,
tool_offload,
);
let mut capped = if should_spill {
match spill_store {
Some(store) => {
let (id, redacted) = content_spill::store_redacted_full(
&out,
Some("run_command".to_string()),
store,
);
let redacted = if selected_view {
crate::agentic::compress::redact_secrets(view)
} else {
redacted
};
let teaser_tokens = est
.tokens_for_chars(content_spill::TOOL_RESULT_SPILL_CAP.saturating_sub(512));
match id {
Some(id) => cap_model_output_with_handle(
&redacted,
max_tokens.min(teaser_tokens),
output_head_tokens(),
Some(&id),
),
None => cap_model_output(&redacted, max_tokens),
}
}
None => cap_model_output(view, max_tokens),
}
} else {
cap_model_output(view, max_tokens)
};
if let Some(notice) = &capture_notice {
if !capped.contains(notice) {
capped.push('\n');
capped.push_str(notice);
}
}
mark_failure(match pr_creation_url(view) {
Some(url) => format!("{capped}{}", pr_next_step_hint(url)),
None => capped,
})
}
}
pub(super) fn pr_creation_url(output: &str) -> Option<&str> {
output.split_whitespace().find(|tok| {
tok.starts_with("https://")
&& (tok.contains("/pull/new/")
|| tok.contains("/merge_requests/new")
|| tok.contains("/compare/"))
})
}
fn pr_next_step_hint(url: &str) -> String {
format!(
"\n\n[newt] A branch was pushed. To open a pull request now, call \
run_command with `gh pr create --fill` (the `gh` CLI is available; the \
`git` tool cannot push or open PRs). Or open this URL: {url}"
)
}
fn is_structural_refusal(reason: &str) -> bool {
reason.contains("refused by design:")
|| reason.contains("dynamic construct the confined shell")
|| reason.contains("not yet supported by the confined shell")
}
pub(super) fn exec_denial_requests(envelope: &serde_json::Value) -> Option<Vec<PermissionRequest>> {
let denials = envelope.get("denials")?.as_array()?;
if denials.is_empty() {
return None;
}
let mut requests = Vec::with_capacity(denials.len());
for d in denials {
if d.get("kind")?.as_str()? != "exec" {
return None;
}
if d.get("reason")
.and_then(serde_json::Value::as_str)
.is_some_and(is_structural_refusal)
{
return None;
}
let target = d.get("target")?.as_str().filter(|t| !t.is_empty())?;
requests.push(PermissionRequest {
tool: "run_command".to_string(),
kind: DenialKind::Exec,
target: target.to_string(),
reason: d
.get("reason")
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
.to_string(),
harness_bound: false,
});
}
Some(requests)
}
pub(super) fn net_denial_requests(envelope: &serde_json::Value) -> Option<Vec<PermissionRequest>> {
let denials = envelope.get("denials")?.as_array()?;
if denials.is_empty() {
return None;
}
let mut requests = Vec::with_capacity(denials.len());
for d in denials {
if d.get("kind")?.as_str()? != "net" {
return None;
}
let host = d.get("target")?.as_str().filter(|t| !t.is_empty())?;
requests.push(PermissionRequest {
tool: "run_command".to_string(),
kind: DenialKind::Net,
target: host.to_string(),
reason: d
.get("reason")
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
.to_string(),
harness_bound: false,
});
}
Some(requests)
}
#[cfg(test)]
mod dispatch_caveats_tests {
use super::*;
use crate::caveats::{Caveats, CaveatsExt as _, CountBound, Scope};
fn base(ws: &str) -> Caveats {
Caveats {
fs_read: Scope::only([ws.to_string()]),
fs_write: Scope::only([ws.to_string()]),
exec: Scope::only(["cargo".to_string()]),
net: Scope::none(),
max_calls: CountBound::Unlimited,
valid_for_generation: Scope::All,
}
}
#[test]
fn a_build_tool_command_gets_its_toolchain_read_root_for_this_dispatch_only() {
let _env = crate::process_env::lock();
let fake = std::env::temp_dir().join("fake-home").join(".rustup");
let fake = fake.to_string_lossy().into_owned();
let saved = std::env::var_os("RUSTUP_HOME");
crate::process_env::set_var("RUSTUP_HOME", &fake);
let widened = dispatch_caveats_for_command("cargo --version", &base("/ws"));
match saved.and_then(|v| v.into_string().ok()) {
Some(v) => crate::process_env::set_var("RUSTUP_HOME", &v),
None => crate::process_env::remove_var("RUSTUP_HOME"),
}
assert!(
widened.permits_fs_read(&fake),
"a build-tool dispatch must be able to read its toolchain home"
);
}
#[test]
fn a_non_build_tool_command_is_unchanged() {
let base = base("/ws");
let widened = dispatch_caveats_for_command("ls -la", &base);
assert_eq!(widened.fs_read, base.fs_read);
}
#[test]
fn a_host_scoped_net_grant_is_narrowed_to_none_for_the_dispatch() {
let mut caveats = base("/ws");
caveats.net = crate::caveats::Scope::only(["api.example.com".to_string()]);
let widened = dispatch_caveats_for_command("cargo --version", &caveats);
assert_eq!(
widened.net,
crate::caveats::Scope::none(),
"a host-scoped net grant cannot be bound by a spawn backend and \
must be narrowed, not passed through as an Unknown scope"
);
}
#[test]
fn dispatch_caveats_for_command_does_not_mutate_session() {
let mut original = base("/ws");
original.net = crate::caveats::Scope::only(["api.example.com".to_string()]);
let original_net = original.net.clone();
let _ = dispatch_caveats_for_command("cargo --version", &original);
assert_eq!(
original.net, original_net,
"session caveats must not be mutated by dispatch"
);
}
#[test]
fn all_and_none_net_scopes_pass_through_the_dispatch_unchanged() {
let mut all_caveats = base("/ws");
all_caveats.net = crate::caveats::Scope::All;
let widened = dispatch_caveats_for_command("cargo --version", &all_caveats);
assert_eq!(widened.net, crate::caveats::Scope::All);
let mut none_caveats = base("/ws");
none_caveats.net = crate::caveats::Scope::none();
let widened = dispatch_caveats_for_command("cargo --version", &none_caveats);
assert_eq!(widened.net, crate::caveats::Scope::none());
}
}
#[cfg(all(windows, feature = "test-util"))]
pub(crate) async fn test_windows_ambient_dispatch(
executable: &std::path::Path,
cmd: &str,
cwd: &str,
windows_cmd: bool,
timeout: std::time::Duration,
) -> std::io::Result<serde_json::Value> {
let route = select_shell_route(
true,
false,
false,
true,
true,
windows_cmd,
crate::ambient_brush::runner_available(executable),
crate::ShellEngine::SafeSubset,
);
let command = host_shell_command_at(route, false, cmd, cwd, executable)?;
let run = supervise_windows_shell(
command,
(route == ShellRoute::AmbientBrush).then_some(cmd),
None,
timeout,
)
.await?;
Ok(
serde_json::json!({"exit_code":run.exit_code,"stdout":decode_shell_stream(&run.stdout),"stderr":decode_shell_stream(&run.stderr),"timed_out":run.timed_out}),
)
}
#[cfg(test)]
mod ambient_runner_tests {
use super::*;
#[test]
fn windows_2776_non_cli_consumer_selects_dedicated_runner() {
let root = tempfile::tempdir().unwrap();
let host = root.path().join("embedding-server.exe");
let runner = root.path().join("tools").join(format!(
"newt-ambient-brush{}",
std::env::consts::EXE_SUFFIX
));
std::fs::create_dir(runner.parent().unwrap()).unwrap();
std::fs::write(&runner, b"fixture").unwrap();
let command =
host_shell_command_at(ShellRoute::AmbientBrush, false, "echo hello", ".", &host)
.unwrap();
assert_eq!(command.as_std().get_program(), runner.as_os_str());
assert_eq!(command.as_std().get_args().count(), 0);
}
}
#[cfg(test)]
mod missing_ambient_runner_tests {
use super::*;
#[test]
fn windows_2776_missing_runner_falls_back_with_one_notice() {
let root = tempfile::tempdir().unwrap();
let host = root.path().join("not-the-cli.exe");
std::fs::write(
root.path().join(format!(
"newt-ambient-brush{}",
std::env::consts::EXE_SUFFIX
)),
b"wrong directory",
)
.unwrap();
let available = crate::ambient_brush::runner_available(&host);
assert!(!available);
let route = select_shell_route(
true,
false,
false,
true,
true,
false,
available,
crate::ShellEngine::SafeSubset,
);
assert_eq!(route, ShellRoute::Cmd);
assert!(route.sentence().contains("cmd.exe /C"));
let child = host_shell_command_at(route, false, "echo hello", ".", &host).unwrap();
assert_eq!(child.as_std().get_program(), "cmd");
let shown = std::sync::atomic::AtomicBool::new(false);
assert_eq!(
missing_runner_notice(route, true, &shown),
None,
"explicit opt-out is silent"
);
assert_eq!(
missing_runner_notice(route, false, &shown),
Some("carried brush not installed; using cmd")
);
assert_eq!(missing_runner_notice(route, false, &shown), None);
for (bypass, broker, lease, floor) in [
(false, false, false, true),
(true, true, false, true),
(true, false, true, true),
(true, false, false, false),
] {
let route = select_shell_route(
bypass,
broker,
lease,
floor,
true,
false,
available,
crate::ShellEngine::SafeSubset,
);
assert!(matches!(route, ShellRoute::Bridled(_)));
assert_eq!(
missing_runner_notice(route, false, &std::sync::atomic::AtomicBool::new(false)),
None
);
}
}
}