use super::artifact_read::{execute_artifact_read_silent, ArtifactReadContext};
use super::crew_tool::CrewRunner;
use super::display::{ToolDisplay, ToolPresentation};
use super::git_tool::GitTool;
use super::mcp::McpTools;
use super::memory_fetch::{execute_memory_fetch, memory_fetch_tool_definition, MemorySource};
use super::note_sink::{execute_save_note, save_note_tool_definition, NoteSink};
use super::permissions::{DenialKind, PermissionDecision, PermissionGate, PermissionRequest};
use super::prompt_intake::PromptDisposition;
use super::prompt_read::{execute_prompt_read_silent, PromptReadContext};
use super::recall::{execute_recall, recall_tool_definition, RecallSource};
use super::report::{execute_render_report, render_report_tool_definition};
use super::spill::{self, SpillStore};
use crate::caveats::CaveatsExt as _;
#[cfg(test)]
use output_budget::DEFAULT_MAX_OUTPUT_TOKENS;
#[cfg(test)]
use output_budget::DEFAULT_OUTPUT_CAP_CHARS_PER_TOKEN;
use output_budget::{
cap_model_output, cap_model_output_with_handle, max_output_tokens, output_head_tokens,
paginate_read,
};
pub use output_budget::{
set_max_output_tokens, set_output_cap_chars_per_token, set_output_head_tokens,
};
mod catalog;
pub(crate) mod exposure;
mod live_output;
mod output_budget;
use live_output::{LiveOutputRelay, LiveOutputSession};
#[cfg(test)]
use catalog::lifecycle_tool_definition;
pub(crate) use catalog::{
classify_gated_off_reach, classify_phantom_reach, is_context_remaining_call, is_hallucination,
known_builtin_tool_name, merged_tool_definitions, resolve_tool_alias, AliasOutcome,
};
use catalog::{
disposition_tool_denied_message, persona_tool_denied_message, run_command_redirect,
unknown_tool_message,
};
pub use catalog::{
filter_advertised_tools, filter_tools_for_disposition, persona_tool_allowed, tool_allowed,
tool_definitions,
};
#[cfg(test)]
use catalog::{
levenshtein, nearest_tool_name, ALL_TOOL_NAMES, BASE_TOOL_NAMES, EXTENDED_TOOL_REGISTRY,
};
pub(crate) use exposure::select_exposed;
pub use exposure::ExposureSettings;
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)
}
}
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));
}
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());
}
}
}
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(),
}
}
fn resolve_exec_cwd(workspace: &str, cwd: Option<&str>) -> String {
match cwd.map(str::trim).filter(|c| !c.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(),
}
}
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(), ""),
}
}
fn confined_dispatch_args(cmd: &str, cwd: &str) -> serde_json::Value {
serde_json::json!({
"cmd": cmd,
"cwd": cwd,
"env": venv_env_map(),
})
}
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 bridle_registry(
engine: crate::ShellEngine,
live: Option<std::sync::Arc<LiveOutputRelay>>,
) -> agent_bridle::Registry {
use std::sync::Arc;
let shell: Arc<dyn agent_bridle::Tool> = match engine {
crate::ShellEngine::SafeSubset => {
let mut tool = agent_bridle::ShellTool::new();
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();
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::new();
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 = {
#[cfg(windows)]
{
use std::sync::Once;
static WARN: Once = Once::new();
WARN.call_once(|| {
tracing::warn!(
"using the 'brush' shell engine on Windows: run_command runs a \
bash-in-Rust shell for internal-tooling compatibility. Native \
PowerShell/cmd code paths are a FUTURE release — not written yet \
(we are opinionated Linux developers who occasionally use a \
MacBook). Bash-isms work; Windows-native shell semantics do not."
);
});
}
let mut tool = agent_bridle::BrushShellTool::new();
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 fn ocap_disabled() -> bool {
std::env::var("NEWT_DISABLE_OCAP").is_ok_and(|v| v == "1")
}
pub fn full_access_requested() -> bool {
std::env::var("NEWT_FULL_ACCESS").is_ok_and(|v| v == "1")
}
fn shadow_records(host_bypass: bool, full_access: bool) -> bool {
host_bypass || full_access
}
pub fn plan_phase_clamp() -> crate::caveats::Caveats {
use crate::caveats::{CountBound, Scope};
crate::caveats::Caveats {
fs_read: Scope::All,
fs_write: Scope::none(),
exec: Scope::none(),
net: Scope::none(),
max_calls: CountBound::Unlimited,
valid_for_generation: Scope::All,
}
}
pub fn routing_disabled() -> bool {
std::env::var("NEWT_NO_ROUTE").is_ok_and(|v| v == "1")
}
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()),
}
}
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> {
let mut live = LiveOutputSession::start(sink);
let result = bridle_registry(shell_engine(), live.as_ref().map(LiveOutputSession::relay))
.dispatch("shell", args, caveats)
.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
}
#[allow(clippy::too_many_arguments)]
async fn exec_confined_command(
cmd: &str,
cwd: &str,
color: bool,
tool_output_lines: usize,
caveats: &crate::caveats::Caveats,
exec_floor: Option<&crate::caveats::Scope<String>>,
permission_gate: 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,
) -> String {
let cmd_with_venv = match venv_cmd_prefix() {
Some(prefix) => format!("{prefix}{cmd}"),
None => cmd.to_string(),
};
let host_bypass = ocap_disabled() && exec_floor_permits(exec_floor, cmd);
if shadow_records(host_bypass, full_access_requested()) {
crate::flight_recorder::log_unconfined(cmd);
}
if host_bypass {
let mut live = LiveOutputSession::start(live_tool_output);
let run = host_shell_dispatch(
&cmd_with_venv,
cwd,
live.as_ref().map(LiveOutputSession::relay),
)
.await;
if let Some(live) = live.as_mut() {
live.finish();
}
return match run {
Ok(envelope) => shell_envelope_output(
&envelope,
tool_output_lines,
color,
tool_offload,
spill_store,
Some(&mut *presentation),
),
Err(e) => format!("error: {e}"),
};
}
let dispatch_args = confined_dispatch_args(cmd, cwd);
match dispatch_bridled_shell(dispatch_args.clone(), caveats, live_tool_output.clone()).await {
Ok(envelope) if envelope_denied(&envelope) => {
crate::denial_journal::record_envelope(
cmd,
cwd,
crate::denial_journal::DenialStage::Initial,
&envelope,
);
if let Some(gate) = permission_gate {
if let Some(requests) =
exec_denial_requests(&envelope).or_else(|| net_denial_requests(&envelope))
{
if let PermissionDecision::Allow(widened) = gate.ask(&requests) {
return match dispatch_bridled_shell(
dispatch_args,
&widened,
live_tool_output,
)
.await
{
Ok(env2) if envelope_denied(&env2) => {
crate::denial_journal::record_envelope(
cmd,
cwd,
crate::denial_journal::DenialStage::AfterGrant,
&env2,
);
denied_run_command_result(&env2, color)
}
Ok(env2) => shell_envelope_output(
&env2,
tool_output_lines,
color,
tool_offload,
spill_store,
Some(&mut *presentation),
),
Err(e) => format!("error: {e}"),
};
}
}
}
denied_run_command_result(&envelope, color)
}
Ok(envelope) => shell_envelope_output(
&envelope,
tool_output_lines,
color,
tool_offload,
spill_store,
Some(&mut *presentation),
),
Err(e) => format!("error: {e}"),
}
}
async fn host_shell_dispatch(
cmd: &str,
cwd: &str,
live: Option<std::sync::Arc<LiveOutputRelay>>,
) -> std::io::Result<serde_json::Value> {
let run = host_shell_output(cmd, cwd, live).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,
"sandbox_kind": "none",
}))
}
struct HostShellRun {
exit_code: i64,
stdout: Vec<u8>,
stderr: Vec<u8>,
timed_out: bool,
}
fn host_exec_timeout() -> std::time::Duration {
const DEFAULT_SECS: u64 = 120;
let secs = std::env::var("NEWT_HOST_EXEC_TIMEOUT_SECS")
.ok()
.and_then(|raw| raw.trim().parse::<u64>().ok())
.filter(|&n| n > 0)
.unwrap_or(DEFAULT_SECS);
std::time::Duration::from_secs(secs)
}
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))]
async fn host_shell_output(
cmd: &str,
cwd: &str,
live: Option<std::sync::Arc<LiveOutputRelay>>,
) -> std::io::Result<HostShellRun> {
host_shell_output_with_timeout(cmd, cwd, live, host_exec_timeout()).await
}
#[cfg(not(windows))]
async fn host_shell_output_with_timeout(
cmd: &str,
cwd: &str,
live: Option<std::sync::Arc<LiveOutputRelay>>,
timeout: std::time::Duration,
) -> std::io::Result<HostShellRun> {
use std::process::Stdio;
fn shell(program: &str, cmd: &str, cwd: &str) -> tokio::process::Command {
let mut c = tokio::process::Command::new(program);
c.arg("-c")
.arg(cmd)
.current_dir(cwd)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.process_group(0)
.kill_on_drop(true);
c
}
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 shell("bash", cmd, cwd).spawn() {
Ok(child) => run_one(child, live, timeout).await,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
run_one(shell("sh", cmd, cwd).spawn()?, live, timeout).await
}
Err(e) => Err(e),
}
}
#[cfg(windows)]
async fn host_shell_output(
cmd: &str,
cwd: &str,
live: Option<std::sync::Arc<LiveOutputRelay>>,
) -> std::io::Result<HostShellRun> {
host_shell_output_with_timeout(cmd, cwd, live, host_exec_timeout()).await
}
#[cfg(windows)]
async fn host_shell_output_with_timeout(
cmd: &str,
cwd: &str,
live: Option<std::sync::Arc<LiveOutputRelay>>,
timeout: std::time::Duration,
) -> std::io::Result<HostShellRun> {
use std::process::Stdio;
let mut child = tokio::process::Command::new("cmd")
.args(["/C", cmd])
.current_dir(cwd)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true)
.spawn()?;
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\r\n",
timeout.as_secs()
)
.into_bytes(),
timed_out: true,
}),
}
}
fn lexically_normalize(path: &str) -> std::path::PathBuf {
use std::path::{Component, PathBuf};
let mut out = PathBuf::new();
for comp in std::path::Path::new(path).components() {
match comp {
Component::CurDir => {}
Component::ParentDir => {
if !out.pop() {
out.push(comp.as_os_str());
}
}
other => out.push(other.as_os_str()),
}
}
out
}
pub(crate) fn tui_permits_path(scope: &crate::caveats::Scope<String>, full_path: &str) -> bool {
match scope {
crate::caveats::Scope::All => true,
crate::caveats::Scope::Only(set) if set.is_empty() => false,
crate::caveats::Scope::Only(set) => {
let candidate = lexically_normalize(full_path);
set.iter()
.any(|root| candidate.starts_with(lexically_normalize(root)))
}
}
}
fn confirm_unrestricted_fs_mutation(
caveats: &crate::caveats::Caveats,
gate: &mut Option<&mut dyn PermissionGate>,
question: &str,
) -> bool {
if !matches!(caveats.fs_write, crate::caveats::Scope::All) {
return true;
}
if ocap_disabled() {
return true;
}
match gate {
Some(g) => g
.ask_question(question)
.is_some_and(|answer| answer.trim().eq_ignore_ascii_case("y")),
None => false,
}
}
pub(crate) fn run_build_check(cmd: &str, workspace: &str) -> String {
let result = build_check_shell(cmd).current_dir(workspace).output();
match result {
Ok(out) if out.status.success() => " ✓ build check passed".to_string(),
Ok(out) => {
let stderr = String::from_utf8_lossy(&out.stderr);
let stdout = String::from_utf8_lossy(&out.stdout);
let combined = format!("{stdout}{stderr}");
let excerpt: String = combined.lines().take(8).collect::<Vec<_>>().join("\n");
format!(" ✗ build check failed:\n{excerpt}")
}
Err(e) => format!(" ⚠ build check could not run: {e}"),
}
}
#[cfg(windows)]
fn build_check_shell(cmd: &str) -> std::process::Command {
let mut shell = std::process::Command::new("cmd");
shell.args(["/C", cmd]);
shell
}
#[cfg(not(windows))]
fn build_check_shell(cmd: &str) -> std::process::Command {
let mut shell = std::process::Command::new("sh");
shell.args(["-c", cmd]);
shell
}
#[cfg(all(test, windows))]
fn passing_build_check_cmd() -> &'static str {
"exit /B 0"
}
#[cfg(all(test, not(windows)))]
fn passing_build_check_cmd() -> &'static str {
"true"
}
#[cfg(all(test, windows))]
fn failing_build_check_cmd(message: &str) -> String {
format!("echo {message} 1>&2 & exit /B 1")
}
#[cfg(all(test, not(windows)))]
fn failing_build_check_cmd(message: &str) -> String {
format!("echo {message} >&2; exit 1")
}
fn envelope_denied(envelope: &serde_json::Value) -> bool {
envelope
.get("denied")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false)
}
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("; ")
}
}
fn exec_allowlist_name(target: &str) -> &str {
target
.rsplit(['/', '\\'])
.find(|part| !part.is_empty())
.unwrap_or(target)
}
fn denial_recovery_hint(capability: &str, target: &str) -> String {
format!(
"This is outside your granted authority — to ask the operator, call \
request_permissions(capability=\"{capability}\", target=\"{target}\", \
reason=\"<why you need it>\"), or take a different approach that stays \
within your current authority."
)
}
const CREW_OFF_RECOVERY_HINT: &str =
"the crew/team surface is not enabled this session (the operator launches it \
with NEWT_TEAM). Accomplish this yourself with the available tools \
(read_file/write_file/edit_file/run_command/...), or ask the operator to \
enable a crew.";
fn crew_off_recovery_result(name: &str) -> String {
format!("'{name}' is unavailable: {CREW_OFF_RECOVERY_HINT}")
}
fn denied_fs_result(kind: &str, path: &str) -> String {
format!(
"capability denied: {kind} does not permit '{path}'. {}",
denial_recovery_hint(kind, path)
)
}
fn denied_run_command_result(envelope: &serde_json::Value, _color: bool) -> String {
format!(
"capability denied: {}. {}",
envelope_denial_reason(envelope),
denial_recovery_hint(
denial_axis_label(envelope),
&exec_denial_target_label(envelope)
)
)
}
fn exec_denial_target_label(envelope: &serde_json::Value) -> String {
let targets: Vec<&str> = envelope
.get("denials")
.and_then(serde_json::Value::as_array)
.map(|arr| {
arr.iter()
.filter_map(|d| d.get("target").and_then(serde_json::Value::as_str))
.filter(|t| !t.is_empty())
.collect()
})
.unwrap_or_default();
if targets.is_empty() {
"a command".to_string()
} else {
targets.join(", ")
}
}
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 {
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 out = format!("{stdout}{stderr}");
if out.trim().is_empty() {
let code = envelope
.get("exit_code")
.and_then(serde_json::Value::as_i64)
.unwrap_or(-1);
format!("(exit {code})")
} else {
if let Some(presentation) = presentation {
presentation.override_result(out.clone());
}
let max_tokens = max_output_tokens();
let est = output_budget::cap_estimator();
let should_spill = output_budget::should_spill_full_output(
out.len(),
out.chars().count(),
max_tokens,
tool_offload,
);
let capped = if should_spill {
match spill_store {
Some(store) => {
let (id, redacted) = spill::store_redacted_full(&out, store);
let teaser_tokens =
est.tokens_for_chars(spill::TOOL_RESULT_SPILL_CAP.saturating_sub(512));
cap_model_output_with_handle(
&redacted,
max_tokens.min(teaser_tokens),
output_head_tokens(),
Some(&id),
)
}
None => cap_model_output(&out, max_tokens),
}
} else {
cap_model_output(&out, max_tokens)
};
match pr_creation_url(&out) {
Some(url) => format!("{capped}{}", pr_next_step_hint(url)),
None => capped,
}
}
}
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")
}
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: exec_allowlist_name(target).to_string(),
reason: d
.get("reason")
.and_then(serde_json::Value::as_str)
.unwrap_or_default()
.to_string(),
});
}
Some(requests)
}
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(),
});
}
Some(requests)
}
fn denial_axis_label(envelope: &serde_json::Value) -> &'static str {
let all_net = envelope
.get("denials")
.and_then(serde_json::Value::as_array)
.filter(|arr| !arr.is_empty())
.is_some_and(|arr| {
arr.iter()
.all(|d| d.get("kind").and_then(serde_json::Value::as_str) == Some("net"))
});
if all_net {
"net"
} else {
"exec"
}
}
fn fs_gate_allows(
gate: &mut dyn PermissionGate,
tool: &str,
kind: DenialKind,
full_path: &str,
axis: impl Fn(&crate::caveats::Caveats) -> &crate::caveats::Scope<String>,
) -> bool {
let request = PermissionRequest {
tool: tool.to_string(),
kind,
target: full_path.to_string(),
reason: format!("{} does not permit '{full_path}'", kind.as_str()),
};
match gate.ask(std::slice::from_ref(&request)) {
PermissionDecision::Allow(widened) => tui_permits_path(axis(&widened), full_path),
PermissionDecision::Deny => false,
}
}
fn is_git_write_denial(out: &str) -> bool {
out.contains("capability denied: git ") && out.contains("not permitted")
}
fn is_git_data_loss_op(op: &str) -> bool {
matches!(op, "stash-drop" | "branch-delete")
}
fn git_data_loss_confirmed(gate: &mut dyn PermissionGate, op: &str) -> bool {
let request = PermissionRequest {
tool: "git".to_string(),
kind: DenialKind::GitWrite,
target: op.to_string(),
reason: format!(
"git {op} DESTROYS work irrecoverably (a dropped stash / deleted branch cannot be recovered) — confirm before proceeding"
),
};
matches!(
gate.ask(std::slice::from_ref(&request)),
PermissionDecision::Allow(_)
)
}
fn git_gate_allows(gate: &mut dyn PermissionGate, op: &str) -> bool {
let request = PermissionRequest {
tool: "git".to_string(),
kind: DenialKind::GitWrite,
target: op.to_string(),
reason: format!("git {op} is outside the granted git-write authority"),
};
matches!(
gate.ask(std::slice::from_ref(&request)),
PermissionDecision::Allow(_)
)
}
fn parse_capability(s: &str) -> Option<DenialKind> {
match s.trim().to_ascii_lowercase().as_str() {
"exec" | "run" | "run_command" | "command" | "shell" => Some(DenialKind::Exec),
"fs_read" | "fs-read" | "read" | "read_file" => Some(DenialKind::FsRead),
"fs_write" | "fs-write" | "write" | "write_file" => Some(DenialKind::FsWrite),
"net" | "network" | "web" | "web_fetch" => Some(DenialKind::Net),
_ => None,
}
}
fn execute_request_permissions(
args: &serde_json::Value,
gate: Option<&mut dyn PermissionGate>,
_color: bool,
_tool_output_lines: usize,
) -> String {
let capability = args["capability"].as_str().unwrap_or("").trim();
let target = args["target"].as_str().unwrap_or("").trim();
let reason = args["reason"].as_str().unwrap_or("").trim();
let Some(kind) = parse_capability(capability) else {
return format!(
"request_permissions: unknown capability '{capability}'. Use one of: \
exec, fs_read, fs_write, net."
);
};
if target.is_empty() {
return "request_permissions: 'target' is required — the command name (exec), \
the path (fs_read/fs_write), or the host (net)."
.to_string();
}
let request = PermissionRequest {
tool: "request_permissions".to_string(),
kind,
target: target.to_string(),
reason: if reason.is_empty() {
format!("model requested {capability} for '{target}'")
} else {
reason.to_string()
},
};
let out = match gate {
Some(g) => match g.ask(std::slice::from_ref(&request)) {
PermissionDecision::Allow(_widened) => format!(
"granted: the operator allowed {capability} for '{target}'. \
Retry the original operation now."
),
PermissionDecision::Deny => format!(
"denied: the operator declined {capability} for '{target}'. \
Do not retry it — take a different approach."
),
},
None => format!(
"no operator available to grant {capability} for '{target}' — this session \
has no interactive permission gate (headless / eval / piped). The capability \
must be configured by the owner (e.g. [tui.permissions] in newt config); \
take a different approach for now."
),
};
out
}
const HEADLESS_NO_HUMAN: &str = "no human available this session (running headless) \
— proceed with your best judgment or state your assumption explicitly.";
fn execute_request_user_input(
args: &serde_json::Value,
gate: Option<&mut dyn PermissionGate>,
_color: bool,
_tool_output_lines: usize,
) -> String {
let question = args["question"].as_str().unwrap_or("").trim();
if question.is_empty() {
return "request_user_input: 'question' is required — the free-text \
question to ask the human."
.to_string();
}
match gate.and_then(|g| g.ask_question(question)) {
Some(answer) => answer,
None => HEADLESS_NO_HUMAN.to_string(),
}
}
pub(crate) fn host_of_url(url: &str) -> Option<String> {
let rest = url
.strip_prefix("https://")
.or_else(|| url.strip_prefix("http://"))?;
let authority = rest.split(['/', '?', '#']).next()?;
let host_port = authority.rsplit('@').next()?;
let host = if let Some(stripped) = host_port.strip_prefix('[') {
stripped.split(']').next()?
} else {
host_port.split(':').next()?
};
if host.is_empty() {
None
} else {
Some(host.to_ascii_lowercase())
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum FindType {
Files,
Dirs,
Any,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum FindCategory {
Any,
Source,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum FindSort {
Name,
Size,
Lines,
}
struct FindOpts<'a> {
name: Option<&'a str>,
type_filter: FindType,
category: FindCategory,
language: Option<&'a str>,
max_depth: Option<usize>,
max_results: usize,
respect_gitignore: bool,
case_sensitive: bool,
show_size: bool,
show_lines: bool,
sort: FindSort,
}
fn find_detail(path: &str, opts: &FindOpts) -> String {
let mut parts: Vec<String> = Vec::new();
if let Some(name) = opts.name {
parts.push(format!("name={name}"));
}
match opts.type_filter {
FindType::Files => parts.push("type=f".to_string()),
FindType::Dirs => parts.push("type=d".to_string()),
FindType::Any => {}
}
if opts.category == FindCategory::Source {
parts.push("category=source".to_string());
}
if let Some(language) = opts.language {
parts.push(format!("language={language}"));
}
if let Some(d) = opts.max_depth {
parts.push(format!("depth={d}"));
}
if opts.max_results != 1000 {
parts.push(format!("max={}", opts.max_results));
}
if !opts.respect_gitignore {
parts.push("no-gitignore".to_string());
}
if !opts.case_sensitive {
parts.push("icase".to_string());
}
match opts.sort {
FindSort::Size => parts.push("sort=size".to_string()),
FindSort::Lines => parts.push("sort=lines".to_string()),
FindSort::Name => {}
}
if opts.show_size {
parts.push("size".to_string());
}
if opts.show_lines {
parts.push("lines".to_string());
}
if parts.is_empty() {
path.to_string()
} else {
format!("{path} ({})", parts.join(", "))
}
}
fn find_opts_from_args(args: &serde_json::Value) -> FindOpts<'_> {
FindOpts {
name: args["name"].as_str(),
type_filter: match args["type"].as_str() {
Some("f") => FindType::Files,
Some("d") => FindType::Dirs,
_ => FindType::Any,
},
category: if args["category"].as_str() == Some("source")
|| args["language"].as_str().is_some()
|| args["code"].as_bool() == Some(true)
{
FindCategory::Source
} else {
FindCategory::Any
},
language: args["language"].as_str(),
max_depth: args["max_depth"].as_u64().map(|d| d as usize),
max_results: args["max_results"]
.as_u64()
.map(|m| m as usize)
.unwrap_or(1000),
respect_gitignore: args["respect_gitignore"].as_bool().unwrap_or(true),
case_sensitive: args["case_sensitive"].as_bool().unwrap_or(true),
show_size: args["show_size"].as_bool().unwrap_or(false),
show_lines: args["show_lines"].as_bool().unwrap_or(false),
sort: match args["sort"].as_str() {
Some("size") => FindSort::Size,
Some("lines") => FindSort::Lines,
_ => FindSort::Name,
},
}
}
fn find_source_extensions(
workspace: &std::path::Path,
opts: &FindOpts<'_>,
) -> Result<Option<Vec<String>>, String> {
if opts.category == FindCategory::Any {
return Ok(None);
}
let api_cfg = crate::Config::resolve()
.ok()
.and_then(|cfg| cfg.context.map(|context| context.api_surface))
.unwrap_or_default();
let packs = crate::api_surface::resolve_language_packs(workspace, &api_cfg);
crate::api_surface::source_extensions_for(&packs, opts.language).map(Some)
}
fn finalize_find(mut entries: Vec<(u64, String)>, opts: &FindOpts<'_>) -> (Vec<String>, bool) {
entries.sort_by(|a, b| a.1.cmp(&b.1));
entries.dedup_by(|a, b| a.1 == b.1);
if matches!(opts.sort, FindSort::Size | FindSort::Lines) {
entries.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.cmp(&b.1)));
}
let truncated = entries.len() > opts.max_results;
entries.truncate(opts.max_results);
let show_metric = opts.show_size || opts.show_lines;
let lines = entries
.into_iter()
.map(|(metric, path)| {
if show_metric {
format!("{metric}\t{path}")
} else {
path
}
})
.collect();
(lines, truncated)
}
fn tool_call_detail(name: &str, args: &serde_json::Value, workspace: &std::path::Path) -> String {
let string = |key: &str, default: &str| {
args.get(key)
.and_then(serde_json::Value::as_str)
.unwrap_or(default)
.to_string()
};
match name {
"run_command" => string("command", ""),
"write_file" => {
let path = string("path", "");
let bytes = args["content"].as_str().unwrap_or("").len();
format!("{path} ({bytes} bytes)")
}
"read_file" | "edit_file" | "delete_file" => string("path", ""),
"list_dir" => string("path", "."),
"find" => {
let path = args["path"].as_str().unwrap_or(".");
find_detail(path, &find_opts_from_args(args))
}
"use_skill" => string("name", ""),
"web_fetch" => string("url", ""),
"request_permissions" => string("capability", ""),
"request_user_input" => string("question", ""),
"select_operating_mode" => string("mode", ""),
"prompt_read" | "artifact_read" => string("address", "current"),
"tool_search" | "recall" | "code_search" | "experience_recall" => string("query", ""),
"where_is" => string("symbol", ""),
"memory_fetch" => string("address", ""),
"save_note" => string("action", ""),
"git" => string("op", ""),
"state_set" | "state_get" => string("key", ""),
"experience_record" => string("task", ""),
"lifecycle" => {
let phase = string("phase", "");
let action = string("action", "run");
let resolved = crate::tooling::Phase::from_key(&phase)
.map(|phase| crate::tooling::resolved_phase_commands(workspace, phase))
.unwrap_or_default();
if resolved.is_empty() {
format!("{phase} ({action})")
} else {
format!("{phase} ({action}) → {}", resolved.join(" && "))
}
}
"render_report" => string("title", ""),
"resume_context"
| "state_clear"
| "update_plan"
| "plan_get"
| "get_context_remaining"
| "enter_plan_mode"
| "exit_plan_mode" => String::new(),
_ => args.to_string(),
}
}
fn correction_alias_detail(args: &serde_json::Value) -> String {
if let Some(path) = args.get("path").and_then(serde_json::Value::as_str) {
if let Some(content) = args.get("content").and_then(serde_json::Value::as_str) {
return format!("{path} ({} bytes)", content.len());
}
let mut sizes = Vec::new();
for key in ["old_string", "new_string"] {
if let Some(value) = args.get(key).and_then(serde_json::Value::as_str) {
sizes.push(format!("{key}={} bytes", value.len()));
}
}
if sizes.is_empty() {
return path.to_string();
}
return format!("{path} ({})", sizes.join(", "));
}
let keys = args
.as_object()
.map(|object| object.keys().cloned().collect::<Vec<_>>())
.unwrap_or_default();
if keys.is_empty() {
"{}".to_string()
} else {
format!("arguments: {}", keys.join(", "))
}
}
pub(crate) fn tool_presentation(
raw_name: &str,
raw_args: &serde_json::Value,
workspace: &std::path::Path,
) -> (String, String) {
let (name, correction) = match resolve_tool_alias(raw_name) {
Some(AliasOutcome::Rewrite(canonical)) => (canonical, false),
Some(AliasOutcome::Correct(_)) => (raw_name, true),
None => (raw_name, false),
};
if correction {
return (name.to_string(), correction_alias_detail(raw_args));
}
if name == "run_command" && !routing_disabled() {
let command = raw_args
.get("command")
.and_then(serde_json::Value::as_str)
.unwrap_or("");
if let super::routing::RouteDecision::Route { tool, args } =
super::routing::RouteTable::builtin().classify(command)
{
let detail = tool_call_detail(tool, &args, workspace);
return (tool.to_string(), detail);
}
}
(
name.to_string(),
tool_call_detail(name, raw_args, workspace),
)
}
fn glob_to_regex(glob: &str, case_sensitive: bool) -> Result<regex::Regex, String> {
let mut re = String::with_capacity(glob.len() + 8);
if !case_sensitive {
re.push_str("(?i)");
}
re.push('^');
for ch in glob.chars() {
match ch {
'*' => re.push_str(".*"),
'?' => re.push('.'),
'.' | '+' | '(' | ')' | '|' | '[' | ']' | '{' | '}' | '^' | '$' | '\\' => {
re.push('\\');
re.push(ch);
}
other => re.push(other),
}
}
re.push('$');
regex::Regex::new(&re).map_err(|e| format!("invalid name pattern: {e}"))
}
fn find_walk(
root: &std::path::Path,
workspace_root: &std::path::Path,
opts: &FindOpts<'_>,
source_extensions: Option<&[String]>,
mut on_hit: impl FnMut(&str),
) -> Result<(Vec<String>, bool), String> {
let pattern = match opts.name {
Some(g) if !g.is_empty() => Some(glob_to_regex(g, opts.case_sensitive)?),
_ => None,
};
let mut builder = ignore::WalkBuilder::new(root);
builder
.hidden(opts.respect_gitignore)
.ignore(opts.respect_gitignore)
.git_ignore(opts.respect_gitignore)
.git_global(opts.respect_gitignore)
.git_exclude(opts.respect_gitignore)
.parents(opts.respect_gitignore)
.require_git(false)
.follow_links(false);
if let Some(d) = opts.max_depth {
builder.max_depth(Some(d));
}
if opts.respect_gitignore {
let mut ob = ignore::overrides::OverrideBuilder::new(root);
if ob.add("!target/").is_ok() && ob.add("!node_modules/").is_ok() {
if let Ok(ov) = ob.build() {
builder.overrides(ov);
}
}
}
let mut entries: Vec<(u64, String)> = Vec::new();
for result in builder.build() {
let entry = match result {
Ok(e) => e,
Err(_) => continue,
};
if entry.depth() == 0 {
continue;
}
let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false);
if let Some(extensions) = source_extensions {
if is_dir
|| !entry
.path()
.extension()
.and_then(|extension| extension.to_str())
.is_some_and(|extension| {
extensions
.iter()
.any(|known| known.eq_ignore_ascii_case(extension))
})
{
continue;
}
}
match opts.type_filter {
FindType::Files if is_dir => continue,
FindType::Dirs if !is_dir => continue,
_ => {}
}
if let Some(re) = &pattern {
let base = entry.file_name().to_string_lossy();
if !re.is_match(&base) {
continue;
}
}
let rel = entry
.path()
.strip_prefix(workspace_root)
.unwrap_or_else(|_| entry.path());
let rel_display = rel.to_string_lossy().replace('\\', "/");
let want_lines = opts.show_lines || matches!(opts.sort, FindSort::Lines);
let want_size = opts.show_size || matches!(opts.sort, FindSort::Size);
let metric = if want_lines {
if is_dir {
0
} else {
count_lines(entry.path())
}
} else if want_size {
entry.metadata().map(|m| m.len()).unwrap_or(0)
} else {
0
};
on_hit(&rel_display);
entries.push((metric, rel_display));
}
Ok(finalize_find(entries, opts))
}
fn count_lines(path: &std::path::Path) -> u64 {
match std::fs::read(path) {
Ok(bytes) => count_newlines(&bytes),
Err(_) => 0,
}
}
fn count_newlines(bytes: &[u8]) -> u64 {
bytes.iter().filter(|&&b| b == b'\n').count() as u64
}
#[cfg(unix)]
fn artifact_open_regular_file(path: &std::path::Path) -> std::io::Result<std::fs::File> {
use std::os::unix::fs::OpenOptionsExt as _;
let file = std::fs::OpenOptions::new()
.read(true)
.custom_flags(libc::O_NONBLOCK | libc::O_NOFOLLOW)
.open(path)?;
if !file.metadata()?.file_type().is_file() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"artifact path is not a regular file",
));
}
Ok(file)
}
#[cfg(windows)]
fn artifact_open_regular_file(path: &std::path::Path) -> std::io::Result<std::fs::File> {
use std::os::windows::fs::OpenOptionsExt as _;
use windows_sys::Win32::Storage::FileSystem::FILE_FLAG_OPEN_REPARSE_POINT;
let file = std::fs::OpenOptions::new()
.read(true)
.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT)
.open(path)?;
if !file.metadata()?.file_type().is_file() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"artifact path is not a regular file",
));
}
Ok(file)
}
#[cfg(not(any(unix, windows)))]
fn artifact_open_regular_file(_path: &std::path::Path) -> std::io::Result<std::fs::File> {
Err(std::io::Error::new(
std::io::ErrorKind::Unsupported,
"race-safe artifact file capture is unavailable on this platform",
))
}
fn artifact_preimage_state(
path: &std::path::Path,
read_authorized: bool,
) -> super::artifact_hooks::ArtifactFileState {
use std::io::Read as _;
if !read_authorized {
return super::artifact_hooks::ArtifactFileState::unavailable("fs_read_not_granted");
}
match std::fs::symlink_metadata(path) {
Ok(metadata) if metadata.file_type().is_symlink() => {
super::artifact_hooks::ArtifactFileState::unavailable("symlink_preimage_not_hashed")
}
Ok(metadata) if !metadata.file_type().is_file() => {
super::artifact_hooks::ArtifactFileState::unavailable("preimage_not_regular_file")
}
Ok(_) => {
let mut file = match artifact_open_regular_file(path) {
Ok(file) => file,
Err(_) => {
return super::artifact_hooks::ArtifactFileState::unavailable(
"preimage_read_failed",
)
}
};
let mut hasher = blake3::Hasher::new();
let mut bytes = 0_u64;
let mut buffer = [0_u8; 64 * 1024];
loop {
match file.read(&mut buffer) {
Ok(0) => break,
Ok(read) => {
hasher.update(&buffer[..read]);
bytes = bytes.saturating_add(u64::try_from(read).unwrap_or(u64::MAX));
}
Err(_) => {
return super::artifact_hooks::ArtifactFileState::unavailable(
"preimage_read_failed",
)
}
}
}
super::artifact_hooks::ArtifactFileState::from_digest(
hasher.finalize().to_hex().to_string(),
bytes,
)
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
super::artifact_hooks::ArtifactFileState::absent()
}
Err(_) => super::artifact_hooks::ArtifactFileState::unavailable("preimage_read_failed"),
}
}
fn artifact_file_matches(path: &std::path::Path, expected: &[u8]) -> std::io::Result<bool> {
use std::io::Read as _;
let mut file = artifact_open_regular_file(path)?;
let mut offset = 0_usize;
let mut buffer = [0_u8; 64 * 1024];
loop {
let read = file.read(&mut buffer)?;
if read == 0 {
return Ok(offset == expected.len());
}
let Some(end) = offset.checked_add(read) else {
return Ok(false);
};
if expected.get(offset..end) != Some(&buffer[..read]) {
return Ok(false);
}
offset = end;
}
}
fn artifact_path_is_physically_within_workspace(
workspace: &std::path::Path,
target: &std::path::Path,
) -> bool {
let Ok(workspace) = workspace.canonicalize() else {
return false;
};
let mut probe = target;
loop {
match std::fs::symlink_metadata(probe) {
Ok(_) => {
return probe
.canonicalize()
.is_ok_and(|resolved| resolved.starts_with(&workspace));
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
let Some(parent) = probe.parent() else {
return false;
};
probe = parent;
}
Err(_) => return false,
}
}
}
#[allow(clippy::too_many_arguments)]
fn record_governed_file_change(
sink: Option<&dyn super::artifact_read::PromptArtifactSink>,
context: Option<ArtifactReadContext<'_>>,
path: &str,
operation: &'static str,
before: Option<super::artifact_hooks::ArtifactFileState>,
after: super::artifact_hooks::ArtifactFileState,
_color: bool,
_tool_output_lines: usize,
) -> String {
let (Some(sink), Some(context), Some(before)) = (sink, context, before) else {
return String::new();
};
match super::artifact_hooks::record_file_change(sink, context, path, operation, before, after) {
Ok(_) => String::new(),
Err(error) => {
let warning = format!("warning: failed to record file-change artifact: {error}");
format!("\n{warning}")
}
}
}
fn exit_plan_mode_result(tenacity: crate::tenacity::Tenacity) -> String {
let base = "exited the model-entered PLAN PHASE. Subsequent tool calls return to this turn's validated disposition and underlying session permissions; the next outer turn returns to the human-selected operating mode. `/mode plan` and other clamps still remain read-only.";
if tenacity.exit_plan_requires_edit() {
format!(
"{base}\n\nThe plan is set — now EXECUTE it. Your NEXT action must be a concrete \
change (edit_file or write_file) that begins the first step — not another \
read, search, or plan. If a prerequisite is missing, make the smallest edit \
that unblocks it."
)
} else {
base.to_string()
}
}
fn artifact_postcondition_warning(
path: &str,
detail: &str,
_color: bool,
_tool_output_lines: usize,
) -> String {
let warning =
format!("warning: {path} changed, but no file-change artifact was recorded: {detail}");
format!("\n{warning}")
}
#[allow(clippy::too_many_arguments)]
#[derive(Default)]
pub(crate) struct ToolCollaborators<'a> {
pub(crate) build_check_cmd: Option<&'a str>,
pub(crate) note_sink: Option<&'a mut dyn NoteSink>,
pub(crate) recall_source: Option<&'a dyn RecallSource>,
pub(crate) memory_source: Option<&'a dyn MemorySource>,
pub(crate) prompt_context: Option<PromptReadContext<'a>>,
pub(crate) artifact_context: Option<ArtifactReadContext<'a>>,
pub(crate) artifact_sink: Option<&'a dyn super::artifact_read::PromptArtifactSink>,
pub(crate) permission_gate: Option<&'a mut dyn PermissionGate>,
pub(crate) exec_floor: Option<&'a crate::caveats::Scope<String>>,
pub(crate) git_tool: Option<&'a dyn GitTool>,
pub(crate) crew_runner: Option<&'a dyn CrewRunner>,
pub(crate) scratchpad_store: Option<&'a dyn super::scratchpad::ScratchpadStore>,
pub(crate) code_search: Option<super::semantic::CodeSearch<'a>>,
pub(crate) where_is: Option<&'a crate::where_is::WhereIsIndex>,
pub(crate) nav: Option<crate::navigator::NavToolCtx<'a>>,
pub(crate) experience_store: Option<&'a dyn super::experiential::ExperienceStore>,
pub(crate) step_ledger: Option<&'a dyn super::scheduled::StepLedger>,
pub(crate) operating_mode_control: Option<&'a dyn super::OperatingModeControl>,
pub(crate) plan_mode_control: Option<&'a dyn super::PlanModeControl>,
pub(crate) spill_store: Option<&'a dyn SpillStore>,
pub(crate) persona_tools: Option<&'a [String]>,
pub(crate) live_tool_output: Option<std::sync::Arc<dyn crate::agentic::LiveToolOutput>>,
}
#[allow(clippy::too_many_arguments)]
pub async fn execute_tool(
name: &str,
args: &serde_json::Value,
workspace: &str,
color: bool,
tool_output_lines: usize,
caveats: &crate::caveats::Caveats,
mcp: &mut dyn McpTools,
build_check_cmd: Option<&str>,
note_sink: Option<&mut dyn NoteSink>,
recall_source: Option<&dyn RecallSource>,
memory_source: Option<&dyn MemorySource>,
permission_gate: Option<&mut dyn PermissionGate>,
exec_floor: Option<&crate::caveats::Scope<String>>,
git_tool: Option<&dyn GitTool>,
crew_runner: Option<&dyn CrewRunner>,
scratchpad_store: Option<&dyn super::scratchpad::ScratchpadStore>,
code_search: Option<super::semantic::CodeSearch<'_>>,
where_is: Option<&crate::where_is::WhereIsIndex>,
experience_store: Option<&dyn super::experiential::ExperienceStore>,
step_ledger: Option<&dyn super::scheduled::StepLedger>,
) -> String {
let collab = ToolCollaborators {
build_check_cmd,
note_sink: note_sink.map(|s| &mut *s as &mut dyn NoteSink),
recall_source,
memory_source,
permission_gate: permission_gate.map(|g| &mut *g as &mut dyn PermissionGate),
exec_floor,
git_tool,
crew_runner,
scratchpad_store,
code_search,
where_is,
nav: None,
experience_store,
step_ledger,
..Default::default()
};
execute_tool_with_collaborators(
name,
args,
workspace,
color,
tool_output_lines,
caveats,
mcp,
collab,
false,
PromptDisposition::Act,
None,
)
.await
.expect("tool execution without a cancellation flag cannot be interrupted")
}
#[allow(clippy::too_many_arguments)]
pub async fn execute_tool_with_offload(
name: &str,
args: &serde_json::Value,
workspace: &str,
color: bool,
tool_output_lines: usize,
caveats: &crate::caveats::Caveats,
mcp: &mut dyn McpTools,
build_check_cmd: Option<&str>,
note_sink: Option<&mut dyn NoteSink>,
recall_source: Option<&dyn RecallSource>,
memory_source: Option<&dyn MemorySource>,
permission_gate: Option<&mut dyn PermissionGate>,
exec_floor: Option<&crate::caveats::Scope<String>>,
git_tool: Option<&dyn GitTool>,
crew_runner: Option<&dyn CrewRunner>,
scratchpad_store: Option<&dyn super::scratchpad::ScratchpadStore>,
code_search: Option<super::semantic::CodeSearch<'_>>,
where_is: Option<&crate::where_is::WhereIsIndex>,
experience_store: Option<&dyn super::experiential::ExperienceStore>,
step_ledger: Option<&dyn super::scheduled::StepLedger>,
tool_offload: bool,
spill_store: Option<&dyn SpillStore>,
persona_tools: Option<&[String]>,
) -> String {
let collab = ToolCollaborators {
build_check_cmd,
note_sink: note_sink.map(|s| &mut *s as &mut dyn NoteSink),
recall_source,
memory_source,
permission_gate: permission_gate.map(|g| &mut *g as &mut dyn PermissionGate),
exec_floor,
git_tool,
crew_runner,
scratchpad_store,
code_search,
where_is,
nav: None,
experience_store,
step_ledger,
spill_store,
persona_tools,
..Default::default()
};
execute_tool_with_collaborators(
name,
args,
workspace,
color,
tool_output_lines,
caveats,
mcp,
collab,
tool_offload,
PromptDisposition::Act,
None,
)
.await
.expect("tool execution without a cancellation flag cannot be interrupted")
}
#[allow(clippy::too_many_arguments)]
pub async fn execute_tool_with_offload_and_prompt(
name: &str,
args: &serde_json::Value,
workspace: &str,
color: bool,
tool_output_lines: usize,
caveats: &crate::caveats::Caveats,
mcp: &mut dyn McpTools,
build_check_cmd: Option<&str>,
note_sink: Option<&mut dyn NoteSink>,
recall_source: Option<&dyn RecallSource>,
memory_source: Option<&dyn MemorySource>,
prompt_context: Option<PromptReadContext<'_>>,
permission_gate: Option<&mut dyn PermissionGate>,
exec_floor: Option<&crate::caveats::Scope<String>>,
git_tool: Option<&dyn GitTool>,
crew_runner: Option<&dyn CrewRunner>,
scratchpad_store: Option<&dyn super::scratchpad::ScratchpadStore>,
code_search: Option<super::semantic::CodeSearch<'_>>,
where_is: Option<&crate::where_is::WhereIsIndex>,
experience_store: Option<&dyn super::experiential::ExperienceStore>,
step_ledger: Option<&dyn super::scheduled::StepLedger>,
tool_offload: bool,
spill_store: Option<&dyn SpillStore>,
persona_tools: Option<&[String]>,
) -> String {
let collab = ToolCollaborators {
build_check_cmd,
note_sink: note_sink.map(|s| &mut *s as &mut dyn NoteSink),
recall_source,
memory_source,
prompt_context,
permission_gate: permission_gate.map(|g| &mut *g as &mut dyn PermissionGate),
exec_floor,
git_tool,
crew_runner,
scratchpad_store,
code_search,
where_is,
nav: None,
experience_store,
step_ledger,
spill_store,
persona_tools,
..Default::default()
};
execute_tool_with_collaborators(
name,
args,
workspace,
color,
tool_output_lines,
caveats,
mcp,
collab,
tool_offload,
PromptDisposition::Act,
None,
)
.await
.expect("tool execution without a cancellation flag cannot be interrupted")
}
#[allow(clippy::too_many_arguments)]
pub async fn execute_tool_with_offload_and_prompt_and_artifacts(
name: &str,
args: &serde_json::Value,
workspace: &str,
color: bool,
tool_output_lines: usize,
caveats: &crate::caveats::Caveats,
mcp: &mut dyn McpTools,
build_check_cmd: Option<&str>,
note_sink: Option<&mut dyn NoteSink>,
recall_source: Option<&dyn RecallSource>,
memory_source: Option<&dyn MemorySource>,
prompt_context: Option<PromptReadContext<'_>>,
artifact_context: Option<ArtifactReadContext<'_>>,
artifact_sink: Option<&dyn super::artifact_read::PromptArtifactSink>,
permission_gate: Option<&mut dyn PermissionGate>,
exec_floor: Option<&crate::caveats::Scope<String>>,
git_tool: Option<&dyn GitTool>,
crew_runner: Option<&dyn CrewRunner>,
scratchpad_store: Option<&dyn super::scratchpad::ScratchpadStore>,
code_search: Option<super::semantic::CodeSearch<'_>>,
where_is: Option<&crate::where_is::WhereIsIndex>,
experience_store: Option<&dyn super::experiential::ExperienceStore>,
step_ledger: Option<&dyn super::scheduled::StepLedger>,
tool_offload: bool,
spill_store: Option<&dyn SpillStore>,
persona_tools: Option<&[String]>,
disposition: PromptDisposition,
) -> String {
let collab = ToolCollaborators {
build_check_cmd,
note_sink: note_sink.map(|s| &mut *s as &mut dyn NoteSink),
recall_source,
memory_source,
prompt_context,
artifact_context,
artifact_sink,
permission_gate: permission_gate.map(|g| &mut *g as &mut dyn PermissionGate),
exec_floor,
git_tool,
crew_runner,
scratchpad_store,
code_search,
where_is,
nav: None,
experience_store,
step_ledger,
spill_store,
persona_tools,
..Default::default()
};
execute_tool_with_collaborators(
name,
args,
workspace,
color,
tool_output_lines,
caveats,
mcp,
collab,
tool_offload,
disposition,
None,
)
.await
.expect("tool execution without a cancellation flag cannot be interrupted")
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn execute_tool_with_collaborators(
name: &str,
args: &serde_json::Value,
workspace: &str,
color: bool,
tool_output_lines: usize,
caveats: &crate::caveats::Caveats,
mcp: &mut dyn McpTools,
collab: ToolCollaborators<'_>,
tool_offload: bool,
disposition: PromptDisposition,
cancel: Option<&std::sync::atomic::AtomicBool>,
) -> Option<String> {
let mut display = ToolDisplay::new(
std::io::stdout(),
color,
super::display::term_cols(),
super::display::spill_lines(),
);
execute_tool_with_display_cancellable(
&mut display,
name,
args,
workspace,
color,
tool_output_lines,
caveats,
mcp,
collab,
tool_offload,
disposition,
cancel,
)
.await
}
async fn wait_for_tool_cancellation(cancel: Option<&std::sync::atomic::AtomicBool>) {
match cancel {
None => std::future::pending::<()>().await,
Some(flag) => {
while !flag.load(std::sync::atomic::Ordering::Relaxed) {
tokio::time::sleep(std::time::Duration::from_millis(15)).await;
}
}
}
}
#[allow(clippy::too_many_arguments)]
async fn execute_tool_with_display_cancellable<W: std::io::Write + Send>(
display: &mut ToolDisplay<W>,
name: &str,
args: &serde_json::Value,
workspace: &str,
color: bool,
tool_output_lines: usize,
caveats: &crate::caveats::Caveats,
mcp: &mut dyn McpTools,
collab: ToolCollaborators<'_>,
tool_offload: bool,
disposition: PromptDisposition,
cancel: Option<&std::sync::atomic::AtomicBool>,
) -> Option<String> {
let (presentation_name, presentation_detail) =
tool_presentation(name, args, std::path::Path::new(workspace));
display.call(&presentation_name, &presentation_detail);
let result = {
let execution = execute_tool_inner(
display,
name,
args,
workspace,
color,
tool_output_lines,
caveats,
mcp,
collab,
tool_offload,
disposition,
);
tokio::pin!(execution);
tokio::select! {
biased;
_ = wait_for_tool_cancellation(cancel) => None,
result = &mut execution => Some(result),
}
};
match result {
Some(result) => {
display.result(&result);
Some(result)
}
None => {
let result = format!("error: {name} interrupted — tool cancelled before completion");
display.result(&result);
None
}
}
}
#[allow(clippy::too_many_arguments)]
async fn execute_tool_inner(
presentation: &mut dyn ToolPresentation,
name: &str,
args: &serde_json::Value,
workspace: &str,
color: bool,
tool_output_lines: usize,
caveats: &crate::caveats::Caveats,
mcp: &mut dyn McpTools,
collab: ToolCollaborators<'_>,
tool_offload: bool,
disposition: PromptDisposition,
) -> String {
let ToolCollaborators {
build_check_cmd,
note_sink,
recall_source,
memory_source,
prompt_context,
artifact_context,
artifact_sink,
mut permission_gate,
exec_floor,
git_tool,
crew_runner,
scratchpad_store,
code_search,
where_is,
nav,
experience_store,
step_ledger,
operating_mode_control,
plan_mode_control,
spill_store,
persona_tools,
live_tool_output,
} = collab;
let disposition = if plan_mode_control.is_some_and(super::PlanModeControl::is_plan_mode)
&& disposition == PromptDisposition::Act
{
PromptDisposition::Plan
} else {
disposition
};
if !tool_allowed(disposition, name) {
let msg = disposition_tool_denied_message(disposition, name);
return msg;
}
if disposition != PromptDisposition::Act {
permission_gate = None;
}
if let Some(denied) = super::deny::deny_check(name, args) {
return denied.reason;
}
if let Some(allow) = persona_tools {
let canonical = match resolve_tool_alias(name) {
Some(AliasOutcome::Rewrite(canonical)) => canonical,
_ => name,
};
if !persona_tool_allowed(canonical, allow) && !mcp.handles(name) {
let msg = persona_tool_denied_message(canonical);
return msg;
}
}
if mcp.handles(name) {
if let Some(allow) = persona_tools {
if !persona_tool_allowed(name, allow) {
let request = PermissionRequest {
tool: name.to_string(),
kind: DenialKind::RemoteTool,
target: name.to_string(),
reason: format!(
"remote tool `{name}` is outside the active persona's tool allow-list"
),
};
let granted = match permission_gate {
Some(gate) => {
matches!(gate.ask(&[request]), PermissionDecision::Allow(_))
}
None => false,
};
if !granted {
let msg = persona_tool_denied_message(name);
return msg;
}
}
}
let out = mcp.call(name, args).await;
return out;
}
let name = match resolve_tool_alias(name) {
Some(AliasOutcome::Rewrite(canonical)) => canonical,
Some(AliasOutcome::Correct(msg)) => return msg,
None => name,
};
let routed: Option<(&'static str, serde_json::Value)> =
if name == "run_command" && !routing_disabled() {
let command = args.get("command").and_then(|v| v.as_str()).unwrap_or("");
let decision = super::routing::RouteTable::builtin().classify(command);
if let Some(line) = super::routing::audit_line(command, &decision) {
tracing::debug!(target: "newt::routing", "{line}");
}
match decision {
super::routing::RouteDecision::Route { tool, args } => Some((tool, args)),
super::routing::RouteDecision::Exec => None,
}
} else {
None
};
let (name, args): (&str, &serde_json::Value) = match &routed {
Some((tool, routed_args)) => (*tool, routed_args),
None => (name, args),
};
match name {
"prompt_read" => match prompt_context {
Some(context) => {
let output = execute_prompt_read_silent(args, context);
presentation.override_result(output.display);
output.model
}
None => "prompt_read error: no active prompt context in this session".to_string(),
},
"artifact_read" => match artifact_context {
Some(context) => {
let output = execute_artifact_read_silent(args, context);
presentation.override_result(output.display);
output.model
}
None => "error: artifact_read: no active artifact context in this session".to_string(),
},
"save_note" => match note_sink {
Some(sink) => execute_save_note(args, sink, color, tool_output_lines),
None => "unknown tool: save_note (no note store in this session)".to_string(),
},
"recall" => match recall_source {
Some(source) => execute_recall(args, source, color, tool_output_lines),
None => "unknown tool: recall (no conversation store in this session)".to_string(),
},
"memory_fetch" => match memory_source {
Some(source) => execute_memory_fetch(args, source, color, tool_output_lines),
None => "unknown tool: memory_fetch (no memory source in this session)".to_string(),
},
"state_set" => match scratchpad_store {
Some(s) => super::scratchpad::execute_state_set(args, s, color, tool_output_lines),
None => "unknown tool: state_set (no scratchpad in this session)".to_string(),
},
"state_get" => match scratchpad_store {
Some(s) => super::scratchpad::execute_state_get(args, s, color, tool_output_lines),
None => "unknown tool: state_get (no scratchpad in this session)".to_string(),
},
"state_clear" => match scratchpad_store {
Some(s) => super::scratchpad::execute_state_clear(s, color, tool_output_lines),
None => "unknown tool: state_clear (no scratchpad in this session)".to_string(),
},
"code_search" => match code_search {
Some(search) => {
super::semantic::execute_code_search(args, search, color, tool_output_lines).await
}
None => {
"unknown tool: code_search (semantic retrieval is off this session)".to_string()
}
},
"where_is" => match where_is {
Some(index) => crate::where_is::execute_where_is(args, index, tool_output_lines),
None => "unknown tool: where_is (no symbol index built for this session)".to_string(),
},
name if crate::navigator::NAV_TOOL_NAMES.contains(&name) => {
let ctx = nav.unwrap_or(crate::navigator::NavToolCtx {
workspace,
where_is,
usage: None,
graph: None,
project: None,
files: None,
status: None,
});
crate::navigator::execute_nav_tool(name, args, &ctx)
.unwrap_or_else(|| format!("unknown tool: {name}"))
}
"experience_record" => match experience_store {
Some(s) => {
super::experiential::execute_experience_record(args, s, color, tool_output_lines)
}
None => "unknown tool: experience_record (experiential memory is off)".to_string(),
},
"experience_recall" => match experience_store {
Some(s) => super::experiential::execute_experience_recall(
args,
s,
super::experiential::EXPERIENCE_TOP_K,
color,
tool_output_lines,
),
None => "unknown tool: experience_recall (experiential memory is off)".to_string(),
},
"update_plan" => match step_ledger {
Some(ledger) => {
let mut out =
super::scheduled::execute_update_plan(args, ledger, color, tool_output_lines);
if tool_result_ok(&out) {
if let (Some(sink), Some(context)) = (artifact_sink, artifact_context) {
let plan = ledger.snapshot();
if !plan.is_empty() {
if let Err(error) =
super::artifact_hooks::record_plan_revision(sink, context, &plan)
{
let warning =
format!("warning: failed to record plan artifact: {error}");
out.push('\n');
out.push_str(&warning);
}
}
}
}
out
}
None => "unknown tool: update_plan (scheduled planning is off)".to_string(),
},
"enter_plan_mode" => match (plan_mode_control, step_ledger) {
(Some(control), Some(_)) => match control.set_plan_mode(true) {
Ok(()) => "entered PLAN MODE (read-only): subsequent tool calls are immediately limited to Plan reads and the plan ledger until you call exit_plan_mode. Read/search the relevant code, draft the ordered steps with update_plan, then exit_plan_mode to execute.".to_string(),
Err(error) => format!("error: enter_plan_mode: {error}"),
},
_ => {
"unknown tool: enter_plan_mode (scheduled planning and a session Plan-mode control are both required)".to_string()
}
},
"exit_plan_mode" => match plan_mode_control {
Some(control) => match control.set_plan_mode(false) {
Ok(()) => exit_plan_mode_result(crate::tenacity::effective_tenacity()),
Err(error) => format!("error: exit_plan_mode: {error}"),
},
None => {
"unknown tool: exit_plan_mode (no session Plan-mode control is available)"
.to_string()
}
},
"select_operating_mode" => {
let mode = args
.get("mode")
.and_then(serde_json::Value::as_str)
.unwrap_or("");
match operating_mode_control {
Some(control) => control
.select_operating_mode(mode)
.unwrap_or_else(|error| format!("error: select_operating_mode: {error}")),
None => {
"unknown tool: select_operating_mode (available only while /mode auto is active)"
.to_string()
}
}
}
"plan_get" => match step_ledger {
Some(l) => super::scheduled::execute_plan_get(l, color, tool_output_lines),
None => "unknown tool: plan_get (scheduled planning is off)".to_string(),
},
"resume_context" => super::resume::execute_resume_context(
recall_source,
step_ledger,
scratchpad_store,
prompt_context,
color,
tool_output_lines,
),
"request_permissions" => {
execute_request_permissions(args, permission_gate, color, tool_output_lines)
}
"request_user_input" => {
execute_request_user_input(args, permission_gate, color, tool_output_lines)
}
"tool_search" => {
let query = args.get("query").and_then(|v| v.as_str()).unwrap_or("");
let catalog = filter_advertised_tools(
merged_tool_definitions(
&*mcp,
note_sink.is_some(),
recall_source.is_some(),
memory_source.is_some(),
git_tool.is_some(),
crew_runner.is_some(),
scratchpad_store.is_some(),
code_search.is_some(),
experience_store.is_some(),
step_ledger.is_some(),
operating_mode_control.is_some(),
plan_mode_control.is_some(),
plan_mode_control.is_some_and(super::PlanModeControl::is_plan_mode),
),
persona_tools,
);
let catalog = filter_tools_for_disposition(catalog, disposition);
super::tool_search::execute_tool_search(query, &catalog)
}
"git" => match git_tool {
Some(tool) => {
let gc = crate::git_caveats::GitCaveats::from_session(caveats);
let op = args.get("op").and_then(|v| v.as_str()).unwrap_or("");
if is_git_data_loss_op(op) {
let confirmed = permission_gate
.as_deref_mut()
.is_some_and(|gate| git_data_loss_confirmed(gate, op));
if !confirmed {
let refusal = format!(
"refused: git {op} destroys work irrecoverably and was not \
confirmed by the operator. If you stashed changes you still \
need, restore them with stash-pop/stash-apply instead of \
dropping; do NOT delete a branch that holds unmerged work. \
The operator must confirm any data-loss git op."
);
return refusal;
}
}
let mut out = match tool.dispatch(op, args, &gc) {
Ok(rendered) => rendered,
Err(e) => format!("error: {e}"),
};
if is_git_write_denial(&out) {
let granted = permission_gate
.as_deref_mut()
.is_some_and(|gate| git_gate_allows(gate, op));
if granted {
out = match tool.dispatch(op, args, &crate::git_caveats::GitCaveats::top())
{
Ok(rendered) => rendered,
Err(e) => format!("error: {e}"),
};
}
}
out
}
None => "unknown tool: git (no git surface in this session)".to_string(),
},
"compose_roster" | "crew" => match crew_runner {
Some(runner) => {
let out = match runner.dispatch(name, args, caveats).await {
Ok(rendered) => rendered,
Err(e) => format!("error: {e}"),
};
out
}
None => crew_off_recovery_result(name),
},
"run_command" => {
let raw_cmd = args["command"].as_str().unwrap_or("");
let (cd_path, cmd_owned) = split_leading_cd(raw_cmd);
let cmd = cmd_owned.as_str();
if cd_path.is_some() && cmd.trim().is_empty() {
let path = cd_path.as_deref().unwrap_or("");
return format!(
"note: a bare `cd` has no effect — each command runs \
independently, so there is no persistent shell to change. \
Prefix the command instead (`cd {path} && <command>`, which \
newt runs in `{path}`) or pass `cwd`."
);
}
if let Some(tool) = run_command_redirect(cmd) {
return format!(
"error: '{tool}' is a tool, not a shell command. \
Call it as a separate tool invocation — \
do not pass '{tool}' as a command argument to run_command."
);
}
let run_cwd = resolve_exec_cwd(
workspace,
cd_path.as_deref().or_else(|| args["cwd"].as_str()),
);
exec_confined_command(
cmd,
&run_cwd,
color,
tool_output_lines,
caveats,
exec_floor,
permission_gate,
tool_offload,
spill_store,
live_tool_output.clone(),
presentation,
)
.await
}
"render_report" => {
let (result, document) = execute_render_report(args, color);
if let Some(document) = document {
presentation.document(&document);
}
result
}
"lifecycle" => {
let phase_key = args.get("phase").and_then(|v| v.as_str()).unwrap_or("");
let Some(phase) = crate::tooling::Phase::from_key(phase_key) else {
let valid = crate::tooling::Phase::ALL
.iter()
.map(|p| p.as_str())
.collect::<Vec<_>>()
.join(", ");
return format!(
"error: unknown lifecycle phase '{phase_key}'. Valid phases: {valid}."
);
};
let action = args.get("action").and_then(|v| v.as_str()).unwrap_or("run");
let cmds =
crate::tooling::resolved_phase_commands(std::path::Path::new(workspace), phase);
if cmds.is_empty() {
return format!(
"no command configured for lifecycle phase '{}'. Set it in \
.newt/config.toml [lifecycle] or a tooling pack; `lifecycle` \
only runs commands the project declares.",
phase.as_str()
);
}
let joined = cmds.join(" && ");
match action {
"list" => format!("lifecycle {} → {joined}", phase.as_str()),
"run" => {
exec_confined_command(
&joined,
workspace,
color,
tool_output_lines,
caveats,
exec_floor,
permission_gate,
tool_offload,
spill_store,
live_tool_output.clone(),
presentation,
)
.await
}
other => format!(
"error: unknown lifecycle action '{other}'. Use 'run' (default) or 'list'."
),
}
}
"read_file" => {
let path = args["path"].as_str().unwrap_or("");
let full = std::path::Path::new(workspace).join(path);
let full_str = full.to_string_lossy();
if !tui_permits_path(&caveats.fs_read, &full_str) {
let allowed = permission_gate.is_some_and(|gate| {
fs_gate_allows(gate, "read_file", DenialKind::FsRead, &full_str, |c| {
&c.fs_read
})
});
if !allowed {
return denied_fs_result("fs_read", path);
}
}
if full_access_requested() {
crate::flight_recorder::log_observed(
crate::flight_recorder::ShadowAxis::FsRead,
&full_str,
"read_file",
);
}
match std::fs::read_to_string(&full) {
Ok(contents) => {
let offset = args["offset"].as_u64().map(|n| n as usize);
let limit = args["limit"].as_u64().map(|n| n as usize);
paginate_read(&contents, offset, limit, max_output_tokens())
}
Err(e) => format!("error reading {path}: {e}"),
}
}
"write_file" => {
let path = args["path"].as_str().unwrap_or("");
let content = args["content"].as_str().unwrap_or("");
let full = std::path::Path::new(workspace).join(path);
let full_str = full.to_string_lossy();
if !tui_permits_path(&caveats.fs_write, &full_str) {
let allowed = permission_gate.as_deref_mut().is_some_and(|gate| {
fs_gate_allows(gate, "write_file", DenialKind::FsWrite, &full_str, |c| {
&c.fs_write
})
});
if !allowed {
return denied_fs_result("fs_write", path);
}
}
if full_access_requested() {
crate::flight_recorder::log_observed(
crate::flight_recorder::ShadowAxis::FsWrite,
&full_str,
"write_file",
);
}
let artifact_tracking = artifact_sink.is_some() && artifact_context.is_some();
let artifact_path_within = artifact_tracking
&& artifact_path_is_physically_within_workspace(
std::path::Path::new(workspace),
&full,
);
let artifact_before = artifact_path_within.then(|| {
artifact_preimage_state(&full, tui_permits_path(&caveats.fs_read, &full_str))
});
if let Ok(existing) = std::fs::read_to_string(&full) {
let orig_lines = existing.lines().count();
let new_lines = content.lines().count();
let removed = orig_lines.saturating_sub(new_lines);
if removed > 30 && new_lines < orig_lines * 7 / 10 {
let pct = removed * 100 / orig_lines.max(1);
let msg = format!(
"error: write_file would shrink {path} from {orig_lines} → {new_lines} lines \
(-{pct}%). This is likely unintentional. Use edit_file to make targeted \
changes, or ensure your content includes the full file."
);
return msg;
}
}
let preview: String = content.lines().take(20).collect::<Vec<_>>().join("\n");
let has_more = content.lines().count() > 20;
presentation.preview(
&format!("{preview}{}", if has_more { "\n…" } else { "" }),
tool_output_lines,
);
let confirmed = confirm_unrestricted_fs_mutation(
caveats,
&mut permission_gate,
"Write this file? [y/N]",
);
if confirmed {
let full = std::path::Path::new(workspace).join(path);
if let Some(parent) = full.parent() {
let _ = std::fs::create_dir_all(parent);
}
match std::fs::write(&full, content) {
Ok(_) => {
let line_count = content.lines().count();
let artifact = if !artifact_tracking {
String::new()
} else if !artifact_path_within
|| !artifact_path_is_physically_within_workspace(
std::path::Path::new(workspace),
&full,
)
{
artifact_postcondition_warning(
path,
"the physical path could not be proven inside the workspace",
color,
tool_output_lines,
)
} else {
match artifact_file_matches(&full, content.as_bytes()) {
Ok(true) => record_governed_file_change(
artifact_sink,
artifact_context,
path,
"write_file",
artifact_before,
super::artifact_hooks::ArtifactFileState::from_bytes(
content.as_bytes(),
),
color,
tool_output_lines,
),
Ok(false) => artifact_postcondition_warning(
path,
"post-write bytes did not match the submitted content",
color,
tool_output_lines,
),
Err(_) => artifact_postcondition_warning(
path,
"post-write bytes could not be verified",
color,
tool_output_lines,
),
}
};
let check = build_check_cmd
.map(|cmd| run_build_check(cmd, workspace))
.unwrap_or_default();
format!("wrote {path} ({line_count} lines){artifact}{check}")
}
Err(e) => format!("error writing {path}: {e}"),
}
} else {
format!("user declined to write {path}")
}
}
"delete_file" => {
let path = args["path"].as_str().unwrap_or("");
if path.trim().is_empty() {
return "error: path is required".to_string();
}
let full = std::path::Path::new(workspace).join(path);
let full_str = full.to_string_lossy();
if !tui_permits_path(&caveats.fs_write, &full_str) {
let allowed = permission_gate.as_deref_mut().is_some_and(|gate| {
fs_gate_allows(gate, "delete_file", DenialKind::FsWrite, &full_str, |c| {
&c.fs_write
})
});
if !allowed {
return denied_fs_result("fs_write", path);
}
}
let meta = match std::fs::symlink_metadata(&full) {
Ok(meta) => meta,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return format!("error deleting {path}: file does not exist");
}
Err(e) => return format!("error deleting {path}: {e}"),
};
if meta.file_type().is_dir() {
return format!("error deleting {path}: delete_file refuses directories");
}
let artifact_tracking = artifact_sink.is_some() && artifact_context.is_some();
let artifact_path_within = artifact_tracking
&& artifact_path_is_physically_within_workspace(
std::path::Path::new(workspace),
&full,
);
let artifact_before = artifact_path_within.then(|| {
artifact_preimage_state(&full, tui_permits_path(&caveats.fs_read, &full_str))
});
let confirmed = confirm_unrestricted_fs_mutation(
caveats,
&mut permission_gate,
"Delete this file? [y/N]",
);
if !confirmed {
return format!("user declined to delete {path}");
}
match std::fs::remove_file(&full) {
Ok(_) => {
let artifact = if !artifact_tracking {
String::new()
} else if !artifact_path_within
|| !artifact_path_is_physically_within_workspace(
std::path::Path::new(workspace),
&full,
)
{
artifact_postcondition_warning(
path,
"the physical path could not be proven inside the workspace",
color,
tool_output_lines,
)
} else {
match std::fs::symlink_metadata(&full) {
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
record_governed_file_change(
artifact_sink,
artifact_context,
path,
"delete_file",
artifact_before,
super::artifact_hooks::ArtifactFileState::absent(),
color,
tool_output_lines,
)
}
_ => artifact_postcondition_warning(
path,
"the path still existed after delete_file returned success",
color,
tool_output_lines,
),
}
};
let check = build_check_cmd
.map(|cmd| run_build_check(cmd, workspace))
.unwrap_or_default();
format!("deleted {path}{artifact}{check}")
}
Err(e) => format!("error deleting {path}: {e}"),
}
}
"edit_file" => {
let path = args["path"].as_str().unwrap_or("");
let old_string = args["old_string"].as_str().unwrap_or("");
let new_string = args["new_string"].as_str().unwrap_or("");
let full = std::path::Path::new(workspace).join(path);
let full_str = full.to_string_lossy();
if !tui_permits_path(&caveats.fs_write, &full_str) {
let allowed = permission_gate.is_some_and(|gate| {
fs_gate_allows(gate, "edit_file", DenialKind::FsWrite, &full_str, |c| {
&c.fs_write
})
});
if !allowed {
return denied_fs_result("fs_write", path);
}
}
if full_access_requested() {
crate::flight_recorder::log_observed(
crate::flight_recorder::ShadowAxis::FsWrite,
&full_str,
"edit_file",
);
}
if old_string.is_empty() {
return "error: old_string must not be empty — use write_file to create new files"
.to_string();
}
let existing = match std::fs::read_to_string(&full) {
Ok(s) => s,
Err(e) => return format!("error reading {path}: {e}"),
};
let count = existing.matches(old_string).count();
if count == 0 {
const HEAD: usize = 40;
let total = existing.lines().count();
let head: String = existing
.lines()
.take(HEAD)
.map(|l| format!(" {l}"))
.collect::<Vec<_>>()
.join("\n");
let more = if total > HEAD {
format!("\n … ({} more line(s))", total - HEAD)
} else {
String::new()
};
return format!(
"error: old_string not found in {path} — do not guess again. Copy the \
EXACT text (including leading whitespace) from the contents below, then \
retry. To add a header/first line, set old_string to the shown first \
line and put your header + that line in new_string; to create a new \
file use write_file.\n--- {path} (first {shown} of {total} line(s)) ---\n{head}{more}",
shown = total.min(HEAD),
);
}
if count > 1 {
return format!(
"error: old_string matches {count} locations in {path}. \
Add more surrounding context to make it unique."
);
}
let artifact_tracking = artifact_sink.is_some() && artifact_context.is_some();
let artifact_path_within = artifact_tracking
&& artifact_path_is_physically_within_workspace(
std::path::Path::new(workspace),
&full,
);
let artifact_before = artifact_path_within.then(|| {
if !tui_permits_path(&caveats.fs_read, &full_str) {
super::artifact_hooks::ArtifactFileState::unavailable("fs_read_not_granted")
} else if std::fs::symlink_metadata(&full)
.is_ok_and(|metadata| metadata.file_type().is_symlink())
{
super::artifact_hooks::ArtifactFileState::unavailable(
"symlink_preimage_not_hashed",
)
} else {
super::artifact_hooks::ArtifactFileState::from_bytes(existing.as_bytes())
}
});
let updated = existing.replacen(old_string, new_string, 1);
let old_lines = existing.lines().count();
let new_lines = updated.lines().count();
let delta = new_lines as i64 - old_lines as i64;
let delta_str = if delta >= 0 {
format!("+{delta}")
} else {
format!("{delta}")
};
match std::fs::write(&full, &updated) {
Ok(_) => {
let artifact = if !artifact_tracking {
String::new()
} else if !artifact_path_within
|| !artifact_path_is_physically_within_workspace(
std::path::Path::new(workspace),
&full,
)
{
artifact_postcondition_warning(
path,
"the physical path could not be proven inside the workspace",
color,
tool_output_lines,
)
} else {
match artifact_file_matches(&full, updated.as_bytes()) {
Ok(true) => record_governed_file_change(
artifact_sink,
artifact_context,
path,
"edit_file",
artifact_before,
super::artifact_hooks::ArtifactFileState::from_bytes(
updated.as_bytes(),
),
color,
tool_output_lines,
),
Ok(false) => artifact_postcondition_warning(
path,
"post-edit bytes did not match the computed replacement",
color,
tool_output_lines,
),
Err(_) => artifact_postcondition_warning(
path,
"post-edit bytes could not be verified",
color,
tool_output_lines,
),
}
};
let check = build_check_cmd
.map(|cmd| run_build_check(cmd, workspace))
.unwrap_or_default();
format!(
"edited {path} ({delta_str} lines, now {new_lines} total){artifact}{check}"
)
}
Err(e) => format!("error writing {path}: {e}"),
}
}
"list_dir" => {
let path = args["path"].as_str().unwrap_or(".");
let full = std::path::Path::new(workspace).join(path);
let full_str = full.to_string_lossy();
if !tui_permits_path(&caveats.fs_read, &full_str) {
let allowed = permission_gate.is_some_and(|gate| {
fs_gate_allows(gate, "list_dir", DenialKind::FsRead, &full_str, |c| {
&c.fs_read
})
});
if !allowed {
return denied_fs_result("fs_read", path);
}
}
if full_access_requested() {
crate::flight_recorder::log_observed(
crate::flight_recorder::ShadowAxis::FsRead,
&full_str,
"list_dir",
);
}
match std::fs::read_dir(&full) {
Ok(entries) => {
let mut names: Vec<String> = entries
.flatten()
.map(|e| e.file_name().to_string_lossy().into_owned())
.collect();
names.sort();
names.join("\n")
}
Err(e) => format!("error: {e}"),
}
}
"find" => {
let path = args["path"].as_str().unwrap_or(".");
let full = std::path::Path::new(workspace).join(path);
let full_str = full.to_string_lossy();
if !tui_permits_path(&caveats.fs_read, &full_str) {
let allowed = permission_gate.is_some_and(|gate| {
fs_gate_allows(gate, "find", DenialKind::FsRead, &full_str, |c| &c.fs_read)
});
if !allowed {
return denied_fs_result("fs_read", path);
}
}
if full_access_requested() {
crate::flight_recorder::log_observed(
crate::flight_recorder::ShadowAxis::FsRead,
&full_str,
"find",
);
}
let opts = find_opts_from_args(args);
let source_extensions =
match find_source_extensions(std::path::Path::new(workspace), &opts) {
Ok(extensions) => extensions,
Err(error) => return format!("error: {error}"),
};
if !full.exists() {
return format!("error: no such path '{path}'");
}
if let (Ok(ws_canon), Ok(root_canon)) = (
std::path::Path::new(workspace).canonicalize(),
full.canonicalize(),
) {
if !root_canon.starts_with(&ws_canon) {
return denied_fs_result("fs_read", path);
}
}
let mut live = LiveOutputSession::start(live_tool_output.clone());
let relay = live.as_ref().map(LiveOutputSession::relay);
let on_hit = |line: &str| {
if let Some(relay) = &relay {
let mut chunk = line.as_bytes().to_vec();
chunk.push(b'\n');
relay.write(crate::agentic::ToolOutputStream::Stdout, &chunk);
}
};
let walked = find_walk(
&full,
std::path::Path::new(workspace),
&opts,
source_extensions.as_deref(),
on_hit,
);
if let Some(live) = live.as_mut() {
live.finish();
}
match walked {
Ok((hits, truncated)) => {
let mut listing = if hits.is_empty() {
"no matches".to_string()
} else {
hits.join("\n")
};
if truncated {
listing
.push_str(&format!("\n… (truncated at {} matches)", opts.max_results));
}
listing
}
Err(e) => format!("error: {e}"),
}
}
"use_skill" => {
let skill_name = args["name"].as_str().unwrap_or("");
let dirs = crate::Config::resolve()
.map(|c| c.skill_search_dirs())
.unwrap_or_default();
match newt_skills::load_body_from(&dirs, skill_name) {
Ok(body) => body,
Err(e) => format!("error: {e}"),
}
}
"web_fetch" => {
let url = args["url"].as_str().unwrap_or("");
let mut fetch_args = serde_json::json!({ "url": url });
if let Some(max_bytes) = args.get("max_bytes").and_then(serde_json::Value::as_u64) {
fetch_args["max_bytes"] = serde_json::json!(max_bytes);
}
let widened_for_net = match (permission_gate, host_of_url(url)) {
(Some(gate), Some(host)) if !caveats.permits_net(&host) => {
let request = PermissionRequest {
tool: "web_fetch".to_string(),
kind: DenialKind::Net,
target: host.clone(),
reason: format!("net does not permit '{host}'"),
};
match gate.ask(std::slice::from_ref(&request)) {
PermissionDecision::Allow(widened) => Some(widened),
PermissionDecision::Deny => None,
}
}
_ => None,
};
let effective_caveats = widened_for_net.as_ref().unwrap_or(caveats);
if full_access_requested() {
if let Some(host) = host_of_url(url) {
crate::flight_recorder::log_observed(
crate::flight_recorder::ShadowAxis::Net,
&host,
url,
);
}
}
match agent_bridle::registry()
.dispatch("web_fetch", fetch_args, effective_caveats)
.await
{
Ok(result) => {
let markdown = result
.get("markdown")
.and_then(serde_json::Value::as_str)
.unwrap_or("");
let title = result
.get("title")
.and_then(serde_json::Value::as_str)
.unwrap_or("");
let final_url = result
.get("final_url")
.and_then(serde_json::Value::as_str)
.unwrap_or(url);
let out = if title.is_empty() {
format!("{final_url}\n\n{markdown}")
} else {
format!("# {title}\n{final_url}\n\n{markdown}")
};
out
}
Err(e) => format!("error: {e}"),
}
}
other => unknown_tool_message(other),
}
}
pub(crate) fn tool_result_ok(result: &str) -> bool {
let r = result.trim_start();
!(r.starts_with("error:")
|| r.starts_with("capability denied:")
|| r.starts_with("unknown tool"))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::agentic::NoMcp;
#[test]
fn exit_plan_mode_result_appends_mandatory_edit_only_when_tenacity_requires_it() {
use crate::tenacity::Tenacity;
for t in [Tenacity::Relaxed, Tenacity::Standard] {
let out = exit_plan_mode_result(t);
assert!(out.starts_with("exited the model-entered PLAN PHASE"));
assert!(
!out.contains("must be a concrete"),
"{t} must not force an edit: {out}"
);
}
for t in [Tenacity::Insistent, Tenacity::Relentless] {
let out = exit_plan_mode_result(t);
assert!(out.contains("now EXECUTE it"), "{t}: {out}");
assert!(out.contains("must be a concrete"), "{t}: {out}");
assert!(out.contains("edit_file or write_file"), "{t}: {out}");
}
for t in Tenacity::all() {
assert_eq!(
exit_plan_mode_result(t).contains("now EXECUTE it"),
t.exit_plan_requires_edit()
);
}
}
fn find_opts(max_results: usize, show_size: bool, sort: FindSort) -> FindOpts<'static> {
FindOpts {
name: None,
type_filter: FindType::Any,
category: FindCategory::Any,
language: None,
max_depth: None,
max_results,
respect_gitignore: true,
case_sensitive: true,
show_size,
show_lines: false,
sort,
}
}
#[test]
fn find_opts_parses_size_column_options() {
let sized = serde_json::json!({ "show_size": true, "sort": "size" });
let opts = find_opts_from_args(&sized);
assert!(opts.show_size);
assert_eq!(opts.sort, FindSort::Size);
let empty = serde_json::json!({});
let d = find_opts_from_args(&empty);
assert!(!d.show_size);
assert_eq!(d.sort, FindSort::Name);
let bogus = serde_json::json!({ "sort": "bogus" });
let bad = find_opts_from_args(&bogus);
assert_eq!(bad.sort, FindSort::Name);
}
#[test]
fn find_opts_parses_line_count_options() {
let lined = serde_json::json!({ "show_lines": true, "sort": "lines" });
let opts = find_opts_from_args(&lined);
assert!(opts.show_lines);
assert_eq!(opts.sort, FindSort::Lines);
let empty = serde_json::json!({});
assert!(!find_opts_from_args(&empty).show_lines);
}
#[test]
fn find_opts_parse_harness_source_category_and_language() {
let source = serde_json::json!({ "category": "source", "language": "C++" });
let opts = find_opts_from_args(&source);
assert_eq!(opts.category, FindCategory::Source);
assert_eq!(opts.language, Some("C++"));
let empty = serde_json::json!({});
let defaults = find_opts_from_args(&empty);
assert_eq!(defaults.category, FindCategory::Any);
assert_eq!(defaults.language, None);
}
#[test]
fn finalize_find_line_sort_is_lines_descending_with_show_lines() {
let entries = vec![
(12, "short.rs".to_string()),
(4247, "huge.rs".to_string()),
(300, "mid.rs".to_string()),
];
let opts = FindOpts {
show_lines: true,
sort: FindSort::Lines,
..find_opts(1000, false, FindSort::Lines)
};
let (lines, _) = finalize_find(entries, &opts);
assert_eq!(
lines,
vec!["4247\thuge.rs", "300\tmid.rs", "12\tshort.rs"],
"line count descending, each line prefixed '<lines>\\t<path>'"
);
}
#[test]
fn count_newlines_matches_wc_l_semantics() {
assert_eq!(count_newlines(b"a\nb\nc\n"), 3);
assert_eq!(
count_newlines(b"a\nb"),
1,
"trailing partial line uncounted"
);
assert_eq!(count_newlines(b""), 0);
assert_eq!(count_newlines(b"no newline at all"), 0);
}
#[test]
fn finalize_find_name_sort_is_paths_ascending() {
let entries = vec![
(10, "src/b.rs".to_string()),
(99, "src/a.rs".to_string()),
(1, "src/c.rs".to_string()),
];
let (lines, truncated) = finalize_find(entries, &find_opts(1000, false, FindSort::Name));
assert_eq!(lines, vec!["src/a.rs", "src/b.rs", "src/c.rs"]);
assert!(!truncated, "under the cap");
}
#[test]
fn finalize_find_size_sort_is_bytes_descending_with_show_size() {
let entries = vec![
(10, "small.rs".to_string()),
(900, "big.rs".to_string()),
(50, "mid.rs".to_string()),
];
let (lines, _) = finalize_find(entries, &find_opts(1000, true, FindSort::Size));
assert_eq!(
lines,
vec!["900\tbig.rs", "50\tmid.rs", "10\tsmall.rs"],
"byte size descending, each line prefixed '<size>\\t<path>'"
);
}
#[test]
fn finalize_find_size_ties_break_by_path_for_determinism() {
let entries = vec![(42, "z.rs".to_string()), (42, "a.rs".to_string())];
let (lines, _) = finalize_find(entries, &find_opts(1000, false, FindSort::Size));
assert_eq!(lines, vec!["a.rs", "z.rs"], "equal sizes → path ascending");
}
#[test]
fn finalize_find_size_sort_truncates_to_true_top_n() {
let entries = vec![
(1, "a".to_string()),
(100, "b".to_string()),
(50, "c".to_string()),
(200, "d".to_string()),
];
let (lines, truncated) = finalize_find(entries, &find_opts(2, true, FindSort::Size));
assert_eq!(lines, vec!["200\td", "100\tb"]);
assert!(truncated, "two matches dropped past the cap");
}
#[test]
fn finalize_find_dedups_by_path() {
let entries = vec![
(10, "dup.rs".to_string()),
(10, "dup.rs".to_string()),
(20, "other.rs".to_string()),
];
let (lines, _) = finalize_find(entries, &find_opts(1000, false, FindSort::Name));
assert_eq!(lines, vec!["dup.rs", "other.rs"]);
}
#[derive(Default)]
pub(super) struct RecordingLiveOutput {
pub(in crate::agentic::tools) events: std::sync::Mutex<Vec<String>>,
}
impl crate::agentic::LiveToolOutput for RecordingLiveOutput {
fn start(&self, _generation: u64) {
self.events.lock().unwrap().push("start".into());
}
fn write(&self, _generation: u64, stream: crate::agentic::ToolOutputStream, chunk: &[u8]) {
self.events
.lock()
.unwrap()
.push(format!("{stream:?}:{}", String::from_utf8_lossy(chunk)));
}
fn finish(&self, _generation: u64) {
self.events.lock().unwrap().push("finish".into());
}
fn abandon(&self, _generation: u64) {
self.events.lock().unwrap().push("abandon".into());
}
}
#[test]
fn find_live_stream_emits_start_incremental_writes_then_finish() {
let sink = std::sync::Arc::new(RecordingLiveOutput::default());
let mut session = LiveOutputSession::start(Some(sink.clone())).expect("live session");
let relay = session.relay();
let on_hit = |line: &str| {
let mut chunk = line.as_bytes().to_vec();
chunk.push(b'\n');
relay.write(crate::agentic::ToolOutputStream::Stdout, &chunk);
};
for hit in ["src/b.rs", "src/a.rs", "src/c.rs"] {
on_hit(hit);
}
session.finish();
assert_eq!(
*sink.events.lock().unwrap(),
[
"start",
"Stdout:src/b.rs\n",
"Stdout:src/a.rs\n",
"Stdout:src/c.rs\n",
"finish"
]
);
}
#[test]
fn find_live_stream_hit_after_finish_is_dropped() {
let sink = std::sync::Arc::new(RecordingLiveOutput::default());
let mut session = LiveOutputSession::start(Some(sink.clone())).expect("live session");
let relay = session.relay();
relay.write(crate::agentic::ToolOutputStream::Stdout, b"early.rs\n");
session.finish();
relay.write(crate::agentic::ToolOutputStream::Stdout, b"late.rs\n");
assert_eq!(
*sink.events.lock().unwrap(),
["start", "Stdout:early.rs\n", "finish"],
"a post-finish hit must be a no-op"
);
}
#[test]
fn dropping_live_output_session_finishes_before_returning() {
struct FinishSignal {
finished: std::sync::mpsc::Sender<()>,
abandoned: std::sync::mpsc::Sender<()>,
}
impl crate::agentic::LiveToolOutput for FinishSignal {
fn start(&self, _generation: u64) {}
fn write(
&self,
_generation: u64,
_stream: crate::agentic::ToolOutputStream,
_chunk: &[u8],
) {
}
fn finish(&self, _generation: u64) {
let _ = self.finished.send(());
}
fn abandon(&self, _generation: u64) {
let _ = self.abandoned.send(());
}
}
let (finished_tx, finished_rx) = std::sync::mpsc::channel();
let (abandoned_tx, abandoned_rx) = std::sync::mpsc::channel();
let session = LiveOutputSession::start(Some(std::sync::Arc::new(FinishSignal {
finished: finished_tx,
abandoned: abandoned_tx,
})))
.unwrap();
drop(session);
finished_rx
.try_recv()
.expect("drop closed the live frame synchronously");
assert!(
abandoned_rx.try_recv().is_err(),
"a responsive sink should finish rather than be abandoned"
);
}
#[cfg(not(windows))]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn blocked_live_sink_cannot_delay_host_timeout() {
struct BlockingOutput {
entered: std::sync::mpsc::Sender<()>,
release: std::sync::Mutex<std::sync::mpsc::Receiver<()>>,
}
impl crate::agentic::LiveToolOutput for BlockingOutput {
fn start(&self, _generation: u64) {}
fn write(
&self,
_generation: u64,
_stream: crate::agentic::ToolOutputStream,
_chunk: &[u8],
) {
let _ = self.entered.send(());
let _ = self.release.lock().unwrap().recv();
}
fn finish(&self, _generation: u64) {}
fn abandon(&self, _generation: u64) {}
}
let (entered_tx, entered_rx) = std::sync::mpsc::channel();
let (release_tx, release_rx) = std::sync::mpsc::channel();
let sink = std::sync::Arc::new(BlockingOutput {
entered: entered_tx,
release: std::sync::Mutex::new(release_rx),
});
let mut session = LiveOutputSession::start(Some(sink)).unwrap();
let relay = session.relay();
let run = tokio::spawn(async move {
host_shell_output_with_timeout(
"printf ready; sleep 5",
".",
Some(relay),
std::time::Duration::from_millis(100),
)
.await
});
tokio::task::spawn_blocking(move || {
entered_rx.recv_timeout(std::time::Duration::from_secs(1))
})
.await
.unwrap()
.expect("renderer entered its blocking write");
let outcome = tokio::time::timeout(std::time::Duration::from_secs(1), run).await;
if outcome.is_err() {
let _ = release_tx.send(());
panic!("blocked presentation defeated the host timeout");
}
let run = outcome.unwrap().unwrap().unwrap();
assert!(run.timed_out);
assert_eq!(run.exit_code, 124);
session.cancel();
release_tx.send(()).unwrap();
}
#[cfg(not(windows))]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn blocked_live_sink_cannot_backpressure_host_pipe_capture() {
struct BlockingOutput {
entered: std::sync::mpsc::Sender<()>,
release: std::sync::Mutex<std::sync::mpsc::Receiver<()>>,
}
impl crate::agentic::LiveToolOutput for BlockingOutput {
fn start(&self, _generation: u64) {}
fn write(
&self,
_generation: u64,
_stream: crate::agentic::ToolOutputStream,
_chunk: &[u8],
) {
let _ = self.entered.send(());
let _ = self.release.lock().unwrap().recv();
}
fn finish(&self, _generation: u64) {}
fn abandon(&self, _generation: u64) {}
}
let (entered_tx, entered_rx) = std::sync::mpsc::channel();
let (release_tx, release_rx) = std::sync::mpsc::channel();
let sink = std::sync::Arc::new(BlockingOutput {
entered: entered_tx,
release: std::sync::Mutex::new(release_rx),
});
let mut session = LiveOutputSession::start(Some(sink)).unwrap();
let relay = session.relay();
let run = tokio::spawn(async move {
host_shell_output_with_timeout(
"head -c 262144 /dev/zero",
".",
Some(relay),
std::time::Duration::from_secs(5),
)
.await
});
tokio::task::spawn_blocking(move || {
entered_rx.recv_timeout(std::time::Duration::from_secs(1))
})
.await
.unwrap()
.expect("renderer entered its blocking write");
let outcome = tokio::time::timeout(std::time::Duration::from_secs(2), run).await;
if outcome.is_err() {
let _ = release_tx.send(());
panic!("blocked presentation backpressured host pipe capture");
}
let run = outcome.unwrap().unwrap().unwrap();
assert!(!run.timed_out);
assert_eq!(run.exit_code, 0);
assert_eq!(run.stdout.len(), 262_144);
session.cancel();
release_tx.send(()).unwrap();
}
#[cfg(not(windows))]
#[tokio::test(flavor = "multi_thread")]
async fn host_bypass_publishes_output_before_command_completion() {
struct ChannelOutput(std::sync::mpsc::Sender<Vec<u8>>);
impl crate::agentic::LiveToolOutput for ChannelOutput {
fn start(&self, _generation: u64) {}
fn write(
&self,
_generation: u64,
_stream: crate::agentic::ToolOutputStream,
chunk: &[u8],
) {
let _ = self.0.send(chunk.to_vec());
}
fn finish(&self, _generation: u64) {}
fn abandon(&self, _generation: u64) {}
}
let (tx, rx) = std::sync::mpsc::channel();
let sink = std::sync::Arc::new(ChannelOutput(tx));
let session = LiveOutputSession::start(Some(sink)).unwrap();
let relay = session.relay();
let handle = tokio::spawn(async move {
host_shell_output("printf ready; sleep 0.2; printf done", ".", Some(relay)).await
});
let first =
tokio::task::spawn_blocking(move || rx.recv_timeout(std::time::Duration::from_secs(2)))
.await
.unwrap()
.expect("first live host-shell chunk");
assert_eq!(first, b"ready");
assert!(
!handle.is_finished(),
"command completed before its live chunk"
);
let run = handle.await.unwrap().unwrap();
assert_eq!(run.stdout, b"readydone");
assert_eq!(run.exit_code, 0);
}
#[cfg(not(windows))]
#[tokio::test]
async fn bridled_shell_forwards_live_bytes_without_changing_the_envelope() {
let sink = std::sync::Arc::new(RecordingLiveOutput::default());
let caveats = crate::caveats::Caveats {
exec: crate::caveats::Scope::only(["echo".to_string()]),
..crate::caveats::Caveats::top()
};
let envelope = dispatch_bridled_shell(
serde_json::json!({"cmd": "echo observed", "cwd": "."}),
&caveats,
Some(sink.clone()),
)
.await
.expect("confined echo dispatch");
assert_eq!(envelope["stdout"], "observed\n");
assert_eq!(
*sink.events.lock().unwrap(),
["start", "Stdout:observed\n", "finish"]
);
}
#[test]
fn classify_phantom_rewrite_alias() {
let got = classify_phantom_reach("bash", &serde_json::json!({"command": "ls"}), "ok", true);
assert_eq!(
got,
Some(crate::PhantomResolution::Rewrite("run_command".into()))
);
}
#[test]
fn classify_phantom_correct_alias() {
let got = classify_phantom_reach(
"str_replace_editor",
&serde_json::json!({}),
"ignored",
false,
);
match got {
Some(crate::PhantomResolution::Correct(msg)) => {
assert!(msg.contains("edit_file"), "guidance names the tool: {msg}");
}
other => panic!("expected Correct, got {other:?}"),
}
}
#[test]
fn classify_phantom_unknown_name() {
let got = classify_phantom_reach(
"summon_kraken",
&serde_json::json!({}),
"unknown tool: summon_kraken",
false,
);
assert_eq!(got, Some(crate::PhantomResolution::Unknown));
}
#[test]
fn classify_phantom_plan_alias_is_correct() {
let got = classify_phantom_reach("make_plan", &serde_json::json!({}), "ignored", false);
match got {
Some(crate::PhantomResolution::Correct(msg)) => {
assert!(
msg.contains("update_plan"),
"guidance names the tool: {msg}"
);
}
other => panic!("expected Correct, got {other:?}"),
}
}
#[test]
fn classify_phantom_state_get_miss() {
let got = classify_phantom_reach(
"state_get",
&serde_json::json!({"key": "nope"}),
"no such key: nope",
true,
);
assert_eq!(
got,
Some(crate::PhantomResolution::RealToolMiss(
"state_get on an unset key".into()
))
);
}
#[test]
fn classify_phantom_recall_miss() {
let got = classify_phantom_reach(
"recall",
&serde_json::json!({"query": "zzz"}),
"no matches in past conversations for \"zzz\" — try different keywords",
true,
);
assert_eq!(
got,
Some(crate::PhantomResolution::RealToolMiss(
"recall returned no matches".into()
))
);
}
#[test]
fn classify_phantom_resume_reach_is_a_rewrite() {
let got = classify_phantom_reach("where_were_we", &serde_json::json!({}), "ignored", false);
assert_eq!(
got,
Some(crate::PhantomResolution::Rewrite("resume_context".into()))
);
}
#[test]
fn classify_phantom_real_success_is_none() {
let got = classify_phantom_reach(
"read_file",
&serde_json::json!({"path": "src/lib.rs"}),
"line 1\nline 2\n",
true,
);
assert_eq!(got, None);
}
#[test]
fn tool_search_is_a_real_tool_name() {
assert!(ALL_TOOL_NAMES.contains(&"tool_search"));
}
#[test]
fn discovery_verbs_alias_to_tool_search() {
for verb in [
"find_tool",
"search_tools",
"list_tools",
"which_tool",
"available_tools",
"what_tools",
"tools",
] {
match resolve_tool_alias(verb) {
Some(AliasOutcome::Rewrite(c)) => assert_eq!(c, "tool_search", "verb: {verb}"),
other => panic!(
"expected Rewrite(tool_search) for {verb}, got something else: {}",
other.is_some()
),
}
}
}
#[test]
fn tool_search_is_not_an_alias_of_itself() {
assert!(resolve_tool_alias("tool_search").is_none());
}
#[test]
fn classify_phantom_discovery_reach_is_a_rewrite() {
let got = classify_phantom_reach("find_tool", &serde_json::json!({}), "ignored", false);
assert_eq!(
got,
Some(crate::PhantomResolution::Rewrite("tool_search".into()))
);
}
#[test]
fn classify_phantom_tool_search_real_call_is_none() {
let got = classify_phantom_reach(
"tool_search",
&serde_json::json!({"query": "read"}),
"Tools matching \"read\":\n- read_file — Read a file",
true,
);
assert_eq!(got, None);
}
#[test]
fn tool_search_is_not_a_hallucination() {
assert!(!is_hallucination(
"tool_search",
&serde_json::json!({"query": "x"})
));
}
#[test]
fn paginate_read_caps_a_large_file_to_the_default_window() {
let body: String = (1..=15_057)
.map(|n| format!("line {n}"))
.collect::<Vec<_>>()
.join("\n");
let out = paginate_read(&body, None, None, DEFAULT_MAX_OUTPUT_TOKENS);
let lines: Vec<&str> = out.lines().collect();
assert_eq!(lines[0], "line 1");
assert_eq!(lines[1999], "line 2000");
assert!(
!out.contains("line 2001"),
"window stops at 2000: {:?}",
&out[..40]
);
assert!(out.contains("of 15057"), "footer names the total");
assert!(
out.contains("offset=2001"),
"footer points at the next window"
);
}
#[test]
fn paginate_read_offset_and_limit_return_just_that_window() {
let body: String = (1..=100)
.map(|n| format!("L{n}"))
.collect::<Vec<_>>()
.join("\n");
let out = paginate_read(&body, Some(10), Some(5), DEFAULT_MAX_OUTPUT_TOKENS);
assert!(out.starts_with("L10\nL11\nL12\nL13\nL14"), "{out:?}");
assert!(out.contains("offset=15"), "continues at line 15: {out:?}");
}
#[test]
fn paginate_read_small_file_is_returned_verbatim_without_a_footer() {
assert_eq!(
paginate_read("a\nb\nc\n", None, None, DEFAULT_MAX_OUTPUT_TOKENS),
"a\nb\nc\n"
);
}
#[test]
fn paginate_read_char_backstop_tracks_the_token_budget() {
let budget = 1_000;
let max_chars = crate::tokens::TokenEstimation::default().chars_for_tokens(budget);
let body = "x".repeat(50_000);
let out = paginate_read(&body, None, None, budget);
assert!(
out.len() < max_chars + 300,
"char-capped to the token budget (~{max_chars} chars): {} bytes",
out.len()
);
assert!(out.contains("truncated"), "marks the truncation");
assert!(
out.contains("~1000 tokens"),
"footer names the token budget: {out:?}"
);
let wide = paginate_read(&body, None, None, 4_000);
assert!(
wide.len() > out.len(),
"a wider token budget keeps more chars: {} vs {}",
wide.len(),
out.len()
);
}
#[test]
fn paginate_read_zero_budget_disables_the_char_backstop() {
let body = "y".repeat(500_000);
let out = paginate_read(&body, None, None, 0);
assert_eq!(out, body, "zero budget = no char backstop");
}
#[test]
fn paginate_read_offset_past_end_is_a_clear_message() {
let out = paginate_read("a\nb", Some(99), None, DEFAULT_MAX_OUTPUT_TOKENS);
assert!(out.contains("past end"), "{out:?}");
}
#[test]
fn cap_model_output_passes_small_output_through_unchanged() {
let small = "hello\nworld\n";
assert_eq!(cap_model_output(small, DEFAULT_MAX_OUTPUT_TOKENS), small);
}
#[test]
fn cap_model_output_truncates_over_budget_as_head_tail() {
let big = format!("HEAD_MARKER\n{}\nTAIL_MARKER", "middle\n".repeat(20_000));
let out = cap_model_output_with_handle(&big, 1_000, 100, None);
assert!(out.len() < big.len(), "must shrink: {} bytes", out.len());
assert!(out.contains("HEAD_MARKER"), "head dropped: {out:?}");
assert!(out.contains("TAIL_MARKER"), "tail dropped: {out:?}");
assert!(out.contains("head+tail shown"), "marker present: {out:?}");
assert!(
!out.contains(&"middle\n".repeat(1_000)),
"middle should be elided"
);
}
#[test]
fn cap_model_output_truncates_at_a_char_boundary() {
let budget = 10; let body = "é".repeat(1_000); let out = cap_model_output(&body, budget);
assert!(out.is_char_boundary(out.len()), "valid boundary");
assert!(
out.chars()
.all(|c| c == 'é' || !c.is_control() || c == '\n'),
"no split char: {out:?}"
);
}
#[test]
fn cap_model_output_zero_budget_is_no_cap() {
let body = "z".repeat(500_000);
assert_eq!(cap_model_output(&body, 0), body);
}
#[test]
fn token_to_char_math_uses_the_default_four_chars_per_token() {
let est = crate::tokens::TokenEstimation::default();
assert_eq!(est.chars_for_tokens(DEFAULT_MAX_OUTPUT_TOKENS), 40_000);
}
#[test]
fn output_cap_sizes_at_the_conservative_ratio_not_the_estimate() {
let cap = crate::tokens::TokenEstimation::new(DEFAULT_OUTPUT_CAP_CHARS_PER_TOKEN);
assert_eq!(cap.chars_for_tokens(DEFAULT_MAX_OUTPUT_TOKENS), 30_000);
assert!(
cap.chars_for_tokens(DEFAULT_MAX_OUTPUT_TOKENS)
< crate::tokens::TokenEstimation::default()
.chars_for_tokens(DEFAULT_MAX_OUTPUT_TOKENS),
"cap must be tighter than the estimate"
);
}
#[test]
fn cap_model_output_caps_dense_body_the_estimate_would_pass() {
let body = "x".repeat(35_000);
let out = cap_model_output(&body, DEFAULT_MAX_OUTPUT_TOKENS);
assert!(
out.len() < body.len(),
"conservative cap must truncate a 35k-char body at a 10k-token budget \
(old 4-c/t backstop of 40k would have passed it); got {} bytes",
out.len()
);
assert!(
out.contains("head+tail shown"),
"cap marker present: {out:?}"
);
}
#[test]
fn find_detail_bare_path_has_no_filters() {
let opts = FindOpts {
name: None,
type_filter: FindType::Any,
category: FindCategory::Any,
language: None,
max_depth: None,
max_results: 1000,
respect_gitignore: true,
case_sensitive: true,
show_size: false,
show_lines: false,
sort: FindSort::Name,
};
assert_eq!(find_detail(".", &opts), ".");
}
#[test]
fn find_detail_shows_only_non_default_filters() {
let opts = FindOpts {
name: Some("*.rs"),
type_filter: FindType::Files,
category: FindCategory::Any,
language: None,
max_depth: Some(2),
max_results: 50,
respect_gitignore: false,
case_sensitive: false,
show_size: false,
show_lines: false,
sort: FindSort::Name,
};
assert_eq!(
find_detail("src", &opts),
"src (name=*.rs, type=f, depth=2, max=50, no-gitignore, icase)"
);
}
#[test]
fn find_detail_omits_each_default_independently() {
let opts = FindOpts {
name: None,
type_filter: FindType::Dirs,
category: FindCategory::Any,
language: None,
max_depth: None,
max_results: 1000,
respect_gitignore: true,
case_sensitive: true,
show_size: false,
show_lines: false,
sort: FindSort::Name,
};
assert_eq!(find_detail(".", &opts), ". (type=d)");
}
#[test]
fn find_detail_notes_the_size_column_and_size_sort() {
let opts = FindOpts {
name: Some("*.rs"),
type_filter: FindType::Files,
category: FindCategory::Any,
language: None,
max_depth: None,
max_results: 10,
respect_gitignore: true,
case_sensitive: true,
show_size: true,
show_lines: false,
sort: FindSort::Size,
};
assert_eq!(
find_detail(".", &opts),
". (name=*.rs, type=f, max=10, sort=size, size)"
);
}
#[test]
fn find_detail_notes_the_line_column_and_line_sort() {
let opts = FindOpts {
name: Some("*.rs"),
type_filter: FindType::Files,
category: FindCategory::Any,
language: None,
max_depth: None,
max_results: 10,
respect_gitignore: true,
case_sensitive: true,
show_size: false,
show_lines: true,
sort: FindSort::Lines,
};
assert_eq!(
find_detail(".", &opts),
". (name=*.rs, type=f, max=10, sort=lines, lines)"
);
}
#[test]
fn find_detail_notes_the_source_category_filter() {
let opts = FindOpts {
name: None,
type_filter: FindType::Files,
max_depth: None,
max_results: 10,
respect_gitignore: true,
case_sensitive: true,
show_size: false,
show_lines: true,
category: FindCategory::Source,
language: None,
sort: FindSort::Lines,
};
assert_eq!(
find_detail(".", &opts),
". (type=f, category=source, max=10, sort=lines, lines)"
);
}
#[test]
fn use_skill_tool_is_advertised_in_definitions() {
let defs = tool_definitions();
let names: Vec<&str> = defs
.as_array()
.unwrap()
.iter()
.filter_map(|d| d["function"]["name"].as_str())
.collect();
assert!(names.contains(&"use_skill"), "got: {names:?}");
}
#[test]
fn merged_tool_definitions_with_empty_mcp_is_builtin_set() {
let merged = merged_tool_definitions(
&NoMcp, false, false, false, false, false, false, false, false, false, false, false,
false,
);
let names: Vec<&str> = merged
.as_array()
.unwrap()
.iter()
.filter_map(|d| d["function"]["name"].as_str())
.collect();
assert_eq!(
names,
vec![
"run_command",
"read_file",
"write_file",
"edit_file",
"delete_file",
"list_dir",
"find",
"use_skill",
"web_fetch",
"request_permissions",
"resume_context",
"prompt_read",
"artifact_read",
"tool_search",
"get_context_remaining",
"request_user_input",
"lifecycle",
"render_report",
"where_is",
"goto_definition",
"text_search",
"find_references",
"find_tests",
"find_callers",
"find_callees",
"find_implementations",
"find_hierarchy",
"inspect_type",
"impact",
]
);
}
#[test]
fn persona_allow_list_filters_the_advertised_catalog() {
let full = merged_tool_definitions(
&NoMcp, true, true, true, true, true, true, true, true, true, true, true, true,
);
let name_set = |v: &serde_json::Value| -> Vec<String> {
v.as_array()
.unwrap()
.iter()
.filter_map(|d| d["function"]["name"].as_str().map(str::to_owned))
.collect()
};
assert_eq!(
name_set(&filter_advertised_tools(full.clone(), None)),
name_set(&full),
"None must be a no-op"
);
let allow = vec!["read_file".to_string()];
let got = name_set(&filter_advertised_tools(full, Some(&allow)));
assert!(got.iter().any(|n| n == "read_file"), "granted tool kept");
for denied in [
"write_file",
"edit_file",
"delete_file",
"run_command",
"list_dir",
] {
assert!(
!got.iter().any(|n| n == denied),
"{denied} must be filtered out"
);
}
for infra in [
"resume_context",
"prompt_read",
"tool_search",
"get_context_remaining",
"request_user_input",
"lifecycle",
"select_operating_mode",
] {
assert!(
got.iter().any(|n| n == infra),
"{infra} is session infrastructure and must survive any persona"
);
}
}
#[test]
fn persona_tool_allowed_admits_named_and_always_on_only() {
let allow = vec!["read_file".to_string()];
assert!(persona_tool_allowed("read_file", &allow), "named → allowed");
assert!(
persona_tool_allowed("request_user_input", &allow),
"always-on infra → allowed even when unlisted"
);
assert!(
persona_tool_allowed("select_operating_mode", &allow),
"presence-gated session control → allowed even when unlisted"
);
assert!(
!persona_tool_allowed("write_file", &allow),
"unlisted non-infra → denied"
);
assert!(
!persona_tool_allowed("delete_file", &allow),
"unlisted non-infra → denied"
);
}
#[test]
fn prompt_disposition_filters_catalog_and_unknown_names_fail_closed() {
let defs = serde_json::json!([
{ "type": "function", "function": { "name": "read_file" } },
{ "type": "function", "function": { "name": "write_file" } },
{ "type": "function", "function": { "name": "run_command" } },
{ "type": "function", "function": { "name": "update_plan" } },
{ "type": "function", "function": { "name": "exit_plan_mode" } },
{ "type": "function", "function": { "name": "select_operating_mode" } },
{ "type": "function", "function": { "name": "request_permissions" } },
{ "type": "function", "function": { "name": "incident__read" } },
{ "not": "a callable definition" }
]);
let names = |defs: &serde_json::Value| {
defs.as_array()
.unwrap()
.iter()
.filter_map(|def| def["function"]["name"].as_str())
.map(str::to_owned)
.collect::<Vec<_>>()
};
let research = filter_tools_for_disposition(defs.clone(), PromptDisposition::Research);
assert_eq!(names(&research), vec!["read_file", "select_operating_mode"]);
let plan = filter_tools_for_disposition(defs.clone(), PromptDisposition::Plan);
assert_eq!(
names(&plan),
vec![
"read_file",
"update_plan",
"exit_plan_mode",
"select_operating_mode"
]
);
assert!(tool_allowed(PromptDisposition::Explain, "read_file"));
assert!(tool_allowed(PromptDisposition::Plan, "update_plan"));
assert!(!tool_allowed(PromptDisposition::Explain, "update_plan"));
assert!(!tool_allowed(PromptDisposition::Research, "update_plan"));
assert!(tool_allowed(PromptDisposition::Plan, "exit_plan_mode"));
assert!(
!tool_allowed(PromptDisposition::Plan, "web_fetch"),
"offline Plan must not advertise a tool its caveat always denies"
);
assert!(
tool_allowed(PromptDisposition::Research, "web_fetch"),
"Research (including Diagnose) may gather remote read-only evidence"
);
assert!(tool_allowed(
PromptDisposition::Research,
"select_operating_mode"
));
assert!(tool_allowed(
PromptDisposition::Plan,
"select_operating_mode"
));
assert!(!tool_allowed(
PromptDisposition::Ask,
"select_operating_mode"
));
assert!(!tool_allowed(PromptDisposition::Explain, "write_file"));
assert!(!tool_allowed(PromptDisposition::Research, "incident__read"));
assert!(!tool_allowed(PromptDisposition::Ask, "read_file"));
assert!(tool_allowed(PromptDisposition::Act, "incident__write"));
assert!(tool_allowed(PromptDisposition::Explain, "find"));
assert!(tool_allowed(PromptDisposition::Research, "find"));
let research_catalog = filter_tools_for_disposition(
merged_tool_definitions(
&NoMcp, false, false, false, false, false, false, false, false, false, false,
false, false,
),
PromptDisposition::Research,
);
let find_def = research_catalog
.as_array()
.into_iter()
.flatten()
.find(|d| d["function"]["name"].as_str() == Some("find"))
.expect("Research must advertise find");
let props = &find_def["function"]["parameters"]["properties"];
assert!(
props.get("show_lines").is_some(),
"Research find schema must expose show_lines: {find_def}"
);
assert!(
props.get("code").is_some(),
"Research find schema must expose code (source-only filter): {find_def}"
);
let desc = find_def["function"]["description"].as_str().unwrap_or("");
assert!(
desc.contains("category") && desc.contains("source"),
"find description must teach category=source for source rankings: {desc}"
);
let sort_enum = props["sort"]["enum"]
.as_array()
.expect("sort must be an enum");
assert!(
sort_enum.iter().any(|v| v.as_str() == Some("lines")),
"Research find sort enum must include 'lines': {sort_enum:?}"
);
assert!(
props.get("category").is_some() && props.get("language").is_some(),
"Research find schema must teach the harness source category + language filter: \
{find_def}"
);
assert!(
find_def["function"]["description"]
.as_str()
.is_some_and(|description| {
description.contains("repository code investigation")
&& description.contains("source by default")
}),
"the tool catalog must reinforce the standing source-first repository policy: \
{find_def}"
);
assert!(tool_allowed(
PromptDisposition::Explain,
"request_user_input"
));
assert!(tool_allowed(
PromptDisposition::Research,
"request_user_input"
));
assert!(!tool_allowed(
PromptDisposition::Explain,
"request_permissions"
));
assert!(!tool_allowed(
PromptDisposition::Research,
"request_permissions"
));
assert_eq!(
filter_tools_for_disposition(
serde_json::json!({ "not": "a catalog" }),
PromptDisposition::Research
),
serde_json::json!([]),
"a non-Act catalog with no enumerable tool names must fail closed"
);
assert_eq!(
filter_tools_for_disposition(defs.clone(), PromptDisposition::Act),
defs
);
}
#[tokio::test]
async fn executor_refuses_tools_outside_the_persona_allow_list() {
let ws = tempfile::TempDir::new().unwrap();
let caveats = crate::caveats::Caveats::top();
let allow = vec!["read_file".to_string()];
let target = ws.path().join("blocked.txt");
let args = serde_json::json!({
"path": target.to_string_lossy(),
"content": "should never be written",
});
let out = call_offload("write_file", &args, &ws, &caveats, Some(&allow)).await;
assert!(
out.contains("not available under the active persona"),
"expected persona refusal, got: {out}"
);
assert!(!target.exists(), "a denied write must not touch the fs");
let infra = call_offload(
"get_context_remaining",
&serde_json::json!({}),
&ws,
&caveats,
Some(&allow),
)
.await;
assert!(
!infra.contains("not available under the active persona"),
"always-on infra must not be refused: {infra}"
);
}
#[tokio::test]
async fn executor_enforces_the_absolute_deny_list() {
let ws = tempfile::TempDir::new().unwrap();
let caveats = crate::caveats::Caveats::top(); let denied = call_offload(
"run_command",
&serde_json::json!({ "command": "ssh host 'uptime'" }),
&ws,
&caveats,
None, )
.await;
assert!(
denied.contains("absolute deny-list"),
"ssh must hit the deny-list, got: {denied}"
);
let ok = call_offload(
"run_command",
&serde_json::json!({ "command": "echo coaching" }),
&ws,
&caveats,
None,
)
.await;
assert!(
!ok.contains("absolute deny-list"),
"an ordinary command must not be denied, got: {ok}"
);
}
async fn call_offload(
name: &str,
args: &serde_json::Value,
ws: &tempfile::TempDir,
caveats: &crate::caveats::Caveats,
persona_tools: Option<&[String]>,
) -> String {
execute_tool_with_offload(
name,
args,
&ws.path().to_string_lossy(),
false,
20,
caveats,
&mut NoMcp,
None, None, None, None, None, None, None, None, None, None, None, None, None, false, None, persona_tools,
)
.await
}
#[test]
fn registry_specs_match_their_definition_names() {
for spec in EXTENDED_TOOL_REGISTRY {
let def = (spec.definition)();
assert_eq!(
def["function"]["name"].as_str(),
Some(spec.name),
"ToolSpec name {:?} != definition name",
spec.name
);
}
}
#[test]
fn builtin_tool_names_are_unique() {
let mut seen = std::collections::HashSet::new();
for name in ALL_TOOL_NAMES.iter() {
assert!(seen.insert(*name), "duplicate built-in tool name: {name}");
}
}
#[test]
fn advertised_set_matches_all_tool_names_both_directions() {
let all = merged_tool_definitions(
&NoMcp, true, true, true, true, true, true, true, true, true, true, true, true,
);
let advertised: std::collections::HashSet<&str> = all
.as_array()
.unwrap()
.iter()
.filter_map(|d| d["function"]["name"].as_str())
.collect();
let names: std::collections::HashSet<&str> = ALL_TOOL_NAMES.iter().copied().collect();
for a in &advertised {
assert!(
names.contains(a),
"advertised but not in ALL_TOOL_NAMES: {a}"
);
}
for n in &names {
assert!(
advertised.contains(n),
"in ALL_TOOL_NAMES but never advertised: {n}"
);
}
}
#[test]
fn base_tool_names_match_tool_definitions() {
let defs = tool_definitions();
let base: Vec<&str> = defs
.as_array()
.unwrap()
.iter()
.filter_map(|d| d["function"]["name"].as_str())
.collect();
assert_eq!(base, BASE_TOOL_NAMES);
}
#[test]
fn lifecycle_is_a_real_tool_name_not_a_hallucination() {
assert!(
ALL_TOOL_NAMES.contains(&"lifecycle"),
"lifecycle must be a real tool name"
);
assert!(
!is_hallucination("lifecycle", &serde_json::json!({"phase": "test"})),
"a real lifecycle call must not be flagged as a hallucination"
);
}
#[test]
fn lifecycle_definition_enum_matches_phase_vocabulary() {
let def = lifecycle_tool_definition();
assert_eq!(def["function"]["name"], "lifecycle");
let enum_vals: Vec<&str> = def["function"]["parameters"]["properties"]["phase"]["enum"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap())
.collect();
let vocab: Vec<&str> = crate::tooling::Phase::ALL
.iter()
.map(|p| p.as_str())
.collect();
assert_eq!(enum_vals, vocab);
}
#[test]
fn run_phase_aliases_route_to_lifecycle() {
for a in ["run_phase", "run_lifecycle", "lifecycle_run"] {
assert!(
matches!(
resolve_tool_alias(a),
Some(AliasOutcome::Rewrite("lifecycle"))
),
"{a} should rewrite to lifecycle"
);
}
assert!(resolve_tool_alias("lifecycle").is_none());
}
#[tokio::test]
async fn lifecycle_unknown_phase_lists_valid_phases() {
let caveats = crate::caveats::Caveats::top();
let args = serde_json::json!({ "phase": "deploy" });
let out = execute_tool(
"lifecycle",
&args,
".",
false,
20,
&caveats,
&mut NoMcp,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
)
.await;
assert!(
out.starts_with("error: unknown lifecycle phase 'deploy'"),
"{out}"
);
assert!(out.contains("check"), "should name valid phases: {out}");
}
#[test]
fn save_note_advertised_only_with_a_sink() {
fn names(defs: &serde_json::Value) -> Vec<&str> {
defs.as_array()
.unwrap()
.iter()
.filter_map(|d| d["function"]["name"].as_str())
.collect()
}
let base = tool_definitions();
assert!(!names(&base).contains(&"save_note"), "got: {base}");
let without = merged_tool_definitions(
&NoMcp, false, false, false, false, false, false, false, false, false, false, false,
false,
);
assert!(!names(&without).contains(&"save_note"));
let with = merged_tool_definitions(
&NoMcp, true, false, false, false, false, false, false, false, false, false, false,
false,
);
assert!(names(&with).contains(&"save_note"), "got: {with}");
}
#[test]
fn recall_advertised_only_with_a_source() {
fn names(defs: &serde_json::Value) -> Vec<&str> {
defs.as_array()
.unwrap()
.iter()
.filter_map(|d| d["function"]["name"].as_str())
.collect()
}
let base = tool_definitions();
assert!(!names(&base).contains(&"recall"), "got: {base}");
let without = merged_tool_definitions(
&NoMcp, false, false, false, false, false, false, false, false, false, false, false,
false,
);
assert!(!names(&without).contains(&"recall"));
let with = merged_tool_definitions(
&NoMcp, false, true, false, false, false, false, false, false, false, false, false,
false,
);
assert!(names(&with).contains(&"recall"), "got: {with}");
let both = merged_tool_definitions(
&NoMcp, true, true, false, false, false, false, false, false, false, false, false,
false,
);
assert!(names(&both).contains(&"save_note"));
assert!(names(&both).contains(&"recall"));
}
#[test]
fn memory_fetch_advertised_only_with_a_source() {
fn names(defs: &serde_json::Value) -> Vec<&str> {
defs.as_array()
.unwrap()
.iter()
.filter_map(|d| d["function"]["name"].as_str())
.collect()
}
let base = tool_definitions();
assert!(!names(&base).contains(&"memory_fetch"), "got: {base}");
let without = merged_tool_definitions(
&NoMcp, false, false, false, false, false, false, false, false, false, false, false,
false,
);
assert!(!names(&without).contains(&"memory_fetch"));
let with = merged_tool_definitions(
&NoMcp, false, false, true, false, false, false, false, false, false, false, false,
false,
);
assert!(names(&with).contains(&"memory_fetch"), "got: {with}");
let all = merged_tool_definitions(
&NoMcp, true, true, true, false, false, false, false, false, false, false, false, false,
);
assert!(names(&all).contains(&"save_note"));
assert!(names(&all).contains(&"recall"));
assert!(names(&all).contains(&"memory_fetch"));
}
#[test]
fn hallucination_detection_coverage() {
assert!(is_hallucination(
"run_command",
&serde_json::json!({"command": "list_dir ."})
));
assert!(!is_hallucination(
"run_command",
&serde_json::json!({"command": "cargo test"})
));
assert!(is_hallucination(
"definitely_not_a_real_tool",
&serde_json::json!({})
));
assert!(!is_hallucination(
"my_server__some_tool",
&serde_json::json!({})
));
for t in [
"list_dir",
"read_file",
"write_file",
"edit_file",
"delete_file",
"use_skill",
"web_fetch",
"save_note",
"recall",
] {
assert!(!is_hallucination(t, &serde_json::json!({"path": "."})));
}
}
#[test]
fn resolve_exec_cwd_confines_to_workspace() {
assert_eq!(resolve_exec_cwd("/ws", None), "/ws");
assert_eq!(resolve_exec_cwd("/ws", Some("")), "/ws");
assert_eq!(resolve_exec_cwd("/ws", Some(" ")), "/ws");
let joined = std::path::Path::new("/ws")
.join("crates/foo")
.to_string_lossy()
.into_owned();
assert_eq!(resolve_exec_cwd("/ws", Some("crates/foo")), joined);
let abs = if cfg!(windows) {
"C:\\ws\\sub"
} else {
"/ws/sub"
};
assert_eq!(resolve_exec_cwd("/ws", Some(abs)), abs);
let a = confined_dispatch_args("ls", &joined);
assert_eq!(a["cwd"], joined);
}
#[test]
fn split_leading_cd_folds_the_habitual_cd_prefix() {
let (path, rest) = split_leading_cd("cd /ws/newt-agent && git checkout -b x");
assert_eq!(path.as_deref(), Some("/ws/newt-agent"));
assert_eq!(rest, "git checkout -b x");
let (path, rest) = split_leading_cd("cd sub ; ls -la");
assert_eq!(path.as_deref(), Some("sub"));
assert_eq!(rest, "ls -la");
let (path, rest) = split_leading_cd("cd \"/a b/c\" && cargo test");
assert_eq!(path.as_deref(), Some("/a b/c"));
assert_eq!(rest, "cargo test");
let (path, rest) = split_leading_cd("cd a && cd b && ls");
assert_eq!(path.as_deref(), Some("a"));
assert_eq!(rest, "cd b && ls");
let (path, rest) = split_leading_cd("cd /somewhere");
assert_eq!(path.as_deref(), Some("/somewhere"));
assert!(rest.is_empty());
}
#[test]
fn split_leading_cd_leaves_non_cd_and_ambiguous_commands_whole() {
assert_eq!(split_leading_cd("git status"), (None, "git status".into()));
assert_eq!(split_leading_cd("cding foo"), (None, "cding foo".into()));
assert_eq!(
split_leading_cd("cd x | grep y"),
(None, "cd x | grep y".into())
);
}
#[test]
fn run_command_redirect_lets_git_network_ops_through() {
for cmd in [
"git push origin fix/foo",
"git push",
"git fetch origin",
"git pull",
"git clone https://example.com/r.git",
"git rm src/cockpit.rs",
] {
assert_eq!(run_command_redirect(cmd), None, "{cmd} must fall through");
}
for cmd in [
"git status",
"git log --oneline",
"git add .",
"git commit -m x",
] {
assert_eq!(
run_command_redirect(cmd),
Some("git"),
"{cmd} must redirect"
);
}
assert_eq!(run_command_redirect("read_file foo.txt"), Some("read_file"));
assert_eq!(run_command_redirect("list_dir ."), Some("list_dir"));
assert_eq!(run_command_redirect("cargo test"), None);
assert_eq!(run_command_redirect("gh pr create --fill"), None);
assert_eq!(run_command_redirect(""), None);
}
#[test]
fn run_command_redirect_passes_composed_commands_through() {
assert_eq!(
run_command_redirect(
"find . -name \"*.rs\" -type f -print0 | xargs -0 du -k | sort -rn | head 20"
),
None,
"a pipeline leading with `find` is not a misdirected find call"
);
assert_eq!(run_command_redirect("find . -name '*.log' > out.txt"), None);
assert_eq!(run_command_redirect("git status && git diff"), None);
assert_eq!(run_command_redirect("list_dir . ; echo done"), None);
assert_eq!(run_command_redirect("read_file $(pick_file)"), None);
assert_eq!(run_command_redirect("read_file `pick_file`"), None);
assert_eq!(run_command_redirect("find . -name \"*.rs\""), Some("find"));
assert_eq!(run_command_redirect("list_dir src"), Some("list_dir"));
assert_eq!(run_command_redirect("git status"), Some("git"));
}
#[test]
fn pipeline_is_never_counted_as_a_hallucination() {
assert!(!is_hallucination(
"run_command",
&serde_json::json!({"command":
"find . -name \"*.rs\" -type f -print0 | xargs -0 du -k | sort -rn | head 20"})
));
assert!(!is_hallucination(
"run_command",
&serde_json::json!({"command": "git status && git diff"})
));
assert!(is_hallucination(
"run_command",
&serde_json::json!({"command": "list_dir ."})
));
}
#[test]
fn is_hallucination_allows_git_network_ops() {
assert!(!is_hallucination(
"run_command",
&serde_json::json!({"command": "git push origin fix/foo"})
));
assert!(!is_hallucination(
"run_command",
&serde_json::json!({"command": "git fetch"})
));
assert!(!is_hallucination(
"run_command",
&serde_json::json!({"command": "git rm src/cockpit.rs"})
));
assert!(is_hallucination(
"run_command",
&serde_json::json!({"command": "git status"})
));
}
#[test]
fn pr_creation_url_extracts_github_and_gitlab() {
let github = "remote: Create a pull request for 'fix/foo' on GitHub by visiting:\n\
remote: https://github.com/OWNER/REPO/pull/new/fix/foo\n";
assert_eq!(
pr_creation_url(github),
Some("https://github.com/OWNER/REPO/pull/new/fix/foo")
);
let gitlab = "remote: To create a merge request for topic, visit:\n\
remote: https://gitlab.com/g/p/-/merge_requests/new?x=topic\n";
assert_eq!(
pr_creation_url(gitlab),
Some("https://gitlab.com/g/p/-/merge_requests/new?x=topic")
);
assert_eq!(pr_creation_url("Already up to date.\n"), None);
assert_eq!(
pr_creation_url("see https://github.com/OWNER/REPO/issues/1"),
None
);
}
#[test]
fn shell_envelope_output_appends_pr_hint_on_push() {
let push = serde_json::json!({
"exit_code": 0,
"stdout": "",
"stderr": "remote: Create a pull request for 'fix/foo' on GitHub by visiting:\n\
remote: https://github.com/OWNER/REPO/pull/new/fix/foo\n",
});
let out = shell_envelope_output(&push, 50, false, false, None, None);
assert!(out.contains("gh pr create --fill"), "hint missing: {out}");
assert!(
out.contains("https://github.com/OWNER/REPO/pull/new/fix/foo"),
"url dropped: {out}"
);
let plain = serde_json::json!({ "exit_code": 0, "stdout": "hello\n", "stderr": "" });
let out = shell_envelope_output(&plain, 50, false, false, None, None);
assert!(!out.contains("gh pr create"), "spurious hint: {out}");
assert_eq!(out, "hello\n");
}
#[test]
fn shell_envelope_output_spills_full_output_before_head_tail_cap() {
let full = format!(
"HEAD_ONLY_MARKER\n{}\nMIDDLE_ONLY_MARKER\n{}\nTAIL_ONLY_MARKER\n",
"alpha\n".repeat(10_000),
"omega\n".repeat(10_000)
);
let envelope = serde_json::json!({
"exit_code": 0,
"stdout": full,
"stderr": "",
});
let store = spill::SessionSpillStore::default();
let mut display = ToolDisplay::new(Vec::new(), false, 80, 3);
display.call("run_command", "large-output-command");
let out =
shell_envelope_output(&envelope, 50, false, true, Some(&store), Some(&mut display));
display.result(&out);
assert!(out.contains("HEAD_ONLY_MARKER"), "head dropped: {out}");
assert!(out.contains("TAIL_ONLY_MARKER"), "tail dropped: {out}");
assert!(
out.contains("memory_fetch(\"spill:s0\")"),
"spill handle missing: {out}"
);
assert!(
out.contains("grep=\"<pattern>\""),
"search affordance missing: {out}"
);
let stored = store.fetch("s0").expect("full output stored");
assert!(
stored.contains("MIDDLE_ONLY_MARKER"),
"spilled payload was capped before storage"
);
assert!(stored.ends_with("TAIL_ONLY_MARKER\n"));
let rendered = String::from_utf8(display.into_inner()).unwrap();
assert!(
rendered.contains("▓ TAIL_ONLY_MARKER\n…\n"),
"operator spill lost the raw shell tail: {rendered}"
);
assert!(
!rendered.contains("memory_fetch(\"spill:s0\")"),
"operator saw the model teaser instead of raw shell output: {rendered}"
);
}
#[test]
fn shell_envelope_without_streams_commits_the_exit_result() {
let envelope = serde_json::json!({
"exit_code": 3,
"stdout": "",
"stderr": "",
});
let mut display = ToolDisplay::new(Vec::new(), false, 80, 3);
display.call("run_command", "exit 3");
let out = shell_envelope_output(&envelope, 50, false, false, None, Some(&mut display));
display.result(&out);
assert_eq!(out, "(exit 3)");
assert_eq!(
String::from_utf8(display.into_inner()).unwrap(),
"⚙ run_command: exit 3\n▒ (exit 3)\n…\n"
);
}
#[test]
fn envelope_denied_reads_structured_flag_only() {
assert!(envelope_denied(&serde_json::json!({"denied": true})));
assert!(!envelope_denied(&serde_json::json!({"denied": false})));
assert!(!envelope_denied(&serde_json::json!({})));
assert!(!envelope_denied(&serde_json::json!({"denied": "yes"})));
}
#[test]
fn envelope_denial_reason_joins_or_falls_back() {
let multi = serde_json::json!({
"denials": [
{"kind": "exec", "target": "rm", "reason": "exec rm denied"},
{"kind": "open", "target": "/etc/shadow", "reason": "open denied"}
]
});
assert_eq!(
envelope_denial_reason(&multi),
"exec rm denied; open denied"
);
let generic = "denied: the capability leash refused an operation";
assert_eq!(envelope_denial_reason(&serde_json::json!({})), generic);
assert_eq!(
envelope_denial_reason(&serde_json::json!({"denials": []})),
generic
);
assert_eq!(
envelope_denial_reason(&serde_json::json!({"denials": [{"kind": "exec"}]})),
generic
);
}
#[test]
fn exec_allowlist_name_takes_basename() {
assert_eq!(exec_allowlist_name("env"), "env");
assert_eq!(exec_allowlist_name("/usr/bin/env"), "env");
assert_eq!(exec_allowlist_name("/usr/bin/"), "bin");
assert_eq!(exec_allowlist_name("C:\\tools\\env.exe"), "env.exe");
}
#[test]
fn exec_denial_target_label_is_the_bare_command_not_the_reason() {
let one = serde_json::json!({
"denied": true,
"denials": [{
"kind": "exec",
"target": "export",
"reason": "exec of \"export\" is not within the granted authority"
}]
});
let label = exec_denial_target_label(&one);
assert_eq!(label, "export");
assert!(!label.contains("is not within the granted authority"));
let multi = serde_json::json!({
"denials": [
{"kind": "exec", "target": "export", "reason": "r"},
{"kind": "exec", "target": "set", "reason": "r"}
]
});
assert_eq!(exec_denial_target_label(&multi), "export, set");
assert_eq!(
exec_denial_target_label(&serde_json::json!({})),
"a command"
);
}
#[test]
fn host_of_url_extracts_hosts_conservatively() {
assert_eq!(host_of_url("https://docs.rs/serde"), Some("docs.rs".into()));
assert_eq!(host_of_url("http://Docs.RS"), Some("docs.rs".into()));
assert_eq!(
host_of_url("https://user:pw@example.com:8443/p?q#f"),
Some("example.com".into())
);
assert_eq!(host_of_url("https://[::1]:8080/x"), Some("::1".into()));
assert_eq!(host_of_url("not a url"), None);
assert_eq!(host_of_url("ftp://example.com"), None);
assert_eq!(host_of_url("https:///path-only"), None);
}
#[test]
fn exec_denial_requests_lifts_only_pure_exec_envelopes() {
let exec_only = serde_json::json!({
"denied": true,
"denials": [
{"kind": "exec", "target": "/usr/bin/npm", "reason": "exec npm denied"},
{"kind": "exec", "target": "node", "reason": "exec node denied"}
]
});
let reqs = exec_denial_requests(&exec_only).expect("promptable");
assert_eq!(reqs.len(), 2);
assert_eq!(reqs[0].tool, "run_command");
assert_eq!(reqs[0].kind, DenialKind::Exec);
assert_eq!(
reqs[0].target, "npm",
"basename, same rule as the config hint"
);
assert_eq!(reqs[0].reason, "exec npm denied");
assert_eq!(reqs[1].target, "node");
let mixed = serde_json::json!({
"denials": [
{"kind": "exec", "target": "npm", "reason": "r"},
{"kind": "open", "target": "/etc/shadow", "reason": "r"}
]
});
assert!(exec_denial_requests(&mixed).is_none());
assert!(exec_denial_requests(&serde_json::json!({})).is_none());
assert!(exec_denial_requests(&serde_json::json!({"denials": []})).is_none());
let empty_target = serde_json::json!({
"denials": [{"kind": "exec", "target": "", "reason": "r"}]
});
assert!(exec_denial_requests(&empty_target).is_none());
let no_target = serde_json::json!({
"denials": [{"kind": "exec", "reason": "r"}]
});
assert!(exec_denial_requests(&no_target).is_none());
let dynamic = serde_json::json!({
"denied": true,
"denials": [{
"kind": "exec",
"target": "command/arithmetic substitution `$(`",
"reason": "refused by design: command/arithmetic substitution `$(` is a \
dynamic construct the confined shell does not interpret (use the \
embedder's unbridled/--yolo path for a full shell)"
}]
});
assert!(
exec_denial_requests(&dynamic).is_none(),
"structural refusal must not offer a grant menu (#1150)"
);
let unsupported = serde_json::json!({
"denials": [{
"kind": "exec",
"target": "heredoc/herestring `<<`",
"reason": "not yet supported by the confined shell engine: \
heredoc/herestring `<<` (tracked on agent-bridle#34)"
}]
});
assert!(exec_denial_requests(&unsupported).is_none());
let authority = serde_json::json!({
"denials": [{"kind": "exec", "target": "cargo",
"reason": "exec of \"cargo\" is not within the granted authority"}]
});
assert!(exec_denial_requests(&authority).is_some());
}
#[test]
fn net_denial_requests_lifts_only_pure_net_envelopes() {
let net_only = serde_json::json!({
"denied": true,
"denials": [
{"kind": "net", "target": "github.com", "reason": "net does not permit 'github.com'"},
{"kind": "net", "target": "api.github.com", "reason": "net does not permit 'api.github.com'"}
]
});
let reqs = net_denial_requests(&net_only).expect("promptable");
assert_eq!(reqs.len(), 2);
assert_eq!(reqs[0].tool, "run_command");
assert_eq!(reqs[0].kind, DenialKind::Net);
assert_eq!(
reqs[0].target, "github.com",
"host verbatim, not a basename"
);
assert_eq!(reqs[0].reason, "net does not permit 'github.com'");
assert_eq!(reqs[1].target, "api.github.com");
let mixed = serde_json::json!({
"denials": [
{"kind": "net", "target": "github.com", "reason": "r"},
{"kind": "exec", "target": "npm", "reason": "r"}
]
});
assert!(net_denial_requests(&mixed).is_none());
let exec_only = serde_json::json!({"denials": [{"kind": "exec", "target": "npm"}]});
assert!(net_denial_requests(&exec_only).is_none());
assert!(net_denial_requests(&serde_json::json!({"denials": []})).is_none());
let empty_target = serde_json::json!({"denials": [{"kind": "net", "target": ""}]});
assert!(net_denial_requests(&empty_target).is_none());
}
#[test]
fn denials_name_the_exact_recovery_call() {
let fs = denied_fs_result("fs_write", "/etc/hosts");
assert!(
fs.contains(r#"request_permissions(capability="fs_write", target="/etc/hosts""#),
"{fs}"
);
let hint = denial_recovery_hint("exec", "cargo");
assert!(
hint.contains(r#"capability="exec""#) && hint.contains(r#"target="cargo""#),
"{hint}"
);
}
#[test]
fn denial_axis_label_is_net_only_for_pure_net() {
let net = serde_json::json!({"denials": [{"kind": "net", "target": "github.com"}]});
assert_eq!(denial_axis_label(&net), "net");
let exec = serde_json::json!({"denials": [{"kind": "exec", "target": "rm"}]});
assert_eq!(denial_axis_label(&exec), "exec");
let mixed = serde_json::json!({
"denials": [{"kind": "net", "target": "h"}, {"kind": "exec", "target": "rm"}]
});
assert_eq!(denial_axis_label(&mixed), "exec", "mixed defaults to exec");
assert_eq!(denial_axis_label(&serde_json::json!({})), "exec");
}
#[test]
fn tui_permits_path_prefix_semantics() {
use crate::caveats::Scope;
assert!(tui_permits_path(&Scope::All, "/anything/at/all"));
assert!(!tui_permits_path(&Scope::<String>::none(), "/ws/file"));
let only = Scope::only(["/ws".to_string()]);
assert!(tui_permits_path(&only, "/ws/sub/file.rs"));
assert!(tui_permits_path(&only, "/ws"), "the workspace root itself");
assert!(!tui_permits_path(&only, "/elsewhere/file.rs"));
assert!(
!tui_permits_path(&only, "/ws/../etc/passwd"),
"`..` traversal escapes the workspace"
);
assert!(
!tui_permits_path(&only, "/ws/../../etc/passwd"),
"repeated `..` traversal escapes the workspace"
);
assert!(
!tui_permits_path(&only, "/ws-secret/file.rs"),
"sibling-prefix collision escapes the workspace"
);
assert!(tui_permits_path(&only, "/ws/sub/../file.rs"));
}
#[cfg(unix)]
#[test]
fn tui_permits_path_symlink_escape_is_the_known_residual() {
use crate::caveats::Scope;
let outside = tempfile::TempDir::new().unwrap();
std::fs::write(outside.path().join("secret"), b"x").unwrap();
let ws = tempfile::TempDir::new().unwrap();
std::os::unix::fs::symlink(outside.path(), ws.path().join("link")).unwrap();
let only = Scope::only([ws.path().to_string_lossy().into_owned()]);
let via_link = ws.path().join("link").join("secret");
assert!(
tui_permits_path(&only, &via_link.to_string_lossy()),
"string gate permits a symlinked escape — known residual (#522)"
);
let dotdot = ws.path().join("..").join("etc").join("passwd");
assert!(
!tui_permits_path(&only, &dotdot.to_string_lossy()),
"`..` escape is denied even though symlink escape is not"
);
}
#[cfg(unix)]
#[test]
fn artifact_provenance_rejects_physical_symlink_escapes() {
let outside = tempfile::TempDir::new().unwrap();
std::fs::write(outside.path().join("existing"), b"x").unwrap();
let ws = tempfile::TempDir::new().unwrap();
std::os::unix::fs::symlink(outside.path(), ws.path().join("link")).unwrap();
assert!(artifact_path_is_physically_within_workspace(
ws.path(),
&ws.path().join("new/leaf.txt")
));
assert!(!artifact_path_is_physically_within_workspace(
ws.path(),
&ws.path().join("link/existing")
));
assert!(!artifact_path_is_physically_within_workspace(
ws.path(),
&ws.path().join("link/new-file")
));
std::os::unix::fs::symlink(outside.path().join("missing"), ws.path().join("dangling"))
.unwrap();
assert!(!artifact_path_is_physically_within_workspace(
ws.path(),
&ws.path().join("dangling")
));
}
#[test]
fn artifact_file_streaming_hash_and_postcondition_are_exact() {
let ws = tempfile::TempDir::new().unwrap();
let bytes = vec![0x5a; 3 * 64 * 1024 + 17];
let path = ws.path().join("large.bin");
std::fs::write(&path, &bytes).unwrap();
assert_eq!(
artifact_preimage_state(&path, true),
super::super::artifact_hooks::ArtifactFileState::from_bytes(&bytes)
);
assert!(artifact_file_matches(&path, &bytes).unwrap());
let mut different = bytes.clone();
different[64 * 1024] ^= 1;
assert!(!artifact_file_matches(&path, &different).unwrap());
}
#[cfg(unix)]
#[test]
fn artifact_preimage_never_opens_non_regular_files() {
let ws = tempfile::TempDir::new().unwrap();
let socket = ws.path().join("local.sock");
let _listener = std::os::unix::net::UnixListener::bind(&socket).unwrap();
assert_eq!(
artifact_preimage_state(&socket, true),
super::super::artifact_hooks::ArtifactFileState::unavailable(
"preimage_not_regular_file"
)
);
}
#[test]
fn git_tool_advertised_only_with_the_presence_gate() {
fn names(defs: &serde_json::Value) -> Vec<&str> {
defs.as_array()
.unwrap()
.iter()
.filter_map(|d| d["function"]["name"].as_str())
.collect()
}
let with = merged_tool_definitions(
&NoMcp, false, false, false, true, false, false, false, false, false, false, false,
false,
);
assert!(names(&with).contains(&"git"), "with_git advertises git");
let without = merged_tool_definitions(
&NoMcp, false, false, false, false, false, false, false, false, false, false, false,
false,
);
assert!(!names(&without).contains(&"git"), "no git without the gate");
let team = merged_tool_definitions(
&NoMcp, false, false, false, false, true, false, false, false, false, false, false,
false,
);
assert!(
names(&team).contains(&"crew") && names(&team).contains(&"compose_roster"),
"with_team advertises crew + compose_roster"
);
assert!(
!names(&without).contains(&"crew"),
"no crew without the gate"
);
let scratch = merged_tool_definitions(
&NoMcp, false, false, false, false, false, true, false, false, false, false, false,
false,
);
for t in ["state_set", "state_get", "state_clear"] {
assert!(
names(&scratch).contains(&t),
"{t} advertised with_scratchpad"
);
assert!(!names(&without).contains(&t), "{t} hidden without the gate");
assert!(
!is_hallucination(t, &serde_json::json!({})),
"{t} is a real tool"
);
}
let code = merged_tool_definitions(
&NoMcp, false, false, false, false, false, false, true, false, false, false, false,
false,
);
assert!(
names(&code).contains(&"code_search"),
"code_search advertised"
);
assert!(
!names(&without).contains(&"code_search"),
"code_search hidden without the gate"
);
assert!(!is_hallucination("code_search", &serde_json::json!({})));
let exp = merged_tool_definitions(
&NoMcp, false, false, false, false, false, false, false, true, false, false, false,
false,
);
for t in ["experience_record", "experience_recall"] {
assert!(names(&exp).contains(&t), "{t} advertised with_experiential");
assert!(!names(&without).contains(&t), "{t} hidden without the gate");
assert!(
!is_hallucination(t, &serde_json::json!({})),
"{t} is a real tool"
);
}
let sched = merged_tool_definitions(
&NoMcp, false, false, false, false, false, false, false, false, true, false, false,
false,
);
for t in ["update_plan", "plan_get"] {
assert!(names(&sched).contains(&t), "{t} advertised with_scheduled");
assert!(!names(&without).contains(&t), "{t} hidden without the gate");
assert!(
!is_hallucination(t, &serde_json::json!({})),
"{t} is a real tool"
);
}
for t in ["enter_plan_mode", "exit_plan_mode"] {
assert!(
!names(&sched).contains(&t),
"{t} needs a session Plan control as well as the scheduled ledger"
);
}
let plan_control_only = merged_tool_definitions(
&NoMcp, false, false, false, false, false, false, false, false, false, false, true,
false,
);
assert!(
!names(&plan_control_only).contains(&"enter_plan_mode"),
"enter_plan_mode needs scheduled planning as well as the session control"
);
assert!(
!names(&plan_control_only).contains(&"exit_plan_mode"),
"an inactive control must not advertise an unnecessary exit"
);
let active_plan_control = merged_tool_definitions(
&NoMcp, false, false, false, false, false, false, false, false, false, false, true,
true,
);
assert!(
!names(&active_plan_control).contains(&"enter_plan_mode"),
"enter still requires the scheduled ledger"
);
assert!(
names(&active_plan_control).contains(&"exit_plan_mode"),
"an active Plan phase must keep exit available if scheduled planning is toggled off"
);
let plan_ready_inactive = merged_tool_definitions(
&NoMcp, false, false, false, false, false, false, false, false, true, false, true,
false,
);
assert!(
names(&plan_ready_inactive).contains(&"enter_plan_mode"),
"scheduled planning plus a control advertises enter"
);
assert!(
names(&plan_ready_inactive).contains(&"exit_plan_mode"),
"a frozen multi-round catalog that advertises enter must also advertise same-turn exit"
);
let plan_mode = merged_tool_definitions(
&NoMcp, false, false, false, false, false, false, false, false, true, false, true, true,
);
for t in ["enter_plan_mode", "exit_plan_mode"] {
assert!(
names(&plan_mode).contains(&t),
"{t} is advertised when both required seams are present"
);
}
let operating_mode = merged_tool_definitions(
&NoMcp, false, false, false, false, false, false, false, false, false, true, false,
false,
);
assert!(
names(&operating_mode).contains(&"select_operating_mode"),
"auto-mode control advertises its selector"
);
assert!(
!names(&without).contains(&"select_operating_mode"),
"selector is hidden outside /mode auto"
);
assert!(!is_hallucination(
"select_operating_mode",
&serde_json::json!({})
));
}
#[tokio::test]
async fn state_tools_dispatch_only_with_a_store() {
use crate::agentic::scratchpad::{ScratchpadStore, SessionScratchpadStore};
let caveats = crate::caveats::Caveats::top();
let args = serde_json::json!({ "key": "k", "value": "v" });
let none = execute_tool(
"state_set",
&args,
".",
false,
20,
&caveats,
&mut NoMcp,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
)
.await;
assert!(none.starts_with("unknown tool: state_set"), "{none}");
let store = SessionScratchpadStore::default();
let set = execute_tool(
"state_set",
&args,
".",
false,
20,
&caveats,
&mut NoMcp,
None,
None,
None,
None,
None,
None,
None,
None,
Some(&store as &dyn ScratchpadStore),
None,
None,
None,
None,
)
.await;
assert_eq!(set, "stored: k");
assert_eq!(store.get("k").as_deref(), Some("v"));
}
#[tokio::test]
async fn code_search_dispatch_only_with_a_searcher() {
use crate::agentic::semantic::{CodeSearch, Embedder, SessionSemanticIndex};
struct E;
#[async_trait::async_trait]
impl Embedder for E {
async fn embed(&self, _t: &str) -> anyhow::Result<Vec<f32>> {
Ok(vec![1.0])
}
}
let caveats = crate::caveats::Caveats::top();
let args = serde_json::json!({ "query": "find it" });
let none = execute_tool(
"code_search",
&args,
".",
false,
20,
&caveats,
&mut NoMcp,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
)
.await;
assert!(none.starts_with("unknown tool: code_search"), "{none}");
let idx = SessionSemanticIndex::default();
let search = CodeSearch {
embedder: &E,
index: &idx,
top_k: 1,
steer: None,
status: None,
};
let out = execute_tool(
"code_search",
&args,
".",
false,
20,
&caveats,
&mut NoMcp,
None,
None,
None,
None,
None,
None,
None,
None,
None,
Some(search),
None,
None,
None,
)
.await;
assert!(out.contains("no code matched"), "{out}");
}
#[tokio::test]
async fn experiential_dispatch_only_with_a_store() {
use crate::agentic::experiential::{ExperienceStore, SessionExperienceStore};
let caveats = crate::caveats::Caveats::top();
let args = serde_json::json!({
"task": "ci flake", "outcome": "fixed", "lesson": "pin the seed for the fuzz test"
});
for name in ["experience_record", "experience_recall"] {
let out = execute_tool(
name, &args, ".", false, 20, &caveats, &mut NoMcp, None, None, None, None, None,
None, None, None, None, None, None, None, None,
)
.await;
assert!(out.starts_with(&format!("unknown tool: {name}")), "{out}");
}
let store = SessionExperienceStore::default();
let out = execute_tool(
"experience_record",
&args,
".",
false,
20,
&caveats,
&mut NoMcp,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
Some(&store as &dyn ExperienceStore),
None,
)
.await;
assert_eq!(out, "recorded experience");
assert_eq!(store.count(), 1);
}
#[tokio::test]
async fn scheduled_dispatch_only_with_a_ledger() {
use crate::agentic::scheduled::{SessionStepLedger, StepLedger};
let caveats = crate::caveats::Caveats::top();
let args = serde_json::json!({ "plan": [
{ "step": "a", "status": "in_progress" },
{ "step": "b", "status": "pending" },
] });
for name in ["update_plan", "plan_get"] {
let out = execute_tool(
name, &args, ".", false, 20, &caveats, &mut NoMcp, None, None, None, None, None,
None, None, None, None, None, None, None, None,
)
.await;
assert!(out.starts_with(&format!("unknown tool: {name}")), "{out}");
}
let ledger = SessionStepLedger::default();
let out = execute_tool(
"update_plan",
&args,
".",
false,
20,
&caveats,
&mut NoMcp,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
Some(&ledger as &dyn StepLedger),
)
.await;
assert!(out.starts_with("<plan>\n"), "{out}");
assert_eq!(ledger.count(), 2);
let got = execute_tool(
"plan_get",
&serde_json::json!({}),
".",
false,
20,
&caveats,
&mut NoMcp,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
Some(&ledger as &dyn StepLedger),
)
.await;
assert!(got.starts_with("<plan>\n"), "{got}");
assert_eq!(ledger.count(), 2, "plan_get is read-only");
}
#[tokio::test]
async fn resume_context_dispatch_degrades_without_a_recall_source() {
let caveats = crate::caveats::Caveats::top();
let out = execute_tool(
"resume_context",
&serde_json::json!({}),
".",
false,
20,
&caveats,
&mut NoMcp,
None, None, None, None, None, None, None, None, None, None, None, None, None, )
.await;
assert!(
out.contains("no conversation history available this session"),
"{out}"
);
assert!(!out.starts_with("unknown tool"), "{out}");
}
#[test]
fn run_build_check_reports_pass_fail_and_spawn_error() {
let ws = tempfile::TempDir::new().unwrap();
let ws_str = ws.path().to_string_lossy();
assert_eq!(
run_build_check(passing_build_check_cmd(), &ws_str),
" ✓ build check passed"
);
let failed = run_build_check(&failing_build_check_cmd("boom"), &ws_str);
assert!(failed.contains("✗ build check failed"), "got: {failed}");
assert!(failed.contains("boom"), "stderr excerpt shown: {failed}");
let err = run_build_check(passing_build_check_cmd(), "/definitely/not/a/dir");
assert!(err.contains("⚠ build check could not run"), "got: {err}");
}
}
#[cfg(test)]
mod execute_tool_branch_tests {
use super::super::NoMcp;
use super::*;
use crate::agentic::{
ArtifactReadContext, ArtifactReadRecord, PromptArtifactSink, SessionArtifactStore,
};
use crate::artifact::{ArtifactId, ArtifactKind, ArtifactRelation, NewPromptArtifact};
use crate::caveats::{Caveats, CountBound, Scope};
use crate::PromptId;
use std::sync::Mutex;
fn caveats_rw(ws: &std::path::Path) -> Caveats {
Caveats {
fs_read: Scope::All,
fs_write: Scope::only([ws.to_string_lossy().into_owned()]),
exec: Scope::none(),
net: Scope::none(),
max_calls: CountBound::Unlimited,
valid_for_generation: Scope::All,
}
}
#[derive(Default)]
struct RecordingArtifactSink {
artifacts: Mutex<Vec<NewPromptArtifact>>,
}
impl RecordingArtifactSink {
fn only_artifact(&self) -> NewPromptArtifact {
let artifacts = self.artifacts.lock().unwrap();
assert_eq!(artifacts.len(), 1, "expected exactly one artifact");
artifacts[0].clone()
}
#[cfg(unix)]
fn is_empty(&self) -> bool {
self.artifacts.lock().unwrap().is_empty()
}
}
impl PromptArtifactSink for RecordingArtifactSink {
fn append_artifact(
&self,
originating_prompt_id: PromptId,
objective_root_id: PromptId,
artifact: NewPromptArtifact,
) -> anyhow::Result<ArtifactReadRecord> {
let mut artifacts = self.artifacts.lock().unwrap();
artifacts.push(artifact.clone());
Ok(ArtifactReadRecord {
id: ArtifactId::new(),
prompt_id: originating_prompt_id,
root_prompt_id: objective_root_id,
writer_fingerprint: "tool-test".to_string(),
seq: artifacts.len() as u64,
prev_hash: "previous".to_string(),
kind: format!("{:?}", artifact.kind()),
relation: format!("{:?}", artifact.relation()),
locator: artifact.locator().map(str::to_string),
body: artifact.body().map(str::to_string),
metadata: artifact.metadata().clone(),
ts_claim: 1,
artifact_hash: "hash".to_string(),
})
}
}
fn artifact_context() -> ArtifactReadContext<'static> {
let prompt = PromptId::new();
ArtifactReadContext::new(Some(prompt), Some(prompt), Some(prompt), None)
}
#[allow(clippy::too_many_arguments)]
async fn run_artifact_tool(
name: &str,
args: serde_json::Value,
ws: &std::path::Path,
caveats: &Caveats,
build_check: Option<&str>,
sink: &RecordingArtifactSink,
) -> String {
execute_tool_with_offload_and_prompt_and_artifacts(
name,
&args,
&ws.to_string_lossy(),
false,
20,
caveats,
&mut NoMcp,
build_check,
None, None, None, None, Some(artifact_context()),
Some(sink),
None, None, None, None, None, None, None, None, None, false, None, None, PromptDisposition::Act,
)
.await
}
struct StubGit;
impl crate::agentic::GitTool for StubGit {
fn dispatch(
&self,
op: &str,
_args: &serde_json::Value,
caps: &crate::git_caveats::GitCaveats,
) -> Result<String, String> {
match op {
"status" => Ok("on branch main (HEAD abc123)".to_string()),
"commit" if !caps.permits_commit() => {
Err("capability denied: git commit not permitted".to_string())
}
"commit" => Ok("committed abc123: msg".to_string()),
"stash-drop" => Ok("dropped stash@{0}".to_string()),
"branch-delete" => Ok("deleted branch feature".to_string()),
other => Err(format!("unknown git op '{other}'")),
}
}
}
async fn run_scheduled_tool(
name: &str,
args: &serde_json::Value,
ws: &tempfile::TempDir,
ledger: &crate::agentic::scheduled::SessionStepLedger,
plan_mode_control: &dyn crate::agentic::PlanModeControl,
) -> String {
execute_tool_with_collaborators(
name,
args,
&ws.path().to_string_lossy(),
false,
20,
&caveats_rw(ws.path()),
&mut NoMcp,
ToolCollaborators {
step_ledger: Some(ledger as &dyn crate::agentic::scheduled::StepLedger),
plan_mode_control: Some(plan_mode_control),
..Default::default()
},
false,
PromptDisposition::Act,
None,
)
.await
.expect("test dispatch is not cancellable")
}
async fn run_git(
op: &str,
caveats: &Caveats,
git: Option<&dyn crate::agentic::GitTool>,
) -> String {
let ws = tempfile::TempDir::new().unwrap();
execute_tool(
"git",
&serde_json::json!({ "op": op }),
&ws.path().to_string_lossy(),
false,
20,
caveats,
&mut NoMcp,
None,
None,
None,
None,
None,
None,
git,
None, None, None, None, None, None, )
.await
}
#[tokio::test]
async fn git_arm_dispatches_when_injected() {
let ws = tempfile::TempDir::new().unwrap();
let out = run_git("status", &caveats_rw(ws.path()), Some(&StubGit)).await;
assert!(out.contains("on branch main"), "got: {out}");
}
#[tokio::test]
async fn git_arm_surfaces_denials_from_projected_caveats() {
let ws = tempfile::TempDir::new().unwrap();
let read_only = Caveats {
fs_write: Scope::none(),
..caveats_rw(ws.path())
};
let out = run_git("commit", &read_only, Some(&StubGit)).await;
assert!(
out.contains("error:") && out.contains("commit"),
"got: {out}"
);
let out = run_git("status", &read_only, Some(&StubGit)).await;
assert!(out.contains("on branch main"), "got: {out}");
}
#[allow(clippy::too_many_arguments)]
async fn run_git_gated(
op: &str,
caveats: &Caveats,
git: &dyn crate::agentic::GitTool,
gate: &mut MockGate,
) -> String {
let ws = tempfile::TempDir::new().unwrap();
execute_tool(
"git",
&serde_json::json!({ "op": op }),
&ws.path().to_string_lossy(),
false,
20,
caveats,
&mut NoMcp,
None,
None,
None,
None,
Some(gate), None, Some(git), None, None, None, None, None, None, )
.await
}
#[test]
fn plan_phase_seam_and_clamp() {
use crate::caveats::ScopeExt as _;
let c = plan_phase_clamp();
assert!(c.fs_read.permits(&"/anything".to_string()));
assert!(!c.fs_write.permits(&"/anything".to_string()));
assert!(!c.exec.permits(&"cargo".to_string()));
assert!(!c.net.permits(&"github.com".to_string()));
let full = crate::caveats::Caveats::top();
let planned = full.meet(&c);
assert!(
!planned.fs_write.permits(&"/x".to_string()),
"writes denied in plan phase"
);
assert!(planned.fs_read.permits(&"/x".to_string()), "reads allowed");
}
#[tokio::test]
async fn enter_and_exit_plan_mode_are_session_local_and_immediate() {
use crate::agentic::PlanModeControl as _;
#[derive(Default)]
struct TestPlanModeControl(std::sync::atomic::AtomicBool);
impl crate::agentic::PlanModeControl for TestPlanModeControl {
fn is_plan_mode(&self) -> bool {
self.0.load(std::sync::atomic::Ordering::Acquire)
}
fn set_plan_mode(&self, active: bool) -> Result<(), String> {
self.0.store(active, std::sync::atomic::Ordering::Release);
Ok(())
}
}
let ws = tempfile::TempDir::new().unwrap();
let ledger = crate::agentic::scheduled::SessionStepLedger::default();
let control = TestPlanModeControl::default();
let other_session = TestPlanModeControl::default();
assert!(!control.is_plan_mode());
let control_only = execute_tool_with_collaborators(
"enter_plan_mode",
&serde_json::json!({}),
&ws.path().to_string_lossy(),
false,
20,
&caveats_rw(ws.path()),
&mut NoMcp,
ToolCollaborators {
plan_mode_control: Some(&control),
..Default::default()
},
false,
PromptDisposition::Act,
None,
)
.await
.expect("test dispatch is not cancellable");
assert!(
control_only.contains("scheduled planning"),
"control-only fabricated call must fail honestly: {control_only}"
);
assert!(
!control.is_plan_mode(),
"a control without a plan ledger must not enter Plan"
);
let control_only_exit = execute_tool_with_collaborators(
"exit_plan_mode",
&serde_json::json!({}),
&ws.path().to_string_lossy(),
false,
20,
&caveats_rw(ws.path()),
&mut NoMcp,
ToolCollaborators {
plan_mode_control: Some(&control),
..Default::default()
},
false,
PromptDisposition::Plan,
None,
)
.await
.expect("test dispatch is not cancellable");
assert!(
control_only_exit.contains("exited the model-entered PLAN PHASE"),
"exit must remain available when scheduled planning is off: {control_only_exit}"
);
let enter = run_scheduled_tool(
"enter_plan_mode",
&serde_json::json!({}),
&ws,
&ledger,
&control,
)
.await;
assert!(enter.contains("PLAN MODE"), "{enter}");
assert!(control.is_plan_mode(), "enter_plan_mode set the phase");
assert!(
!other_session.is_plan_mode(),
"one session must not change another"
);
let denied_write = run_scheduled_tool(
"write_file",
&serde_json::json!({
"path": "must-not-write.txt",
"content": "no",
}),
&ws,
&ledger,
&control,
)
.await;
assert!(
denied_write.contains("current prompt disposition"),
"a write later in the same tool round must hit the immediate Plan clamp: {denied_write}"
);
assert!(
!ws.path().join("must-not-write.txt").exists(),
"entering Plan must prevent a later call from mutating the workspace"
);
let exit = run_scheduled_tool(
"exit_plan_mode",
&serde_json::json!({}),
&ws,
&ledger,
&control,
)
.await;
assert!(
exit.contains("exited the model-entered PLAN PHASE"),
"{exit}"
);
assert!(!control.is_plan_mode(), "exit_plan_mode cleared the phase");
}
#[tokio::test]
async fn git_data_loss_ops_are_gated_even_under_full_write_authority() {
let ws = tempfile::TempDir::new().unwrap();
let full = caveats_rw(ws.path());
let mut deny = MockGate::new(false, &full);
let out = run_git_gated("stash-drop", &full, &StubGit, &mut deny).await;
assert!(
out.starts_with("refused:"),
"must refuse without confirm: {out}"
);
assert!(
!out.contains("dropped stash"),
"the drop must NOT have run: {out}"
);
assert!(
deny.asks
.iter()
.any(|(t, k)| t == "git" && k.contains("stash-drop")),
"the data-loss confirmation was asked: {:?}",
deny.asks
);
let mut allow = MockGate::new(true, &full);
let out = run_git_gated("branch-delete", &full, &StubGit, &mut allow).await;
assert!(
out.contains("deleted branch"),
"confirmed → proceeds: {out}"
);
let mut g = MockGate::new(false, &full);
let out = run_git_gated("status", &full, &StubGit, &mut g).await;
assert!(out.contains("on branch main"), "safe op runs: {out}");
assert!(
!g.asks
.iter()
.any(|(_, k)| k.contains("stash-drop") || k.contains("branch-delete")),
"status must not trip the data-loss gate: {:?}",
g.asks
);
}
#[tokio::test]
async fn git_data_loss_op_refused_headless_no_gate() {
let ws = tempfile::TempDir::new().unwrap();
let out = run_git("stash-drop", &caveats_rw(ws.path()), Some(&StubGit)).await;
assert!(
out.starts_with("refused:"),
"headless data-loss refused: {out}"
);
}
#[tokio::test]
async fn git_write_denial_routes_through_gate_and_commits_on_allow() {
let ws = tempfile::TempDir::new().unwrap();
let read_only = Caveats {
fs_write: Scope::none(),
..caveats_rw(ws.path())
};
let mut gate = MockGate::new(true, &read_only);
let out = run_git_gated("commit", &read_only, &StubGit, &mut gate).await;
assert!(
out.contains("committed"),
"gate-granted commit should land: {out}"
);
assert!(
gate.asks
.iter()
.any(|(t, k)| t == "git" && k.starts_with("git_write:commit")),
"a git_write grant was requested: {:?}",
gate.asks
);
}
#[tokio::test]
async fn git_write_denied_when_operator_declines() {
let ws = tempfile::TempDir::new().unwrap();
let read_only = Caveats {
fs_write: Scope::none(),
..caveats_rw(ws.path())
};
let mut gate = MockGate::new(false, &read_only);
let out = run_git_gated("commit", &read_only, &StubGit, &mut gate).await;
assert!(
out.contains("capability denied: git commit not permitted"),
"a declined git write stays denied: {out}"
);
}
#[tokio::test]
async fn git_read_is_never_gated() {
let ws = tempfile::TempDir::new().unwrap();
let read_only = Caveats {
fs_write: Scope::none(),
..caveats_rw(ws.path())
};
let mut gate = MockGate::new(false, &read_only);
let out = run_git_gated("status", &read_only, &StubGit, &mut gate).await;
assert!(
out.contains("on branch main"),
"read op runs ungated: {out}"
);
assert!(gate.asks.is_empty(), "a read must not consult the gate");
}
#[tokio::test]
async fn git_arm_unknown_op_is_an_error_not_a_panic() {
let ws = tempfile::TempDir::new().unwrap();
let out = run_git("frobnicate", &caveats_rw(ws.path()), Some(&StubGit)).await;
assert!(
out.contains("error:") && out.contains("unknown git op"),
"got: {out}"
);
}
#[tokio::test]
async fn git_arm_without_injection_is_unknown_tool() {
let ws = tempfile::TempDir::new().unwrap();
let out = run_git("status", &caveats_rw(ws.path()), None).await;
assert!(out.contains("unknown tool: git"), "got: {out}");
}
struct StubCrew;
#[async_trait::async_trait]
impl crate::agentic::CrewRunner for StubCrew {
async fn dispatch(
&self,
op: &str,
_args: &serde_json::Value,
_caveats: &Caveats,
) -> Result<String, String> {
match op {
"compose_roster" => Ok("proposed roster: planner <- qwen3-coder:30b".to_string()),
"crew" => Ok("crew ran: diff +1/-0, status PASS".to_string()),
other => Err(format!("unknown op: {other}")),
}
}
}
async fn run_crew_tool(
name: &str,
args: serde_json::Value,
crew: Option<&dyn crate::agentic::CrewRunner>,
) -> String {
let ws = tempfile::TempDir::new().unwrap();
execute_tool(
name,
&args,
&ws.path().to_string_lossy(),
false,
20,
&caveats_rw(ws.path()),
&mut NoMcp,
None, None, None, None, None, None, None, crew,
None, None, None, None, None, )
.await
}
#[tokio::test]
async fn crew_arm_dispatches_when_injected() {
let out = run_crew_tool(
"crew",
serde_json::json!({ "task": "do X" }),
Some(&StubCrew),
)
.await;
assert!(
out.contains("crew ran") && out.contains("PASS"),
"got: {out}"
);
let out = run_crew_tool(
"compose_roster",
serde_json::json!({ "mode": "crew" }),
Some(&StubCrew),
)
.await;
assert!(out.contains("proposed roster"), "got: {out}");
}
#[tokio::test]
async fn crew_arm_without_injection_coaches_recovery() {
for name in ["crew", "compose_roster"] {
let out = run_crew_tool(name, serde_json::json!({ "task": "x" }), None).await;
assert!(out.contains("NEWT_TEAM"), "{name}: {out}");
assert!(out.contains("read_file"), "{name}: {out}");
assert!(!out.contains("unknown tool"), "{name}: {out}");
}
}
#[test]
fn crew_off_recovery_result_names_gate_and_alternative() {
let out = crew_off_recovery_result("crew");
assert!(out.contains("'crew'"), "{out}");
assert!(out.contains("NEWT_TEAM"), "{out}");
assert!(out.contains("write_file"), "{out}");
assert!(!out.contains("unknown tool"), "{out}");
}
#[test]
fn classify_gated_off_reach_only_fires_for_off_crew_names() {
for name in ["crew", "compose_roster"] {
assert_eq!(
classify_gated_off_reach(name, false),
Some(crate::PhantomResolution::GatedOff(
"crew/team surface off (NEWT_TEAM)".into()
)),
"{name} OFF should record GatedOff"
);
assert_eq!(
classify_gated_off_reach(name, true),
None,
"{name} ON dispatches normally — no phantom"
);
}
assert_eq!(classify_gated_off_reach("read_file", false), None);
assert_eq!(classify_gated_off_reach("read_file", true), None);
}
#[test]
fn crew_names_stay_real_and_unflagged_by_existing_seams() {
for name in ["crew", "compose_roster"] {
assert!(
!is_hallucination(name, &serde_json::json!({ "task": "x" })),
"{name} must stay a real tool name"
);
assert_eq!(
classify_phantom_reach(name, &serde_json::json!({ "task": "x" }), "ok", true),
None,
"{name} must not be flagged by classify_phantom_reach"
);
}
}
async fn run_find(args: serde_json::Value, ws: &std::path::Path) -> String {
run_tool("find", args, ws, &caveats_rw(ws), None).await
}
fn touch(root: &std::path::Path, rel: &str) {
let p = root.join(rel);
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
std::fs::write(p, b"x").unwrap();
}
#[tokio::test]
async fn find_locates_file_by_name_issue_496() {
let ws = tempfile::TempDir::new().unwrap();
touch(ws.path(), "newt-core/src/pyo3_module.rs");
touch(ws.path(), "newt-data/src/other.rs");
touch(ws.path(), "docs/pyo3_module.md"); let out = run_find(serde_json::json!({ "name": "pyo3_module.rs" }), ws.path()).await;
assert_eq!(out, "newt-core/src/pyo3_module.rs", "got: {out}");
}
#[tokio::test]
async fn find_code_true_excludes_docs_and_lockfiles_from_line_ranking() {
let ws = tempfile::TempDir::new().unwrap();
std::fs::write(ws.path().join("tall.rs"), "x\n".repeat(20)).unwrap();
std::fs::write(ws.path().join("short.rs"), "x\n".repeat(5)).unwrap();
std::fs::write(ws.path().join("AGENTS.md"), "d\n".repeat(200)).unwrap();
std::fs::write(ws.path().join("Cargo.lock"), "l\n".repeat(100)).unwrap();
std::fs::write(ws.path().join("LICENSE"), "L\n".repeat(50)).unwrap();
let out = run_find(
serde_json::json!({
"path": ".",
"type": "f",
"code": true,
"sort": "lines",
"show_lines": true,
"max_results": 10
}),
ws.path(),
)
.await;
assert!(
out.contains("20\ttall.rs") && out.contains("5\tshort.rs"),
"code sources with line counts: {out}"
);
assert!(
!out.contains("AGENTS.md") && !out.contains("Cargo.lock") && !out.contains("LICENSE"),
"docs/lockfiles/LICENSE must be excluded: {out}"
);
let tall = out.find("20\ttall.rs").expect("tall first");
let short = out.find("5\tshort.rs").expect("short second");
assert!(tall < short, "lines descending: {out}");
}
#[tokio::test]
async fn find_glob_type_and_maxdepth_together() {
let ws = tempfile::TempDir::new().unwrap();
touch(ws.path(), "examples/a.py"); touch(ws.path(), "examples/sub/b.py"); touch(ws.path(), "examples/sub/deep/c.py"); touch(ws.path(), "examples/readme.md"); std::fs::create_dir_all(ws.path().join("examples/empty_dir")).unwrap();
let out = run_find(
serde_json::json!({
"path": "examples", "name": "*.py", "type": "f", "max_depth": 2
}),
ws.path(),
)
.await;
assert_eq!(out, "examples/a.py\nexamples/sub/b.py", "got: {out}");
}
#[tokio::test]
async fn find_source_category_filters_repository_metadata_across_languages() {
let ws = tempfile::TempDir::new().unwrap();
for file in [
"src/main.rs",
"src/app.py",
"web/app.ts",
"java/App.java",
"native/app.cpp",
"dotnet/App.cs",
"ruby/app.rb",
"scripts/build.sh",
"AGENTS.md",
"Cargo.toml",
"Cargo.lock",
] {
touch(ws.path(), file);
}
let out = run_find(
serde_json::json!({ "category": "source", "type": "f" }),
ws.path(),
)
.await;
for source in [
"src/main.rs",
"src/app.py",
"web/app.ts",
"java/App.java",
"native/app.cpp",
"dotnet/App.cs",
"ruby/app.rb",
"scripts/build.sh",
] {
assert!(
out.lines().any(|line| line == source),
"missing {source}: {out}"
);
}
for metadata in ["AGENTS.md", "Cargo.toml", "Cargo.lock"] {
assert!(
!out.lines().any(|line| line == metadata),
"metadata is not source code ({metadata}): {out}"
);
}
}
#[tokio::test]
async fn find_language_alias_narrows_source_files() {
let ws = tempfile::TempDir::new().unwrap();
for file in ["native/a.c", "native/b.cpp", "dotnet/App.cs", "src/main.rs"] {
touch(ws.path(), file);
}
let cpp = run_find(serde_json::json!({ "language": "C++" }), ws.path()).await;
assert_eq!(cpp, "native/a.c\nnative/b.cpp");
let csharp = run_find(serde_json::json!({ "language": "C#" }), ws.path()).await;
assert_eq!(csharp, "dotnet/App.cs");
}
#[tokio::test]
async fn find_output_is_sorted() {
let ws = tempfile::TempDir::new().unwrap();
for f in ["m.txt", "a.txt", "z.txt", "c.txt"] {
touch(ws.path(), f);
}
let out = run_find(serde_json::json!({ "name": "*.txt" }), ws.path()).await;
let lines: Vec<&str> = out.lines().collect();
assert_eq!(
lines,
vec!["a.txt", "c.txt", "m.txt", "z.txt"],
"got: {out}"
);
}
#[tokio::test]
async fn find_command_and_full_result_share_the_spill_boundary() {
let ws = tempfile::TempDir::new().unwrap();
for f in ["e.rs", "b.rs", "d.rs", "a.rs", "c.rs"] {
touch(ws.path(), f);
}
let args = serde_json::json!({
"path": ".",
"name": "*.rs",
"type": "f",
});
let caveats = caveats_rw(ws.path());
let (out, rendered) =
run_tool_captured("find", args, ws.path(), &caveats, &mut NoMcp).await;
assert_eq!(out, "a.rs\nb.rs\nc.rs\nd.rs\ne.rs");
assert_eq!(
rendered,
"⚙ find: . (name=*.rs, type=f)\n\
▲ 2 more lines above · /spill N raises this view\n\
▒ c.rs\n\
▒ d.rs\n\
▓ e.rs\n\
…\n"
);
}
#[tokio::test]
async fn routed_find_uses_the_governed_tool_in_the_audit_header() {
let ws = tempfile::TempDir::new().unwrap();
for f in ["b.rs", "a.rs"] {
touch(ws.path(), f);
}
let caveats = caveats_rw(ws.path());
let (out, rendered) = run_tool_captured(
"run_command",
serde_json::json!({"command": "find . -name '*.rs' -type f"}),
ws.path(),
&caveats,
&mut NoMcp,
)
.await;
assert_eq!(out, "a.rs\nb.rs");
assert!(
rendered.starts_with("⚙ find: . (name=*.rs, type=f)\n"),
"routed action was not audited canonically: {rendered}"
);
assert!(!rendered.contains("⚙ run_command:"));
}
#[tokio::test]
async fn correction_alias_header_never_echoes_file_content() {
let ws = tempfile::TempDir::new().unwrap();
let caveats = caveats_rw(ws.path());
let secret = "PRIVATE_BODY_MUST_NOT_APPEAR_IN_HEADER";
let (out, rendered) = run_tool_captured(
"create_file",
serde_json::json!({"path": "secret.txt", "content": secret}),
ws.path(),
&caveats,
&mut NoMcp,
)
.await;
assert!(out.contains("write_file"), "got: {out}");
assert!(
rendered.starts_with(&format!(
"⚙ create_file: secret.txt ({} bytes)\n",
secret.len()
)),
"unsafe or unhelpful alias audit: {rendered}"
);
assert!(!rendered.contains(secret));
}
#[test]
fn lifecycle_audit_names_the_resolved_command() {
let ws = tempfile::TempDir::new().unwrap();
std::fs::write(
ws.path().join("Cargo.toml"),
"[package]\nname='audit-fixture'\n",
)
.unwrap();
let (name, detail) = tool_presentation(
"lifecycle",
&serde_json::json!({"phase": "test", "action": "run"}),
ws.path(),
);
let resolved =
crate::tooling::resolved_phase_commands(ws.path(), crate::tooling::Phase::Test);
assert_eq!(name, "lifecycle");
assert!(!resolved.is_empty());
assert_eq!(detail, format!("test (run) → {}", resolved.join(" && ")));
}
#[test]
fn audit_preserves_whitespace_in_real_paths() {
let ws = tempfile::TempDir::new().unwrap();
let (name, detail) = tool_presentation(
"read_file",
&serde_json::json!({"path": " leading and trailing "}),
ws.path(),
);
assert_eq!(name, "read_file");
assert_eq!(detail, " leading and trailing ");
let (name, detail) = tool_presentation(
"run_command",
&serde_json::json!({"command": "cd nested && printf exact-command"}),
ws.path(),
);
assert_eq!(name, "run_command");
assert_eq!(detail, "cd nested && printf exact-command");
}
#[tokio::test]
async fn find_error_uses_the_same_spill_boundary() {
let ws = tempfile::TempDir::new().unwrap();
let caveats = caveats_rw(ws.path());
let (out, rendered) = run_tool_captured(
"find",
serde_json::json!({"path": "missing", "name": "*.rs", "type": "f"}),
ws.path(),
&caveats,
&mut NoMcp,
)
.await;
assert_eq!(out, "error: no such path 'missing'");
assert_eq!(
rendered,
"⚙ find: missing (name=*.rs, type=f)\n\
▒ error: no such path 'missing'\n\
…\n"
);
}
struct EmptyRemote;
#[async_trait::async_trait]
impl McpTools for EmptyRemote {
fn handles(&self, name: &str) -> bool {
name == "test__empty"
}
fn tool_defs(&self) -> Vec<serde_json::Value> {
Vec::new()
}
async fn call(&mut self, _name: &str, _args: &serde_json::Value) -> String {
String::new()
}
}
#[tokio::test]
async fn empty_tool_result_still_commits_a_complete_spill_block() {
let ws = tempfile::TempDir::new().unwrap();
let caveats = caveats_rw(ws.path());
let (out, rendered) = run_tool_captured(
"test__empty",
serde_json::json!({}),
ws.path(),
&caveats,
&mut EmptyRemote,
)
.await;
assert!(out.is_empty());
assert_eq!(
rendered,
"⚙ test__empty: {}\n\
▒ (no output)\n\
…\n"
);
}
#[tokio::test]
async fn unknown_tool_has_exactly_one_complete_audit_block() {
let ws = tempfile::TempDir::new().unwrap();
let caveats = caveats_rw(ws.path());
let (out, rendered) = run_tool_captured(
"definitely_unknown",
serde_json::json!({}),
ws.path(),
&caveats,
&mut NoMcp,
)
.await;
assert!(out.contains("unknown tool"), "got: {out}");
assert_eq!(rendered.matches("⚙ definitely_unknown:").count(), 1);
assert_eq!(rendered.matches("…\n").count(), 1);
assert_eq!(rendered.matches("▒ unknown tool:").count(), 1);
}
#[tokio::test]
async fn pre_set_cancellation_closes_the_block_without_polling_a_mutation() {
let ws = tempfile::TempDir::new().unwrap();
let caveats = caveats_rw(ws.path());
let args = serde_json::json!({"path": "must-not-exist.txt", "content": "blocked"});
let cancel = std::sync::atomic::AtomicBool::new(true);
let mut display = crate::agentic::display::ToolDisplay::new(Vec::new(), false, 80, 3);
let out = execute_tool_with_display_cancellable(
&mut display,
"write_file",
&args,
&ws.path().to_string_lossy(),
false,
20,
&caveats,
&mut NoMcp,
ToolCollaborators::default(),
false,
PromptDisposition::Act,
Some(&cancel),
)
.await;
assert!(out.is_none());
assert!(!ws.path().join("must-not-exist.txt").exists());
assert_eq!(
String::from_utf8(display.into_inner()).unwrap(),
"⚙ write_file: must-not-exist.txt (7 bytes)\n\
▒ error: write_file interrupted — tool cancelled before completion\n\
…\n"
);
}
#[tokio::test]
async fn prompt_read_central_display_never_echoes_recovered_prompt_text() {
let ws = tempfile::TempDir::new().unwrap();
let caveats = caveats_rw(ws.path());
let exact = "operator secret that must reach only the model";
let context = PromptReadContext::new(None, exact, None);
let (out, rendered) = run_tool_captured_with_context(
"prompt_read",
serde_json::json!({}),
ws.path(),
&caveats,
&mut NoMcp,
Some(context),
None,
)
.await;
let model: serde_json::Value = serde_json::from_str(&out).unwrap();
assert_eq!(model["model_text"], exact);
assert_eq!(rendered.matches("⚙ prompt_read:").count(), 1);
assert!(rendered.contains("ephemeral prompt: returned"));
assert!(!rendered.contains(exact));
}
#[tokio::test]
async fn artifact_read_central_display_never_echoes_recovered_body() {
let ws = tempfile::TempDir::new().unwrap();
let caveats = caveats_rw(ws.path());
let prompt = PromptId::new();
let secret = "artifact body that must reach only the model";
let store = SessionArtifactStore::new("central-display-test").unwrap();
let record = store
.append_artifact(
prompt,
prompt,
NewPromptArtifact::new(ArtifactKind::Decision, ArtifactRelation::DerivedFrom)
.with_body(secret),
)
.unwrap();
let context =
ArtifactReadContext::new(Some(prompt), Some(prompt), Some(prompt), Some(&store));
let (out, rendered) = run_tool_captured_with_context(
"artifact_read",
serde_json::json!({"address": record.id.to_string()}),
ws.path(),
&caveats,
&mut NoMcp,
None,
Some(context),
)
.await;
let model: serde_json::Value = serde_json::from_str(&out).unwrap();
assert_eq!(model["artifact"]["body"], secret);
assert_eq!(rendered.matches("⚙ artifact_read:").count(), 1);
assert!(rendered.contains(&format!(
"returned {} of {} body characters",
secret.chars().count(),
secret.chars().count()
)));
assert!(!rendered.contains(secret));
}
#[tokio::test]
async fn render_report_has_one_header_document_and_ack_block() {
let ws = tempfile::TempDir::new().unwrap();
let caveats = caveats_rw(ws.path());
let (out, rendered) = run_tool_captured(
"render_report",
serde_json::json!({
"title": "Build status",
"body": "All required checks passed."
}),
ws.path(),
&caveats,
&mut NoMcp,
)
.await;
assert!(out.starts_with("report rendered:"), "got: {out}");
assert_eq!(rendered.matches("⚙ render_report:").count(), 1);
assert_eq!(rendered.matches("All required checks passed.").count(), 1);
assert_eq!(rendered.matches("▒ report rendered:").count(), 1);
}
#[tokio::test]
async fn find_type_filter() {
let ws = tempfile::TempDir::new().unwrap();
touch(ws.path(), "pkg/file.rs");
std::fs::create_dir_all(ws.path().join("pkg/sub")).unwrap();
let dirs = run_find(serde_json::json!({ "type": "d" }), ws.path()).await;
assert!(
dirs.contains("pkg") && dirs.contains("pkg/sub"),
"got: {dirs}"
);
assert!(!dirs.contains("file.rs"), "dirs-only leaked a file: {dirs}");
let files = run_find(serde_json::json!({ "type": "f" }), ws.path()).await;
assert!(files.contains("pkg/file.rs"), "got: {files}");
assert!(
!files.lines().any(|l| l == "pkg" || l == "pkg/sub"),
"files-only leaked a dir: {files}"
);
}
#[tokio::test]
async fn find_gitignore_and_default_skips() {
let ws = tempfile::TempDir::new().unwrap();
std::fs::write(ws.path().join(".gitignore"), "ignored.txt\n").unwrap();
touch(ws.path(), "kept.txt");
touch(ws.path(), "ignored.txt");
touch(ws.path(), "target/build_artifact.txt");
touch(ws.path(), "node_modules/dep.txt");
let on = run_find(serde_json::json!({ "name": "*.txt" }), ws.path()).await;
assert!(on.contains("kept.txt"), "got: {on}");
assert!(!on.contains("ignored.txt"), "gitignore not honoured: {on}");
assert!(!on.contains("target/"), "target not skipped: {on}");
assert!(
!on.contains("node_modules/"),
"node_modules not skipped: {on}"
);
let off = run_find(
serde_json::json!({ "name": "*.txt", "respect_gitignore": false }),
ws.path(),
)
.await;
assert!(off.contains("ignored.txt"), "opt-out should show it: {off}");
assert!(off.contains("target/build_artifact.txt"), "got: {off}");
}
#[tokio::test]
async fn find_max_results_caps_and_notes_truncation() {
let ws = tempfile::TempDir::new().unwrap();
for i in 0..10 {
touch(ws.path(), &format!("f{i}.txt"));
}
let out = run_find(
serde_json::json!({ "name": "*.txt", "max_results": 3 }),
ws.path(),
)
.await;
let body: Vec<&str> = out.lines().filter(|l| l.ends_with(".txt")).collect();
assert_eq!(body.len(), 3, "should cap at 3: {out}");
assert!(out.contains("truncated at 3"), "got: {out}");
}
#[tokio::test]
async fn find_missing_root_and_no_matches() {
let ws = tempfile::TempDir::new().unwrap();
touch(ws.path(), "a.txt");
let missing = run_find(serde_json::json!({ "path": "does/not/exist" }), ws.path()).await;
assert!(missing.starts_with("error:"), "got: {missing}");
let empty = run_find(serde_json::json!({ "name": "*.nope" }), ws.path()).await;
assert_eq!(empty, "no matches", "got: {empty}");
}
#[tokio::test]
async fn find_denied_without_fs_read() {
let ws = tempfile::TempDir::new().unwrap();
touch(ws.path(), "secret.txt");
let denied = Caveats {
fs_read: Scope::none(),
..caveats_rw(ws.path())
};
let out = run_tool(
"find",
serde_json::json!({ "name": "*" }),
ws.path(),
&denied,
None,
)
.await;
assert!(out.starts_with("capability denied"), "got: {out}");
}
#[tokio::test]
async fn find_refuses_root_outside_workspace() {
let parent = tempfile::TempDir::new().unwrap();
std::fs::write(parent.path().join("outside.txt"), b"x").unwrap();
let ws = parent.path().join("ws");
std::fs::create_dir_all(&ws).unwrap();
let out = run_find(serde_json::json!({ "path": ".." }), &ws).await;
assert!(out.starts_with("capability denied"), "got: {out}");
}
#[tokio::test]
async fn find_empty_name_matches_everything() {
let ws = tempfile::TempDir::new().unwrap();
touch(ws.path(), "a.txt");
touch(ws.path(), "sub/b.rs");
let out = run_find(serde_json::json!({ "name": "" }), ws.path()).await;
for expected in ["a.txt", "sub", "sub/b.rs"] {
assert!(
out.lines().any(|l| l == expected),
"empty name should match `{expected}`: {out}"
);
}
}
#[tokio::test]
async fn find_hidden_entries_gated_by_respect_gitignore() {
let ws = tempfile::TempDir::new().unwrap();
touch(ws.path(), "visible.txt");
touch(ws.path(), ".hidden.txt");
touch(ws.path(), ".config/secret.txt");
let default = run_find(serde_json::json!({ "name": "*" }), ws.path()).await;
assert!(
default.lines().any(|l| l == "visible.txt"),
"got: {default}"
);
assert!(
!default.contains(".hidden") && !default.contains(".config"),
"hidden entries must be skipped by default: {default}"
);
let all = run_find(
serde_json::json!({ "name": "*", "respect_gitignore": false }),
ws.path(),
)
.await;
assert!(all.contains(".hidden.txt"), "opt-out should show it: {all}");
assert!(all.contains(".config/secret.txt"), "got: {all}");
}
#[cfg(unix)]
#[tokio::test]
async fn find_does_not_follow_symlinks_out_of_workspace() {
let outside = tempfile::TempDir::new().unwrap();
std::fs::write(outside.path().join("secret.txt"), b"x").unwrap();
let ws = tempfile::TempDir::new().unwrap();
touch(ws.path(), "inside.txt");
std::os::unix::fs::symlink(outside.path(), ws.path().join("link")).unwrap();
let leaked = run_find(serde_json::json!({ "name": "secret.txt" }), ws.path()).await;
assert_eq!(
leaked, "no matches",
"symlink was followed out of ws: {leaked}"
);
let found = run_find(serde_json::json!({ "name": "inside.txt" }), ws.path()).await;
assert_eq!(found, "inside.txt", "got: {found}");
}
#[test]
fn glob_to_regex_anchors_and_escapes() {
let re = glob_to_regex("*.py", true).unwrap();
assert!(re.is_match("foo.py"));
assert!(!re.is_match("foo.pyc")); assert!(!re.is_match("fooxpy")); assert!(glob_to_regex("a?c", true).unwrap().is_match("abc"));
assert!(!glob_to_regex("a?c", true).unwrap().is_match("ac"));
assert!(glob_to_regex("readme.md", false)
.unwrap()
.is_match("README.MD"));
assert!(!glob_to_regex("readme.md", true)
.unwrap()
.is_match("README.MD"));
}
async fn run_tool(
name: &str,
args: serde_json::Value,
ws: &std::path::Path,
caveats: &Caveats,
build_check: Option<&str>,
) -> String {
execute_tool(
name,
&args,
&ws.to_string_lossy(),
false,
20,
caveats,
&mut NoMcp,
build_check,
None,
None,
None, None,
None,
None, None, None, None, None, None, None, )
.await
}
async fn run_tool_captured(
name: &str,
args: serde_json::Value,
ws: &std::path::Path,
caveats: &Caveats,
mcp: &mut dyn McpTools,
) -> (String, String) {
run_tool_captured_with_context(name, args, ws, caveats, mcp, None, None).await
}
async fn run_tool_captured_with_context(
name: &str,
args: serde_json::Value,
ws: &std::path::Path,
caveats: &Caveats,
mcp: &mut dyn McpTools,
prompt_context: Option<PromptReadContext<'_>>,
artifact_context: Option<ArtifactReadContext<'_>>,
) -> (String, String) {
run_tool_captured_with_context_and_live(
name,
args,
ws,
caveats,
mcp,
prompt_context,
artifact_context,
None,
)
.await
}
#[allow(clippy::too_many_arguments)]
async fn run_tool_captured_with_context_and_live(
name: &str,
args: serde_json::Value,
ws: &std::path::Path,
caveats: &Caveats,
mcp: &mut dyn McpTools,
prompt_context: Option<PromptReadContext<'_>>,
artifact_context: Option<ArtifactReadContext<'_>>,
live_tool_output: Option<std::sync::Arc<dyn crate::agentic::LiveToolOutput>>,
) -> (String, String) {
let mut display = crate::agentic::display::ToolDisplay::new(Vec::new(), false, 80, 3);
let out = execute_tool_with_display_cancellable(
&mut display,
name,
&args,
&ws.to_string_lossy(),
false,
20,
caveats,
mcp,
ToolCollaborators {
prompt_context,
artifact_context,
live_tool_output,
..Default::default()
},
false,
PromptDisposition::Act,
None,
)
.await
.expect("uncancelled test dispatch should complete");
let rendered = String::from_utf8(display.into_inner()).unwrap();
(out, rendered)
}
#[cfg(not(windows))]
#[tokio::test]
async fn live_shell_observation_does_not_change_headless_completion_bytes() {
#[derive(Default)]
struct CapturedLiveOutput {
events: std::sync::Mutex<Vec<String>>,
}
impl crate::agentic::LiveToolOutput for CapturedLiveOutput {
fn start(&self, _generation: u64) {
self.events.lock().unwrap().push("start".into());
}
fn write(
&self,
_generation: u64,
_stream: crate::agentic::ToolOutputStream,
chunk: &[u8],
) {
self.events
.lock()
.unwrap()
.push(String::from_utf8_lossy(chunk).into_owned());
}
fn finish(&self, _generation: u64) {
self.events.lock().unwrap().push("finish".into());
}
fn abandon(&self, _generation: u64) {
self.events.lock().unwrap().push("abandon".into());
}
}
let ws = tempfile::TempDir::new().unwrap();
let caveats = Caveats {
exec: crate::caveats::Scope::only(["echo".to_string()]),
..caveats_rw(ws.path())
};
let args = serde_json::json!({"command": "echo byte-stable"});
let (headless_out, headless_rendered) = run_tool_captured_with_context_and_live(
"run_command",
args.clone(),
ws.path(),
&caveats,
&mut NoMcp,
None,
None,
None,
)
.await;
let sink = std::sync::Arc::new(CapturedLiveOutput::default());
let (live_out, live_rendered) = run_tool_captured_with_context_and_live(
"run_command",
args,
ws.path(),
&caveats,
&mut NoMcp,
None,
None,
Some(sink.clone()),
)
.await;
assert_eq!(live_out, headless_out);
assert_eq!(live_rendered.as_bytes(), headless_rendered.as_bytes());
assert!(
!headless_rendered.as_bytes().contains(&0x1b),
"headless completion emitted cursor-control bytes: {headless_rendered:?}"
);
let events = sink.events.lock().unwrap();
assert_eq!(events.first().map(String::as_str), Some("start"));
assert_eq!(events.last().map(String::as_str), Some("finish"));
assert!(
events.iter().any(|event| event.contains("byte-stable")),
"live events: {events:?}; model output: {live_out:?}"
);
}
#[tokio::test]
async fn edit_file_replaces_unique_match_and_reports_delta() {
let ws = tempfile::TempDir::new().unwrap();
std::fs::write(ws.path().join("f.txt"), "hello world\nsecond line\n").unwrap();
let caveats = caveats_rw(ws.path());
let out = run_tool(
"edit_file",
serde_json::json!({
"path": "f.txt",
"old_string": "world",
"new_string": "rust\nand more"
}),
ws.path(),
&caveats,
None,
)
.await;
assert!(out.starts_with("edited f.txt (+1 lines"), "got: {out}");
assert_eq!(
std::fs::read_to_string(ws.path().join("f.txt")).unwrap(),
"hello rust\nand more\nsecond line\n"
);
}
#[tokio::test]
async fn edit_file_rejects_empty_missing_and_ambiguous_old_string() {
let ws = tempfile::TempDir::new().unwrap();
std::fs::write(ws.path().join("f.txt"), "dup\ndup\n").unwrap();
let caveats = caveats_rw(ws.path());
let out = run_tool(
"edit_file",
serde_json::json!({"path": "f.txt", "old_string": "", "new_string": "x"}),
ws.path(),
&caveats,
None,
)
.await;
assert!(out.contains("old_string must not be empty"), "got: {out}");
let out = run_tool(
"edit_file",
serde_json::json!({"path": "f.txt", "old_string": "absent", "new_string": "x"}),
ws.path(),
&caveats,
None,
)
.await;
assert!(out.contains("old_string not found in f.txt"), "got: {out}");
assert!(out.contains("do not guess again"), "got: {out}");
assert!(
out.contains("dup"),
"miss error must include the file content: {out}"
);
let out = run_tool(
"edit_file",
serde_json::json!({"path": "f.txt", "old_string": "dup", "new_string": "x"}),
ws.path(),
&caveats,
None,
)
.await;
assert!(out.contains("matches 2 locations"), "got: {out}");
assert_eq!(
std::fs::read_to_string(ws.path().join("f.txt")).unwrap(),
"dup\ndup\n"
);
}
#[tokio::test]
async fn edit_file_denied_outside_fs_write_scope_and_missing_file() {
let ws = tempfile::TempDir::new().unwrap();
let caveats = Caveats {
fs_write: Scope::none(),
..caveats_rw(ws.path())
};
let out = run_tool(
"edit_file",
serde_json::json!({"path": "f.txt", "old_string": "a", "new_string": "b"}),
ws.path(),
&caveats,
None,
)
.await;
assert!(
out.contains("capability denied: fs_write"),
"denied before any fs access, got: {out}"
);
let caveats = caveats_rw(ws.path());
let out = run_tool(
"edit_file",
serde_json::json!({"path": "missing.txt", "old_string": "a", "new_string": "b"}),
ws.path(),
&caveats,
None,
)
.await;
assert!(out.contains("error reading missing.txt"), "got: {out}");
}
#[tokio::test]
async fn edit_file_appends_build_check_result() {
let ws = tempfile::TempDir::new().unwrap();
std::fs::write(ws.path().join("f.txt"), "old\n").unwrap();
let caveats = caveats_rw(ws.path());
let out = run_tool(
"edit_file",
serde_json::json!({"path": "f.txt", "old_string": "old", "new_string": "new"}),
ws.path(),
&caveats,
Some(passing_build_check_cmd()),
)
.await;
assert!(out.contains("✓ build check passed"), "got: {out}");
let failing_check = failing_build_check_cmd("broke");
let out = run_tool(
"edit_file",
serde_json::json!({"path": "f.txt", "old_string": "new", "new_string": "newer"}),
ws.path(),
&caveats,
Some(&failing_check),
)
.await;
assert!(out.contains("✗ build check failed"), "got: {out}");
assert!(out.contains("broke"), "model sees the failure text: {out}");
}
#[tokio::test]
async fn write_file_shrink_guard_refuses_large_deletion() {
let ws = tempfile::TempDir::new().unwrap();
let big: String = (0..100).map(|i| format!("line {i}\n")).collect();
std::fs::write(ws.path().join("big.txt"), &big).unwrap();
let caveats = caveats_rw(ws.path());
let out = run_tool(
"write_file",
serde_json::json!({"path": "big.txt", "content": "tiny\n"}),
ws.path(),
&caveats,
None,
)
.await;
assert!(
out.contains("would shrink big.txt from 100 → 1 lines"),
"got: {out}"
);
assert!(out.contains("edit_file"), "points at the safer tool: {out}");
assert_eq!(
std::fs::read_to_string(ws.path().join("big.txt")).unwrap(),
big
);
}
#[tokio::test]
async fn write_file_creates_parent_directories() {
let ws = tempfile::TempDir::new().unwrap();
let caveats = caveats_rw(ws.path());
let out = run_tool(
"write_file",
serde_json::json!({"path": "a/b/c.txt", "content": "nested"}),
ws.path(),
&caveats,
None,
)
.await;
assert!(out.starts_with("wrote a/b/c.txt"), "got: {out}");
assert_eq!(
std::fs::read_to_string(ws.path().join("a/b/c.txt")).unwrap(),
"nested"
);
}
#[tokio::test]
async fn delete_file_removes_one_file_and_appends_build_check() {
let ws = tempfile::TempDir::new().unwrap();
std::fs::write(ws.path().join("old.rs"), "fn main() {}\n").unwrap();
let caveats = caveats_rw(ws.path());
let out = run_tool(
"delete_file",
serde_json::json!({"path": "old.rs"}),
ws.path(),
&caveats,
Some(passing_build_check_cmd()),
)
.await;
assert!(out.starts_with("deleted old.rs"), "got: {out}");
assert!(out.contains("✓ build check passed"), "got: {out}");
assert!(
!ws.path().join("old.rs").exists(),
"delete_file must remove the target file"
);
}
#[tokio::test]
async fn delete_file_records_digest_to_absent_transition() {
let ws = tempfile::TempDir::new().unwrap();
let original = b"retired implementation\n";
std::fs::write(ws.path().join("old.rs"), original).unwrap();
let sink = RecordingArtifactSink::default();
let out = run_artifact_tool(
"delete_file",
serde_json::json!({"path": "old.rs"}),
ws.path(),
&caveats_rw(ws.path()),
None,
&sink,
)
.await;
assert!(out.starts_with("deleted old.rs"), "got: {out}");
let artifact = sink.only_artifact();
assert_eq!(artifact.kind(), ArtifactKind::FileChange);
assert_eq!(artifact.locator(), Some("old.rs"));
assert_eq!(artifact.metadata()["operation"], "delete_file");
assert_eq!(artifact.metadata()["before"]["available"], true);
assert_eq!(artifact.metadata()["before"]["exists"], true);
assert_eq!(
artifact.metadata()["before"]["digest"],
blake3::hash(original).to_hex().to_string()
);
assert_eq!(artifact.metadata()["after"]["available"], true);
assert_eq!(artifact.metadata()["after"]["exists"], false);
assert!(artifact.metadata()["after"]["digest"].is_null());
}
#[tokio::test]
async fn write_only_authority_does_not_record_a_preimage_digest() {
let ws = tempfile::TempDir::new().unwrap();
let original = b"secret preimage\n";
let replacement = b"public result\n";
std::fs::write(ws.path().join("state.txt"), original).unwrap();
let caveats = Caveats {
fs_read: Scope::none(),
..caveats_rw(ws.path())
};
let sink = RecordingArtifactSink::default();
let out = run_artifact_tool(
"write_file",
serde_json::json!({
"path": "state.txt",
"content": std::str::from_utf8(replacement).unwrap(),
}),
ws.path(),
&caveats,
None,
&sink,
)
.await;
assert!(out.starts_with("wrote state.txt"), "got: {out}");
let artifact = sink.only_artifact();
assert_eq!(artifact.metadata()["before"]["available"], false);
assert_eq!(
artifact.metadata()["before"]["reason"],
"fs_read_not_granted"
);
assert!(artifact.metadata()["before"].get("digest").is_none());
assert_eq!(
artifact.metadata()["after"]["digest"],
blake3::hash(replacement).to_hex().to_string()
);
assert!(
!artifact
.metadata()
.to_string()
.contains(&blake3::hash(original).to_hex().to_string()),
"the preimage digest must not become a persistent read oracle"
);
}
#[cfg(unix)]
#[tokio::test]
async fn build_check_mutation_is_not_recorded_as_the_governed_write_postimage() {
let ws = tempfile::TempDir::new().unwrap();
let governed = b"governed bytes\n";
let build_hook = b"build-hook bytes\n";
let sink = RecordingArtifactSink::default();
let out = run_artifact_tool(
"write_file",
serde_json::json!({
"path": "target.txt",
"content": std::str::from_utf8(governed).unwrap(),
}),
ws.path(),
&caveats_rw(ws.path()),
Some("printf 'build-hook bytes\\n' > target.txt"),
&sink,
)
.await;
assert!(out.contains("build check passed"), "got: {out}");
assert_eq!(
std::fs::read(ws.path().join("target.txt")).unwrap(),
build_hook
);
let artifact = sink.only_artifact();
assert_eq!(artifact.metadata()["operation"], "write_file");
assert_eq!(
artifact.metadata()["after"]["digest"],
blake3::hash(governed).to_hex().to_string(),
"the artifact must describe the tool's immediate verified write"
);
assert_ne!(
artifact.metadata()["after"]["digest"],
blake3::hash(build_hook).to_hex().to_string(),
"a later build hook mutation must not be attributed to write_file"
);
}
#[cfg(unix)]
#[tokio::test]
async fn physical_symlink_escape_mutates_under_existing_policy_but_emits_no_artifact() {
let ws = tempfile::TempDir::new().unwrap();
let outside = tempfile::TempDir::new().unwrap();
std::fs::write(outside.path().join("target.txt"), "outside before\n").unwrap();
std::os::unix::fs::symlink(outside.path(), ws.path().join("link")).unwrap();
let sink = RecordingArtifactSink::default();
let out = run_artifact_tool(
"write_file",
serde_json::json!({
"path": "link/target.txt",
"content": "outside after\n",
}),
ws.path(),
&caveats_rw(ws.path()),
None,
&sink,
)
.await;
assert!(out.starts_with("wrote link/target.txt"), "got: {out}");
assert_eq!(
std::fs::read_to_string(outside.path().join("target.txt")).unwrap(),
"outside after\n",
"this pins the existing lexical mutation policy"
);
assert!(
out.contains("no file-change artifact was recorded")
&& out.contains("physical path could not be proven inside the workspace"),
"the physical escape must be surfaced honestly: {out}"
);
assert!(
sink.is_empty(),
"an out-of-workspace mutation must not be claimed as a workspace artifact"
);
}
#[tokio::test]
async fn delete_file_denies_missing_files_directories_and_fs_write_misses() {
let ws = tempfile::TempDir::new().unwrap();
std::fs::write(ws.path().join("secret.txt"), "x").unwrap();
std::fs::create_dir(ws.path().join("dir")).unwrap();
let denied = Caveats {
fs_write: Scope::none(),
..caveats_rw(ws.path())
};
let out = run_tool(
"delete_file",
serde_json::json!({"path": "secret.txt"}),
ws.path(),
&denied,
None,
)
.await;
assert!(out.contains("capability denied: fs_write"), "got: {out}");
assert!(
ws.path().join("secret.txt").exists(),
"denied delete must not remove the file"
);
let caveats = caveats_rw(ws.path());
let out = run_tool(
"delete_file",
serde_json::json!({"path": "missing.txt"}),
ws.path(),
&caveats,
None,
)
.await;
assert!(out.contains("file does not exist"), "got: {out}");
let out = run_tool(
"delete_file",
serde_json::json!({"path": "dir"}),
ws.path(),
&caveats,
None,
)
.await;
assert!(out.contains("refuses directories"), "got: {out}");
assert!(ws.path().join("dir").is_dir(), "directory must remain");
}
#[tokio::test]
async fn read_file_denial_and_missing_file_errors() {
let ws = tempfile::TempDir::new().unwrap();
std::fs::write(ws.path().join("secret.txt"), "x").unwrap();
let denied = Caveats {
fs_read: Scope::none(),
..caveats_rw(ws.path())
};
let out = run_tool(
"read_file",
serde_json::json!({"path": "secret.txt"}),
ws.path(),
&denied,
None,
)
.await;
assert!(out.contains("capability denied: fs_read"), "got: {out}");
let caveats = caveats_rw(ws.path());
let out = run_tool(
"read_file",
serde_json::json!({"path": "nope.txt"}),
ws.path(),
&caveats,
None,
)
.await;
assert!(out.contains("error reading nope.txt"), "got: {out}");
}
#[tokio::test]
async fn list_dir_denial_and_missing_dir_errors() {
let ws = tempfile::TempDir::new().unwrap();
let denied = Caveats {
fs_read: Scope::none(),
..caveats_rw(ws.path())
};
let out = run_tool(
"list_dir",
serde_json::json!({"path": "."}),
ws.path(),
&denied,
None,
)
.await;
assert!(out.contains("capability denied: fs_read"), "got: {out}");
let caveats = caveats_rw(ws.path());
let out = run_tool(
"list_dir",
serde_json::json!({"path": "not-a-dir"}),
ws.path(),
&caveats,
None,
)
.await;
assert!(out.starts_with("error:"), "got: {out}");
}
#[tokio::test]
async fn unknown_tool_name_is_reported_not_executed() {
let ws = tempfile::TempDir::new().unwrap();
let caveats = caveats_rw(ws.path());
let out = run_tool(
"definitely_not_a_tool",
serde_json::json!({}),
ws.path(),
&caveats,
None,
)
.await;
assert!(
out.starts_with("unknown tool: definitely_not_a_tool"),
"got: {out}"
);
assert!(out.contains("Available tools include:"), "got: {out}");
}
#[test]
fn alias_rewrites_shell_names_to_run_command() {
for n in [
"execute",
"exec",
"bash",
"shell",
"sh",
"zsh",
"terminal",
"run_shell_command",
"shell_command",
"system",
] {
assert!(
matches!(
resolve_tool_alias(n),
Some(AliasOutcome::Rewrite("run_command"))
),
"{n} should rewrite to run_command"
);
}
}
#[test]
fn alias_corrects_edit_and_create_names() {
for n in [
"str_replace_editor",
"str_replace",
"apply_patch",
"edit",
"replace_in_file",
] {
let Some(AliasOutcome::Correct(msg)) = resolve_tool_alias(n) else {
panic!("{n} should produce a Correct outcome");
};
assert!(msg.contains("edit_file"), "{n}: {msg}");
assert!(msg.contains("write_file"), "{n}: {msg}");
}
for n in ["create_file", "new_file", "touch"] {
let Some(AliasOutcome::Correct(msg)) = resolve_tool_alias(n) else {
panic!("{n} should produce a Correct outcome");
};
assert!(msg.contains("write_file"), "{n}: {msg}");
}
for n in ["remove_file", "delete", "remove", "unlink", "rm_file"] {
let Some(AliasOutcome::Correct(msg)) = resolve_tool_alias(n) else {
panic!("{n} should produce a Correct outcome");
};
assert!(msg.contains("delete_file"), "{n}: {msg}");
assert!(msg.contains("fs_write"), "{n}: {msg}");
}
}
#[test]
fn alias_coaches_mkdir_to_write_file() {
for n in [
"mkdir",
"make_dir",
"makedirs",
"mkdirs",
"create_dir",
"create_directory",
] {
let Some(AliasOutcome::Correct(msg)) = resolve_tool_alias(n) else {
panic!("{n} should produce a Correct outcome");
};
assert!(msg.contains("write_file"), "{n}: {msg}");
assert!(msg.contains("create_dir_all"), "{n}: {msg}");
}
let Some(AliasOutcome::Correct(msg)) = resolve_tool_alias("touch") else {
panic!("touch should still be a create-file Correct outcome");
};
assert!(msg.contains("write_file"), "touch: {msg}");
}
#[test]
fn alias_passes_through_real_and_mcp_names() {
for n in [
"run_command",
"read_file",
"write_file",
"edit_file",
"delete_file",
"git",
"update_plan",
"plan_get",
"server__do_thing",
] {
assert!(
resolve_tool_alias(n).is_none(),
"{n} must dispatch unchanged"
);
}
}
#[test]
fn alias_corrects_plan_names_to_update_plan() {
for n in [
"make_plan",
"create_plan",
"plan",
"planning",
"todo",
"todos",
"todo_write",
] {
let Some(AliasOutcome::Correct(msg)) = resolve_tool_alias(n) else {
panic!("{n} should produce a Correct outcome");
};
assert!(msg.contains("update_plan"), "{n}: {msg}");
}
for n in ["enter_plan_mode", "exit_plan_mode"] {
assert!(
resolve_tool_alias(n).is_none(),
"{n} is a real tool, not an alias"
);
}
for n in [
"next_step",
"complete_step",
"finish_step",
"mark_done",
"step_done",
] {
let Some(AliasOutcome::Correct(msg)) = resolve_tool_alias(n) else {
panic!("{n} should produce a Correct outcome");
};
assert!(msg.contains("update_plan"), "{n}: {msg}");
assert!(msg.contains("completed"), "{n}: {msg}");
}
assert!(
resolve_tool_alias("update_plan").is_none(),
"update_plan must dispatch as the real tool, not a self-alias"
);
}
#[test]
fn alias_rewrites_plan_read_names_to_plan_get() {
for n in [
"get_plan",
"show_plan",
"read_plan",
"current_plan",
"what_was_i_doing",
] {
assert!(
matches!(
resolve_tool_alias(n),
Some(AliasOutcome::Rewrite("plan_get"))
),
"{n} should rewrite to plan_get"
);
}
}
#[test]
fn alias_rewrites_resume_reaches_to_resume_context() {
for n in [
"resume",
"where_were_we",
"where_did_we_leave_off",
"catch_me_up",
"recap",
] {
assert!(
matches!(
resolve_tool_alias(n),
Some(AliasOutcome::Rewrite("resume_context"))
),
"{n} should rewrite to resume_context"
);
}
assert!(
resolve_tool_alias("resume_context").is_none(),
"the real tool name must return None, not a self-Rewrite"
);
assert!(
matches!(
resolve_tool_alias("what_was_i_doing"),
Some(AliasOutcome::Rewrite("plan_get"))
),
"what_was_i_doing must stay → plan_get"
);
}
#[test]
fn alias_corrects_crew_names_and_flags_team_gating() {
for n in [
"delegate",
"spawn_agent",
"subagent",
"sub_agent",
"crew_dispatch",
"run_crew",
"dispatch_crew",
"fork_agent",
"assign",
"team",
] {
let Some(AliasOutcome::Correct(msg)) = resolve_tool_alias(n) else {
panic!("{n} should produce a Correct outcome");
};
assert!(msg.contains("compose_roster"), "{n}: {msg}");
assert!(msg.contains("crew"), "{n}: {msg}");
assert!(msg.contains("/team"), "{n}: {msg}");
assert!(
msg.contains("human enables") || msg.contains("cannot turn it on yourself"),
"crew correction must not imply the model can invoke it: {msg}"
);
}
}
#[test]
fn alias_corrects_workflow_names_to_plan_plus_crew() {
for n in ["workflow", "run_workflow", "start_workflow", "pipeline"] {
let Some(AliasOutcome::Correct(msg)) = resolve_tool_alias(n) else {
panic!("{n} should produce a Correct outcome");
};
assert!(msg.contains("no workflow tool"), "{n}: {msg}");
assert!(msg.contains("update_plan"), "{n}: {msg}");
}
}
#[test]
fn levenshtein_matches_known_distances() {
assert_eq!(levenshtein("kitten", "sitting"), 3);
assert_eq!(levenshtein("read_file", "read_file"), 0);
assert_eq!(levenshtein("read_fil", "read_file"), 1);
assert_eq!(levenshtein("", "abc"), 3);
}
#[test]
fn nearest_tool_name_suggests_close_only() {
assert_eq!(nearest_tool_name("read_fil"), Some("read_file"));
assert_eq!(nearest_tool_name("edit_fil"), Some("edit_file"));
assert_eq!(nearest_tool_name("memory_fetchh"), Some("memory_fetch"));
assert_eq!(nearest_tool_name("definitely_not_a_tool"), None);
}
#[test]
fn unknown_tool_message_names_catalog_and_suggestion() {
let m = unknown_tool_message("read_fil");
assert!(m.starts_with("unknown tool: read_fil"), "{m}");
assert!(m.contains("Did you mean 'read_file'"), "{m}");
assert!(m.contains("Available tools include:"), "{m}");
let m2 = unknown_tool_message("zzzzzzzzzzzz");
assert!(m2.starts_with("unknown tool: zzzzzzzzzzzz"), "{m2}");
assert!(!m2.contains("Did you mean"), "{m2}");
assert!(m2.contains("Available tools include:"), "{m2}");
}
#[tokio::test]
async fn execute_tool_corrects_str_replace_editor_alias() {
let ws = tempfile::TempDir::new().unwrap();
let caveats = caveats_rw(ws.path());
let out = run_tool(
"str_replace_editor",
serde_json::json!({"command": "str_replace", "path": "f.txt"}),
ws.path(),
&caveats,
None,
)
.await;
assert!(out.contains("edit_file"), "got: {out}");
assert!(!out.starts_with("unknown tool"), "got: {out}");
}
struct MockGate {
allow: bool,
base: Caveats,
asks: Vec<(String, String)>,
}
impl MockGate {
fn new(allow: bool, base: &Caveats) -> Self {
Self {
allow,
base: base.clone(),
asks: Vec::new(),
}
}
}
impl super::PermissionGate for MockGate {
fn ask(&mut self, requests: &[super::PermissionRequest]) -> super::PermissionDecision {
for r in requests {
self.asks
.push((r.tool.clone(), format!("{}:{}", r.kind.as_str(), r.target)));
}
if self.allow {
let grants: Vec<_> = requests
.iter()
.map(|r| (r.kind, r.target.clone()))
.collect();
super::PermissionDecision::Allow(crate::agentic::widen_caveats(&self.base, &grants))
} else {
super::PermissionDecision::Deny
}
}
fn ask_question(&mut self, _question: &str) -> Option<String> {
None
}
}
#[allow(clippy::too_many_arguments)]
async fn run_tool_gated(
name: &str,
args: serde_json::Value,
ws: &std::path::Path,
caveats: &Caveats,
gate: &mut MockGate,
) -> String {
execute_tool(
name,
&args,
&ws.to_string_lossy(),
false,
20,
caveats,
&mut NoMcp,
None,
None,
None,
None, Some(gate),
None,
None, None, None, None, None, None, None, )
.await
}
struct OneRemoteTool {
name: &'static str,
called: bool,
}
impl OneRemoteTool {
fn new(name: &'static str) -> Self {
Self {
name,
called: false,
}
}
}
#[async_trait::async_trait]
impl McpTools for OneRemoteTool {
fn handles(&self, name: &str) -> bool {
name == self.name
}
fn tool_defs(&self) -> Vec<serde_json::Value> {
vec![serde_json::json!({
"type": "function",
"function": { "name": self.name, "description": "", "parameters": {} }
})]
}
async fn call(&mut self, _name: &str, _args: &serde_json::Value) -> String {
self.called = true;
"remote-tool-ran".to_string()
}
}
async fn run_remote_gated(
name: &str,
ws: &std::path::Path,
caveats: &Caveats,
persona_tools: Option<&[String]>,
mcp: &mut dyn McpTools,
gate: Option<&mut MockGate>,
) -> String {
let gate = gate.map(|g| g as &mut dyn super::PermissionGate);
execute_tool_with_offload(
name,
&serde_json::json!({}),
&ws.to_string_lossy(),
false,
20,
caveats,
mcp,
None,
None,
None,
None,
gate,
None,
None,
None,
None,
None,
None,
None,
None,
false,
None,
persona_tools,
)
.await
}
#[allow(clippy::too_many_arguments)]
async fn run_tool_with_disposition(
name: &str,
args: serde_json::Value,
ws: &std::path::Path,
caveats: &Caveats,
mcp: &mut dyn McpTools,
gate: Option<&mut MockGate>,
step_ledger: Option<&dyn crate::agentic::scheduled::StepLedger>,
disposition: PromptDisposition,
) -> String {
let gate = gate.map(|gate| gate as &mut dyn PermissionGate);
execute_tool_with_offload_and_prompt_and_artifacts(
name,
&args,
&ws.to_string_lossy(),
false,
20,
caveats,
mcp,
None, None, None, None, None, None, None, gate,
None, None, None, None, None, None, None, step_ledger,
false, None, None, disposition,
)
.await
}
#[tokio::test]
async fn non_act_disposition_denies_mutation_exec_grants_and_generic_mcp() {
let ws = tempfile::TempDir::new().unwrap();
let caveats = Caveats::top();
let mut no_mcp = NoMcp;
let write = run_tool_with_disposition(
"write_file",
serde_json::json!({ "path": "must-not-write.txt", "content": "no" }),
ws.path(),
&caveats,
&mut no_mcp,
None,
None,
PromptDisposition::Research,
)
.await;
assert!(write.contains("current prompt disposition"), "got: {write}");
assert!(
!ws.path().join("must-not-write.txt").exists(),
"disposition rejection must precede the write handler"
);
let exec = run_tool_with_disposition(
"run_command",
serde_json::json!({ "command": "touch must-not-exec.txt" }),
ws.path(),
&caveats,
&mut no_mcp,
None,
None,
PromptDisposition::Explain,
)
.await;
assert!(exec.contains("current prompt disposition"), "got: {exec}");
assert!(
!ws.path().join("must-not-exec.txt").exists(),
"disposition rejection must precede the shell handler"
);
let mut gate = MockGate::new(true, &caveats);
let grant = run_tool_with_disposition(
"request_permissions",
serde_json::json!({
"capability": "fs_write",
"target": "/tmp/should-not-be-granted",
"reason": "test",
}),
ws.path(),
&caveats,
&mut no_mcp,
Some(&mut gate),
None,
PromptDisposition::Research,
)
.await;
assert!(grant.contains("current prompt disposition"), "got: {grant}");
assert!(
gate.asks.is_empty(),
"non-Act must not consult a grant gate"
);
let mut mcp = OneRemoteTool::new("incident__read");
let remote = run_tool_with_disposition(
"incident__read",
serde_json::json!({}),
ws.path(),
&caveats,
&mut mcp,
None,
None,
PromptDisposition::Research,
)
.await;
assert!(
remote.contains("current prompt disposition"),
"got: {remote}"
);
assert!(
!mcp.called,
"generic MCP must be denied before remote routing in non-Act"
);
std::fs::write(ws.path().join("evidence.txt"), "durable evidence\n").unwrap();
let read = run_tool_with_disposition(
"read_file",
serde_json::json!({ "path": "evidence.txt" }),
ws.path(),
&caveats,
&mut no_mcp,
None,
None,
PromptDisposition::Research,
)
.await;
assert!(
read.contains("durable evidence"),
"safe read must remain usable: {read}"
);
}
#[tokio::test]
async fn plan_disposition_updates_ledger_but_still_denies_workspace_mutation() {
use crate::agentic::scheduled::{SessionStepLedger, StepLedger};
let ws = tempfile::TempDir::new().unwrap();
let caveats = Caveats::top();
let ledger = SessionStepLedger::default();
let mut no_mcp = NoMcp;
let plan = run_tool_with_disposition(
"update_plan",
serde_json::json!({ "plan": [
{ "step": "inspect", "status": "completed" },
{ "step": "repair", "status": "in_progress" }
] }),
ws.path(),
&caveats,
&mut no_mcp,
None,
Some(&ledger),
PromptDisposition::Plan,
)
.await;
assert!(plan.starts_with("<plan>\n"), "{plan}");
assert_eq!(ledger.count(), 2);
let write = run_tool_with_disposition(
"write_file",
serde_json::json!({ "path": "must-not-write.txt", "content": "no" }),
ws.path(),
&caveats,
&mut no_mcp,
None,
Some(&ledger),
PromptDisposition::Plan,
)
.await;
assert!(write.contains("current prompt disposition"), "{write}");
assert!(!ws.path().join("must-not-write.txt").exists());
}
#[tokio::test]
async fn auto_mode_selector_dispatches_through_session_control_without_current_widening() {
#[derive(Default)]
struct RecordingControl(std::sync::Mutex<Vec<String>>);
impl crate::agentic::OperatingModeControl for RecordingControl {
fn select_operating_mode(&self, mode: &str) -> Result<String, String> {
self.0.lock().unwrap().push(mode.to_string());
Ok(format!("scheduled {mode}; current turn unchanged"))
}
}
let ws = tempfile::TempDir::new().unwrap();
let caveats = Caveats::top();
let control = RecordingControl::default();
let mut no_mcp = NoMcp;
let result = execute_tool_with_collaborators(
"select_operating_mode",
&serde_json::json!({ "mode": "dev" }),
ws.path().to_str().unwrap(),
false,
20,
&caveats,
&mut no_mcp,
ToolCollaborators {
operating_mode_control: Some(&control),
..Default::default()
},
false,
PromptDisposition::Research,
None,
)
.await
.unwrap();
assert!(result.contains("current turn unchanged"), "{result}");
assert_eq!(*control.0.lock().unwrap(), vec!["dev"]);
let unavailable = execute_tool_with_collaborators(
"select_operating_mode",
&serde_json::json!({ "mode": "dev" }),
ws.path().to_str().unwrap(),
false,
20,
&caveats,
&mut no_mcp,
ToolCollaborators::default(),
false,
PromptDisposition::Research,
None,
)
.await
.unwrap();
assert!(unavailable.contains("/mode auto"), "{unavailable}");
}
#[tokio::test]
async fn non_act_read_tools_do_not_consult_permission_gate() {
let ws = tempfile::TempDir::new().unwrap();
let mut caveats = Caveats::top();
caveats.net = crate::caveats::Scope::none();
let mut gate = MockGate::new(true, &caveats);
let mut mcp = NoMcp;
let _ = run_tool_with_disposition(
"web_fetch",
serde_json::json!({ "url": "https://example.com" }),
ws.path(),
&caveats,
&mut mcp,
Some(&mut gate),
None,
PromptDisposition::Research,
)
.await;
assert!(
gate.asks.is_empty(),
"a non-Act web read may be caveat-denied but must never mint net authority"
);
}
#[tokio::test]
async fn remote_tool_outside_allow_list_is_prompted_not_hard_vetoed() {
let ws = tempfile::TempDir::new().unwrap();
let caveats = crate::caveats::Caveats::top();
let coach = vec!["read_file".to_string()];
let mut mcp = OneRemoteTool::new("incident__create");
let mut gate = MockGate::new(false, &caveats);
let out = run_remote_gated(
"incident__create",
ws.path(),
&caveats,
Some(&coach),
&mut mcp,
Some(&mut gate),
)
.await;
assert!(!mcp.called, "denied remote tool must NOT dispatch");
assert_eq!(gate.asks.len(), 1, "the human was prompted");
assert_eq!(gate.asks[0].1, "remote_tool:incident__create");
assert!(out.contains("persona"), "returns a denial: {out}");
let mut mcp = OneRemoteTool::new("incident__create");
let mut gate = MockGate::new(true, &caveats);
let out = run_remote_gated(
"incident__create",
ws.path(),
&caveats,
Some(&coach),
&mut mcp,
Some(&mut gate),
)
.await;
assert!(mcp.called, "granted remote tool dispatches");
assert_eq!(out, "remote-tool-ran");
let granted = vec!["incident__create".to_string()];
let mut mcp = OneRemoteTool::new("incident__create");
let mut gate = MockGate::new(false, &caveats); run_remote_gated(
"incident__create",
ws.path(),
&caveats,
Some(&granted),
&mut mcp,
Some(&mut gate),
)
.await;
assert!(mcp.called, "allow-listed remote tool dispatches");
assert!(
gate.asks.is_empty(),
"no prompt when the persona already grants it"
);
let mut mcp = OneRemoteTool::new("incident__create");
let out = run_remote_gated(
"incident__create",
ws.path(),
&caveats,
Some(&coach),
&mut mcp,
None,
)
.await;
assert!(
!mcp.called,
"headless must fail closed for an ungranted remote tool"
);
assert!(out.contains("persona"), "headless denial: {out}");
}
#[test]
fn exec_denial_is_recoverable_not_a_dead_end() {
let envelope = serde_json::json!({
"denied": true,
"denials": [{
"kind": "exec",
"target": "mkdir",
"reason": "exec of \"mkdir\" is not within the granted authority"
}]
});
let out = denied_run_command_result(&envelope, false);
assert!(out.starts_with("capability denied:"), "got: {out}");
assert!(out.contains("request_permissions"), "got: {out}");
assert!(
!out.contains("extra_exec"),
"the model message must not carry the stale config hint: {out}"
);
}
#[test]
fn run_command_denial_is_single_level_not_nested() {
let envelope = serde_json::json!({
"denied": true,
"denials": [{
"kind": "exec",
"target": "export",
"reason": "exec of \"export\" is not within the granted authority"
}]
});
let out = denied_run_command_result(&envelope, false);
assert_eq!(
out.matches("capability denied:").count(),
1,
"exactly one denial level: {out}"
);
assert!(!out.contains("add it via"), "stale config hint: {out}");
assert!(!out.contains("extra_exec"), "stale config hint: {out}");
assert!(
!out.contains("does not permit 'exec of"),
"nested denial sentence: {out}"
);
assert!(
out.contains("exec of \"export\" is not within the granted authority"),
"got: {out}"
);
assert!(out.contains("request_permissions"), "got: {out}");
}
#[test]
fn parse_capability_maps_synonyms_and_rejects_unknown() {
assert_eq!(parse_capability("exec"), Some(DenialKind::Exec));
assert_eq!(parse_capability("shell"), Some(DenialKind::Exec));
assert_eq!(parse_capability("FS_READ"), Some(DenialKind::FsRead));
assert_eq!(parse_capability("write"), Some(DenialKind::FsWrite));
assert_eq!(parse_capability("network"), Some(DenialKind::Net));
assert_eq!(parse_capability("gpu"), None);
assert_eq!(parse_capability(""), None);
}
#[test]
fn request_permissions_grant_deny_and_no_gate() {
let base = Caveats::top();
let mut gate = MockGate::new(true, &base);
let out = execute_request_permissions(
&serde_json::json!({"capability": "exec", "target": "mkdir", "reason": "make a dir"}),
Some(&mut gate),
false,
20,
);
assert!(out.starts_with("granted:"), "got: {out}");
assert!(out.contains("Retry the original operation"), "got: {out}");
assert_eq!(gate.asks.len(), 1);
assert_eq!(
gate.asks[0],
("request_permissions".to_string(), "exec:mkdir".to_string())
);
let mut gate = MockGate::new(false, &base);
let out = execute_request_permissions(
&serde_json::json!({"capability": "fs_write", "target": "/tmp/x", "reason": "w"}),
Some(&mut gate),
false,
20,
);
assert!(out.starts_with("denied:"), "got: {out}");
assert!(out.contains("different approach"), "got: {out}");
let out = execute_request_permissions(
&serde_json::json!({"capability": "net", "target": "docs.rs", "reason": "fetch"}),
None,
false,
20,
);
assert!(out.contains("no operator available"), "got: {out}");
}
#[test]
fn request_permissions_coaches_bad_inputs() {
let out = execute_request_permissions(
&serde_json::json!({"capability": "gpu", "target": "x", "reason": "y"}),
None,
false,
20,
);
assert!(out.contains("unknown capability"), "got: {out}");
assert!(out.contains("fs_read"), "got: {out}");
let out = execute_request_permissions(
&serde_json::json!({"capability": "exec", "reason": "y"}),
None,
false,
20,
);
assert!(out.contains("'target' is required"), "got: {out}");
}
#[test]
fn request_permissions_is_a_real_tool_not_a_phantom() {
assert!(resolve_tool_alias("request_permissions").is_none());
assert!(ALL_TOOL_NAMES.contains(&"request_permissions"));
assert!(classify_phantom_reach(
"request_permissions",
&serde_json::json!({"capability": "exec", "target": "mkdir", "reason": "r"}),
"granted: the operator allowed exec for 'mkdir'.",
true,
)
.is_none());
}
struct AskGate {
answer: Option<String>,
asked: Vec<String>,
}
impl AskGate {
fn new(answer: Option<&str>) -> Self {
Self {
answer: answer.map(str::to_string),
asked: Vec::new(),
}
}
}
impl super::PermissionGate for AskGate {
fn ask(&mut self, _requests: &[super::PermissionRequest]) -> super::PermissionDecision {
super::PermissionDecision::Deny
}
fn ask_question(&mut self, question: &str) -> Option<String> {
self.asked.push(question.to_string());
self.answer.clone()
}
}
#[test]
fn request_user_input_returns_the_human_answer() {
let mut gate = AskGate::new(Some("postgres"));
let out = execute_request_user_input(
&serde_json::json!({"question": "which database should I target?"}),
Some(&mut gate),
false,
20,
);
assert_eq!(out, "postgres");
assert_eq!(
gate.asked,
vec!["which database should I target?".to_string()]
);
}
#[test]
fn request_user_input_no_gate_reports_headless_never_hangs() {
let out = execute_request_user_input(
&serde_json::json!({"question": "are you sure?"}),
None,
false,
20,
);
assert_eq!(out, HEADLESS_NO_HUMAN);
assert!(out.contains("no human available"), "got: {out}");
}
#[test]
fn request_user_input_gate_with_no_human_reports_headless() {
let mut gate = AskGate::new(None);
let out = execute_request_user_input(
&serde_json::json!({"question": "pick one"}),
Some(&mut gate),
false,
20,
);
assert_eq!(out, HEADLESS_NO_HUMAN);
}
#[test]
fn request_user_input_requires_a_question() {
let mut gate = AskGate::new(Some("unused"));
let out = execute_request_user_input(
&serde_json::json!({"question": " "}),
Some(&mut gate),
false,
20,
);
assert!(out.contains("'question' is required"), "got: {out}");
assert!(
gate.asked.is_empty(),
"gate not consulted for a blank question"
);
}
#[test]
fn request_user_input_is_a_real_tool_not_a_phantom() {
assert!(resolve_tool_alias("request_user_input").is_none());
assert!(ALL_TOOL_NAMES.contains(&"request_user_input"));
assert!(classify_phantom_reach(
"request_user_input",
&serde_json::json!({"question": "which db?"}),
"postgres",
true,
)
.is_none());
let defs = merged_tool_definitions(
&NoMcp, false, false, false, false, false, false, false, false, false, false, false,
false,
);
let names: Vec<&str> = defs
.as_array()
.unwrap()
.iter()
.filter_map(|d| d["function"]["name"].as_str())
.collect();
assert!(names.contains(&"request_user_input"), "got: {names:?}");
}
#[test]
fn ask_verbs_rewrite_to_request_user_input() {
for verb in [
"ask_user",
"ask_human",
"prompt_user",
"get_user_input",
"ask_question",
"clarify",
"ask",
] {
match resolve_tool_alias(verb) {
Some(AliasOutcome::Rewrite(c)) => {
assert_eq!(c, "request_user_input", "verb: {verb}");
}
_ => panic!("expected Rewrite(request_user_input) for {verb}"),
}
}
}
#[tokio::test]
async fn request_user_input_dispatches_through_execute_tool() {
let ws = tempfile::TempDir::new().unwrap();
let caveats = Caveats::top();
let mut gate = AskGate::new(Some("the answer"));
let out = execute_tool(
"request_user_input",
&serde_json::json!({"question": "what now?"}),
&ws.path().to_string_lossy(),
false,
20,
&caveats,
&mut NoMcp,
None,
None,
None,
None, Some(&mut gate),
None,
None, None, None, None, None, None, None, )
.await;
assert_eq!(out, "the answer");
assert_eq!(gate.asked, vec!["what now?".to_string()]);
}
#[test]
fn get_context_remaining_is_a_real_tool_not_a_phantom() {
assert!(resolve_tool_alias("get_context_remaining").is_none());
assert!(ALL_TOOL_NAMES.contains(&"get_context_remaining"));
assert!(classify_phantom_reach(
"get_context_remaining",
&serde_json::json!({}),
"Context budget: ~10 tokens used of an input ceiling of ~80 (80% of num_ctx 100).",
true,
)
.is_none());
let defs = merged_tool_definitions(
&NoMcp, false, false, false, false, false, false, false, false, false, false, false,
false,
);
assert!(defs
.as_array()
.unwrap()
.iter()
.any(|d| d["function"]["name"] == "get_context_remaining"));
}
#[test]
fn budget_verbs_rewrite_to_get_context_remaining() {
for n in [
"context_remaining",
"tokens_left",
"remaining_tokens",
"budget",
"how_much_context",
"context_budget",
"token_budget",
] {
assert!(
matches!(
resolve_tool_alias(n),
Some(AliasOutcome::Rewrite("get_context_remaining"))
),
"{n} must rewrite to get_context_remaining"
);
assert!(
is_context_remaining_call(n),
"{n} must be recognized as a budget call by the loop"
);
}
assert!(is_context_remaining_call("get_context_remaining"));
assert!(resolve_tool_alias("get_context_remaining").is_none());
assert!(!is_context_remaining_call("read_file"));
}
#[tokio::test]
async fn no_gate_denials_are_bit_for_bit_unchanged() {
let ws = tempfile::TempDir::new().unwrap();
std::fs::write(ws.path().join("secret.txt"), "x").unwrap();
let denied = Caveats {
fs_read: Scope::none(),
fs_write: Scope::none(),
..caveats_rw(ws.path())
};
let out = run_tool(
"read_file",
serde_json::json!({"path": "secret.txt"}),
ws.path(),
&denied,
None,
)
.await;
assert_eq!(out, denied_fs_result("fs_read", "secret.txt"));
let out = run_tool(
"list_dir",
serde_json::json!({"path": "."}),
ws.path(),
&denied,
None,
)
.await;
assert_eq!(out, denied_fs_result("fs_read", "."));
let out = run_tool(
"write_file",
serde_json::json!({"path": "a.txt", "content": "c"}),
ws.path(),
&denied,
None,
)
.await;
assert_eq!(out, denied_fs_result("fs_write", "a.txt"));
let out = run_tool(
"edit_file",
serde_json::json!({"path": "a.txt", "old_string": "a", "new_string": "b"}),
ws.path(),
&denied,
None,
)
.await;
assert_eq!(out, denied_fs_result("fs_write", "a.txt"));
let out = run_tool(
"delete_file",
serde_json::json!({"path": "secret.txt"}),
ws.path(),
&denied,
None,
)
.await;
assert_eq!(out, denied_fs_result("fs_write", "secret.txt"));
assert!(out.contains("request_permissions"), "got: {out}");
}
#[tokio::test]
async fn gate_allow_turns_fs_read_denial_into_the_real_result() {
let ws = tempfile::TempDir::new().unwrap();
std::fs::write(ws.path().join("secret.txt"), "the contents").unwrap();
let denied = Caveats {
fs_read: Scope::none(),
..caveats_rw(ws.path())
};
let mut gate = MockGate::new(true, &denied);
let out = run_tool_gated(
"read_file",
serde_json::json!({"path": "secret.txt"}),
ws.path(),
&denied,
&mut gate,
)
.await;
assert_eq!(out, "the contents");
let full = ws.path().join("secret.txt").to_string_lossy().into_owned();
assert_eq!(
gate.asks,
vec![("read_file".to_string(), format!("fs_read:{full}"))]
);
}
#[cfg(not(windows))]
#[tokio::test]
async fn permission_retry_closes_each_live_generation_before_the_next_starts() {
let _l = super::disable_ocap_tests::env_lock().await;
let _eng = super::disable_ocap_tests::EnvVar::set("NEWT_SHELL_ENGINE", "safe-subset");
#[derive(Default)]
struct LifecycleOutput(std::sync::Mutex<Vec<String>>);
impl crate::agentic::LiveToolOutput for LifecycleOutput {
fn start(&self, generation: u64) {
self.0.lock().unwrap().push(format!("start:{generation}"));
}
fn write(
&self,
generation: u64,
_stream: crate::agentic::ToolOutputStream,
chunk: &[u8],
) {
self.0.lock().unwrap().push(format!(
"write:{generation}:{}",
String::from_utf8_lossy(chunk)
));
}
fn finish(&self, generation: u64) {
self.0.lock().unwrap().push(format!("finish:{generation}"));
}
fn abandon(&self, generation: u64) {
self.0.lock().unwrap().push(format!("abandon:{generation}"));
}
}
let ws = tempfile::TempDir::new().unwrap();
let denied = Caveats {
exec: Scope::none(),
..caveats_rw(ws.path())
};
let mut gate = MockGate::new(true, &denied);
let sink = std::sync::Arc::new(LifecycleOutput::default());
let mut display = crate::agentic::display::ToolDisplay::new(Vec::new(), false, 80, 3);
let out = exec_confined_command(
"/bin/echo retry-visible",
&ws.path().to_string_lossy(),
false,
20,
&denied,
None,
Some(&mut gate),
false,
None,
Some(sink.clone()),
&mut display,
)
.await;
assert!(out.contains("retry-visible"), "retry result: {out}");
assert_eq!(gate.asks.len(), 1, "permission prompt count");
let events = sink.0.lock().unwrap();
let starts: Vec<_> = events
.iter()
.filter(|event| event.starts_with("start:"))
.cloned()
.collect();
assert_eq!(starts.len(), 2, "one viewport per attempt: {events:?}");
let first_generation = starts[0].trim_start_matches("start:");
let retry_start = events
.iter()
.position(|event| event == &starts[1])
.expect("retry start event");
assert!(
events[..retry_start]
.iter()
.any(|event| event == &format!("finish:{first_generation}")),
"retry started before the denied generation finished: {events:?}"
);
let second_generation = starts[1].trim_start_matches("start:");
assert!(
events.iter().any(|event| {
event.starts_with(&format!("write:{second_generation}:"))
&& event.contains("retry-visible")
}),
"retry bytes were not delivered to its generation: {events:?}"
);
let expected_finish = format!("finish:{second_generation}");
assert_eq!(events.last(), Some(&expected_finish), "events: {events:?}");
}
#[tokio::test]
async fn gate_deny_keeps_the_standard_denial_bit_for_bit() {
let ws = tempfile::TempDir::new().unwrap();
std::fs::write(ws.path().join("secret.txt"), "x").unwrap();
let denied = Caveats {
fs_read: Scope::none(),
..caveats_rw(ws.path())
};
let mut gate = MockGate::new(false, &denied);
let gated = run_tool_gated(
"read_file",
serde_json::json!({"path": "secret.txt"}),
ws.path(),
&denied,
&mut gate,
)
.await;
let ungated = run_tool(
"read_file",
serde_json::json!({"path": "secret.txt"}),
ws.path(),
&denied,
None,
)
.await;
assert_eq!(gated, ungated);
assert_eq!(gated, denied_fs_result("fs_read", "secret.txt"));
assert_eq!(gate.asks.len(), 1, "the human was asked exactly once");
}
#[tokio::test]
async fn gate_allow_turns_fs_write_denials_into_real_writes() {
let ws = tempfile::TempDir::new().unwrap();
std::fs::write(ws.path().join("f.txt"), "old\n").unwrap();
std::fs::write(ws.path().join("stale.txt"), "remove me\n").unwrap();
let denied = Caveats {
fs_write: Scope::none(),
..caveats_rw(ws.path())
};
let mut gate = MockGate::new(true, &denied);
let out = run_tool_gated(
"write_file",
serde_json::json!({"path": "new.txt", "content": "fresh"}),
ws.path(),
&denied,
&mut gate,
)
.await;
assert!(out.starts_with("wrote new.txt"), "got: {out}");
assert_eq!(
std::fs::read_to_string(ws.path().join("new.txt")).unwrap(),
"fresh"
);
let out = run_tool_gated(
"edit_file",
serde_json::json!({"path": "f.txt", "old_string": "old", "new_string": "new"}),
ws.path(),
&denied,
&mut gate,
)
.await;
assert!(out.starts_with("edited f.txt"), "got: {out}");
let out = run_tool_gated(
"delete_file",
serde_json::json!({"path": "stale.txt"}),
ws.path(),
&denied,
&mut gate,
)
.await;
assert!(out.starts_with("deleted stale.txt"), "got: {out}");
assert!(
!ws.path().join("stale.txt").exists(),
"gate-approved delete must remove the file"
);
assert_eq!(gate.asks.len(), 3);
assert_eq!(gate.asks[0].0, "write_file");
assert!(
gate.asks[1].1.starts_with("fs_write:"),
"got: {:?}",
gate.asks[1]
);
assert_eq!(gate.asks[2].0, "delete_file");
}
#[tokio::test]
async fn gate_allow_turns_list_dir_denial_into_the_listing() {
let ws = tempfile::TempDir::new().unwrap();
std::fs::write(ws.path().join("seen.txt"), "x").unwrap();
let denied = Caveats {
fs_read: Scope::none(),
..caveats_rw(ws.path())
};
let mut gate = MockGate::new(true, &denied);
let out = run_tool_gated(
"list_dir",
serde_json::json!({"path": "."}),
ws.path(),
&denied,
&mut gate,
)
.await;
assert!(out.contains("seen.txt"), "got: {out}");
}
#[tokio::test]
async fn gate_allow_without_real_coverage_is_still_denied() {
struct LyingGate;
impl super::PermissionGate for LyingGate {
fn ask(&mut self, _requests: &[super::PermissionRequest]) -> super::PermissionDecision {
super::PermissionDecision::Allow(Caveats {
fs_read: Scope::none(),
fs_write: Scope::none(),
exec: Scope::none(),
net: Scope::none(),
max_calls: CountBound::Unlimited,
valid_for_generation: Scope::All,
})
}
fn ask_question(&mut self, _question: &str) -> Option<String> {
None
}
}
let ws = tempfile::TempDir::new().unwrap();
std::fs::write(ws.path().join("secret.txt"), "x").unwrap();
let denied = Caveats {
fs_read: Scope::none(),
..caveats_rw(ws.path())
};
let mut gate = LyingGate;
let out = execute_tool(
"read_file",
&serde_json::json!({"path": "secret.txt"}),
&ws.path().to_string_lossy(),
false,
20,
&denied,
&mut NoMcp,
None,
None,
None,
None, Some(&mut gate),
None,
None, None, None, None, None, None, None, )
.await;
assert_eq!(out, denied_fs_result("fs_read", "secret.txt"));
}
#[tokio::test]
async fn web_fetch_gate_deny_dispatches_under_original_caveats() {
let ws = tempfile::TempDir::new().unwrap();
let caveats = caveats_rw(ws.path()); let mut gate = MockGate::new(false, &caveats);
let out = run_tool_gated(
"web_fetch",
serde_json::json!({"url": "https://denied.example.com:8443/page"}),
ws.path(),
&caveats,
&mut gate,
)
.await;
assert!(out.starts_with("error:"), "leash denial surfaces: {out}");
assert_eq!(
gate.asks,
vec![(
"web_fetch".to_string(),
"net:denied.example.com".to_string()
)]
);
}
#[tokio::test]
async fn web_fetch_github_denial_consults_permission_gate() {
let ws = tempfile::TempDir::new().unwrap();
let caveats = caveats_rw(ws.path()); let mut gate = MockGate::new(false, &caveats);
let out = run_tool_gated(
"web_fetch",
serde_json::json!({"url": "https://github.com/openai/codex"}),
ws.path(),
&caveats,
&mut gate,
)
.await;
assert!(out.starts_with("error:"), "leash denial surfaces: {out}");
assert_eq!(
gate.asks,
vec![("web_fetch".to_string(), "net:github.com".to_string())]
);
}
#[tokio::test]
async fn web_fetch_unparseable_url_never_prompts() {
let ws = tempfile::TempDir::new().unwrap();
let caveats = caveats_rw(ws.path());
let mut gate = MockGate::new(true, &caveats);
let out = run_tool_gated(
"web_fetch",
serde_json::json!({"url": "not-a-url"}),
ws.path(),
&caveats,
&mut gate,
)
.await;
assert!(out.starts_with("error:"), "got: {out}");
assert!(gate.asks.is_empty(), "no prompt for an unparseable URL");
}
#[tokio::test]
async fn save_note_without_sink_is_unknown_tool() {
let ws = tempfile::TempDir::new().unwrap();
let caveats = caveats_rw(ws.path());
let out = run_tool(
"save_note",
serde_json::json!({"action": "add", "text": "a fact"}),
ws.path(),
&caveats,
None,
)
.await;
assert!(out.starts_with("unknown tool: save_note"), "got: {out}");
}
#[tokio::test]
async fn save_note_with_sink_routes_through_execute_tool() {
use crate::agentic::note_sink::tests::MockSink;
let ws = tempfile::TempDir::new().unwrap();
let caveats = caveats_rw(ws.path());
let mut sink = MockSink::default();
let out = execute_tool(
"save_note",
&serde_json::json!({"action": "add", "text": "workspace builds with just check"}),
&ws.path().to_string_lossy(),
false,
20,
&caveats,
&mut NoMcp,
None,
Some(&mut sink),
None,
None, None,
None,
None, None, None, None, None, None, None, )
.await;
assert_eq!(sink.calls, vec!["add:workspace builds with just check"]);
assert!(
out.starts_with("note saved: workspace builds"),
"got: {out}"
);
}
#[tokio::test]
async fn recall_without_source_is_unknown_tool() {
let ws = tempfile::TempDir::new().unwrap();
let caveats = caveats_rw(ws.path());
let out = run_tool(
"recall",
serde_json::json!({"query": "tokio panic"}),
ws.path(),
&caveats,
None,
)
.await;
assert!(out.starts_with("unknown tool: recall"), "got: {out}");
}
#[tokio::test]
async fn recall_with_source_routes_through_execute_tool() {
use crate::agentic::recall::tests::{hit, MockSource};
let ws = tempfile::TempDir::new().unwrap();
let caveats = caveats_rw(ws.path());
let source = MockSource {
hits: vec![hit(
"123456789012-abcd",
"past work",
3,
">>>tokio<<< panic",
)],
..Default::default()
};
let out = execute_tool(
"recall",
&serde_json::json!({"query": "tokio panic"}),
&ws.path().to_string_lossy(),
false,
20,
&caveats,
&mut NoMcp,
None,
None,
Some(&source),
None, None,
None,
None, None, None, None, None, None, None, )
.await;
assert_eq!(
*source.calls.lock().unwrap(),
vec![("tokio panic".to_string(), 5)]
);
assert!(out.contains("«tokio» panic"), "got: {out}");
assert!(out.contains("past work"), "got: {out}");
}
#[tokio::test]
async fn memory_fetch_without_source_is_unknown_tool() {
let ws = tempfile::TempDir::new().unwrap();
let caveats = caveats_rw(ws.path());
let out = run_tool(
"memory_fetch",
serde_json::json!({"address": "note:1"}),
ws.path(),
&caveats,
None,
)
.await;
assert!(out.starts_with("unknown tool: memory_fetch"), "got: {out}");
}
#[tokio::test]
async fn memory_fetch_with_source_routes_through_execute_tool() {
use crate::agentic::memory_fetch::tests::MockSource;
use crate::agentic::MemAddr;
let ws = tempfile::TempDir::new().unwrap();
let caveats = caveats_rw(ws.path());
let source = MockSource {
body: Some("the exact note body".to_string()),
..Default::default()
};
let out = execute_tool(
"memory_fetch",
&serde_json::json!({"address": "note:1"}),
&ws.path().to_string_lossy(),
false,
20,
&caveats,
&mut NoMcp,
None,
None,
None,
Some(&source),
None,
None,
None, None, None, None, None, None, None, )
.await;
assert_eq!(out, "the exact note body");
assert_eq!(
*source.calls.lock().unwrap(),
vec![MemAddr::Note { id: "1".into() }]
);
}
}
#[cfg(test)]
mod disable_ocap_tests {
use super::super::NoMcp;
use super::*;
use crate::caveats::{Caveats, CountBound, Scope};
use tokio::sync::{Mutex, MutexGuard};
pub(crate) static ENV_LOCK: Mutex<()> = Mutex::const_new(());
pub(crate) async fn env_lock() -> MutexGuard<'static, ()> {
ENV_LOCK.lock().await
}
pub(crate) struct EnvVar {
key: &'static str,
saved: Option<String>,
}
impl EnvVar {
pub(crate) fn set(key: &'static str, value: &str) -> Self {
let saved = std::env::var(key).ok();
std::env::set_var(key, value);
Self { key, saved }
}
fn unset(key: &'static str) -> Self {
let saved = std::env::var(key).ok();
std::env::remove_var(key);
Self { key, saved }
}
}
impl Drop for EnvVar {
fn drop(&mut self) {
match self.saved.take() {
Some(v) => std::env::set_var(self.key, v),
None => std::env::remove_var(self.key),
}
}
}
fn caveats_no_exec(ws: &std::path::Path) -> Caveats {
Caveats {
fs_read: Scope::only([ws.to_string_lossy().into_owned()]),
fs_write: Scope::only([ws.to_string_lossy().into_owned()]),
exec: Scope::none(),
net: Scope::none(),
max_calls: CountBound::Unlimited,
valid_for_generation: Scope::All,
}
}
async fn run_tool(
name: &str,
args: serde_json::Value,
ws: &std::path::Path,
caveats: &Caveats,
) -> String {
run_tool_with_floor(name, args, ws, caveats, None).await
}
async fn run_tool_with_floor(
name: &str,
args: serde_json::Value,
ws: &std::path::Path,
caveats: &Caveats,
exec_floor: Option<&Scope<String>>,
) -> String {
execute_tool(
name,
&args,
&ws.to_string_lossy(),
false,
20,
caveats,
&mut NoMcp,
None,
None,
None,
None, None,
exec_floor,
None, None, None, None, None, None, None, )
.await
}
#[test]
fn ocap_disabled_requires_exactly_1() {
let _l = ENV_LOCK.blocking_lock();
{
let _unset = EnvVar::unset("NEWT_DISABLE_OCAP");
assert!(!ocap_disabled(), "absent ⇒ confinement stays on");
}
for (value, expected) in [
("1", true),
("0", false),
("", false),
("true", false),
("yes", false),
("YOLO", false),
] {
let _set = EnvVar::set("NEWT_DISABLE_OCAP", value);
assert_eq!(
ocap_disabled(),
expected,
"NEWT_DISABLE_OCAP={value:?} must read as {expected}"
);
}
}
#[test]
fn full_access_requested_requires_exactly_1() {
let _l = ENV_LOCK.blocking_lock();
{
let _unset = EnvVar::unset("NEWT_FULL_ACCESS");
assert!(!full_access_requested(), "absent ⇒ configured preset rules");
}
for (value, expected) in [
("1", true),
("0", false),
("", false),
("true", false),
("yes", false),
("FULL", false),
] {
let _set = EnvVar::set("NEWT_FULL_ACCESS", value);
assert_eq!(
full_access_requested(),
expected,
"NEWT_FULL_ACCESS={value:?} must read as {expected}"
);
}
}
#[test]
fn shadow_records_iff_the_run_is_unconfined() {
assert!(shadow_records(true, false), "yolo host bypass records");
assert!(
shadow_records(false, true),
"--full-access confined dispatch records (the #1176 parity fix)"
);
assert!(shadow_records(true, true), "both routes still record");
assert!(
!shadow_records(false, false),
"a genuinely confined session has a real leash — nothing to shadow"
);
}
#[tokio::test]
async fn flag_off_run_command_keeps_the_confined_dispatch_verbatim() {
let _l = env_lock().await;
let _off = EnvVar::unset("NEWT_DISABLE_OCAP");
let _eng = EnvVar::set("NEWT_SHELL_ENGINE", "safe-subset");
let ws = tempfile::TempDir::new().unwrap();
let caveats = caveats_no_exec(ws.path());
let out = run_tool(
"run_command",
serde_json::json!({"command": "echo hi"}),
ws.path(),
&caveats,
)
.await;
assert!(
out.contains("capability denied"),
"flag off ⇒ the confined dispatch must govern (deny) the command, got: {out}"
);
}
#[cfg(unix)]
#[tokio::test]
async fn yolo_runs_the_denied_command_on_the_host_shell() {
let _l = env_lock().await;
let _on = EnvVar::set("NEWT_DISABLE_OCAP", "1");
let ws = tempfile::TempDir::new().unwrap();
let caveats = caveats_no_exec(ws.path());
let out = run_tool(
"run_command",
serde_json::json!({"command": "echo yolo-ok"}),
ws.path(),
&caveats,
)
.await;
assert_eq!(out, "yolo-ok\n");
let out = run_tool(
"run_command",
serde_json::json!({"command": "exit 3"}),
ws.path(),
&caveats,
)
.await;
assert_eq!(out, "(exit 3)");
}
#[cfg(unix)]
#[tokio::test]
async fn run_command_output_over_budget_is_token_capped() {
let _l = env_lock().await;
let _on = EnvVar::set("NEWT_DISABLE_OCAP", "1");
let ws = tempfile::TempDir::new().unwrap();
let caveats = caveats_no_exec(ws.path());
let out = run_tool(
"run_command",
serde_json::json!({"command": "seq 1 60000"}),
ws.path(),
&caveats,
)
.await;
assert!(
out.len() < 41_500,
"model-facing output capped near the ~40k-char budget, got {} bytes",
out.len()
);
assert!(
out.contains("chars elided (head+tail shown"),
"carries the head+tail elision marker: {:?}",
&out[..out.len().min(400)]
);
assert!(
out.starts_with("1\n2\n3\n"),
"head preserved: {:?}",
&out[..out.len().min(160)]
);
assert!(
out.trim_end().ends_with("60000"),
"tail preserved, not dropped by the cap: {:?}",
&out[out.len().saturating_sub(160)..]
);
}
#[test]
fn exec_floor_permits_covers_each_branch() {
use crate::caveats::Scope;
assert!(exec_floor_permits(None, "rm -rf /"));
let only_echo = Scope::only(["echo".to_string()]);
assert!(exec_floor_permits(Some(&only_echo), ""));
assert!(exec_floor_permits(Some(&only_echo), "echo hi"));
assert!(!exec_floor_permits(Some(&only_echo), "rm hi"));
assert!(!exec_floor_permits(Some(&only_echo), "echo hi && rm x"));
assert!(!exec_floor_permits(Some(&only_echo), "echo a | tee b"));
assert!(!exec_floor_permits(Some(&only_echo), "echo $(rm x)"));
let all: Scope<String> = Scope::All;
assert!(exec_floor_permits(Some(&all), "anything goes"));
assert!(!exec_floor_permits(Some(&all), "anything; sneaky"));
}
#[test]
fn exec_floor_refuses_every_metacharacter_form() {
use crate::caveats::Scope;
let echo = Scope::only(["echo".to_string()]);
let attacks = [
"echo ok && rm -rf /tmp/x", "echo ok || rm -rf /tmp/x", "echo ok ; rm -rf /tmp/x", "echo ok | sh", "echo ok|sh", "echo $(rm x)", "echo ${IFS}rm", "echo `rm x`", "echo ok & rm x", "echo ok > /etc/passwd", "echo ok >> /etc/passwd", "echo < /etc/shadow", "echo ok 2> err", "(rm x)", "echo ok\nrm -rf /tmp/x", "echo ok\nrm x\n", ];
for a in attacks {
assert!(
!exec_floor_permits(Some(&echo), a),
"metacharacter form must NOT bypass the floor: {a:?}"
);
}
let leading_token_attacks = [
"rm -rf /tmp/x", "FOO=bar rm x", "/bin/echo ok", " rm x", "env rm x", "bash -c rm", ];
for a in leading_token_attacks {
assert!(
!exec_floor_permits(Some(&echo), a),
"out-of-floor leading token must be refused: {a:?}"
);
}
assert!(exec_floor_permits(Some(&echo), "echo hello world"));
assert!(exec_floor_permits(Some(&echo), "echo -n trailing"));
}
#[tokio::test]
async fn floor_blocks_disable_ocap_for_a_denied_exec() {
let _l = env_lock().await;
let _on = EnvVar::set("NEWT_DISABLE_OCAP", "1");
let _eng = EnvVar::set("NEWT_SHELL_ENGINE", "safe-subset");
let ws = tempfile::TempDir::new().unwrap();
let caveats = caveats_no_exec(ws.path());
let floor = crate::NamedPermissionPreset {
readonly: true,
..Default::default()
}
.clamp();
let out = run_tool_with_floor(
"run_command",
serde_json::json!({"command": "echo should-not-run"}),
ws.path(),
&caveats,
Some(&floor.exec),
)
.await;
assert_ne!(out, "should-not-run\n", "the floor must block the bypass");
assert!(
out.contains("capability denied"),
"fell to confined dispatch and was denied, got: {out}"
);
}
#[cfg(unix)]
#[tokio::test]
async fn floor_allows_disable_ocap_for_an_in_floor_exec() {
let _l = env_lock().await;
let _on = EnvVar::set("NEWT_DISABLE_OCAP", "1");
let ws = tempfile::TempDir::new().unwrap();
let caveats = caveats_no_exec(ws.path());
let floor = crate::NamedPermissionPreset {
readonly: true,
exec_allow: vec!["echo".to_string()],
..Default::default()
}
.clamp();
let out = run_tool_with_floor(
"run_command",
serde_json::json!({"command": "echo in-floor-ok"}),
ws.path(),
&caveats,
Some(&floor.exec),
)
.await;
assert_eq!(out, "in-floor-ok\n", "in-floor command runs unconfined");
}
#[tokio::test]
async fn floor_refuses_bypass_for_a_compound_command() {
let _l = env_lock().await;
let _on = EnvVar::set("NEWT_DISABLE_OCAP", "1");
let _eng = EnvVar::set("NEWT_SHELL_ENGINE", "safe-subset");
let ws = tempfile::TempDir::new().unwrap();
let caveats = caveats_no_exec(ws.path());
let floor = crate::NamedPermissionPreset {
readonly: true,
exec_allow: vec!["echo".to_string()],
..Default::default()
}
.clamp();
let out = run_tool_with_floor(
"run_command",
serde_json::json!({"command": "echo ok && rm -rf /tmp/x"}),
ws.path(),
&caveats,
Some(&floor.exec),
)
.await;
assert_ne!(out, "ok\n", "a compound command must not bypass the floor");
assert!(
out.contains("capability denied"),
"fell to confined dispatch and was denied, got: {out}"
);
}
#[cfg(unix)]
#[tokio::test]
async fn no_floor_keeps_disable_ocap_bit_for_bit() {
let _l = env_lock().await;
let _on = EnvVar::set("NEWT_DISABLE_OCAP", "1");
let ws = tempfile::TempDir::new().unwrap();
let caveats = caveats_no_exec(ws.path());
let out = run_tool_with_floor(
"run_command",
serde_json::json!({"command": "echo no-floor-ok"}),
ws.path(),
&caveats,
None,
)
.await;
assert_eq!(out, "no-floor-ok\n", "no floor ⇒ bypass unchanged");
}
#[cfg(unix)]
#[tokio::test]
async fn host_shell_envelope_matches_the_bridle_shape() {
let ws = tempfile::TempDir::new().unwrap();
let envelope = host_shell_dispatch(
"echo out; echo err >&2; exit 3",
&ws.path().to_string_lossy(),
None,
)
.await
.expect("host shell runs");
assert_eq!(envelope["exit_code"], 3);
assert_eq!(envelope["stdout"], "out\n");
assert_eq!(envelope["stderr"], "err\n");
assert_eq!(envelope["sandbox_kind"], "none");
assert!(envelope.get("denied").is_none(), "got: {envelope}");
assert!(envelope.get("denials").is_none(), "got: {envelope}");
assert!(!envelope_denied(&envelope));
assert_eq!(
shell_envelope_output(&envelope, 20, false, false, None, None),
"out\nerr\n"
);
}
#[test]
fn decode_shell_stream_preserves_valid_utf8() {
let text = "// ── Model — test ──\n";
assert_eq!(decode_shell_stream(text.as_bytes()), text);
}
#[test]
fn decode_shell_stream_repairs_bsd_cat_v_utf8_notation() {
let cat_v = b"\xe2M-^TM-^@ \xe2M-^@M-^T\n";
assert_eq!(decode_shell_stream(cat_v), "─ —\n");
}
#[test]
fn decode_shell_stream_repairs_two_byte_bsd_cat_v_notation() {
let cat_v = b"caf\xc3M-)\n";
assert_eq!(decode_shell_stream(cat_v), "café\n");
}
#[cfg(unix)]
#[tokio::test]
async fn yolo_keeps_the_venv_prefix_logic() {
let _l = env_lock().await;
let _on = EnvVar::set("NEWT_DISABLE_OCAP", "1");
let _venv = EnvVar::set("NEWT_VENV", "/opt/fake-venv");
let _virtual = EnvVar::unset("VIRTUAL_ENV");
let _paths = EnvVar::unset("NEWT_EXEC_PATHS");
let ws = tempfile::TempDir::new().unwrap();
let caveats = caveats_no_exec(ws.path());
let out = run_tool(
"run_command",
serde_json::json!({"command": "echo \"$VIRTUAL_ENV\""}),
ws.path(),
&caveats,
)
.await;
assert_eq!(out, "/opt/fake-venv\n");
}
#[tokio::test]
async fn yolo_auto_confirms_unrestricted_write_and_delete_prompts() {
let _l = env_lock().await;
let _on = EnvVar::set("NEWT_DISABLE_OCAP", "1");
let ws = tempfile::TempDir::new().unwrap();
let caveats = Caveats::top();
let out = run_tool(
"write_file",
serde_json::json!({"path": "auto.txt", "content": "ok\n"}),
ws.path(),
&caveats,
)
.await;
assert!(out.starts_with("wrote auto.txt"), "got: {out}");
assert_eq!(
std::fs::read_to_string(ws.path().join("auto.txt")).unwrap(),
"ok\n"
);
let out = run_tool(
"delete_file",
serde_json::json!({"path": "auto.txt"}),
ws.path(),
&caveats,
)
.await;
assert!(out.starts_with("deleted auto.txt"), "got: {out}");
assert!(
!ws.path().join("auto.txt").exists(),
"yolo-confirmed delete must remove the file"
);
}
#[tokio::test]
async fn unrestricted_write_and_delete_confirm_through_permission_gate() {
struct ConfirmGate {
answer: Option<String>,
questions: Vec<String>,
}
impl super::PermissionGate for ConfirmGate {
fn ask(&mut self, _requests: &[super::PermissionRequest]) -> super::PermissionDecision {
super::PermissionDecision::Deny
}
fn ask_question(&mut self, question: &str) -> Option<String> {
self.questions.push(question.to_string());
self.answer.clone()
}
}
let _l = env_lock().await;
let _off = EnvVar::unset("NEWT_DISABLE_OCAP");
let ws = tempfile::TempDir::new().unwrap();
let caveats = Caveats::top();
let mut gate = ConfirmGate {
answer: Some("y".to_string()),
questions: Vec::new(),
};
let out = execute_tool(
"write_file",
&serde_json::json!({"path": "guarded.txt", "content": "ok\n"}),
&ws.path().to_string_lossy(),
false,
20,
&caveats,
&mut NoMcp,
None,
None,
None,
None, Some(&mut gate),
None,
None, None, None, None, None, None, None, )
.await;
assert!(out.starts_with("wrote guarded.txt"), "got: {out}");
assert_eq!(
std::fs::read_to_string(ws.path().join("guarded.txt")).unwrap(),
"ok\n"
);
let out = execute_tool(
"delete_file",
&serde_json::json!({"path": "guarded.txt"}),
&ws.path().to_string_lossy(),
false,
20,
&caveats,
&mut NoMcp,
None,
None,
None,
None, Some(&mut gate),
None,
None, None, None, None, None, None, None, )
.await;
assert!(out.starts_with("deleted guarded.txt"), "got: {out}");
assert!(!ws.path().join("guarded.txt").exists());
assert_eq!(
gate.questions,
vec![
"Write this file? [y/N]".to_string(),
"Delete this file? [y/N]".to_string()
]
);
}
#[tokio::test]
async fn confined_dispatch_uses_env_seam_not_export_prefix_783() {
let _l = env_lock().await;
let _venv = EnvVar::set("NEWT_VENV", "/opt/fake-venv");
let _virtual = EnvVar::unset("VIRTUAL_ENV");
let _paths = EnvVar::unset("NEWT_EXEC_PATHS");
let cmd = "hostname; sw_vers 2>/dev/null | head -1; uname -s";
let args = confined_dispatch_args(cmd, "/work/dir");
assert_eq!(args["cmd"], cmd);
assert!(
!args["cmd"]
.as_str()
.expect("cmd is a string")
.contains("export "),
"confined cmd must not carry an export prefix: {args}"
);
assert_eq!(args["cwd"], "/work/dir");
assert_eq!(args["env"]["VIRTUAL_ENV"], "/opt/fake-venv");
let path = args["env"]["PATH"].as_str().expect("PATH in the env seam");
assert!(
path.starts_with("/opt/fake-venv/bin"),
"venv bin must be prepended to PATH: {path}"
);
}
#[tokio::test]
async fn confined_dispatch_env_seam_without_venv_783() {
let _l = env_lock().await;
let _venv = EnvVar::unset("NEWT_VENV");
let _virtual = EnvVar::unset("VIRTUAL_ENV");
let _paths = EnvVar::unset("NEWT_EXEC_PATHS");
let _pass = EnvVar::unset("NEWT_SHELL_ENV_PASSTHROUGH"); let _home = EnvVar::set("HOME", "/home/testuser");
let args = confined_dispatch_args("ls -la", "/work/dir");
assert_eq!(args["cmd"], "ls -la");
let env = &args["env"];
assert!(
env.get("VIRTUAL_ENV").is_none(),
"no venv ⇒ no VIRTUAL_ENV: {args}"
);
assert!(
env.get("PATH").is_none(),
"no venv/exec-paths ⇒ no PATH override: {args}"
);
assert_eq!(
env["HOME"], "/home/testuser",
"HOME must pass through: {args}"
);
assert!(
env.get("SHELL").is_some(),
"SHELL must identify the confined engine: {args}"
);
}
#[tokio::test]
async fn yolo_keeps_the_fs_workspace_fence() {
let _l = env_lock().await;
let _on = EnvVar::set("NEWT_DISABLE_OCAP", "1");
let ws = tempfile::TempDir::new().unwrap();
let caveats = caveats_no_exec(ws.path());
let escape = "/definitely-outside-the-fence/escape.txt";
let out = run_tool(
"write_file",
serde_json::json!({"path": escape, "content": "nope"}),
ws.path(),
&caveats,
)
.await;
assert_eq!(out, denied_fs_result("fs_write", escape));
assert!(!std::path::Path::new(escape).exists());
let out = run_tool(
"delete_file",
serde_json::json!({"path": escape}),
ws.path(),
&caveats,
)
.await;
assert_eq!(out, denied_fs_result("fs_write", escape));
let out = run_tool(
"read_file",
serde_json::json!({"path": "/etc/hostname"}),
ws.path(),
&caveats,
)
.await;
assert_eq!(out, denied_fs_result("fs_read", "/etc/hostname"));
}
#[cfg(unix)]
#[tokio::test]
async fn yolo_never_consults_the_permission_gate_for_exec() {
struct PanicGate;
impl super::PermissionGate for PanicGate {
fn ask(&mut self, requests: &[super::PermissionRequest]) -> super::PermissionDecision {
panic!("yolo exec must never prompt, but the gate was asked: {requests:?}");
}
fn ask_question(&mut self, question: &str) -> Option<String> {
panic!("yolo exec must never prompt, but the gate was asked: {question:?}");
}
}
let _l = env_lock().await;
let _on = EnvVar::set("NEWT_DISABLE_OCAP", "1");
let ws = tempfile::TempDir::new().unwrap();
let caveats = caveats_no_exec(ws.path());
let mut gate = PanicGate;
let out = execute_tool(
"run_command",
&serde_json::json!({"command": "echo no-prompt"}),
&ws.path().to_string_lossy(),
false,
20,
&caveats,
&mut NoMcp,
None,
None,
None,
None, Some(&mut gate),
None,
None, None, None, None, None, None, None, )
.await;
assert_eq!(out, "no-prompt\n");
}
#[tokio::test]
async fn yolo_keeps_the_tool_name_corrective_guard() {
let _l = env_lock().await;
let _on = EnvVar::set("NEWT_DISABLE_OCAP", "1");
let ws = tempfile::TempDir::new().unwrap();
let caveats = caveats_no_exec(ws.path());
let out = run_tool(
"run_command",
serde_json::json!({"command": "read_file foo.txt"}),
ws.path(),
&caveats,
)
.await;
assert!(out.contains("is a tool, not a shell command"), "got: {out}");
}
struct RoutingStubGit;
impl crate::agentic::GitTool for RoutingStubGit {
fn dispatch(
&self,
op: &str,
_args: &serde_json::Value,
_caps: &crate::git_caveats::GitCaveats,
) -> Result<String, String> {
match op {
"status" => Ok("on branch main (routed via git built-in)".to_string()),
other => Err(format!("unexpected routed git op '{other}'")),
}
}
}
async fn run_routed_with_git(command: &str, ws: &std::path::Path, caveats: &Caveats) -> String {
execute_tool(
"run_command",
&serde_json::json!({ "command": command }),
&ws.to_string_lossy(),
false,
20,
caveats,
&mut NoMcp,
None,
None,
None,
None, None, None, Some(&RoutingStubGit as &dyn crate::agentic::GitTool),
None, None, None, None, None, None, )
.await
}
#[test]
fn routing_disabled_requires_exactly_1_and_is_independent_of_ocap() {
let _l = ENV_LOCK.blocking_lock();
let _no_ocap = EnvVar::unset("NEWT_DISABLE_OCAP");
{
let _unset = EnvVar::unset("NEWT_NO_ROUTE");
assert!(!routing_disabled(), "absent ⇒ routing stays on");
}
for (value, expected) in [("1", true), ("0", false), ("", false), ("true", false)] {
let _set = EnvVar::set("NEWT_NO_ROUTE", value);
assert_eq!(routing_disabled(), expected, "NEWT_NO_ROUTE={value:?}");
assert!(
!ocap_disabled(),
"NEWT_NO_ROUTE must not imply --disable-ocap"
);
}
let _unset_route = EnvVar::unset("NEWT_NO_ROUTE");
let _on_ocap = EnvVar::set("NEWT_DISABLE_OCAP", "1");
assert!(ocap_disabled());
assert!(
!routing_disabled(),
"--disable-ocap must not imply --no-route"
);
}
#[tokio::test]
async fn routed_cat_goes_through_the_fs_floor_not_a_bypass() {
let _l = env_lock().await;
let _route_on = EnvVar::unset("NEWT_NO_ROUTE");
let _ocap_off = EnvVar::unset("NEWT_DISABLE_OCAP");
let _eng = EnvVar::set("NEWT_SHELL_ENGINE", "safe-subset");
let ws = tempfile::TempDir::new().unwrap();
let caveats = caveats_no_exec(ws.path()); let out = run_tool(
"run_command",
serde_json::json!({ "command": "cat /etc/shadow" }),
ws.path(),
&caveats,
)
.await;
assert!(
out.contains("capability denied: fs_read does not permit")
&& out.contains("/etc/shadow"),
"routed cat must hit the fs floor, not run unconfined; got: {out}"
);
}
#[tokio::test]
async fn routed_git_status_dispatches_through_the_git_builtin() {
let _l = env_lock().await;
let _route_on = EnvVar::unset("NEWT_NO_ROUTE");
let _ocap_off = EnvVar::unset("NEWT_DISABLE_OCAP");
let ws = tempfile::TempDir::new().unwrap();
let caveats = caveats_no_exec(ws.path());
let out = run_routed_with_git("git status", ws.path(), &caveats).await;
assert!(
out.contains("routed via git built-in"),
"git status must route to the governed git built-in; got: {out}"
);
}
#[tokio::test]
async fn routed_rm_dispatches_through_delete_file() {
let _l = env_lock().await;
let _route_on = EnvVar::unset("NEWT_NO_ROUTE");
let _ocap_off = EnvVar::unset("NEWT_DISABLE_OCAP");
let ws = tempfile::TempDir::new().unwrap();
std::fs::write(ws.path().join("stale.txt"), "remove me\n").unwrap();
let caveats = caveats_no_exec(ws.path());
let out = run_tool(
"run_command",
serde_json::json!({ "command": "rm stale.txt" }),
ws.path(),
&caveats,
)
.await;
assert!(out.starts_with("deleted stale.txt"), "got: {out}");
assert!(
!ws.path().join("stale.txt").exists(),
"routed rm must remove the file through delete_file"
);
}
#[tokio::test]
async fn state_modifying_git_add_is_not_routed() {
let _l = env_lock().await;
let _route_on = EnvVar::unset("NEWT_NO_ROUTE");
let _ocap_off = EnvVar::unset("NEWT_DISABLE_OCAP");
let ws = tempfile::TempDir::new().unwrap();
let caveats = caveats_no_exec(ws.path());
let out = run_routed_with_git("git add a.txt", ws.path(), &caveats).await;
assert!(
!out.contains("routed"),
"git add must NOT route to the git built-in; got: {out}"
);
assert!(out.contains("is a tool, not a shell command"), "got: {out}");
}
#[tokio::test]
async fn no_route_bypasses_routing_but_keeps_l3() {
let _l = env_lock().await;
let _route_off = EnvVar::set("NEWT_NO_ROUTE", "1");
let _ocap_off = EnvVar::unset("NEWT_DISABLE_OCAP");
let _eng = EnvVar::set("NEWT_SHELL_ENGINE", "safe-subset");
assert!(routing_disabled() && !ocap_disabled(), "L2 off, L3 on");
let ws = tempfile::TempDir::new().unwrap();
let caveats = caveats_no_exec(ws.path());
let out = run_tool(
"run_command",
serde_json::json!({ "command": "cat /etc/shadow" }),
ws.path(),
&caveats,
)
.await;
assert!(
!out.contains("fs_read does not permit"),
"--no-route must not route to read_file; got: {out}"
);
assert!(
out.contains("capability denied"),
"the L3 confined dispatch must still gate the command; got: {out}"
);
}
}