use super::crew_tool::CrewRunner;
use super::display::{print_denied, print_tool_call, print_tool_output};
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::recall::{execute_recall, recall_tool_definition, RecallSource};
use super::spill::{self, SpillStore};
use crate::caveats::CaveatsExt as _;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::LazyLock;
const DEFAULT_READ_LIMIT: usize = 2_000;
const DEFAULT_MAX_OUTPUT_TOKENS: usize = 10_000;
const DEFAULT_OUTPUT_HEAD_TOKENS: usize = 1_500;
static MAX_OUTPUT_TOKENS: AtomicUsize = AtomicUsize::new(DEFAULT_MAX_OUTPUT_TOKENS);
static OUTPUT_HEAD_TOKENS: AtomicUsize = AtomicUsize::new(DEFAULT_OUTPUT_HEAD_TOKENS);
pub fn set_max_output_tokens(max_tokens: usize) {
MAX_OUTPUT_TOKENS.store(max_tokens, Ordering::Relaxed);
}
pub fn set_output_head_tokens(head_tokens: usize) {
OUTPUT_HEAD_TOKENS.store(head_tokens, Ordering::Relaxed);
}
fn max_output_tokens() -> usize {
MAX_OUTPUT_TOKENS.load(Ordering::Relaxed)
}
fn output_head_tokens() -> usize {
OUTPUT_HEAD_TOKENS.load(Ordering::Relaxed)
}
fn cap_model_output(text: &str, max_tokens: usize) -> String {
cap_model_output_with_handle(text, max_tokens, output_head_tokens(), None)
}
fn cap_model_output_with_handle(
text: &str,
max_tokens: usize,
head_tokens: usize,
spill_id: Option<&str>,
) -> String {
if max_tokens == 0 {
return text.to_string();
}
let est = crate::tokens::TokenEstimation::default();
if est.tokens_for_chars(text.len()) <= max_tokens {
return text.to_string();
}
let max_chars = est.chars_for_tokens(max_tokens);
let head_tokens = head_tokens.min(max_tokens);
let head_chars = est.chars_for_tokens(head_tokens).min(max_chars);
let tail_chars = max_chars.saturating_sub(head_chars);
let total_chars = text.chars().count();
let shown_chars = head_chars.saturating_add(tail_chars).min(total_chars);
let elided = total_chars.saturating_sub(shown_chars);
let head = take_chars(text, head_chars);
let tail = take_tail_chars(text, tail_chars);
let marker = match spill_id {
Some(id) => format!(
"[… {elided} chars elided (head+tail shown). Full output: \
memory_fetch(\"spill:{id}\"); search it with \
memory_fetch(\"spill:{id}\", grep=\"<pattern>\") …]"
),
None => format!(
"[… {elided} chars elided (head+tail shown; ~{max_tokens} token budget). \
Narrow the command or use a more specific grep/filter if needed …]"
),
};
format!("{head}\n\n{marker}\n\n{tail}")
}
fn take_chars(text: &str, max_chars: usize) -> String {
text.chars().take(max_chars).collect()
}
fn take_tail_chars(text: &str, max_chars: usize) -> String {
let mut chars: Vec<char> = text.chars().rev().take(max_chars).collect();
chars.reverse();
chars.into_iter().collect()
}
fn paginate_read(
contents: &str,
offset: Option<usize>,
limit: Option<usize>,
max_output_tokens: usize,
) -> String {
let max_chars = if max_output_tokens == 0 {
usize::MAX
} else {
crate::tokens::TokenEstimation::default().chars_for_tokens(max_output_tokens)
};
let total = contents.lines().count();
let start = offset.filter(|&o| o > 0).unwrap_or(1); let limit = limit.filter(|&l| l > 0).unwrap_or(DEFAULT_READ_LIMIT);
if start == 1 && limit >= total && contents.len() <= max_chars {
return contents.to_string();
}
let start0 = start - 1;
if start0 >= total {
return format!("(offset {start} is past end of file — {total} lines total)");
}
let window: Vec<&str> = contents.lines().skip(start0).take(limit).collect();
let end = start0 + window.len(); let mut body = window.join("\n");
let char_capped = body.len() > max_chars;
if char_capped {
let mut cut = max_chars;
while cut > 0 && !body.is_char_boundary(cut) {
cut -= 1;
}
body.truncate(cut);
}
let footer = if char_capped {
Some(format!(
"payload truncated to {max_chars} chars (~{max_output_tokens} tokens) from line \
{start}; call read_file with a higher offset (and/or smaller limit) to continue"
))
} else if end < total {
Some(format!(
"showing lines {start}-{end} of {total}; \
call read_file with offset={} to continue",
end + 1
))
} else {
None
};
match footer {
Some(f) => format!("{body}\n\n[{f}]"),
None => body,
}
}
pub fn tool_definitions() -> serde_json::Value {
serde_json::json!([
{
"type": "function",
"function": {
"name": "run_command",
"description": "Run a shell command in the workspace directory and return its output",
"parameters": {
"type": "object",
"properties": {
"command": { "type": "string", "description": "The shell command to run" }
},
"required": ["command"]
}
}
},
{
"type": "function",
"function": {
"name": "read_file",
"description": "Read a file in the workspace. Returns up to `limit` lines \
(default 2000) starting at 1-based `offset` (default 1). Large \
files come back with a footer pointing at the next window — read \
them in pages with offset/limit rather than all at once, or the \
full file can saturate the context window.",
"parameters": {
"type": "object",
"properties": {
"path": { "type": "string", "description": "File path relative to workspace root" },
"offset": { "type": "integer", "description": "1-based line number to start at (default 1)" },
"limit": { "type": "integer", "description": "Maximum number of lines to return (default 2000)" }
},
"required": ["path"]
}
}
},
{
"type": "function",
"function": {
"name": "write_file",
"description": "Write or overwrite a file in the workspace. \
WARNING: use edit_file instead when modifying an existing file — \
write_file replaces the entire contents and will fail if the new \
content is significantly shorter than the original (shrink guard). \
Only use write_file for new files or full rewrites you have \
explicitly generated in their entirety.",
"parameters": {
"type": "object",
"properties": {
"path": { "type": "string", "description": "File path relative to workspace root" },
"content": { "type": "string", "description": "The complete new file contents" }
},
"required": ["path", "content"]
}
}
},
{
"type": "function",
"function": {
"name": "edit_file",
"description": "Make a targeted edit to an existing file by replacing one exact \
string with another. Safer than write_file for modifying existing \
files — you only generate the change, not the whole file. \
Fails with a clear error if old_string is not found or matches \
multiple times (add more surrounding context to make it unique).",
"parameters": {
"type": "object",
"properties": {
"path": { "type": "string", "description": "File path relative to workspace root" },
"old_string": { "type": "string", "description": "Exact string to find and replace (must match exactly once)" },
"new_string": { "type": "string", "description": "Replacement string" }
},
"required": ["path", "old_string", "new_string"]
}
}
},
{
"type": "function",
"function": {
"name": "list_dir",
"description": "List files in a directory",
"parameters": {
"type": "object",
"properties": {
"path": { "type": "string", "description": "Directory path relative to workspace root (use '.' for root)" }
},
"required": ["path"]
}
}
},
{
"type": "function",
"function": {
"name": "find",
"description": "Find files and directories by name under the workspace, recursively, WITHOUT a shell (use this instead of the `find` shell command). Returns matching paths relative to the workspace root, one per line, already sorted — no need to pipe to `sort`. Respects .gitignore and skips noise (.git, target, node_modules) by default.",
"parameters": {
"type": "object",
"properties": {
"path": { "type": "string", "description": "Directory to search under, relative to workspace root. Default '.' (the whole workspace)." },
"name": { "type": "string", "description": "Glob matched against each entry's basename, e.g. '*.py' or 'pyo3_module.rs'. '*' matches any run, '?' any single char. Omit to match everything." },
"type": { "type": "string", "enum": ["f", "d", "any"], "description": "Restrict to files ('f'), directories ('d'), or both ('any', the default)." },
"max_depth": { "type": "integer", "description": "Maximum directory depth below `path` (1 = immediate children only). Omit for unlimited." },
"max_results": { "type": "integer", "description": "Cap on the number of matches returned. Default 1000; output notes when truncated." },
"respect_gitignore": { "type": "boolean", "description": "When true (default) skip .gitignored paths plus .git/target/node_modules/hidden dirs. Set false to search everything." },
"case_sensitive": { "type": "boolean", "description": "Case-sensitive basename match. Default true." }
},
"required": []
}
}
},
{
"type": "function",
"function": {
"name": "use_skill",
"description": "Load a skill's full procedural instructions on demand. The system prompt lists the available skills (name + description); call this with a skill's name to get its complete SKILL.md body plus the paths of any bundled files (scripts/templates) you can read or run.",
"parameters": {
"type": "object",
"properties": {
"name": { "type": "string", "description": "The skill name as shown in the 'Available skills' index" }
},
"required": ["name"]
}
}
},
{
"type": "function",
"function": {
"name": "web_fetch",
"description": "Fetch an http(s) URL and return its main content as clean markdown. Use this to read documentation, issues, or pages the task references. Reachable hosts are gated by the session's network capability; the returned text is untrusted page content.",
"parameters": {
"type": "object",
"properties": {
"url": { "type": "string", "description": "The http(s) URL to fetch" },
"max_bytes": { "type": "integer", "description": "Optional cap on bytes downloaded (default 5 MiB, max 25 MiB)" }
},
"required": ["url"]
}
}
},
{
"type": "function",
"function": {
"name": "request_permissions",
"description": "Ask the operator to GRANT a capability you were denied — the \
capability-grant path (#721). Call this AFTER a `capability denied` \
result to request authority you don't currently have. If an operator \
is present they may allow it; then retry the original operation. In a \
headless session (no operator) you'll be told the capability must be \
configured by the owner — change approach. This requests AUTHORITY \
(it mints a capability grant); it is NOT a way to ask the user a \
free-text question.",
"parameters": {
"type": "object",
"properties": {
"capability": { "type": "string", "enum": ["exec", "fs_read", "fs_write", "net"], "description": "Which capability axis to request" },
"target": { "type": "string", "description": "What to grant: a command name (exec), a path (fs_read/fs_write), or a host (net)" },
"reason": { "type": "string", "description": "Why you need it — shown to the operator deciding" }
},
"required": ["capability", "target", "reason"]
}
}
}
])
}
fn request_user_input_tool_definition() -> serde_json::Value {
serde_json::json!({
"type": "function",
"function": {
"name": "request_user_input",
"description": "Ask the human operator a free-text question and get \
their answer. Use this to resolve genuine ambiguity \
instead of guessing or narrating (e.g. 'which database \
should I target?', 'is this the file you meant?'). This \
asks for INFORMATION, not authority — to request a \
capability you were denied, use request_permissions \
instead. In a headless session (no operator) you'll be \
told no human is available — then proceed with your best \
judgment and state your assumption explicitly.",
"parameters": {
"type": "object",
"properties": {
"question": { "type": "string", "description": "The free-text question to ask the human" }
},
"required": ["question"]
}
}
})
}
pub fn lifecycle_tool_definition() -> serde_json::Value {
let phases: Vec<&str> = crate::tooling::Phase::ALL
.iter()
.map(|p| p.as_str())
.collect();
serde_json::json!({
"type": "function",
"function": {
"name": "lifecycle",
"description": "Run a named project lifecycle phase using THIS repo's \
configured command instead of guessing a raw shell command. \
Phases: setup (resolve deps / prepare a checkout), format \
(auto-format the tree), lint (static analysis), test (run the \
tests), check (the full gate a change must pass), clean (remove \
build artifacts). The command is resolved from \
.newt/config.toml [lifecycle] and the repo's tooling packs \
(Rust / Python / PyO3 / Go / custom), so `check` runs the RIGHT \
gate for this project. Prefer this over run_command for build / \
test / format / lint / check work so the project's own \
conventions are honored uniformly across build systems. Use \
action=list to see the resolved command without running it.",
"parameters": {
"type": "object",
"properties": {
"phase": {
"type": "string",
"enum": phases,
"description": "The lifecycle phase to run."
},
"action": {
"type": "string",
"enum": ["run", "list"],
"description": "run (default) executes the phase's resolved command; \
list returns the command without running it."
}
},
"required": ["phase"]
}
}
})
}
#[allow(clippy::too_many_arguments)] pub(crate) fn merged_tool_definitions(
mcp: &dyn McpTools,
with_save_note: bool,
with_recall: bool,
with_memory_fetch: bool,
with_git: bool,
with_team: bool,
with_scratchpad: bool,
with_code_search: bool,
with_experiential: bool,
with_scheduled: bool,
) -> serde_json::Value {
let mut defs = match tool_definitions() {
serde_json::Value::Array(a) => a,
other => vec![other],
};
for spec in EXTENDED_TOOL_REGISTRY {
if gate_satisfied(
spec.gate,
with_save_note,
with_recall,
with_memory_fetch,
with_git,
with_team,
with_scratchpad,
with_code_search,
with_experiential,
with_scheduled,
) {
defs.push((spec.definition)());
}
}
defs.extend(mcp.tool_defs());
serde_json::Value::Array(defs)
}
const DIRECT_TOOL_NAMES: &[&str] = &[
"list_dir",
"read_file",
"write_file",
"edit_file",
"use_skill",
"web_fetch",
"find",
"git",
];
const GIT_NETWORK_SUBCOMMANDS: &[&str] = &["push", "fetch", "pull", "clone"];
fn run_command_redirect(command: &str) -> Option<&'static str> {
let mut tokens = command.split_ascii_whitespace();
let first = tokens.next().unwrap_or("");
if first == "git" {
let sub = tokens.next().unwrap_or("");
if GIT_NETWORK_SUBCOMMANDS.contains(&sub) {
return None;
}
return Some("git");
}
DIRECT_TOOL_NAMES.iter().copied().find(|&t| t == first)
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Gate {
Always,
SaveNote,
Recall,
MemoryFetch,
Git,
Team,
Scratchpad,
CodeSearch,
Experiential,
Scheduled,
}
struct ToolSpec {
name: &'static str,
definition: fn() -> serde_json::Value,
gate: Gate,
}
const EXTENDED_TOOL_REGISTRY: &[ToolSpec] = &[
ToolSpec {
name: "resume_context",
definition: super::resume::resume_context_tool_definition,
gate: Gate::Always,
},
ToolSpec {
name: "tool_search",
definition: super::tool_search::tool_search_tool_definition,
gate: Gate::Always,
},
ToolSpec {
name: "get_context_remaining",
definition: super::budget::get_context_remaining_tool_definition,
gate: Gate::Always,
},
ToolSpec {
name: "request_user_input",
definition: request_user_input_tool_definition,
gate: Gate::Always,
},
ToolSpec {
name: "lifecycle",
definition: lifecycle_tool_definition,
gate: Gate::Always,
},
ToolSpec {
name: "save_note",
definition: save_note_tool_definition,
gate: Gate::SaveNote,
},
ToolSpec {
name: "recall",
definition: recall_tool_definition,
gate: Gate::Recall,
},
ToolSpec {
name: "memory_fetch",
definition: memory_fetch_tool_definition,
gate: Gate::MemoryFetch,
},
ToolSpec {
name: "git",
definition: super::git_tool::git_tool_definition,
gate: Gate::Git,
},
ToolSpec {
name: "compose_roster",
definition: super::crew_tool::compose_roster_tool_definition,
gate: Gate::Team,
},
ToolSpec {
name: "crew",
definition: super::crew_tool::crew_tool_definition,
gate: Gate::Team,
},
ToolSpec {
name: "state_set",
definition: super::scratchpad::state_set_tool_definition,
gate: Gate::Scratchpad,
},
ToolSpec {
name: "state_get",
definition: super::scratchpad::state_get_tool_definition,
gate: Gate::Scratchpad,
},
ToolSpec {
name: "state_clear",
definition: super::scratchpad::state_clear_tool_definition,
gate: Gate::Scratchpad,
},
ToolSpec {
name: "code_search",
definition: super::semantic::code_search_tool_definition,
gate: Gate::CodeSearch,
},
ToolSpec {
name: "experience_record",
definition: super::experiential::experience_record_tool_definition,
gate: Gate::Experiential,
},
ToolSpec {
name: "experience_recall",
definition: super::experiential::experience_recall_tool_definition,
gate: Gate::Experiential,
},
ToolSpec {
name: "update_plan",
definition: super::scheduled::update_plan_tool_definition,
gate: Gate::Scheduled,
},
ToolSpec {
name: "plan_get",
definition: super::scheduled::plan_get_tool_definition,
gate: Gate::Scheduled,
},
];
const BASE_TOOL_NAMES: &[&str] = &[
"run_command",
"read_file",
"write_file",
"edit_file",
"list_dir",
"find",
"use_skill",
"web_fetch",
"request_permissions",
];
static ALL_TOOL_NAMES: LazyLock<Vec<&'static str>> = LazyLock::new(|| {
BASE_TOOL_NAMES
.iter()
.copied()
.chain(EXTENDED_TOOL_REGISTRY.iter().map(|s| s.name))
.collect()
});
pub(crate) fn known_builtin_tool_name(tool_name: &str) -> bool {
ALL_TOOL_NAMES.contains(&tool_name)
}
#[allow(clippy::too_many_arguments)] fn gate_satisfied(
gate: Gate,
with_save_note: bool,
with_recall: bool,
with_memory_fetch: bool,
with_git: bool,
with_team: bool,
with_scratchpad: bool,
with_code_search: bool,
with_experiential: bool,
with_scheduled: bool,
) -> bool {
match gate {
Gate::Always => true,
Gate::SaveNote => with_save_note,
Gate::Recall => with_recall,
Gate::MemoryFetch => with_memory_fetch,
Gate::Git => with_git,
Gate::Team => with_team,
Gate::Scratchpad => with_scratchpad,
Gate::CodeSearch => with_code_search,
Gate::Experiential => with_experiential,
Gate::Scheduled => with_scheduled,
}
}
pub(crate) fn is_hallucination(tool_name: &str, args: &serde_json::Value) -> bool {
if tool_name == "run_command" {
let cmd = args["command"].as_str().unwrap_or("");
return run_command_redirect(cmd).is_some();
}
if tool_name.contains("__") {
return false;
}
!ALL_TOOL_NAMES.contains(&tool_name)
}
pub(crate) enum AliasOutcome {
Rewrite(&'static str),
Correct(String),
}
pub(crate) fn resolve_tool_alias(name: &str) -> Option<AliasOutcome> {
match name {
"execute" | "exec" | "bash" | "shell" | "sh" | "zsh" | "terminal" | "run_shell_command"
| "shell_command" | "system" => Some(AliasOutcome::Rewrite("run_command")),
"run_phase" | "run_lifecycle" | "lifecycle_run" => Some(AliasOutcome::Rewrite("lifecycle")),
"str_replace_editor" | "str_replace" | "str-replace-editor" | "apply_patch" | "edit"
| "editor" | "replace_in_file" | "search_replace" => Some(AliasOutcome::Correct(format!(
"'{name}' is not a newt tool. To change an existing file, call \
edit_file with {{\"path\", \"old_string\", \"new_string\"}} \
(replaces one exact occurrence). For a new file or a full \
rewrite, call write_file with {{\"path\", \"content\"}}."
))),
"create_file" | "new_file" | "createfile" | "add_file" | "touch" => {
Some(AliasOutcome::Correct(format!(
"'{name}' is not a newt tool. To create or overwrite a file, call \
write_file with {{\"path\", \"content\"}}. To change part of an \
existing file, call edit_file with \
{{\"path\", \"old_string\", \"new_string\"}}."
)))
}
"mkdir" | "make_dir" | "makedirs" | "mkdirs" | "create_dir" | "create_directory" => {
Some(AliasOutcome::Correct(
"newt has no mkdir/touch tool — call write_file; it creates parent \
directories automatically (create_dir_all). For an empty file, call \
write_file with empty content."
.to_string(),
))
}
"cat" | "open_file" | "view_file" | "view" | "open" => {
Some(AliasOutcome::Correct(format!(
"'{name}' is not a newt tool. To read a file, call read_file with \
{{\"path\"}}. To list a directory, call list_dir with {{\"path\"}}."
)))
}
"enter_plan" | "enter_plan_mode" | "plan_mode" | "start_plan" | "begin_plan"
| "make_plan" | "create_plan" | "plan" | "planning" | "todo" | "todos" | "todo_write" => {
Some(AliasOutcome::Correct(format!(
"'{name}' is not a newt tool. To start or revise your plan, call update_plan with \
{{\"plan\":[{{\"step\",\"status\"}}]}} — send the full ordered list each time, \
each step's status one of pending/in_progress/completed (exactly one \
in_progress)."
)))
}
"next_step" | "complete_step" | "finish_step" | "mark_done" | "step_done" => {
Some(AliasOutcome::Correct(format!(
"'{name}' is not a newt tool. To advance your plan, call update_plan with the \
full plan and mark the finished step \"completed\" (and the next one \
\"in_progress\")."
)))
}
"get_plan" | "show_plan" | "read_plan" | "current_plan" | "what_was_i_doing" => {
Some(AliasOutcome::Rewrite("plan_get"))
}
"resume" | "where_were_we" | "where_did_we_leave_off" | "catch_me_up" | "recap" => {
Some(AliasOutcome::Rewrite("resume_context"))
}
"delegate" | "spawn_agent" | "subagent" | "sub_agent" | "crew_dispatch" | "run_crew"
| "dispatch_crew" | "fork_agent" | "assign" | "team" => {
Some(AliasOutcome::Correct(format!(
"'{name}' is not a newt tool. Crew/team delegation is only available once the \
human enables /team this session — you cannot turn it on yourself. When /team \
is on, compose_roster ({{\"mode\"}}) proposes a roster and crew ({{\"task\"}}) \
dispatches it."
)))
}
"find_tool" | "search_tools" | "list_tools" | "which_tool" | "available_tools"
| "what_tools" | "tools" => Some(AliasOutcome::Rewrite("tool_search")),
"workflow" | "run_workflow" | "start_workflow" | "pipeline" => Some(AliasOutcome::Correct(
"newt has no workflow tool; sequence the work with update_plan (the full ordered \
plan with statuses), or delegate subtasks via crew/team (needs /team)."
.to_string(),
)),
"context_remaining" | "tokens_left" | "remaining_tokens" | "budget"
| "how_much_context" | "context_budget" | "token_budget" => {
Some(AliasOutcome::Rewrite("get_context_remaining"))
}
"ask_user" | "ask_human" | "prompt_user" | "get_user_input" | "ask_question"
| "clarify" | "ask" => Some(AliasOutcome::Rewrite("request_user_input")),
_ => None,
}
}
pub(crate) fn is_context_remaining_call(name: &str) -> bool {
name == "get_context_remaining"
|| matches!(
resolve_tool_alias(name),
Some(AliasOutcome::Rewrite("get_context_remaining"))
)
}
pub(crate) fn classify_phantom_reach(
name: &str,
args: &serde_json::Value,
result: &str,
ok: bool,
) -> Option<crate::PhantomResolution> {
let _ = ok;
match resolve_tool_alias(name) {
Some(AliasOutcome::Rewrite(canonical)) => {
return Some(crate::PhantomResolution::Rewrite(canonical.to_string()))
}
Some(AliasOutcome::Correct(msg)) => return Some(crate::PhantomResolution::Correct(msg)),
None => {}
}
if is_hallucination(name, args) {
return Some(crate::PhantomResolution::Unknown);
}
let r = result.trim_start();
if name == "state_get" && r.starts_with("no such key") {
return Some(crate::PhantomResolution::RealToolMiss(
"state_get on an unset key".into(),
));
}
if name == "recall" && r.starts_with("no matches in past conversations") {
return Some(crate::PhantomResolution::RealToolMiss(
"recall returned no matches".into(),
));
}
None
}
pub(crate) fn classify_gated_off_reach(
name: &str,
advertise_team: bool,
) -> Option<crate::PhantomResolution> {
if !advertise_team && (name == "crew" || name == "compose_roster") {
return Some(crate::PhantomResolution::GatedOff(
"crew/team surface off (NEWT_TEAM)".into(),
));
}
None
}
fn levenshtein(a: &str, b: &str) -> usize {
let a: Vec<char> = a.chars().collect();
let b: Vec<char> = b.chars().collect();
let mut prev: Vec<usize> = (0..=b.len()).collect();
let mut cur = vec![0usize; b.len() + 1];
for (i, ca) in a.iter().enumerate() {
cur[0] = i + 1;
for (j, cb) in b.iter().enumerate() {
let cost = usize::from(ca != cb);
cur[j + 1] = (prev[j + 1] + 1).min(cur[j] + 1).min(prev[j] + cost);
}
std::mem::swap(&mut prev, &mut cur);
}
prev[b.len()]
}
fn nearest_tool_name(name: &str) -> Option<&'static str> {
let threshold = (name.chars().count() / 3).max(1);
ALL_TOOL_NAMES
.iter()
.map(|&t| (levenshtein(name, t), t))
.filter(|(d, _)| *d <= threshold)
.min_by_key(|(d, _)| *d)
.map(|(_, t)| t)
}
fn unknown_tool_message(name: &str) -> String {
const BASE: &str =
"run_command, read_file, write_file, edit_file, list_dir, find, use_skill, web_fetch";
match nearest_tool_name(name) {
Some(sugg) => format!(
"unknown tool: {name}. Did you mean '{sugg}'? Available tools include: \
{BASE} (plus git and any memory/plan tools enabled this session)."
),
None => format!(
"unknown tool: {name}. Available tools include: {BASE} (plus git and \
any memory/plan tools enabled this session)."
),
}
}
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();
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 map;
}
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 confined_dispatch_args(cmd: &str, workspace: &str) -> serde_json::Value {
serde_json::json!({
"cmd": cmd,
"cwd": workspace,
"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() {
crate::full_access_default_engine()
} else {
crate::ShellEngine::default()
}
}
fn bridle_registry() -> agent_bridle::Registry {
use std::sync::Arc;
let shell: Arc<dyn agent_bridle::Tool> = match shell_engine() {
crate::ShellEngine::SafeSubset => Arc::new(agent_bridle::ShellTool::new()),
crate::ShellEngine::Host => Arc::new(agent_bridle::HostShellTool::new()),
crate::ShellEngine::Brush => {
#[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."
);
});
}
Arc::new(agent_bridle::BrushShellTool::new())
}
};
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")
}
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()),
}
}
#[allow(clippy::too_many_arguments)]
async fn exec_confined_command(
cmd: &str,
workspace: &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>,
) -> String {
let cmd_with_venv = match venv_cmd_prefix() {
Some(prefix) => format!("{prefix}{cmd}"),
None => cmd.to_string(),
};
if ocap_disabled() && exec_floor_permits(exec_floor, cmd) {
return match host_shell_dispatch(&cmd_with_venv, workspace).await {
Ok(envelope) => shell_envelope_output(
&envelope,
tool_output_lines,
color,
tool_offload,
spill_store,
),
Err(e) => format!("error: {e}"),
};
}
let dispatch_args = confined_dispatch_args(cmd, workspace);
match bridle_registry()
.dispatch("shell", dispatch_args.clone(), caveats)
.await
{
Ok(envelope) if envelope_denied(&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 bridle_registry()
.dispatch("shell", dispatch_args, &widened)
.await
{
Ok(env2) if envelope_denied(&env2) => {
denied_run_command_result(&env2, color)
}
Ok(env2) => shell_envelope_output(
&env2,
tool_output_lines,
color,
tool_offload,
spill_store,
),
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,
),
Err(e) => format!("error: {e}"),
}
}
async fn host_shell_dispatch(cmd: &str, cwd: &str) -> std::io::Result<serde_json::Value> {
let output = host_shell_output(cmd, cwd).await?;
Ok(serde_json::json!({
"exit_code": output.status.code().unwrap_or(-1),
"stdout": decode_shell_stream(&output.stdout),
"stderr": decode_shell_stream(&output.stderr),
"sandbox_kind": "none",
}))
}
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,
}
}
#[cfg(not(windows))]
async fn host_shell_output(cmd: &str, cwd: &str) -> std::io::Result<std::process::Output> {
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);
c
}
match shell("bash", cmd, cwd).output().await {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => shell("sh", cmd, cwd).output().await,
other => other,
}
}
#[cfg(windows)]
async fn host_shell_output(cmd: &str, cwd: &str) -> std::io::Result<std::process::Output> {
tokio::process::Command::new("cmd")
.args(["/C", cmd])
.current_dir(cwd)
.output()
.await
}
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)))
}
}
}
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)
}
const DENIAL_RECOVERY_HINT: &str =
"This is outside your granted authority — call request_permissions with the \
capability, a target, and a reason to ask the operator to grant 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}")
}
fn denied_run_command_result(envelope: &serde_json::Value, color: bool) -> String {
print_denied(
denial_axis_label(envelope),
&exec_denial_target_label(envelope),
color,
);
format!(
"capability denied: {}. {DENIAL_RECOVERY_HINT}",
envelope_denial_reason(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>,
) -> 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}");
print_tool_output(&out, tool_output_lines, color);
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 {
let max_tokens = max_output_tokens();
let est = crate::tokens::TokenEstimation::default();
let over_model_budget = max_tokens != 0 && est.tokens_for_chars(out.len()) > max_tokens;
let over_spill_budget = out.chars().count() > spill::TOOL_RESULT_SPILL_CAP;
let should_spill =
max_tokens != 0 && tool_offload && (over_model_budget || over_spill_budget);
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 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;
}
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 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();
print_tool_call("request_permissions", capability, color);
let Some(kind) = parse_capability(capability) else {
let out = format!(
"request_permissions: unknown capability '{capability}'. Use one of: \
exec, fs_read, fs_write, net."
);
print_tool_output(&out, tool_output_lines, color);
return out;
};
if target.is_empty() {
let out = "request_permissions: 'target' is required — the command name (exec), \
the path (fs_read/fs_write), or the host (net)."
.to_string();
print_tool_output(&out, tool_output_lines, color);
return out;
}
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."
),
};
print_tool_output(&out, tool_output_lines, color);
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();
print_tool_call("request_user_input", question, color);
if question.is_empty() {
let out = "request_user_input: 'question' is required — the free-text \
question to ask the human."
.to_string();
print_tool_output(&out, tool_output_lines, color);
return out;
}
let out = match gate.and_then(|g| g.ask_question(question)) {
Some(answer) => answer,
None => HEADLESS_NO_HUMAN.to_string(),
};
print_tool_output(&out, tool_output_lines, color);
out
}
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,
}
struct FindOpts<'a> {
name: Option<&'a str>,
type_filter: FindType,
max_depth: Option<usize>,
max_results: usize,
respect_gitignore: bool,
case_sensitive: bool,
}
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 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());
}
if parts.is_empty() {
path.to_string()
} else {
format!("{path} ({})", parts.join(", "))
}
}
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<'_>,
) -> 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 out: Vec<String> = Vec::new();
let mut truncated = false;
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);
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;
}
}
if out.len() >= opts.max_results {
truncated = true;
break;
}
let rel = entry
.path()
.strip_prefix(workspace_root)
.unwrap_or_else(|_| entry.path());
out.push(rel.to_string_lossy().replace('\\', "/"));
}
out.sort();
out.dedup();
Ok((out, truncated))
}
#[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<'_>>,
experience_store: Option<&dyn super::experiential::ExperienceStore>,
step_ledger: Option<&dyn super::scheduled::StepLedger>,
) -> String {
execute_tool_with_offload(
name,
args,
workspace,
color,
tool_output_lines,
caveats,
mcp,
build_check_cmd,
note_sink,
recall_source,
memory_source,
permission_gate,
exec_floor,
git_tool,
crew_runner,
scratchpad_store,
code_search,
experience_store,
step_ledger,
false,
None,
)
.await
}
#[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<'_>>,
experience_store: Option<&dyn super::experiential::ExperienceStore>,
step_ledger: Option<&dyn super::scheduled::StepLedger>,
tool_offload: bool,
spill_store: Option<&dyn SpillStore>,
) -> String {
if mcp.handles(name) {
print_tool_call(name, &args.to_string(), color);
let out = mcp.call(name, args).await;
print_tool_output(&out, tool_output_lines, color);
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 {
"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()
}
},
"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(l) => super::scheduled::execute_update_plan(args, l, color, tool_output_lines),
None => "unknown tool: update_plan (scheduled planning is off)".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,
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("");
print_tool_call("tool_search", query, color);
let catalog = 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(),
);
let out = super::tool_search::execute_tool_search(query, &catalog);
print_tool_output(&out, tool_output_lines, color);
out
}
"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("");
print_tool_call("git", op, color);
let out = match tool.dispatch(op, args, &gc) {
Ok(rendered) => rendered,
Err(e) => format!("error: {e}"),
};
print_tool_output(&out, tool_output_lines, color);
out
}
None => "unknown tool: git (no git surface in this session)".to_string(),
},
"compose_roster" | "crew" => match crew_runner {
Some(runner) => {
print_tool_call(name, &args.to_string(), color);
let out = match runner.dispatch(name, args, caveats).await {
Ok(rendered) => rendered,
Err(e) => format!("error: {e}"),
};
print_tool_output(&out, tool_output_lines, color);
out
}
None => crew_off_recovery_result(name),
},
"run_command" => {
let cmd = args["command"].as_str().unwrap_or("");
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."
);
}
print_tool_call("run_command", cmd, color);
exec_confined_command(
cmd,
workspace,
color,
tool_output_lines,
caveats,
exec_floor,
permission_gate,
tool_offload,
spill_store,
)
.await
}
"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" => {
print_tool_call(
"lifecycle",
&format!("{} → {joined}", phase.as_str()),
color,
);
exec_confined_command(
&joined,
workspace,
color,
tool_output_lines,
caveats,
exec_floor,
permission_gate,
tool_offload,
spill_store,
)
.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 {
let msg = denied_fs_result("fs_read", path);
print_denied("fs_read", path, color);
return msg;
}
}
print_tool_call("read_file", path, color);
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);
let out = paginate_read(&contents, offset, limit, max_output_tokens());
print_tool_output(&out, tool_output_lines, color);
out
}
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.is_some_and(|gate| {
fs_gate_allows(gate, "write_file", DenialKind::FsWrite, &full_str, |c| {
&c.fs_write
})
});
if !allowed {
let msg = denied_fs_result("fs_write", path);
print_denied("fs_write", path, color);
return msg;
}
}
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."
);
print_denied("shrink-guard", path, color);
return msg;
}
}
print_tool_call(
"write_file",
&format!("{path} ({} bytes)", content.len()),
color,
);
let preview: String = content.lines().take(20).collect::<Vec<_>>().join("\n");
let has_more = content.lines().count() > 20;
print_tool_output(
&format!("{preview}{}", if has_more { "\n…" } else { "" }),
tool_output_lines,
color,
);
let needs_confirm = matches!(caveats.fs_write, crate::caveats::Scope::All);
let confirmed = if needs_confirm {
print!("Write this file? [y/N] ");
use std::io::Write as _;
std::io::stdout().flush().ok();
let mut answer = String::new();
std::io::stdin().read_line(&mut answer).is_ok()
&& answer.trim().eq_ignore_ascii_case("y")
} else {
true
};
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();
println!("✓ wrote {path} ({line_count} lines)");
let check = build_check_cmd
.map(|cmd| run_build_check(cmd, workspace))
.unwrap_or_default();
format!("wrote {path} ({line_count} lines){check}")
}
Err(e) => format!("error writing {path}: {e}"),
}
} else {
println!("skipped");
format!("user declined to write {path}")
}
}
"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 {
let msg = denied_fs_result("fs_write", path);
print_denied("fs_write", path, color);
return msg;
}
}
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 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}")
};
print_tool_call("edit_file", &format!("{path} ({delta_str} lines)"), color);
match std::fs::write(&full, &updated) {
Ok(_) => {
println!("✓ edited {path} ({delta_str} lines, now {new_lines} total)");
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){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 {
let msg = denied_fs_result("fs_read", path);
print_denied("fs_read", path, color);
return msg;
}
}
print_tool_call("list_dir", path, color);
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();
let listing = names.join("\n");
print_tool_output(&listing, tool_output_lines, color);
listing
}
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 {
let msg = denied_fs_result("fs_read", path);
print_denied("fs_read", path, color);
return msg;
}
}
let opts = FindOpts {
name: args["name"].as_str(),
type_filter: match args["type"].as_str() {
Some("f") => FindType::Files,
Some("d") => FindType::Dirs,
_ => FindType::Any,
},
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),
};
print_tool_call("find", &find_detail(path, &opts), color);
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) {
let msg = denied_fs_result("fs_read", path);
print_denied("fs_read", path, color);
return msg;
}
}
match find_walk(&full, std::path::Path::new(workspace), &opts) {
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));
}
print_tool_output(&listing, tool_output_lines, color);
listing
}
Err(e) => format!("error: {e}"),
}
}
"use_skill" => {
let skill_name = args["name"].as_str().unwrap_or("");
print_tool_call("use_skill", skill_name, color);
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) => {
print_tool_output(&body, tool_output_lines, color);
body
}
Err(e) => format!("error: {e}"),
}
}
"web_fetch" => {
let url = args["url"].as_str().unwrap_or("");
print_tool_call("web_fetch", url, color);
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);
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}")
};
print_tool_output(&out, tool_output_lines, color);
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 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 find_detail_bare_path_has_no_filters() {
let opts = FindOpts {
name: None,
type_filter: FindType::Any,
max_depth: None,
max_results: 1000,
respect_gitignore: true,
case_sensitive: true,
};
assert_eq!(find_detail(".", &opts), ".");
}
#[test]
fn find_detail_shows_only_non_default_filters() {
let opts = FindOpts {
name: Some("*.rs"),
type_filter: FindType::Files,
max_depth: Some(2),
max_results: 50,
respect_gitignore: false,
case_sensitive: false,
};
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,
max_depth: None,
max_results: 1000,
respect_gitignore: true,
case_sensitive: true,
};
assert_eq!(find_detail(".", &opts), ". (type=d)");
}
#[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,
);
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",
"list_dir",
"find",
"use_skill",
"web_fetch",
"request_permissions",
"resume_context",
"tool_search",
"get_context_remaining",
"request_user_input",
"lifecycle",
]
);
}
#[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);
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,
)
.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,
);
assert!(!names(&without).contains(&"save_note"));
let with = merged_tool_definitions(
&NoMcp, true, 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,
);
assert!(!names(&without).contains(&"recall"));
let with = merged_tool_definitions(
&NoMcp, false, true, 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,
);
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,
);
assert!(!names(&without).contains(&"memory_fetch"));
let with = merged_tool_definitions(
&NoMcp, false, false, true, 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,
);
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",
"use_skill",
"web_fetch",
"save_note",
"recall",
] {
assert!(!is_hallucination(t, &serde_json::json!({"path": "."})));
}
}
#[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",
] {
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 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 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);
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);
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 out = shell_envelope_output(&envelope, 50, false, true, Some(&store));
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"));
}
#[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());
}
#[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 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"
);
}
#[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,
);
assert!(names(&with).contains(&"git"), "with_git advertises git");
let without = merged_tool_definitions(
&NoMcp, 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,
);
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,
);
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,
);
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,
);
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,
);
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"
);
}
}
#[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,
)
.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,
)
.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,
)
.await;
assert!(none.starts_with("unknown tool: code_search"), "{none}");
let idx = SessionSemanticIndex::default();
let search = CodeSearch {
embedder: &E,
index: &idx,
top_k: 1,
};
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,
)
.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,
)
.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,
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,
)
.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,
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,
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, )
.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::caveats::{Caveats, CountBound, Scope};
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,
}
}
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()),
other => Err(format!("unknown git op '{other}'")),
}
}
}
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, )
.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}");
}
#[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, )
.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_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_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_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, )
.await
}
#[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 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}");
}
}
#[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",
"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 [
"enter_plan",
"enter_plan_mode",
"plan_mode",
"start_plan",
"begin_plan",
"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 [
"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, )
.await
}
#[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,
);
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, )
.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,
);
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"));
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}"))]
);
}
#[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();
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}");
assert_eq!(gate.asks.len(), 2);
assert_eq!(gate.asks[0].0, "write_file");
assert!(
gate.asks[1].1.starts_with("fs_write:"),
"got: {:?}",
gate.asks[1]
);
}
#[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, )
.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, )
.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, )
.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, )
.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};
static ENV_LOCK: Mutex<()> = Mutex::const_new(());
async fn env_lock() -> MutexGuard<'static, ()> {
ENV_LOCK.lock().await
}
struct EnvVar {
key: &'static str,
saved: Option<String>,
}
impl EnvVar {
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, )
.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}"
);
}
}
#[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 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 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 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(),
)
.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),
"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 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_empty_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 args = confined_dispatch_args("ls -la", "/work/dir");
assert_eq!(args["cmd"], "ls -la");
assert_eq!(
args["env"],
serde_json::json!({}),
"no venv inputs ⇒ empty env map: {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(
"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, )
.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, )
.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 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 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");
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}"
);
}
}