1use super::crew_tool::CrewRunner;
6use super::display::{print_denied, print_tool_call, print_tool_output};
7use super::git_tool::GitTool;
8use super::mcp::McpTools;
9use super::memory_fetch::{execute_memory_fetch, memory_fetch_tool_definition, MemorySource};
10use super::note_sink::{execute_save_note, save_note_tool_definition, NoteSink};
11use super::permissions::{DenialKind, PermissionDecision, PermissionGate, PermissionRequest};
12use super::recall::{execute_recall, recall_tool_definition, RecallSource};
13use super::spill::{self, SpillStore};
14use crate::caveats::CaveatsExt as _;
15use std::sync::atomic::{AtomicUsize, Ordering};
16use std::sync::LazyLock;
17
18const DEFAULT_READ_LIMIT: usize = 2_000;
23
24const DEFAULT_MAX_OUTPUT_TOKENS: usize = 10_000;
32const DEFAULT_OUTPUT_HEAD_TOKENS: usize = 1_500;
33
34static MAX_OUTPUT_TOKENS: AtomicUsize = AtomicUsize::new(DEFAULT_MAX_OUTPUT_TOKENS);
43static OUTPUT_HEAD_TOKENS: AtomicUsize = AtomicUsize::new(DEFAULT_OUTPUT_HEAD_TOKENS);
44
45pub fn set_max_output_tokens(max_tokens: usize) {
49 MAX_OUTPUT_TOKENS.store(max_tokens, Ordering::Relaxed);
50}
51
52pub fn set_output_head_tokens(head_tokens: usize) {
56 OUTPUT_HEAD_TOKENS.store(head_tokens, Ordering::Relaxed);
57}
58
59fn max_output_tokens() -> usize {
62 MAX_OUTPUT_TOKENS.load(Ordering::Relaxed)
63}
64
65fn output_head_tokens() -> usize {
66 OUTPUT_HEAD_TOKENS.load(Ordering::Relaxed)
67}
68
69fn cap_model_output(text: &str, max_tokens: usize) -> String {
77 cap_model_output_with_handle(text, max_tokens, output_head_tokens(), None)
78}
79
80fn cap_model_output_with_handle(
81 text: &str,
82 max_tokens: usize,
83 head_tokens: usize,
84 spill_id: Option<&str>,
85) -> String {
86 if max_tokens == 0 {
87 return text.to_string();
88 }
89 let est = crate::tokens::TokenEstimation::default();
90 if est.tokens_for_chars(text.len()) <= max_tokens {
91 return text.to_string();
92 }
93 let max_chars = est.chars_for_tokens(max_tokens);
94 let head_tokens = head_tokens.min(max_tokens);
95 let head_chars = est.chars_for_tokens(head_tokens).min(max_chars);
96 let tail_chars = max_chars.saturating_sub(head_chars);
97 let total_chars = text.chars().count();
98 let shown_chars = head_chars.saturating_add(tail_chars).min(total_chars);
99 let elided = total_chars.saturating_sub(shown_chars);
100 let head = take_chars(text, head_chars);
101 let tail = take_tail_chars(text, tail_chars);
102 let marker = match spill_id {
103 Some(id) => format!(
104 "[… {elided} chars elided (head+tail shown). Full output: \
105 memory_fetch(\"spill:{id}\"); search it with \
106 memory_fetch(\"spill:{id}\", grep=\"<pattern>\") …]"
107 ),
108 None => format!(
109 "[… {elided} chars elided (head+tail shown; ~{max_tokens} token budget). \
110 Narrow the command or use a more specific grep/filter if needed …]"
111 ),
112 };
113 format!("{head}\n\n{marker}\n\n{tail}")
114}
115
116fn take_chars(text: &str, max_chars: usize) -> String {
117 text.chars().take(max_chars).collect()
118}
119
120fn take_tail_chars(text: &str, max_chars: usize) -> String {
121 let mut chars: Vec<char> = text.chars().rev().take(max_chars).collect();
122 chars.reverse();
123 chars.into_iter().collect()
124}
125
126fn paginate_read(
136 contents: &str,
137 offset: Option<usize>,
138 limit: Option<usize>,
139 max_output_tokens: usize,
140) -> String {
141 let max_chars = if max_output_tokens == 0 {
142 usize::MAX
143 } else {
144 crate::tokens::TokenEstimation::default().chars_for_tokens(max_output_tokens)
145 };
146 let total = contents.lines().count();
147 let start = offset.filter(|&o| o > 0).unwrap_or(1); let limit = limit.filter(|&l| l > 0).unwrap_or(DEFAULT_READ_LIMIT);
149 if start == 1 && limit >= total && contents.len() <= max_chars {
151 return contents.to_string();
152 }
153 let start0 = start - 1;
154 if start0 >= total {
155 return format!("(offset {start} is past end of file — {total} lines total)");
156 }
157 let window: Vec<&str> = contents.lines().skip(start0).take(limit).collect();
158 let end = start0 + window.len(); let mut body = window.join("\n");
160 let char_capped = body.len() > max_chars;
161 if char_capped {
162 let mut cut = max_chars;
163 while cut > 0 && !body.is_char_boundary(cut) {
164 cut -= 1;
165 }
166 body.truncate(cut);
167 }
168 let footer = if char_capped {
169 Some(format!(
170 "payload truncated to {max_chars} chars (~{max_output_tokens} tokens) from line \
171 {start}; call read_file with a higher offset (and/or smaller limit) to continue"
172 ))
173 } else if end < total {
174 Some(format!(
175 "showing lines {start}-{end} of {total}; \
176 call read_file with offset={} to continue",
177 end + 1
178 ))
179 } else {
180 None
181 };
182 match footer {
183 Some(f) => format!("{body}\n\n[{f}]"),
184 None => body,
185 }
186}
187
188pub fn tool_definitions() -> serde_json::Value {
189 serde_json::json!([
190 {
191 "type": "function",
192 "function": {
193 "name": "run_command",
194 "description": "Run a shell command in the workspace directory and return its output",
195 "parameters": {
196 "type": "object",
197 "properties": {
198 "command": { "type": "string", "description": "The shell command to run" }
199 },
200 "required": ["command"]
201 }
202 }
203 },
204 {
205 "type": "function",
206 "function": {
207 "name": "read_file",
208 "description": "Read a file in the workspace. Returns up to `limit` lines \
209 (default 2000) starting at 1-based `offset` (default 1). Large \
210 files come back with a footer pointing at the next window — read \
211 them in pages with offset/limit rather than all at once, or the \
212 full file can saturate the context window.",
213 "parameters": {
214 "type": "object",
215 "properties": {
216 "path": { "type": "string", "description": "File path relative to workspace root" },
217 "offset": { "type": "integer", "description": "1-based line number to start at (default 1)" },
218 "limit": { "type": "integer", "description": "Maximum number of lines to return (default 2000)" }
219 },
220 "required": ["path"]
221 }
222 }
223 },
224 {
225 "type": "function",
226 "function": {
227 "name": "write_file",
228 "description": "Write or overwrite a file in the workspace. \
229 WARNING: use edit_file instead when modifying an existing file — \
230 write_file replaces the entire contents and will fail if the new \
231 content is significantly shorter than the original (shrink guard). \
232 Only use write_file for new files or full rewrites you have \
233 explicitly generated in their entirety.",
234 "parameters": {
235 "type": "object",
236 "properties": {
237 "path": { "type": "string", "description": "File path relative to workspace root" },
238 "content": { "type": "string", "description": "The complete new file contents" }
239 },
240 "required": ["path", "content"]
241 }
242 }
243 },
244 {
245 "type": "function",
246 "function": {
247 "name": "edit_file",
248 "description": "Make a targeted edit to an existing file by replacing one exact \
249 string with another. Safer than write_file for modifying existing \
250 files — you only generate the change, not the whole file. \
251 Fails with a clear error if old_string is not found or matches \
252 multiple times (add more surrounding context to make it unique).",
253 "parameters": {
254 "type": "object",
255 "properties": {
256 "path": { "type": "string", "description": "File path relative to workspace root" },
257 "old_string": { "type": "string", "description": "Exact string to find and replace (must match exactly once)" },
258 "new_string": { "type": "string", "description": "Replacement string" }
259 },
260 "required": ["path", "old_string", "new_string"]
261 }
262 }
263 },
264 {
265 "type": "function",
266 "function": {
267 "name": "list_dir",
268 "description": "List files in a directory",
269 "parameters": {
270 "type": "object",
271 "properties": {
272 "path": { "type": "string", "description": "Directory path relative to workspace root (use '.' for root)" }
273 },
274 "required": ["path"]
275 }
276 }
277 },
278 {
279 "type": "function",
280 "function": {
281 "name": "find",
282 "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.",
283 "parameters": {
284 "type": "object",
285 "properties": {
286 "path": { "type": "string", "description": "Directory to search under, relative to workspace root. Default '.' (the whole workspace)." },
287 "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." },
288 "type": { "type": "string", "enum": ["f", "d", "any"], "description": "Restrict to files ('f'), directories ('d'), or both ('any', the default)." },
289 "max_depth": { "type": "integer", "description": "Maximum directory depth below `path` (1 = immediate children only). Omit for unlimited." },
290 "max_results": { "type": "integer", "description": "Cap on the number of matches returned. Default 1000; output notes when truncated." },
291 "respect_gitignore": { "type": "boolean", "description": "When true (default) skip .gitignored paths plus .git/target/node_modules/hidden dirs. Set false to search everything." },
292 "case_sensitive": { "type": "boolean", "description": "Case-sensitive basename match. Default true." }
293 },
294 "required": []
295 }
296 }
297 },
298 {
299 "type": "function",
300 "function": {
301 "name": "use_skill",
302 "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.",
303 "parameters": {
304 "type": "object",
305 "properties": {
306 "name": { "type": "string", "description": "The skill name as shown in the 'Available skills' index" }
307 },
308 "required": ["name"]
309 }
310 }
311 },
312 {
313 "type": "function",
314 "function": {
315 "name": "web_fetch",
316 "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.",
317 "parameters": {
318 "type": "object",
319 "properties": {
320 "url": { "type": "string", "description": "The http(s) URL to fetch" },
321 "max_bytes": { "type": "integer", "description": "Optional cap on bytes downloaded (default 5 MiB, max 25 MiB)" }
322 },
323 "required": ["url"]
324 }
325 }
326 },
327 {
328 "type": "function",
329 "function": {
330 "name": "request_permissions",
331 "description": "Ask the operator to GRANT a capability you were denied — the \
332 capability-grant path (#721). Call this AFTER a `capability denied` \
333 result to request authority you don't currently have. If an operator \
334 is present they may allow it; then retry the original operation. In a \
335 headless session (no operator) you'll be told the capability must be \
336 configured by the owner — change approach. This requests AUTHORITY \
337 (it mints a capability grant); it is NOT a way to ask the user a \
338 free-text question.",
339 "parameters": {
340 "type": "object",
341 "properties": {
342 "capability": { "type": "string", "enum": ["exec", "fs_read", "fs_write", "net"], "description": "Which capability axis to request" },
343 "target": { "type": "string", "description": "What to grant: a command name (exec), a path (fs_read/fs_write), or a host (net)" },
344 "reason": { "type": "string", "description": "Why you need it — shown to the operator deciding" }
345 },
346 "required": ["capability", "target", "reason"]
347 }
348 }
349 }
350 ])
351}
352
353fn request_user_input_tool_definition() -> serde_json::Value {
361 serde_json::json!({
362 "type": "function",
363 "function": {
364 "name": "request_user_input",
365 "description": "Ask the human operator a free-text question and get \
366 their answer. Use this to resolve genuine ambiguity \
367 instead of guessing or narrating (e.g. 'which database \
368 should I target?', 'is this the file you meant?'). This \
369 asks for INFORMATION, not authority — to request a \
370 capability you were denied, use request_permissions \
371 instead. In a headless session (no operator) you'll be \
372 told no human is available — then proceed with your best \
373 judgment and state your assumption explicitly.",
374 "parameters": {
375 "type": "object",
376 "properties": {
377 "question": { "type": "string", "description": "The free-text question to ask the human" }
378 },
379 "required": ["question"]
380 }
381 }
382 })
383}
384
385pub fn lifecycle_tool_definition() -> serde_json::Value {
396 let phases: Vec<&str> = crate::tooling::Phase::ALL
397 .iter()
398 .map(|p| p.as_str())
399 .collect();
400 serde_json::json!({
401 "type": "function",
402 "function": {
403 "name": "lifecycle",
404 "description": "Run a named project lifecycle phase using THIS repo's \
405 configured command instead of guessing a raw shell command. \
406 Phases: setup (resolve deps / prepare a checkout), format \
407 (auto-format the tree), lint (static analysis), test (run the \
408 tests), check (the full gate a change must pass), clean (remove \
409 build artifacts). The command is resolved from \
410 .newt/config.toml [lifecycle] and the repo's tooling packs \
411 (Rust / Python / PyO3 / Go / custom), so `check` runs the RIGHT \
412 gate for this project. Prefer this over run_command for build / \
413 test / format / lint / check work so the project's own \
414 conventions are honored uniformly across build systems. Use \
415 action=list to see the resolved command without running it.",
416 "parameters": {
417 "type": "object",
418 "properties": {
419 "phase": {
420 "type": "string",
421 "enum": phases,
422 "description": "The lifecycle phase to run."
423 },
424 "action": {
425 "type": "string",
426 "enum": ["run", "list"],
427 "description": "run (default) executes the phase's resolved command; \
428 list returns the command without running it."
429 }
430 },
431 "required": ["phase"]
432 }
433 }
434 })
435}
436
437#[allow(clippy::too_many_arguments)] pub(crate) fn merged_tool_definitions(
451 mcp: &dyn McpTools,
452 with_save_note: bool,
453 with_recall: bool,
454 with_memory_fetch: bool,
455 with_git: bool,
456 with_team: bool,
457 with_scratchpad: bool,
458 with_code_search: bool,
459 with_experiential: bool,
460 with_scheduled: bool,
461) -> serde_json::Value {
462 let mut defs = match tool_definitions() {
463 serde_json::Value::Array(a) => a,
464 other => vec![other],
465 };
466 for spec in EXTENDED_TOOL_REGISTRY {
476 if gate_satisfied(
477 spec.gate,
478 with_save_note,
479 with_recall,
480 with_memory_fetch,
481 with_git,
482 with_team,
483 with_scratchpad,
484 with_code_search,
485 with_experiential,
486 with_scheduled,
487 ) {
488 defs.push((spec.definition)());
489 }
490 }
491 defs.extend(mcp.tool_defs());
492 serde_json::Value::Array(defs)
493}
494
495const DIRECT_TOOL_NAMES: &[&str] = &[
498 "list_dir",
499 "read_file",
500 "write_file",
501 "edit_file",
502 "use_skill",
503 "web_fetch",
504 "find",
507 "git",
511];
512
513const GIT_NETWORK_SUBCOMMANDS: &[&str] = &["push", "fetch", "pull", "clone"];
523
524fn run_command_redirect(command: &str) -> Option<&'static str> {
533 let mut tokens = command.split_ascii_whitespace();
534 let first = tokens.next().unwrap_or("");
535 if first == "git" {
536 let sub = tokens.next().unwrap_or("");
537 if GIT_NETWORK_SUBCOMMANDS.contains(&sub) {
538 return None;
539 }
540 return Some("git");
541 }
542 DIRECT_TOOL_NAMES.iter().copied().find(|&t| t == first)
543}
544
545#[derive(Clone, Copy, PartialEq, Eq)]
560enum Gate {
561 Always,
562 SaveNote,
563 Recall,
564 MemoryFetch,
565 Git,
566 Team,
567 Scratchpad,
568 CodeSearch,
569 Experiential,
570 Scheduled,
571}
572
573struct ToolSpec {
575 name: &'static str,
577 definition: fn() -> serde_json::Value,
580 gate: Gate,
582}
583
584const EXTENDED_TOOL_REGISTRY: &[ToolSpec] = &[
588 ToolSpec {
591 name: "resume_context",
592 definition: super::resume::resume_context_tool_definition,
593 gate: Gate::Always,
594 },
595 ToolSpec {
596 name: "tool_search",
597 definition: super::tool_search::tool_search_tool_definition,
598 gate: Gate::Always,
599 },
600 ToolSpec {
601 name: "get_context_remaining",
602 definition: super::budget::get_context_remaining_tool_definition,
603 gate: Gate::Always,
604 },
605 ToolSpec {
606 name: "request_user_input",
607 definition: request_user_input_tool_definition,
608 gate: Gate::Always,
609 },
610 ToolSpec {
611 name: "lifecycle",
612 definition: lifecycle_tool_definition,
613 gate: Gate::Always,
614 },
615 ToolSpec {
617 name: "save_note",
618 definition: save_note_tool_definition,
619 gate: Gate::SaveNote,
620 },
621 ToolSpec {
622 name: "recall",
623 definition: recall_tool_definition,
624 gate: Gate::Recall,
625 },
626 ToolSpec {
627 name: "memory_fetch",
628 definition: memory_fetch_tool_definition,
629 gate: Gate::MemoryFetch,
630 },
631 ToolSpec {
632 name: "git",
633 definition: super::git_tool::git_tool_definition,
634 gate: Gate::Git,
635 },
636 ToolSpec {
637 name: "compose_roster",
638 definition: super::crew_tool::compose_roster_tool_definition,
639 gate: Gate::Team,
640 },
641 ToolSpec {
642 name: "crew",
643 definition: super::crew_tool::crew_tool_definition,
644 gate: Gate::Team,
645 },
646 ToolSpec {
647 name: "state_set",
648 definition: super::scratchpad::state_set_tool_definition,
649 gate: Gate::Scratchpad,
650 },
651 ToolSpec {
652 name: "state_get",
653 definition: super::scratchpad::state_get_tool_definition,
654 gate: Gate::Scratchpad,
655 },
656 ToolSpec {
657 name: "state_clear",
658 definition: super::scratchpad::state_clear_tool_definition,
659 gate: Gate::Scratchpad,
660 },
661 ToolSpec {
662 name: "code_search",
663 definition: super::semantic::code_search_tool_definition,
664 gate: Gate::CodeSearch,
665 },
666 ToolSpec {
667 name: "experience_record",
668 definition: super::experiential::experience_record_tool_definition,
669 gate: Gate::Experiential,
670 },
671 ToolSpec {
672 name: "experience_recall",
673 definition: super::experiential::experience_recall_tool_definition,
674 gate: Gate::Experiential,
675 },
676 ToolSpec {
677 name: "update_plan",
678 definition: super::scheduled::update_plan_tool_definition,
679 gate: Gate::Scheduled,
680 },
681 ToolSpec {
682 name: "plan_get",
683 definition: super::scheduled::plan_get_tool_definition,
684 gate: Gate::Scheduled,
685 },
686];
687
688const BASE_TOOL_NAMES: &[&str] = &[
694 "run_command",
695 "read_file",
696 "write_file",
697 "edit_file",
698 "list_dir",
699 "find",
700 "use_skill",
701 "web_fetch",
702 "request_permissions",
703];
704
705static ALL_TOOL_NAMES: LazyLock<Vec<&'static str>> = LazyLock::new(|| {
712 BASE_TOOL_NAMES
713 .iter()
714 .copied()
715 .chain(EXTENDED_TOOL_REGISTRY.iter().map(|s| s.name))
716 .collect()
717});
718
719pub(crate) fn known_builtin_tool_name(tool_name: &str) -> bool {
723 ALL_TOOL_NAMES.contains(&tool_name)
724}
725
726#[allow(clippy::too_many_arguments)] fn gate_satisfied(
730 gate: Gate,
731 with_save_note: bool,
732 with_recall: bool,
733 with_memory_fetch: bool,
734 with_git: bool,
735 with_team: bool,
736 with_scratchpad: bool,
737 with_code_search: bool,
738 with_experiential: bool,
739 with_scheduled: bool,
740) -> bool {
741 match gate {
742 Gate::Always => true,
743 Gate::SaveNote => with_save_note,
744 Gate::Recall => with_recall,
745 Gate::MemoryFetch => with_memory_fetch,
746 Gate::Git => with_git,
747 Gate::Team => with_team,
748 Gate::Scratchpad => with_scratchpad,
749 Gate::CodeSearch => with_code_search,
750 Gate::Experiential => with_experiential,
751 Gate::Scheduled => with_scheduled,
752 }
753}
754
755pub(crate) fn is_hallucination(tool_name: &str, args: &serde_json::Value) -> bool {
759 if tool_name == "run_command" {
760 let cmd = args["command"].as_str().unwrap_or("");
761 return run_command_redirect(cmd).is_some();
764 }
765 if tool_name.contains("__") {
767 return false;
768 }
769 !ALL_TOOL_NAMES.contains(&tool_name)
772}
773
774pub(crate) enum AliasOutcome {
781 Rewrite(&'static str),
784 Correct(String),
787}
788
789pub(crate) fn resolve_tool_alias(name: &str) -> Option<AliasOutcome> {
792 match name {
793 "execute" | "exec" | "bash" | "shell" | "sh" | "zsh" | "terminal" | "run_shell_command"
797 | "shell_command" | "system" => Some(AliasOutcome::Rewrite("run_command")),
798 "run_phase" | "run_lifecycle" | "lifecycle_run" => Some(AliasOutcome::Rewrite("lifecycle")),
802 "str_replace_editor" | "str_replace" | "str-replace-editor" | "apply_patch" | "edit"
804 | "editor" | "replace_in_file" | "search_replace" => Some(AliasOutcome::Correct(format!(
805 "'{name}' is not a newt tool. To change an existing file, call \
806 edit_file with {{\"path\", \"old_string\", \"new_string\"}} \
807 (replaces one exact occurrence). For a new file or a full \
808 rewrite, call write_file with {{\"path\", \"content\"}}."
809 ))),
810 "create_file" | "new_file" | "createfile" | "add_file" | "touch" => {
812 Some(AliasOutcome::Correct(format!(
813 "'{name}' is not a newt tool. To create or overwrite a file, call \
814 write_file with {{\"path\", \"content\"}}. To change part of an \
815 existing file, call edit_file with \
816 {{\"path\", \"old_string\", \"new_string\"}}."
817 )))
818 }
819 "mkdir" | "make_dir" | "makedirs" | "mkdirs" | "create_dir" | "create_directory" => {
828 Some(AliasOutcome::Correct(
829 "newt has no mkdir/touch tool — call write_file; it creates parent \
830 directories automatically (create_dir_all). For an empty file, call \
831 write_file with empty content."
832 .to_string(),
833 ))
834 }
835 "cat" | "open_file" | "view_file" | "view" | "open" => {
837 Some(AliasOutcome::Correct(format!(
838 "'{name}' is not a newt tool. To read a file, call read_file with \
839 {{\"path\"}}. To list a directory, call list_dir with {{\"path\"}}."
840 )))
841 }
842 "enter_plan" | "enter_plan_mode" | "plan_mode" | "start_plan" | "begin_plan"
850 | "make_plan" | "create_plan" | "plan" | "planning" | "todo" | "todos" | "todo_write" => {
851 Some(AliasOutcome::Correct(format!(
852 "'{name}' is not a newt tool. To start or revise your plan, call update_plan with \
853 {{\"plan\":[{{\"step\",\"status\"}}]}} — send the full ordered list each time, \
854 each step's status one of pending/in_progress/completed (exactly one \
855 in_progress)."
856 )))
857 }
858 "next_step" | "complete_step" | "finish_step" | "mark_done" | "step_done" => {
862 Some(AliasOutcome::Correct(format!(
863 "'{name}' is not a newt tool. To advance your plan, call update_plan with the \
864 full plan and mark the finished step \"completed\" (and the next one \
865 \"in_progress\")."
866 )))
867 }
868 "get_plan" | "show_plan" | "read_plan" | "current_plan" | "what_was_i_doing" => {
873 Some(AliasOutcome::Rewrite("plan_get"))
874 }
875 "resume" | "where_were_we" | "where_did_we_leave_off" | "catch_me_up" | "recap" => {
881 Some(AliasOutcome::Rewrite("resume_context"))
882 }
883 "delegate" | "spawn_agent" | "subagent" | "sub_agent" | "crew_dispatch" | "run_crew"
888 | "dispatch_crew" | "fork_agent" | "assign" | "team" => {
889 Some(AliasOutcome::Correct(format!(
890 "'{name}' is not a newt tool. Crew/team delegation is only available once the \
891 human enables /team this session — you cannot turn it on yourself. When /team \
892 is on, compose_roster ({{\"mode\"}}) proposes a roster and crew ({{\"task\"}}) \
893 dispatches it."
894 )))
895 }
896 "find_tool" | "search_tools" | "list_tools" | "which_tool" | "available_tools"
902 | "what_tools" | "tools" => Some(AliasOutcome::Rewrite("tool_search")),
903 "workflow" | "run_workflow" | "start_workflow" | "pipeline" => Some(AliasOutcome::Correct(
906 "newt has no workflow tool; sequence the work with update_plan (the full ordered \
907 plan with statuses), or delegate subtasks via crew/team (needs /team)."
908 .to_string(),
909 )),
910 "context_remaining" | "tokens_left" | "remaining_tokens" | "budget"
916 | "how_much_context" | "context_budget" | "token_budget" => {
917 Some(AliasOutcome::Rewrite("get_context_remaining"))
918 }
919 "ask_user" | "ask_human" | "prompt_user" | "get_user_input" | "ask_question"
926 | "clarify" | "ask" => Some(AliasOutcome::Rewrite("request_user_input")),
927 _ => None,
928 }
929}
930
931pub(crate) fn is_context_remaining_call(name: &str) -> bool {
937 name == "get_context_remaining"
938 || matches!(
939 resolve_tool_alias(name),
940 Some(AliasOutcome::Rewrite("get_context_remaining"))
941 )
942}
943
944pub(crate) fn classify_phantom_reach(
954 name: &str,
955 args: &serde_json::Value,
956 result: &str,
957 ok: bool,
958) -> Option<crate::PhantomResolution> {
959 let _ = ok;
960 match resolve_tool_alias(name) {
963 Some(AliasOutcome::Rewrite(canonical)) => {
964 return Some(crate::PhantomResolution::Rewrite(canonical.to_string()))
965 }
966 Some(AliasOutcome::Correct(msg)) => return Some(crate::PhantomResolution::Correct(msg)),
967 None => {}
968 }
969 if is_hallucination(name, args) {
971 return Some(crate::PhantomResolution::Unknown);
972 }
973 let r = result.trim_start();
977 if name == "state_get" && r.starts_with("no such key") {
978 return Some(crate::PhantomResolution::RealToolMiss(
979 "state_get on an unset key".into(),
980 ));
981 }
982 if name == "recall" && r.starts_with("no matches in past conversations") {
983 return Some(crate::PhantomResolution::RealToolMiss(
984 "recall returned no matches".into(),
985 ));
986 }
987 None
988}
989
990pub(crate) fn classify_gated_off_reach(
1006 name: &str,
1007 advertise_team: bool,
1008) -> Option<crate::PhantomResolution> {
1009 if !advertise_team && (name == "crew" || name == "compose_roster") {
1010 return Some(crate::PhantomResolution::GatedOff(
1011 "crew/team surface off (NEWT_TEAM)".into(),
1012 ));
1013 }
1014 None
1015}
1016
1017fn levenshtein(a: &str, b: &str) -> usize {
1020 let a: Vec<char> = a.chars().collect();
1021 let b: Vec<char> = b.chars().collect();
1022 let mut prev: Vec<usize> = (0..=b.len()).collect();
1023 let mut cur = vec![0usize; b.len() + 1];
1024 for (i, ca) in a.iter().enumerate() {
1025 cur[0] = i + 1;
1026 for (j, cb) in b.iter().enumerate() {
1027 let cost = usize::from(ca != cb);
1028 cur[j + 1] = (prev[j + 1] + 1).min(cur[j] + 1).min(prev[j] + cost);
1029 }
1030 std::mem::swap(&mut prev, &mut cur);
1031 }
1032 prev[b.len()]
1033}
1034
1035fn nearest_tool_name(name: &str) -> Option<&'static str> {
1039 let threshold = (name.chars().count() / 3).max(1);
1040 ALL_TOOL_NAMES
1041 .iter()
1042 .map(|&t| (levenshtein(name, t), t))
1043 .filter(|(d, _)| *d <= threshold)
1044 .min_by_key(|(d, _)| *d)
1045 .map(|(_, t)| t)
1046}
1047
1048fn unknown_tool_message(name: &str) -> String {
1053 const BASE: &str =
1054 "run_command, read_file, write_file, edit_file, list_dir, find, use_skill, web_fetch";
1055 match nearest_tool_name(name) {
1056 Some(sugg) => format!(
1057 "unknown tool: {name}. Did you mean '{sugg}'? Available tools include: \
1058 {BASE} (plus git and any memory/plan tools enabled this session)."
1059 ),
1060 None => format!(
1061 "unknown tool: {name}. Available tools include: {BASE} (plus git and \
1062 any memory/plan tools enabled this session)."
1063 ),
1064 }
1065}
1066
1067pub fn venv_cmd_prefix() -> Option<String> {
1077 let venv = std::env::var("NEWT_VENV")
1078 .or_else(|_| std::env::var("VIRTUAL_ENV"))
1079 .ok();
1080 let exec_paths = std::env::var("NEWT_EXEC_PATHS").ok();
1081
1082 if venv.is_none() && exec_paths.is_none() {
1083 return None;
1084 }
1085
1086 let q = |s: &str| format!("'{}'", s.replace('\'', r"'\''"));
1088
1089 let mut path_dirs: Vec<String> = Vec::new();
1091 let mut prefix = String::new();
1092
1093 if let Some(ref venv) = venv {
1094 let venv_bin = format!("{venv}/bin");
1095 prefix.push_str(&format!("export VIRTUAL_ENV={}; ", q(venv)));
1096 path_dirs.push(venv_bin);
1097 }
1098 if let Some(ref paths) = exec_paths {
1099 for dir in paths.split(':') {
1100 if !dir.is_empty() {
1101 path_dirs.push(dir.to_string());
1102 }
1103 }
1104 }
1105
1106 if !path_dirs.is_empty() {
1107 let quoted: Vec<String> = path_dirs.iter().map(|d| q(d)).collect();
1108 prefix.push_str(&format!("export PATH={}:\"$PATH\"; ", quoted.join(":")));
1109 }
1110
1111 if prefix.is_empty() {
1112 None
1113 } else {
1114 Some(prefix)
1115 }
1116}
1117
1118fn venv_env_map() -> std::collections::BTreeMap<String, String> {
1135 let mut map = std::collections::BTreeMap::new();
1136 let venv = std::env::var("NEWT_VENV")
1137 .or_else(|_| std::env::var("VIRTUAL_ENV"))
1138 .ok();
1139 let exec_paths = std::env::var("NEWT_EXEC_PATHS").ok();
1140
1141 if venv.is_none() && exec_paths.is_none() {
1142 return map;
1143 }
1144
1145 let mut path_dirs: Vec<String> = Vec::new();
1148 if let Some(ref venv) = venv {
1149 map.insert("VIRTUAL_ENV".to_string(), venv.clone());
1150 path_dirs.push(format!("{venv}/bin"));
1151 }
1152 if let Some(ref paths) = exec_paths {
1153 for dir in paths.split(':') {
1154 if !dir.is_empty() {
1155 path_dirs.push(dir.to_string());
1156 }
1157 }
1158 }
1159
1160 if !path_dirs.is_empty() {
1161 let prepend = path_dirs.join(":");
1162 let path = match std::env::var("PATH") {
1163 Ok(inherited) if !inherited.is_empty() => format!("{prepend}:{inherited}"),
1164 _ => prepend,
1165 };
1166 map.insert("PATH".to_string(), path);
1167 }
1168
1169 map
1170}
1171
1172fn confined_dispatch_args(cmd: &str, workspace: &str) -> serde_json::Value {
1180 serde_json::json!({
1181 "cmd": cmd,
1182 "cwd": workspace,
1183 "env": venv_env_map(),
1184 })
1185}
1186
1187fn shell_engine() -> crate::ShellEngine {
1196 if let Some(engine) = std::env::var("NEWT_SHELL_ENGINE")
1197 .ok()
1198 .and_then(|s| s.parse::<crate::ShellEngine>().ok())
1199 {
1200 return engine;
1201 }
1202 if full_access_requested() {
1207 crate::full_access_default_engine()
1208 } else {
1209 crate::ShellEngine::default()
1210 }
1211}
1212
1213fn bridle_registry() -> agent_bridle::Registry {
1220 use std::sync::Arc;
1221 let shell: Arc<dyn agent_bridle::Tool> = match shell_engine() {
1222 crate::ShellEngine::SafeSubset => Arc::new(agent_bridle::ShellTool::new()),
1223 crate::ShellEngine::Host => Arc::new(agent_bridle::HostShellTool::new()),
1224 crate::ShellEngine::Brush => {
1225 #[cfg(windows)]
1229 {
1230 use std::sync::Once;
1231 static WARN: Once = Once::new();
1232 WARN.call_once(|| {
1233 tracing::warn!(
1234 "using the 'brush' shell engine on Windows: run_command runs a \
1235 bash-in-Rust shell for internal-tooling compatibility. Native \
1236 PowerShell/cmd code paths are a FUTURE release — not written yet \
1237 (we are opinionated Linux developers who occasionally use a \
1238 MacBook). Bash-isms work; Windows-native shell semantics do not."
1239 );
1240 });
1241 }
1242 Arc::new(agent_bridle::BrushShellTool::new())
1243 }
1244 };
1245 agent_bridle::Registry::builder()
1246 .tool(shell)
1247 .tool(Arc::new(agent_bridle::WebFetchTool::new()))
1248 .build()
1249}
1250
1251pub fn ocap_disabled() -> bool {
1279 std::env::var("NEWT_DISABLE_OCAP").is_ok_and(|v| v == "1")
1280}
1281
1282pub fn full_access_requested() -> bool {
1299 std::env::var("NEWT_FULL_ACCESS").is_ok_and(|v| v == "1")
1300}
1301
1302pub fn routing_disabled() -> bool {
1318 std::env::var("NEWT_NO_ROUTE").is_ok_and(|v| v == "1")
1319}
1320
1321fn exec_floor_permits(floor: Option<&crate::caveats::Scope<String>>, cmd: &str) -> bool {
1344 use crate::caveats::ScopeExt as _;
1345 let Some(scope) = floor else {
1346 return true; };
1348 const SHELL_META: &[char] = &['&', '|', ';', '`', '$', '\n', '>', '<', '(', ')'];
1352 if cmd.contains(SHELL_META) {
1353 return false;
1354 }
1355 match cmd.split_ascii_whitespace().next() {
1356 None => true,
1358 Some(prog) => scope.permits(&prog.to_string()),
1359 }
1360}
1361
1362#[allow(clippy::too_many_arguments)]
1379async fn exec_confined_command(
1380 cmd: &str,
1381 workspace: &str,
1382 color: bool,
1383 tool_output_lines: usize,
1384 caveats: &crate::caveats::Caveats,
1385 exec_floor: Option<&crate::caveats::Scope<String>>,
1386 permission_gate: Option<&mut dyn PermissionGate>,
1387 tool_offload: bool,
1388 spill_store: Option<&dyn SpillStore>,
1389) -> String {
1390 let cmd_with_venv = match venv_cmd_prefix() {
1398 Some(prefix) => format!("{prefix}{cmd}"),
1399 None => cmd.to_string(),
1400 };
1401
1402 if ocap_disabled() && exec_floor_permits(exec_floor, cmd) {
1411 return match host_shell_dispatch(&cmd_with_venv, workspace).await {
1412 Ok(envelope) => shell_envelope_output(
1413 &envelope,
1414 tool_output_lines,
1415 color,
1416 tool_offload,
1417 spill_store,
1418 ),
1419 Err(e) => format!("error: {e}"),
1420 };
1421 }
1422
1423 let dispatch_args = confined_dispatch_args(cmd, workspace);
1426 match bridle_registry()
1427 .dispatch("shell", dispatch_args.clone(), caveats)
1428 .await
1429 {
1430 Ok(envelope) if envelope_denied(&envelope) => {
1439 if let Some(gate) = permission_gate {
1444 if let Some(requests) =
1448 exec_denial_requests(&envelope).or_else(|| net_denial_requests(&envelope))
1449 {
1450 if let PermissionDecision::Allow(widened) = gate.ask(&requests) {
1451 return match bridle_registry()
1452 .dispatch("shell", dispatch_args, &widened)
1453 .await
1454 {
1455 Ok(env2) if envelope_denied(&env2) => {
1456 denied_run_command_result(&env2, color)
1457 }
1458 Ok(env2) => shell_envelope_output(
1459 &env2,
1460 tool_output_lines,
1461 color,
1462 tool_offload,
1463 spill_store,
1464 ),
1465 Err(e) => format!("error: {e}"),
1466 };
1467 }
1468 }
1469 }
1470 denied_run_command_result(&envelope, color)
1471 }
1472 Ok(envelope) => shell_envelope_output(
1473 &envelope,
1474 tool_output_lines,
1475 color,
1476 tool_offload,
1477 spill_store,
1478 ),
1479 Err(e) => format!("error: {e}"),
1482 }
1483}
1484
1485async fn host_shell_dispatch(cmd: &str, cwd: &str) -> std::io::Result<serde_json::Value> {
1486 let output = host_shell_output(cmd, cwd).await?;
1487 Ok(serde_json::json!({
1488 "exit_code": output.status.code().unwrap_or(-1),
1489 "stdout": decode_shell_stream(&output.stdout),
1490 "stderr": decode_shell_stream(&output.stderr),
1491 "sandbox_kind": "none",
1494 }))
1495}
1496
1497fn decode_shell_stream(bytes: &[u8]) -> String {
1498 match std::str::from_utf8(bytes) {
1499 Ok(text) => text.to_string(),
1500 Err(_) => repair_bsd_cat_v_utf8(bytes)
1501 .unwrap_or_else(|| String::from_utf8_lossy(bytes).into_owned()),
1502 }
1503}
1504
1505fn repair_bsd_cat_v_utf8(bytes: &[u8]) -> Option<String> {
1513 let mut repaired = Vec::with_capacity(bytes.len());
1514 let mut changed = false;
1515 let mut i = 0;
1516 while i < bytes.len() {
1517 let lead = bytes[i];
1518 let Some(cont_count) = utf8_continuation_count(lead) else {
1519 repaired.push(lead);
1520 i += 1;
1521 continue;
1522 };
1523
1524 let mut seq = Vec::with_capacity(cont_count + 1);
1525 seq.push(lead);
1526 let mut j = i + 1;
1527 let mut ok = true;
1528 for _ in 0..cont_count {
1529 match parse_cat_v_meta_byte(bytes, j) {
1530 Some((cont, next)) if (0x80..=0xbf).contains(&cont) => {
1531 seq.push(cont);
1532 j = next;
1533 }
1534 _ => {
1535 ok = false;
1536 break;
1537 }
1538 }
1539 }
1540
1541 if ok && std::str::from_utf8(&seq).is_ok() {
1542 repaired.extend_from_slice(&seq);
1543 changed = true;
1544 i = j;
1545 } else {
1546 repaired.push(lead);
1547 i += 1;
1548 }
1549 }
1550
1551 changed.then(|| String::from_utf8(repaired).ok()).flatten()
1552}
1553
1554fn utf8_continuation_count(lead: u8) -> Option<usize> {
1555 match lead {
1556 0xc2..=0xdf => Some(1),
1557 0xe0..=0xef => Some(2),
1558 0xf0..=0xf4 => Some(3),
1559 _ => None,
1560 }
1561}
1562
1563fn parse_cat_v_meta_byte(bytes: &[u8], start: usize) -> Option<(u8, usize)> {
1564 if start + 2 > bytes.len() || &bytes[start..start + 2] != b"M-" {
1565 return None;
1566 }
1567 let pos = start + 2;
1568 match bytes.get(pos).copied()? {
1569 b'^' => {
1570 let c = bytes.get(pos + 1).copied()?;
1571 let low = if c == b'?' {
1572 0x7f
1573 } else if (b'@'..=b'_').contains(&c) {
1574 c - b'@'
1575 } else {
1576 return None;
1577 };
1578 Some((low | 0x80, pos + 2))
1579 }
1580 c if (0x20..=0x7e).contains(&c) => Some((c | 0x80, pos + 1)),
1581 _ => None,
1582 }
1583}
1584
1585#[cfg(not(windows))]
1589async fn host_shell_output(cmd: &str, cwd: &str) -> std::io::Result<std::process::Output> {
1590 fn shell(program: &str, cmd: &str, cwd: &str) -> tokio::process::Command {
1591 let mut c = tokio::process::Command::new(program);
1592 c.arg("-c").arg(cmd).current_dir(cwd);
1593 c
1594 }
1595 match shell("bash", cmd, cwd).output().await {
1596 Err(e) if e.kind() == std::io::ErrorKind::NotFound => shell("sh", cmd, cwd).output().await,
1597 other => other,
1598 }
1599}
1600
1601#[cfg(windows)]
1604async fn host_shell_output(cmd: &str, cwd: &str) -> std::io::Result<std::process::Output> {
1605 tokio::process::Command::new("cmd")
1606 .args(["/C", cmd])
1607 .current_dir(cwd)
1608 .output()
1609 .await
1610}
1611
1612fn lexically_normalize(path: &str) -> std::path::PathBuf {
1620 use std::path::{Component, PathBuf};
1621 let mut out = PathBuf::new();
1622 for comp in std::path::Path::new(path).components() {
1623 match comp {
1624 Component::CurDir => {}
1625 Component::ParentDir => {
1626 if !out.pop() {
1628 out.push(comp.as_os_str());
1629 }
1630 }
1631 other => out.push(other.as_os_str()),
1632 }
1633 }
1634 out
1635}
1636
1637pub(crate) fn tui_permits_path(scope: &crate::caveats::Scope<String>, full_path: &str) -> bool {
1649 match scope {
1650 crate::caveats::Scope::All => true,
1651 crate::caveats::Scope::Only(set) if set.is_empty() => false,
1652 crate::caveats::Scope::Only(set) => {
1653 let candidate = lexically_normalize(full_path);
1654 set.iter()
1655 .any(|root| candidate.starts_with(lexically_normalize(root)))
1656 }
1657 }
1658}
1659
1660pub(crate) fn run_build_check(cmd: &str, workspace: &str) -> String {
1663 let result = build_check_shell(cmd).current_dir(workspace).output();
1664 match result {
1665 Ok(out) if out.status.success() => " ✓ build check passed".to_string(),
1666 Ok(out) => {
1667 let stderr = String::from_utf8_lossy(&out.stderr);
1668 let stdout = String::from_utf8_lossy(&out.stdout);
1669 let combined = format!("{stdout}{stderr}");
1670 let excerpt: String = combined.lines().take(8).collect::<Vec<_>>().join("\n");
1671 format!(" ✗ build check failed:\n{excerpt}")
1672 }
1673 Err(e) => format!(" ⚠ build check could not run: {e}"),
1674 }
1675}
1676
1677#[cfg(windows)]
1678fn build_check_shell(cmd: &str) -> std::process::Command {
1679 let mut shell = std::process::Command::new("cmd");
1680 shell.args(["/C", cmd]);
1681 shell
1682}
1683
1684#[cfg(not(windows))]
1685fn build_check_shell(cmd: &str) -> std::process::Command {
1686 let mut shell = std::process::Command::new("sh");
1687 shell.args(["-c", cmd]);
1688 shell
1689}
1690
1691#[cfg(all(test, windows))]
1692fn passing_build_check_cmd() -> &'static str {
1693 "exit /B 0"
1694}
1695
1696#[cfg(all(test, not(windows)))]
1697fn passing_build_check_cmd() -> &'static str {
1698 "true"
1699}
1700
1701#[cfg(all(test, windows))]
1702fn failing_build_check_cmd(message: &str) -> String {
1703 format!("echo {message} 1>&2 & exit /B 1")
1704}
1705
1706#[cfg(all(test, not(windows)))]
1707fn failing_build_check_cmd(message: &str) -> String {
1708 format!("echo {message} >&2; exit 1")
1709}
1710
1711fn envelope_denied(envelope: &serde_json::Value) -> bool {
1719 envelope
1720 .get("denied")
1721 .and_then(serde_json::Value::as_bool)
1722 .unwrap_or(false)
1723}
1724
1725fn envelope_denial_reason(envelope: &serde_json::Value) -> String {
1729 let reasons: Vec<String> = envelope
1730 .get("denials")
1731 .and_then(serde_json::Value::as_array)
1732 .map(|arr| {
1733 arr.iter()
1734 .filter_map(|d| d.get("reason").and_then(serde_json::Value::as_str))
1735 .map(str::to_string)
1736 .collect()
1737 })
1738 .unwrap_or_default();
1739 if reasons.is_empty() {
1740 "denied: the capability leash refused an operation".to_string()
1741 } else {
1742 reasons.join("; ")
1743 }
1744}
1745
1746fn exec_allowlist_name(target: &str) -> &str {
1751 target
1752 .rsplit(['/', '\\'])
1753 .find(|part| !part.is_empty())
1754 .unwrap_or(target)
1755}
1756
1757const DENIAL_RECOVERY_HINT: &str =
1767 "This is outside your granted authority — call request_permissions with the \
1768 capability, a target, and a reason to ask the operator to grant it, or take \
1769 a different approach that stays within your current authority.";
1770
1771const CREW_OFF_RECOVERY_HINT: &str =
1781 "the crew/team surface is not enabled this session (the operator launches it \
1782 with NEWT_TEAM). Accomplish this yourself with the available tools \
1783 (read_file/write_file/edit_file/run_command/...), or ask the operator to \
1784 enable a crew.";
1785
1786fn crew_off_recovery_result(name: &str) -> String {
1790 format!("'{name}' is unavailable: {CREW_OFF_RECOVERY_HINT}")
1791}
1792
1793fn denied_fs_result(kind: &str, path: &str) -> String {
1799 format!("capability denied: {kind} does not permit '{path}'. {DENIAL_RECOVERY_HINT}")
1800}
1801
1802fn denied_run_command_result(envelope: &serde_json::Value, color: bool) -> String {
1825 print_denied(
1829 denial_axis_label(envelope),
1830 &exec_denial_target_label(envelope),
1831 color,
1832 );
1833 format!(
1835 "capability denied: {}. {DENIAL_RECOVERY_HINT}",
1836 envelope_denial_reason(envelope)
1837 )
1838}
1839
1840fn exec_denial_target_label(envelope: &serde_json::Value) -> String {
1846 let targets: Vec<&str> = envelope
1847 .get("denials")
1848 .and_then(serde_json::Value::as_array)
1849 .map(|arr| {
1850 arr.iter()
1851 .filter_map(|d| d.get("target").and_then(serde_json::Value::as_str))
1852 .filter(|t| !t.is_empty())
1853 .collect()
1854 })
1855 .unwrap_or_default();
1856 if targets.is_empty() {
1857 "a command".to_string()
1858 } else {
1859 targets.join(", ")
1860 }
1861}
1862
1863fn shell_envelope_output(
1867 envelope: &serde_json::Value,
1868 tool_output_lines: usize,
1869 color: bool,
1870 tool_offload: bool,
1871 spill_store: Option<&dyn SpillStore>,
1872) -> String {
1873 let stdout = envelope
1874 .get("stdout")
1875 .and_then(serde_json::Value::as_str)
1876 .unwrap_or("");
1877 let stderr = envelope
1878 .get("stderr")
1879 .and_then(serde_json::Value::as_str)
1880 .unwrap_or("");
1881 let out = format!("{stdout}{stderr}");
1882 print_tool_output(&out, tool_output_lines, color);
1884 if out.trim().is_empty() {
1885 let code = envelope
1886 .get("exit_code")
1887 .and_then(serde_json::Value::as_i64)
1888 .unwrap_or(-1);
1889 format!("(exit {code})")
1890 } else {
1891 let max_tokens = max_output_tokens();
1896 let est = crate::tokens::TokenEstimation::default();
1897 let over_model_budget = max_tokens != 0 && est.tokens_for_chars(out.len()) > max_tokens;
1898 let over_spill_budget = out.chars().count() > spill::TOOL_RESULT_SPILL_CAP;
1899 let should_spill =
1900 max_tokens != 0 && tool_offload && (over_model_budget || over_spill_budget);
1901 let capped = if should_spill {
1902 match spill_store {
1903 Some(store) => {
1904 let (id, redacted) = spill::store_redacted_full(&out, store);
1905 let teaser_tokens =
1906 est.tokens_for_chars(spill::TOOL_RESULT_SPILL_CAP.saturating_sub(512));
1907 cap_model_output_with_handle(
1908 &redacted,
1909 max_tokens.min(teaser_tokens),
1910 output_head_tokens(),
1911 Some(&id),
1912 )
1913 }
1914 None => cap_model_output(&out, max_tokens),
1915 }
1916 } else {
1917 cap_model_output(&out, max_tokens)
1918 };
1919 match pr_creation_url(&out) {
1925 Some(url) => format!("{capped}{}", pr_next_step_hint(url)),
1926 None => capped,
1927 }
1928 }
1929}
1930
1931fn pr_creation_url(output: &str) -> Option<&str> {
1938 output.split_whitespace().find(|tok| {
1939 tok.starts_with("https://")
1940 && (tok.contains("/pull/new/")
1941 || tok.contains("/merge_requests/new")
1942 || tok.contains("/compare/"))
1943 })
1944}
1945
1946fn pr_next_step_hint(url: &str) -> String {
1951 format!(
1952 "\n\n[newt] A branch was pushed. To open a pull request now, call \
1953 run_command with `gh pr create --fill` (the `gh` CLI is available; the \
1954 `git` tool cannot push or open PRs). Or open this URL: {url}"
1955 )
1956}
1957
1958fn exec_denial_requests(envelope: &serde_json::Value) -> Option<Vec<PermissionRequest>> {
1966 let denials = envelope.get("denials")?.as_array()?;
1967 if denials.is_empty() {
1968 return None;
1969 }
1970 let mut requests = Vec::with_capacity(denials.len());
1971 for d in denials {
1972 if d.get("kind")?.as_str()? != "exec" {
1973 return None;
1974 }
1975 let target = d.get("target")?.as_str().filter(|t| !t.is_empty())?;
1976 requests.push(PermissionRequest {
1977 tool: "run_command".to_string(),
1978 kind: DenialKind::Exec,
1979 target: exec_allowlist_name(target).to_string(),
1980 reason: d
1981 .get("reason")
1982 .and_then(serde_json::Value::as_str)
1983 .unwrap_or_default()
1984 .to_string(),
1985 });
1986 }
1987 Some(requests)
1988}
1989
1990fn net_denial_requests(envelope: &serde_json::Value) -> Option<Vec<PermissionRequest>> {
2001 let denials = envelope.get("denials")?.as_array()?;
2002 if denials.is_empty() {
2003 return None;
2004 }
2005 let mut requests = Vec::with_capacity(denials.len());
2006 for d in denials {
2007 if d.get("kind")?.as_str()? != "net" {
2008 return None;
2009 }
2010 let host = d.get("target")?.as_str().filter(|t| !t.is_empty())?;
2011 requests.push(PermissionRequest {
2012 tool: "run_command".to_string(),
2013 kind: DenialKind::Net,
2014 target: host.to_string(),
2015 reason: d
2016 .get("reason")
2017 .and_then(serde_json::Value::as_str)
2018 .unwrap_or_default()
2019 .to_string(),
2020 });
2021 }
2022 Some(requests)
2023}
2024
2025fn denial_axis_label(envelope: &serde_json::Value) -> &'static str {
2029 let all_net = envelope
2030 .get("denials")
2031 .and_then(serde_json::Value::as_array)
2032 .filter(|arr| !arr.is_empty())
2033 .is_some_and(|arr| {
2034 arr.iter()
2035 .all(|d| d.get("kind").and_then(serde_json::Value::as_str) == Some("net"))
2036 });
2037 if all_net {
2038 "net"
2039 } else {
2040 "exec"
2041 }
2042}
2043
2044fn fs_gate_allows(
2048 gate: &mut dyn PermissionGate,
2049 tool: &str,
2050 kind: DenialKind,
2051 full_path: &str,
2052 axis: impl Fn(&crate::caveats::Caveats) -> &crate::caveats::Scope<String>,
2053) -> bool {
2054 let request = PermissionRequest {
2055 tool: tool.to_string(),
2056 kind,
2057 target: full_path.to_string(),
2058 reason: format!("{} does not permit '{full_path}'", kind.as_str()),
2059 };
2060 match gate.ask(std::slice::from_ref(&request)) {
2061 PermissionDecision::Allow(widened) => tui_permits_path(axis(&widened), full_path),
2062 PermissionDecision::Deny => false,
2063 }
2064}
2065
2066fn parse_capability(s: &str) -> Option<DenialKind> {
2072 match s.trim().to_ascii_lowercase().as_str() {
2073 "exec" | "run" | "run_command" | "command" | "shell" => Some(DenialKind::Exec),
2074 "fs_read" | "fs-read" | "read" | "read_file" => Some(DenialKind::FsRead),
2075 "fs_write" | "fs-write" | "write" | "write_file" => Some(DenialKind::FsWrite),
2076 "net" | "network" | "web" | "web_fetch" => Some(DenialKind::Net),
2077 _ => None,
2078 }
2079}
2080
2081fn execute_request_permissions(
2097 args: &serde_json::Value,
2098 gate: Option<&mut dyn PermissionGate>,
2099 color: bool,
2100 tool_output_lines: usize,
2101) -> String {
2102 let capability = args["capability"].as_str().unwrap_or("").trim();
2103 let target = args["target"].as_str().unwrap_or("").trim();
2104 let reason = args["reason"].as_str().unwrap_or("").trim();
2105 print_tool_call("request_permissions", capability, color);
2106
2107 let Some(kind) = parse_capability(capability) else {
2108 let out = format!(
2109 "request_permissions: unknown capability '{capability}'. Use one of: \
2110 exec, fs_read, fs_write, net."
2111 );
2112 print_tool_output(&out, tool_output_lines, color);
2113 return out;
2114 };
2115 if target.is_empty() {
2116 let out = "request_permissions: 'target' is required — the command name (exec), \
2117 the path (fs_read/fs_write), or the host (net)."
2118 .to_string();
2119 print_tool_output(&out, tool_output_lines, color);
2120 return out;
2121 }
2122
2123 let request = PermissionRequest {
2124 tool: "request_permissions".to_string(),
2125 kind,
2126 target: target.to_string(),
2127 reason: if reason.is_empty() {
2128 format!("model requested {capability} for '{target}'")
2129 } else {
2130 reason.to_string()
2131 },
2132 };
2133
2134 let out = match gate {
2135 Some(g) => match g.ask(std::slice::from_ref(&request)) {
2140 PermissionDecision::Allow(_widened) => format!(
2141 "granted: the operator allowed {capability} for '{target}'. \
2142 Retry the original operation now."
2143 ),
2144 PermissionDecision::Deny => format!(
2145 "denied: the operator declined {capability} for '{target}'. \
2146 Do not retry it — take a different approach."
2147 ),
2148 },
2149 None => format!(
2153 "no operator available to grant {capability} for '{target}' — this session \
2154 has no interactive permission gate (headless / eval / piped). The capability \
2155 must be configured by the owner (e.g. [tui.permissions] in newt config); \
2156 take a different approach for now."
2157 ),
2158 };
2159 print_tool_output(&out, tool_output_lines, color);
2160 out
2161}
2162
2163const HEADLESS_NO_HUMAN: &str = "no human available this session (running headless) \
2168 — proceed with your best judgment or state your assumption explicitly.";
2169
2170fn execute_request_user_input(
2183 args: &serde_json::Value,
2184 gate: Option<&mut dyn PermissionGate>,
2185 color: bool,
2186 tool_output_lines: usize,
2187) -> String {
2188 let question = args["question"].as_str().unwrap_or("").trim();
2189 print_tool_call("request_user_input", question, color);
2190
2191 if question.is_empty() {
2192 let out = "request_user_input: 'question' is required — the free-text \
2193 question to ask the human."
2194 .to_string();
2195 print_tool_output(&out, tool_output_lines, color);
2196 return out;
2197 }
2198
2199 let out = match gate.and_then(|g| g.ask_question(question)) {
2203 Some(answer) => answer,
2204 None => HEADLESS_NO_HUMAN.to_string(),
2205 };
2206 print_tool_output(&out, tool_output_lines, color);
2207 out
2208}
2209
2210pub(crate) fn host_of_url(url: &str) -> Option<String> {
2215 let rest = url
2216 .strip_prefix("https://")
2217 .or_else(|| url.strip_prefix("http://"))?;
2218 let authority = rest.split(['/', '?', '#']).next()?;
2219 let host_port = authority.rsplit('@').next()?;
2220 let host = if let Some(stripped) = host_port.strip_prefix('[') {
2222 stripped.split(']').next()?
2223 } else {
2224 host_port.split(':').next()?
2225 };
2226 if host.is_empty() {
2227 None
2228 } else {
2229 Some(host.to_ascii_lowercase())
2230 }
2231}
2232
2233#[derive(Clone, Copy, PartialEq, Eq, Debug)]
2235enum FindType {
2236 Files,
2237 Dirs,
2238 Any,
2239}
2240
2241struct FindOpts<'a> {
2243 name: Option<&'a str>,
2245 type_filter: FindType,
2246 max_depth: Option<usize>,
2249 max_results: usize,
2251 respect_gitignore: bool,
2253 case_sensitive: bool,
2254}
2255
2256fn find_detail(path: &str, opts: &FindOpts) -> String {
2261 let mut parts: Vec<String> = Vec::new();
2262 if let Some(name) = opts.name {
2263 parts.push(format!("name={name}"));
2264 }
2265 match opts.type_filter {
2266 FindType::Files => parts.push("type=f".to_string()),
2267 FindType::Dirs => parts.push("type=d".to_string()),
2268 FindType::Any => {}
2269 }
2270 if let Some(d) = opts.max_depth {
2271 parts.push(format!("depth={d}"));
2272 }
2273 if opts.max_results != 1000 {
2275 parts.push(format!("max={}", opts.max_results));
2276 }
2277 if !opts.respect_gitignore {
2278 parts.push("no-gitignore".to_string());
2279 }
2280 if !opts.case_sensitive {
2281 parts.push("icase".to_string());
2282 }
2283 if parts.is_empty() {
2284 path.to_string()
2285 } else {
2286 format!("{path} ({})", parts.join(", "))
2287 }
2288}
2289
2290fn glob_to_regex(glob: &str, case_sensitive: bool) -> Result<regex::Regex, String> {
2294 let mut re = String::with_capacity(glob.len() + 8);
2295 if !case_sensitive {
2296 re.push_str("(?i)");
2297 }
2298 re.push('^');
2299 for ch in glob.chars() {
2300 match ch {
2301 '*' => re.push_str(".*"),
2302 '?' => re.push('.'),
2303 '.' | '+' | '(' | ')' | '|' | '[' | ']' | '{' | '}' | '^' | '$' | '\\' => {
2305 re.push('\\');
2306 re.push(ch);
2307 }
2308 other => re.push(other),
2309 }
2310 }
2311 re.push('$');
2312 regex::Regex::new(&re).map_err(|e| format!("invalid name pattern: {e}"))
2313}
2314
2315fn find_walk(
2321 root: &std::path::Path,
2322 workspace_root: &std::path::Path,
2323 opts: &FindOpts<'_>,
2324) -> Result<(Vec<String>, bool), String> {
2325 let pattern = match opts.name {
2326 Some(g) if !g.is_empty() => Some(glob_to_regex(g, opts.case_sensitive)?),
2327 _ => None,
2328 };
2329
2330 let mut builder = ignore::WalkBuilder::new(root);
2331 builder
2332 .hidden(opts.respect_gitignore)
2333 .ignore(opts.respect_gitignore)
2334 .git_ignore(opts.respect_gitignore)
2335 .git_global(opts.respect_gitignore)
2336 .git_exclude(opts.respect_gitignore)
2337 .parents(opts.respect_gitignore)
2338 .require_git(false)
2341 .follow_links(false);
2342 if let Some(d) = opts.max_depth {
2343 builder.max_depth(Some(d));
2344 }
2345 if opts.respect_gitignore {
2351 let mut ob = ignore::overrides::OverrideBuilder::new(root);
2352 if ob.add("!target/").is_ok() && ob.add("!node_modules/").is_ok() {
2355 if let Ok(ov) = ob.build() {
2356 builder.overrides(ov);
2357 }
2358 }
2359 }
2360
2361 let mut out: Vec<String> = Vec::new();
2362 let mut truncated = false;
2363 for result in builder.build() {
2364 let entry = match result {
2365 Ok(e) => e,
2366 Err(_) => continue,
2368 };
2369 if entry.depth() == 0 {
2371 continue;
2372 }
2373 let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false);
2374 match opts.type_filter {
2375 FindType::Files if is_dir => continue,
2376 FindType::Dirs if !is_dir => continue,
2377 _ => {}
2378 }
2379 if let Some(re) = &pattern {
2380 let base = entry.file_name().to_string_lossy();
2381 if !re.is_match(&base) {
2382 continue;
2383 }
2384 }
2385 if out.len() >= opts.max_results {
2386 truncated = true;
2387 break;
2388 }
2389 let rel = entry
2390 .path()
2391 .strip_prefix(workspace_root)
2392 .unwrap_or_else(|_| entry.path());
2393 out.push(rel.to_string_lossy().replace('\\', "/"));
2394 }
2395 out.sort();
2396 out.dedup();
2397 Ok((out, truncated))
2398}
2399
2400#[allow(clippy::too_many_arguments)]
2441pub async fn execute_tool(
2442 name: &str,
2443 args: &serde_json::Value,
2444 workspace: &str,
2445 color: bool,
2446 tool_output_lines: usize,
2447 caveats: &crate::caveats::Caveats,
2448 mcp: &mut dyn McpTools,
2449 build_check_cmd: Option<&str>,
2450 note_sink: Option<&mut dyn NoteSink>,
2451 recall_source: Option<&dyn RecallSource>,
2452 memory_source: Option<&dyn MemorySource>,
2453 permission_gate: Option<&mut dyn PermissionGate>,
2454 exec_floor: Option<&crate::caveats::Scope<String>>,
2455 git_tool: Option<&dyn GitTool>,
2456 crew_runner: Option<&dyn CrewRunner>,
2457 scratchpad_store: Option<&dyn super::scratchpad::ScratchpadStore>,
2458 code_search: Option<super::semantic::CodeSearch<'_>>,
2459 experience_store: Option<&dyn super::experiential::ExperienceStore>,
2460 step_ledger: Option<&dyn super::scheduled::StepLedger>,
2461) -> String {
2462 execute_tool_with_offload(
2463 name,
2464 args,
2465 workspace,
2466 color,
2467 tool_output_lines,
2468 caveats,
2469 mcp,
2470 build_check_cmd,
2471 note_sink,
2472 recall_source,
2473 memory_source,
2474 permission_gate,
2475 exec_floor,
2476 git_tool,
2477 crew_runner,
2478 scratchpad_store,
2479 code_search,
2480 experience_store,
2481 step_ledger,
2482 false,
2483 None,
2484 )
2485 .await
2486}
2487
2488#[allow(clippy::too_many_arguments)]
2489pub async fn execute_tool_with_offload(
2490 name: &str,
2491 args: &serde_json::Value,
2492 workspace: &str,
2493 color: bool,
2494 tool_output_lines: usize,
2495 caveats: &crate::caveats::Caveats,
2496 mcp: &mut dyn McpTools,
2497 build_check_cmd: Option<&str>,
2498 note_sink: Option<&mut dyn NoteSink>,
2499 recall_source: Option<&dyn RecallSource>,
2500 memory_source: Option<&dyn MemorySource>,
2501 permission_gate: Option<&mut dyn PermissionGate>,
2502 exec_floor: Option<&crate::caveats::Scope<String>>,
2503 git_tool: Option<&dyn GitTool>,
2504 crew_runner: Option<&dyn CrewRunner>,
2505 scratchpad_store: Option<&dyn super::scratchpad::ScratchpadStore>,
2506 code_search: Option<super::semantic::CodeSearch<'_>>,
2507 experience_store: Option<&dyn super::experiential::ExperienceStore>,
2508 step_ledger: Option<&dyn super::scheduled::StepLedger>,
2509 tool_offload: bool,
2510 spill_store: Option<&dyn SpillStore>,
2511) -> String {
2512 if mcp.handles(name) {
2515 print_tool_call(name, &args.to_string(), color);
2516 let out = mcp.call(name, args).await;
2517 print_tool_output(&out, tool_output_lines, color);
2518 return out;
2519 }
2520
2521 let name = match resolve_tool_alias(name) {
2527 Some(AliasOutcome::Rewrite(canonical)) => canonical,
2528 Some(AliasOutcome::Correct(msg)) => return msg,
2529 None => name,
2530 };
2531
2532 let routed: Option<(&'static str, serde_json::Value)> =
2547 if name == "run_command" && !routing_disabled() {
2548 let command = args.get("command").and_then(|v| v.as_str()).unwrap_or("");
2549 let decision = super::routing::RouteTable::builtin().classify(command);
2550 if let Some(line) = super::routing::audit_line(command, &decision) {
2553 tracing::debug!(target: "newt::routing", "{line}");
2554 }
2555 match decision {
2556 super::routing::RouteDecision::Route { tool, args } => Some((tool, args)),
2557 super::routing::RouteDecision::Exec => None,
2558 }
2559 } else {
2560 None
2561 };
2562 let (name, args): (&str, &serde_json::Value) = match &routed {
2563 Some((tool, routed_args)) => (*tool, routed_args),
2564 None => (name, args),
2565 };
2566
2567 match name {
2568 "save_note" => match note_sink {
2573 Some(sink) => execute_save_note(args, sink, color, tool_output_lines),
2574 None => "unknown tool: save_note (no note store in this session)".to_string(),
2577 },
2578
2579 "recall" => match recall_source {
2583 Some(source) => execute_recall(args, source, color, tool_output_lines),
2584 None => "unknown tool: recall (no conversation store in this session)".to_string(),
2587 },
2588
2589 "memory_fetch" => match memory_source {
2594 Some(source) => execute_memory_fetch(args, source, color, tool_output_lines),
2595 None => "unknown tool: memory_fetch (no memory source in this session)".to_string(),
2598 },
2599
2600 "state_set" => match scratchpad_store {
2603 Some(s) => super::scratchpad::execute_state_set(args, s, color, tool_output_lines),
2604 None => "unknown tool: state_set (no scratchpad in this session)".to_string(),
2605 },
2606 "state_get" => match scratchpad_store {
2607 Some(s) => super::scratchpad::execute_state_get(args, s, color, tool_output_lines),
2608 None => "unknown tool: state_get (no scratchpad in this session)".to_string(),
2609 },
2610 "state_clear" => match scratchpad_store {
2611 Some(s) => super::scratchpad::execute_state_clear(s, color, tool_output_lines),
2612 None => "unknown tool: state_clear (no scratchpad in this session)".to_string(),
2613 },
2614
2615 "code_search" => match code_search {
2618 Some(search) => {
2619 super::semantic::execute_code_search(args, search, color, tool_output_lines).await
2620 }
2621 None => {
2622 "unknown tool: code_search (semantic retrieval is off this session)".to_string()
2623 }
2624 },
2625
2626 "experience_record" => match experience_store {
2629 Some(s) => {
2630 super::experiential::execute_experience_record(args, s, color, tool_output_lines)
2631 }
2632 None => "unknown tool: experience_record (experiential memory is off)".to_string(),
2633 },
2634 "experience_recall" => match experience_store {
2635 Some(s) => super::experiential::execute_experience_recall(
2636 args,
2637 s,
2638 super::experiential::EXPERIENCE_TOP_K,
2639 color,
2640 tool_output_lines,
2641 ),
2642 None => "unknown tool: experience_recall (experiential memory is off)".to_string(),
2643 },
2644
2645 "update_plan" => match step_ledger {
2649 Some(l) => super::scheduled::execute_update_plan(args, l, color, tool_output_lines),
2650 None => "unknown tool: update_plan (scheduled planning is off)".to_string(),
2651 },
2652 "plan_get" => match step_ledger {
2655 Some(l) => super::scheduled::execute_plan_get(l, color, tool_output_lines),
2656 None => "unknown tool: plan_get (scheduled planning is off)".to_string(),
2657 },
2658
2659 "resume_context" => super::resume::execute_resume_context(
2665 recall_source,
2666 step_ledger,
2667 scratchpad_store,
2668 color,
2669 tool_output_lines,
2670 ),
2671
2672 "request_permissions" => {
2678 execute_request_permissions(args, permission_gate, color, tool_output_lines)
2679 }
2680
2681 "request_user_input" => {
2689 execute_request_user_input(args, permission_gate, color, tool_output_lines)
2690 }
2691
2692 "tool_search" => {
2702 let query = args.get("query").and_then(|v| v.as_str()).unwrap_or("");
2703 print_tool_call("tool_search", query, color);
2704 let catalog = merged_tool_definitions(
2705 &*mcp,
2706 note_sink.is_some(),
2707 recall_source.is_some(),
2708 memory_source.is_some(),
2709 git_tool.is_some(),
2710 crew_runner.is_some(),
2711 scratchpad_store.is_some(),
2712 code_search.is_some(),
2713 experience_store.is_some(),
2714 step_ledger.is_some(),
2715 );
2716 let out = super::tool_search::execute_tool_search(query, &catalog);
2717 print_tool_output(&out, tool_output_lines, color);
2718 out
2719 }
2720
2721 "git" => match git_tool {
2727 Some(tool) => {
2728 let gc = crate::git_caveats::GitCaveats::from_session(caveats);
2729 let op = args.get("op").and_then(|v| v.as_str()).unwrap_or("");
2730 print_tool_call("git", op, color);
2731 let out = match tool.dispatch(op, args, &gc) {
2732 Ok(rendered) => rendered,
2733 Err(e) => format!("error: {e}"),
2736 };
2737 print_tool_output(&out, tool_output_lines, color);
2738 out
2739 }
2740 None => "unknown tool: git (no git surface in this session)".to_string(),
2741 },
2742
2743 "compose_roster" | "crew" => match crew_runner {
2750 Some(runner) => {
2751 print_tool_call(name, &args.to_string(), color);
2752 let out = match runner.dispatch(name, args, caveats).await {
2753 Ok(rendered) => rendered,
2754 Err(e) => format!("error: {e}"),
2755 };
2756 print_tool_output(&out, tool_output_lines, color);
2757 out
2758 }
2759 None => crew_off_recovery_result(name),
2762 },
2763
2764 "run_command" => {
2765 let cmd = args["command"].as_str().unwrap_or("");
2766
2767 if let Some(tool) = run_command_redirect(cmd) {
2773 return format!(
2774 "error: '{tool}' is a tool, not a shell command. \
2775 Call it as a separate tool invocation — \
2776 do not pass '{tool}' as a command argument to run_command."
2777 );
2778 }
2779
2780 print_tool_call("run_command", cmd, color);
2781
2782 exec_confined_command(
2787 cmd,
2788 workspace,
2789 color,
2790 tool_output_lines,
2791 caveats,
2792 exec_floor,
2793 permission_gate,
2794 tool_offload,
2795 spill_store,
2796 )
2797 .await
2798 }
2799
2800 "lifecycle" => {
2806 let phase_key = args.get("phase").and_then(|v| v.as_str()).unwrap_or("");
2807 let Some(phase) = crate::tooling::Phase::from_key(phase_key) else {
2808 let valid = crate::tooling::Phase::ALL
2809 .iter()
2810 .map(|p| p.as_str())
2811 .collect::<Vec<_>>()
2812 .join(", ");
2813 return format!(
2814 "error: unknown lifecycle phase '{phase_key}'. Valid phases: {valid}."
2815 );
2816 };
2817 let action = args.get("action").and_then(|v| v.as_str()).unwrap_or("run");
2818 let cmds =
2823 crate::tooling::resolved_phase_commands(std::path::Path::new(workspace), phase);
2824 if cmds.is_empty() {
2825 return format!(
2826 "no command configured for lifecycle phase '{}'. Set it in \
2827 .newt/config.toml [lifecycle] or a tooling pack; `lifecycle` \
2828 only runs commands the project declares.",
2829 phase.as_str()
2830 );
2831 }
2832 let joined = cmds.join(" && ");
2833 match action {
2834 "list" => format!("lifecycle {} → {joined}", phase.as_str()),
2835 "run" => {
2836 print_tool_call(
2837 "lifecycle",
2838 &format!("{} → {joined}", phase.as_str()),
2839 color,
2840 );
2841 exec_confined_command(
2842 &joined,
2843 workspace,
2844 color,
2845 tool_output_lines,
2846 caveats,
2847 exec_floor,
2848 permission_gate,
2849 tool_offload,
2850 spill_store,
2851 )
2852 .await
2853 }
2854 other => format!(
2855 "error: unknown lifecycle action '{other}'. Use 'run' (default) or 'list'."
2856 ),
2857 }
2858 }
2859
2860 "read_file" => {
2861 let path = args["path"].as_str().unwrap_or("");
2862 let full = std::path::Path::new(workspace).join(path);
2863 let full_str = full.to_string_lossy();
2864 if !tui_permits_path(&caveats.fs_read, &full_str) {
2865 let allowed = permission_gate.is_some_and(|gate| {
2868 fs_gate_allows(gate, "read_file", DenialKind::FsRead, &full_str, |c| {
2869 &c.fs_read
2870 })
2871 });
2872 if !allowed {
2873 let msg = denied_fs_result("fs_read", path);
2874 print_denied("fs_read", path, color);
2875 return msg;
2876 }
2877 }
2878 print_tool_call("read_file", path, color);
2879 match std::fs::read_to_string(&full) {
2880 Ok(contents) => {
2881 let offset = args["offset"].as_u64().map(|n| n as usize);
2885 let limit = args["limit"].as_u64().map(|n| n as usize);
2886 let out = paginate_read(&contents, offset, limit, max_output_tokens());
2889 print_tool_output(&out, tool_output_lines, color);
2890 out
2891 }
2892 Err(e) => format!("error reading {path}: {e}"),
2893 }
2894 }
2895
2896 "write_file" => {
2897 let path = args["path"].as_str().unwrap_or("");
2898 let content = args["content"].as_str().unwrap_or("");
2899 let full = std::path::Path::new(workspace).join(path);
2900 let full_str = full.to_string_lossy();
2901 if !tui_permits_path(&caveats.fs_write, &full_str) {
2902 let allowed = permission_gate.is_some_and(|gate| {
2907 fs_gate_allows(gate, "write_file", DenialKind::FsWrite, &full_str, |c| {
2908 &c.fs_write
2909 })
2910 });
2911 if !allowed {
2912 let msg = denied_fs_result("fs_write", path);
2913 print_denied("fs_write", path, color);
2914 return msg;
2915 }
2916 }
2917
2918 if let Ok(existing) = std::fs::read_to_string(&full) {
2923 let orig_lines = existing.lines().count();
2924 let new_lines = content.lines().count();
2925 let removed = orig_lines.saturating_sub(new_lines);
2926 if removed > 30 && new_lines < orig_lines * 7 / 10 {
2927 let pct = removed * 100 / orig_lines.max(1);
2928 let msg = format!(
2929 "error: write_file would shrink {path} from {orig_lines} → {new_lines} lines \
2930 (-{pct}%). This is likely unintentional. Use edit_file to make targeted \
2931 changes, or ensure your content includes the full file."
2932 );
2933 print_denied("shrink-guard", path, color);
2934 return msg;
2935 }
2936 }
2937
2938 print_tool_call(
2939 "write_file",
2940 &format!("{path} ({} bytes)", content.len()),
2941 color,
2942 );
2943
2944 let preview: String = content.lines().take(20).collect::<Vec<_>>().join("\n");
2946 let has_more = content.lines().count() > 20;
2947 print_tool_output(
2948 &format!("{preview}{}", if has_more { "\n…" } else { "" }),
2949 tool_output_lines,
2950 color,
2951 );
2952
2953 let needs_confirm = matches!(caveats.fs_write, crate::caveats::Scope::All);
2957
2958 let confirmed = if needs_confirm {
2959 print!("Write this file? [y/N] ");
2960 use std::io::Write as _;
2961 std::io::stdout().flush().ok();
2962 let mut answer = String::new();
2963 std::io::stdin().read_line(&mut answer).is_ok()
2964 && answer.trim().eq_ignore_ascii_case("y")
2965 } else {
2966 true
2967 };
2968
2969 if confirmed {
2970 let full = std::path::Path::new(workspace).join(path);
2971 if let Some(parent) = full.parent() {
2972 let _ = std::fs::create_dir_all(parent);
2973 }
2974 match std::fs::write(&full, content) {
2975 Ok(_) => {
2976 let line_count = content.lines().count();
2977 println!("✓ wrote {path} ({line_count} lines)");
2978 let check = build_check_cmd
2979 .map(|cmd| run_build_check(cmd, workspace))
2980 .unwrap_or_default();
2981 format!("wrote {path} ({line_count} lines){check}")
2982 }
2983 Err(e) => format!("error writing {path}: {e}"),
2984 }
2985 } else {
2986 println!("skipped");
2987 format!("user declined to write {path}")
2988 }
2989 }
2990
2991 "edit_file" => {
2992 let path = args["path"].as_str().unwrap_or("");
2993 let old_string = args["old_string"].as_str().unwrap_or("");
2994 let new_string = args["new_string"].as_str().unwrap_or("");
2995 let full = std::path::Path::new(workspace).join(path);
2996 let full_str = full.to_string_lossy();
2997 if !tui_permits_path(&caveats.fs_write, &full_str) {
2998 let allowed = permission_gate.is_some_and(|gate| {
3000 fs_gate_allows(gate, "edit_file", DenialKind::FsWrite, &full_str, |c| {
3001 &c.fs_write
3002 })
3003 });
3004 if !allowed {
3005 let msg = denied_fs_result("fs_write", path);
3006 print_denied("fs_write", path, color);
3007 return msg;
3008 }
3009 }
3010 if old_string.is_empty() {
3011 return "error: old_string must not be empty — use write_file to create new files"
3012 .to_string();
3013 }
3014 let existing = match std::fs::read_to_string(&full) {
3015 Ok(s) => s,
3016 Err(e) => return format!("error reading {path}: {e}"),
3017 };
3018 let count = existing.matches(old_string).count();
3019 if count == 0 {
3020 const HEAD: usize = 40;
3026 let total = existing.lines().count();
3027 let head: String = existing
3028 .lines()
3029 .take(HEAD)
3030 .map(|l| format!(" {l}"))
3031 .collect::<Vec<_>>()
3032 .join("\n");
3033 let more = if total > HEAD {
3034 format!("\n … ({} more line(s))", total - HEAD)
3035 } else {
3036 String::new()
3037 };
3038 return format!(
3039 "error: old_string not found in {path} — do not guess again. Copy the \
3040 EXACT text (including leading whitespace) from the contents below, then \
3041 retry. To add a header/first line, set old_string to the shown first \
3042 line and put your header + that line in new_string; to create a new \
3043 file use write_file.\n--- {path} (first {shown} of {total} line(s)) ---\n{head}{more}",
3044 shown = total.min(HEAD),
3045 );
3046 }
3047 if count > 1 {
3048 return format!(
3049 "error: old_string matches {count} locations in {path}. \
3050 Add more surrounding context to make it unique."
3051 );
3052 }
3053 let updated = existing.replacen(old_string, new_string, 1);
3054 let old_lines = existing.lines().count();
3055 let new_lines = updated.lines().count();
3056 let delta = new_lines as i64 - old_lines as i64;
3057 let delta_str = if delta >= 0 {
3058 format!("+{delta}")
3059 } else {
3060 format!("{delta}")
3061 };
3062 print_tool_call("edit_file", &format!("{path} ({delta_str} lines)"), color);
3063 match std::fs::write(&full, &updated) {
3064 Ok(_) => {
3065 println!("✓ edited {path} ({delta_str} lines, now {new_lines} total)");
3066 let check = build_check_cmd
3067 .map(|cmd| run_build_check(cmd, workspace))
3068 .unwrap_or_default();
3069 format!("edited {path} ({delta_str} lines, now {new_lines} total){check}")
3070 }
3071 Err(e) => format!("error writing {path}: {e}"),
3072 }
3073 }
3074
3075 "list_dir" => {
3076 let path = args["path"].as_str().unwrap_or(".");
3077 let full = std::path::Path::new(workspace).join(path);
3078 let full_str = full.to_string_lossy();
3079 if !tui_permits_path(&caveats.fs_read, &full_str) {
3080 let allowed = permission_gate.is_some_and(|gate| {
3082 fs_gate_allows(gate, "list_dir", DenialKind::FsRead, &full_str, |c| {
3083 &c.fs_read
3084 })
3085 });
3086 if !allowed {
3087 let msg = denied_fs_result("fs_read", path);
3088 print_denied("fs_read", path, color);
3089 return msg;
3090 }
3091 }
3092 print_tool_call("list_dir", path, color);
3093 match std::fs::read_dir(&full) {
3094 Ok(entries) => {
3095 let mut names: Vec<String> = entries
3096 .flatten()
3097 .map(|e| e.file_name().to_string_lossy().into_owned())
3098 .collect();
3099 names.sort();
3100 let listing = names.join("\n");
3101 print_tool_output(&listing, tool_output_lines, color);
3102 listing
3103 }
3104 Err(e) => format!("error: {e}"),
3105 }
3106 }
3107
3108 "find" => {
3113 let path = args["path"].as_str().unwrap_or(".");
3114 let full = std::path::Path::new(workspace).join(path);
3115 let full_str = full.to_string_lossy();
3116 if !tui_permits_path(&caveats.fs_read, &full_str) {
3117 let allowed = permission_gate.is_some_and(|gate| {
3118 fs_gate_allows(gate, "find", DenialKind::FsRead, &full_str, |c| &c.fs_read)
3119 });
3120 if !allowed {
3121 let msg = denied_fs_result("fs_read", path);
3122 print_denied("fs_read", path, color);
3123 return msg;
3124 }
3125 }
3126 let opts = FindOpts {
3127 name: args["name"].as_str(),
3128 type_filter: match args["type"].as_str() {
3129 Some("f") => FindType::Files,
3130 Some("d") => FindType::Dirs,
3131 _ => FindType::Any,
3132 },
3133 max_depth: args["max_depth"].as_u64().map(|d| d as usize),
3134 max_results: args["max_results"]
3135 .as_u64()
3136 .map(|m| m as usize)
3137 .unwrap_or(1000),
3138 respect_gitignore: args["respect_gitignore"].as_bool().unwrap_or(true),
3139 case_sensitive: args["case_sensitive"].as_bool().unwrap_or(true),
3140 };
3141 print_tool_call("find", &find_detail(path, &opts), color);
3144 if !full.exists() {
3145 return format!("error: no such path '{path}'");
3146 }
3147 if let (Ok(ws_canon), Ok(root_canon)) = (
3151 std::path::Path::new(workspace).canonicalize(),
3152 full.canonicalize(),
3153 ) {
3154 if !root_canon.starts_with(&ws_canon) {
3155 let msg = denied_fs_result("fs_read", path);
3156 print_denied("fs_read", path, color);
3157 return msg;
3158 }
3159 }
3160 match find_walk(&full, std::path::Path::new(workspace), &opts) {
3161 Ok((hits, truncated)) => {
3162 let mut listing = if hits.is_empty() {
3163 "no matches".to_string()
3164 } else {
3165 hits.join("\n")
3166 };
3167 if truncated {
3168 listing
3169 .push_str(&format!("\n… (truncated at {} matches)", opts.max_results));
3170 }
3171 print_tool_output(&listing, tool_output_lines, color);
3172 listing
3173 }
3174 Err(e) => format!("error: {e}"),
3175 }
3176 }
3177
3178 "use_skill" => {
3179 let skill_name = args["name"].as_str().unwrap_or("");
3180 print_tool_call("use_skill", skill_name, color);
3181 let dirs = crate::Config::resolve()
3189 .map(|c| c.skill_search_dirs())
3190 .unwrap_or_default();
3191 match newt_skills::load_body_from(&dirs, skill_name) {
3192 Ok(body) => {
3193 print_tool_output(&body, tool_output_lines, color);
3194 body
3195 }
3196 Err(e) => format!("error: {e}"),
3197 }
3198 }
3199
3200 "web_fetch" => {
3201 let url = args["url"].as_str().unwrap_or("");
3202 print_tool_call("web_fetch", url, color);
3203
3204 let mut fetch_args = serde_json::json!({ "url": url });
3211 if let Some(max_bytes) = args.get("max_bytes").and_then(serde_json::Value::as_u64) {
3212 fetch_args["max_bytes"] = serde_json::json!(max_bytes);
3213 }
3214 let widened_for_net = match (permission_gate, host_of_url(url)) {
3220 (Some(gate), Some(host)) if !caveats.permits_net(&host) => {
3221 let request = PermissionRequest {
3222 tool: "web_fetch".to_string(),
3223 kind: DenialKind::Net,
3224 target: host.clone(),
3225 reason: format!("net does not permit '{host}'"),
3226 };
3227 match gate.ask(std::slice::from_ref(&request)) {
3228 PermissionDecision::Allow(widened) => Some(widened),
3229 PermissionDecision::Deny => None,
3230 }
3231 }
3232 _ => None,
3233 };
3234 let effective_caveats = widened_for_net.as_ref().unwrap_or(caveats);
3235 match agent_bridle::registry()
3236 .dispatch("web_fetch", fetch_args, effective_caveats)
3237 .await
3238 {
3239 Ok(result) => {
3240 let markdown = result
3241 .get("markdown")
3242 .and_then(serde_json::Value::as_str)
3243 .unwrap_or("");
3244 let title = result
3245 .get("title")
3246 .and_then(serde_json::Value::as_str)
3247 .unwrap_or("");
3248 let final_url = result
3249 .get("final_url")
3250 .and_then(serde_json::Value::as_str)
3251 .unwrap_or(url);
3252 let out = if title.is_empty() {
3253 format!("{final_url}\n\n{markdown}")
3254 } else {
3255 format!("# {title}\n{final_url}\n\n{markdown}")
3256 };
3257 print_tool_output(&out, tool_output_lines, color);
3258 out
3259 }
3260 Err(e) => format!("error: {e}"),
3263 }
3264 }
3265
3266 other => unknown_tool_message(other),
3267 }
3268}
3269
3270pub(crate) fn tool_result_ok(result: &str) -> bool {
3278 let r = result.trim_start();
3279 !(r.starts_with("error:")
3280 || r.starts_with("capability denied:")
3281 || r.starts_with("unknown tool"))
3282}
3283
3284#[cfg(test)]
3285mod tests {
3286 use super::*;
3287 use crate::agentic::NoMcp;
3288
3289 #[test]
3292 fn classify_phantom_rewrite_alias() {
3293 let got = classify_phantom_reach("bash", &serde_json::json!({"command": "ls"}), "ok", true);
3295 assert_eq!(
3296 got,
3297 Some(crate::PhantomResolution::Rewrite("run_command".into()))
3298 );
3299 }
3300
3301 #[test]
3302 fn classify_phantom_correct_alias() {
3303 let got = classify_phantom_reach(
3305 "str_replace_editor",
3306 &serde_json::json!({}),
3307 "ignored",
3308 false,
3309 );
3310 match got {
3311 Some(crate::PhantomResolution::Correct(msg)) => {
3312 assert!(msg.contains("edit_file"), "guidance names the tool: {msg}");
3313 }
3314 other => panic!("expected Correct, got {other:?}"),
3315 }
3316 }
3317
3318 #[test]
3319 fn classify_phantom_unknown_name() {
3320 let got = classify_phantom_reach(
3324 "summon_kraken",
3325 &serde_json::json!({}),
3326 "unknown tool: summon_kraken",
3327 false,
3328 );
3329 assert_eq!(got, Some(crate::PhantomResolution::Unknown));
3330 }
3331
3332 #[test]
3333 fn classify_phantom_plan_alias_is_correct() {
3334 let got = classify_phantom_reach("make_plan", &serde_json::json!({}), "ignored", false);
3338 match got {
3339 Some(crate::PhantomResolution::Correct(msg)) => {
3340 assert!(
3341 msg.contains("update_plan"),
3342 "guidance names the tool: {msg}"
3343 );
3344 }
3345 other => panic!("expected Correct, got {other:?}"),
3346 }
3347 }
3348
3349 #[test]
3350 fn classify_phantom_state_get_miss() {
3351 let got = classify_phantom_reach(
3353 "state_get",
3354 &serde_json::json!({"key": "nope"}),
3355 "no such key: nope",
3356 true,
3357 );
3358 assert_eq!(
3359 got,
3360 Some(crate::PhantomResolution::RealToolMiss(
3361 "state_get on an unset key".into()
3362 ))
3363 );
3364 }
3365
3366 #[test]
3367 fn classify_phantom_recall_miss() {
3368 let got = classify_phantom_reach(
3370 "recall",
3371 &serde_json::json!({"query": "zzz"}),
3372 "no matches in past conversations for \"zzz\" — try different keywords",
3373 true,
3374 );
3375 assert_eq!(
3376 got,
3377 Some(crate::PhantomResolution::RealToolMiss(
3378 "recall returned no matches".into()
3379 ))
3380 );
3381 }
3382
3383 #[test]
3384 fn classify_phantom_resume_reach_is_a_rewrite() {
3385 let got = classify_phantom_reach("where_were_we", &serde_json::json!({}), "ignored", false);
3388 assert_eq!(
3389 got,
3390 Some(crate::PhantomResolution::Rewrite("resume_context".into()))
3391 );
3392 }
3393
3394 #[test]
3395 fn classify_phantom_real_success_is_none() {
3396 let got = classify_phantom_reach(
3398 "read_file",
3399 &serde_json::json!({"path": "src/lib.rs"}),
3400 "line 1\nline 2\n",
3401 true,
3402 );
3403 assert_eq!(got, None);
3404 }
3405
3406 #[test]
3409 fn tool_search_is_a_real_tool_name() {
3410 assert!(ALL_TOOL_NAMES.contains(&"tool_search"));
3413 }
3414
3415 #[test]
3416 fn discovery_verbs_alias_to_tool_search() {
3417 for verb in [
3420 "find_tool",
3421 "search_tools",
3422 "list_tools",
3423 "which_tool",
3424 "available_tools",
3425 "what_tools",
3426 "tools",
3427 ] {
3428 match resolve_tool_alias(verb) {
3429 Some(AliasOutcome::Rewrite(c)) => assert_eq!(c, "tool_search", "verb: {verb}"),
3430 other => panic!(
3431 "expected Rewrite(tool_search) for {verb}, got something else: {}",
3432 other.is_some()
3433 ),
3434 }
3435 }
3436 }
3437
3438 #[test]
3439 fn tool_search_is_not_an_alias_of_itself() {
3440 assert!(resolve_tool_alias("tool_search").is_none());
3442 }
3443
3444 #[test]
3445 fn classify_phantom_discovery_reach_is_a_rewrite() {
3446 let got = classify_phantom_reach("find_tool", &serde_json::json!({}), "ignored", false);
3449 assert_eq!(
3450 got,
3451 Some(crate::PhantomResolution::Rewrite("tool_search".into()))
3452 );
3453 }
3454
3455 #[test]
3456 fn classify_phantom_tool_search_real_call_is_none() {
3457 let got = classify_phantom_reach(
3459 "tool_search",
3460 &serde_json::json!({"query": "read"}),
3461 "Tools matching \"read\":\n- read_file — Read a file",
3462 true,
3463 );
3464 assert_eq!(got, None);
3465 }
3466
3467 #[test]
3468 fn tool_search_is_not_a_hallucination() {
3469 assert!(!is_hallucination(
3470 "tool_search",
3471 &serde_json::json!({"query": "x"})
3472 ));
3473 }
3474
3475 #[test]
3478 fn paginate_read_caps_a_large_file_to_the_default_window() {
3479 let body: String = (1..=15_057)
3482 .map(|n| format!("line {n}"))
3483 .collect::<Vec<_>>()
3484 .join("\n");
3485 let out = paginate_read(&body, None, None, DEFAULT_MAX_OUTPUT_TOKENS);
3486 let lines: Vec<&str> = out.lines().collect();
3487 assert_eq!(lines[0], "line 1");
3488 assert_eq!(lines[1999], "line 2000");
3489 assert!(
3490 !out.contains("line 2001"),
3491 "window stops at 2000: {:?}",
3492 &out[..40]
3493 );
3494 assert!(out.contains("of 15057"), "footer names the total");
3495 assert!(
3496 out.contains("offset=2001"),
3497 "footer points at the next window"
3498 );
3499 }
3500
3501 #[test]
3502 fn paginate_read_offset_and_limit_return_just_that_window() {
3503 let body: String = (1..=100)
3504 .map(|n| format!("L{n}"))
3505 .collect::<Vec<_>>()
3506 .join("\n");
3507 let out = paginate_read(&body, Some(10), Some(5), DEFAULT_MAX_OUTPUT_TOKENS);
3508 assert!(out.starts_with("L10\nL11\nL12\nL13\nL14"), "{out:?}");
3509 assert!(out.contains("offset=15"), "continues at line 15: {out:?}");
3510 }
3511
3512 #[test]
3513 fn paginate_read_small_file_is_returned_verbatim_without_a_footer() {
3514 assert_eq!(
3516 paginate_read("a\nb\nc\n", None, None, DEFAULT_MAX_OUTPUT_TOKENS),
3517 "a\nb\nc\n"
3518 );
3519 }
3520
3521 #[test]
3522 fn paginate_read_char_backstop_tracks_the_token_budget() {
3523 let budget = 1_000;
3528 let max_chars = crate::tokens::TokenEstimation::default().chars_for_tokens(budget);
3529 let body = "x".repeat(50_000);
3530 let out = paginate_read(&body, None, None, budget);
3531 assert!(
3532 out.len() < max_chars + 300,
3533 "char-capped to the token budget (~{max_chars} chars): {} bytes",
3534 out.len()
3535 );
3536 assert!(out.contains("truncated"), "marks the truncation");
3537 assert!(
3538 out.contains("~1000 tokens"),
3539 "footer names the token budget: {out:?}"
3540 );
3541
3542 let wide = paginate_read(&body, None, None, 4_000);
3545 assert!(
3546 wide.len() > out.len(),
3547 "a wider token budget keeps more chars: {} vs {}",
3548 wide.len(),
3549 out.len()
3550 );
3551 }
3552
3553 #[test]
3554 fn paginate_read_zero_budget_disables_the_char_backstop() {
3555 let body = "y".repeat(500_000);
3558 let out = paginate_read(&body, None, None, 0);
3559 assert_eq!(out, body, "zero budget = no char backstop");
3560 }
3561
3562 #[test]
3563 fn paginate_read_offset_past_end_is_a_clear_message() {
3564 let out = paginate_read("a\nb", Some(99), None, DEFAULT_MAX_OUTPUT_TOKENS);
3565 assert!(out.contains("past end"), "{out:?}");
3566 }
3567
3568 #[test]
3571 fn cap_model_output_passes_small_output_through_unchanged() {
3572 let small = "hello\nworld\n";
3574 assert_eq!(cap_model_output(small, DEFAULT_MAX_OUTPUT_TOKENS), small);
3575 }
3576
3577 #[test]
3578 fn cap_model_output_truncates_over_budget_as_head_tail() {
3579 let big = format!("HEAD_MARKER\n{}\nTAIL_MARKER", "middle\n".repeat(20_000));
3580 let out = cap_model_output_with_handle(&big, 1_000, 100, None);
3581 assert!(out.len() < big.len(), "must shrink: {} bytes", out.len());
3582 assert!(out.contains("HEAD_MARKER"), "head dropped: {out:?}");
3583 assert!(out.contains("TAIL_MARKER"), "tail dropped: {out:?}");
3584 assert!(out.contains("head+tail shown"), "marker present: {out:?}");
3585 assert!(
3586 !out.contains(&"middle\n".repeat(1_000)),
3587 "middle should be elided"
3588 );
3589 }
3590
3591 #[test]
3592 fn cap_model_output_truncates_at_a_char_boundary() {
3593 let budget = 10; let body = "é".repeat(1_000); let out = cap_model_output(&body, budget);
3598 assert!(out.is_char_boundary(out.len()), "valid boundary");
3599 assert!(
3600 out.chars()
3601 .all(|c| c == 'é' || !c.is_control() || c == '\n'),
3602 "no split char: {out:?}"
3603 );
3604 }
3605
3606 #[test]
3607 fn cap_model_output_zero_budget_is_no_cap() {
3608 let body = "z".repeat(500_000);
3609 assert_eq!(cap_model_output(&body, 0), body);
3610 }
3611
3612 #[test]
3613 fn token_to_char_math_uses_the_default_four_chars_per_token() {
3614 let est = crate::tokens::TokenEstimation::default();
3617 assert_eq!(est.chars_for_tokens(DEFAULT_MAX_OUTPUT_TOKENS), 40_000);
3618 }
3619
3620 #[test]
3621 fn find_detail_bare_path_has_no_filters() {
3622 let opts = FindOpts {
3623 name: None,
3624 type_filter: FindType::Any,
3625 max_depth: None,
3626 max_results: 1000,
3627 respect_gitignore: true,
3628 case_sensitive: true,
3629 };
3630 assert_eq!(find_detail(".", &opts), ".");
3631 }
3632
3633 #[test]
3634 fn find_detail_shows_only_non_default_filters() {
3635 let opts = FindOpts {
3636 name: Some("*.rs"),
3637 type_filter: FindType::Files,
3638 max_depth: Some(2),
3639 max_results: 50,
3640 respect_gitignore: false,
3641 case_sensitive: false,
3642 };
3643 assert_eq!(
3644 find_detail("src", &opts),
3645 "src (name=*.rs, type=f, depth=2, max=50, no-gitignore, icase)"
3646 );
3647 }
3648
3649 #[test]
3650 fn find_detail_omits_each_default_independently() {
3651 let opts = FindOpts {
3652 name: None,
3653 type_filter: FindType::Dirs,
3654 max_depth: None,
3655 max_results: 1000,
3656 respect_gitignore: true,
3657 case_sensitive: true,
3658 };
3659 assert_eq!(find_detail(".", &opts), ". (type=d)");
3660 }
3661
3662 #[test]
3663 fn use_skill_tool_is_advertised_in_definitions() {
3664 let defs = tool_definitions();
3665 let names: Vec<&str> = defs
3666 .as_array()
3667 .unwrap()
3668 .iter()
3669 .filter_map(|d| d["function"]["name"].as_str())
3670 .collect();
3671 assert!(names.contains(&"use_skill"), "got: {names:?}");
3672 }
3673
3674 #[test]
3675 fn merged_tool_definitions_with_empty_mcp_is_builtin_set() {
3676 let merged = merged_tool_definitions(
3677 &NoMcp, false, false, false, false, false, false, false, false, false,
3678 );
3679 let names: Vec<&str> = merged
3680 .as_array()
3681 .unwrap()
3682 .iter()
3683 .filter_map(|d| d["function"]["name"].as_str())
3684 .collect();
3685 assert_eq!(
3686 names,
3687 vec![
3688 "run_command",
3689 "read_file",
3690 "write_file",
3691 "edit_file",
3692 "list_dir",
3693 "find",
3694 "use_skill",
3695 "web_fetch",
3696 "request_permissions",
3699 "resume_context",
3702 "tool_search",
3705 "get_context_remaining",
3708 "request_user_input",
3711 "lifecycle",
3715 ]
3716 );
3717 }
3718
3719 #[test]
3723 fn registry_specs_match_their_definition_names() {
3724 for spec in EXTENDED_TOOL_REGISTRY {
3725 let def = (spec.definition)();
3726 assert_eq!(
3727 def["function"]["name"].as_str(),
3728 Some(spec.name),
3729 "ToolSpec name {:?} != definition name",
3730 spec.name
3731 );
3732 }
3733 }
3734
3735 #[test]
3738 fn builtin_tool_names_are_unique() {
3739 let mut seen = std::collections::HashSet::new();
3740 for name in ALL_TOOL_NAMES.iter() {
3741 assert!(seen.insert(*name), "duplicate built-in tool name: {name}");
3742 }
3743 }
3744
3745 #[test]
3751 fn advertised_set_matches_all_tool_names_both_directions() {
3752 let all =
3753 merged_tool_definitions(&NoMcp, true, true, true, true, true, true, true, true, true);
3754 let advertised: std::collections::HashSet<&str> = all
3755 .as_array()
3756 .unwrap()
3757 .iter()
3758 .filter_map(|d| d["function"]["name"].as_str())
3759 .collect();
3760 let names: std::collections::HashSet<&str> = ALL_TOOL_NAMES.iter().copied().collect();
3761 for a in &advertised {
3763 assert!(
3764 names.contains(a),
3765 "advertised but not in ALL_TOOL_NAMES: {a}"
3766 );
3767 }
3768 for n in &names {
3770 assert!(
3771 advertised.contains(n),
3772 "in ALL_TOOL_NAMES but never advertised: {n}"
3773 );
3774 }
3775 }
3776
3777 #[test]
3780 fn base_tool_names_match_tool_definitions() {
3781 let defs = tool_definitions();
3782 let base: Vec<&str> = defs
3783 .as_array()
3784 .unwrap()
3785 .iter()
3786 .filter_map(|d| d["function"]["name"].as_str())
3787 .collect();
3788 assert_eq!(base, BASE_TOOL_NAMES);
3789 }
3790
3791 #[test]
3797 fn lifecycle_is_a_real_tool_name_not_a_hallucination() {
3798 assert!(
3799 ALL_TOOL_NAMES.contains(&"lifecycle"),
3800 "lifecycle must be a real tool name"
3801 );
3802 assert!(
3803 !is_hallucination("lifecycle", &serde_json::json!({"phase": "test"})),
3804 "a real lifecycle call must not be flagged as a hallucination"
3805 );
3806 }
3807
3808 #[test]
3809 fn lifecycle_definition_enum_matches_phase_vocabulary() {
3810 let def = lifecycle_tool_definition();
3813 assert_eq!(def["function"]["name"], "lifecycle");
3814 let enum_vals: Vec<&str> = def["function"]["parameters"]["properties"]["phase"]["enum"]
3815 .as_array()
3816 .unwrap()
3817 .iter()
3818 .map(|v| v.as_str().unwrap())
3819 .collect();
3820 let vocab: Vec<&str> = crate::tooling::Phase::ALL
3821 .iter()
3822 .map(|p| p.as_str())
3823 .collect();
3824 assert_eq!(enum_vals, vocab);
3825 }
3826
3827 #[test]
3828 fn run_phase_aliases_route_to_lifecycle() {
3829 for a in ["run_phase", "run_lifecycle", "lifecycle_run"] {
3830 assert!(
3831 matches!(
3832 resolve_tool_alias(a),
3833 Some(AliasOutcome::Rewrite("lifecycle"))
3834 ),
3835 "{a} should rewrite to lifecycle"
3836 );
3837 }
3838 assert!(resolve_tool_alias("lifecycle").is_none());
3840 }
3841
3842 #[tokio::test]
3843 async fn lifecycle_unknown_phase_lists_valid_phases() {
3844 let caveats = crate::caveats::Caveats::top();
3847 let args = serde_json::json!({ "phase": "deploy" });
3848 let out = execute_tool(
3849 "lifecycle",
3850 &args,
3851 ".",
3852 false,
3853 20,
3854 &caveats,
3855 &mut NoMcp,
3856 None,
3857 None,
3858 None,
3859 None,
3860 None,
3861 None,
3862 None,
3863 None,
3864 None,
3865 None,
3866 None,
3867 None,
3868 )
3869 .await;
3870 assert!(
3871 out.starts_with("error: unknown lifecycle phase 'deploy'"),
3872 "{out}"
3873 );
3874 assert!(out.contains("check"), "should name valid phases: {out}");
3875 }
3876
3877 #[test]
3881 fn save_note_advertised_only_with_a_sink() {
3882 fn names(defs: &serde_json::Value) -> Vec<&str> {
3883 defs.as_array()
3884 .unwrap()
3885 .iter()
3886 .filter_map(|d| d["function"]["name"].as_str())
3887 .collect()
3888 }
3889 let base = tool_definitions();
3891 assert!(!names(&base).contains(&"save_note"), "got: {base}");
3892 let without = merged_tool_definitions(
3894 &NoMcp, false, false, false, false, false, false, false, false, false,
3895 );
3896 assert!(!names(&without).contains(&"save_note"));
3897 let with = merged_tool_definitions(
3899 &NoMcp, true, false, false, false, false, false, false, false, false,
3900 );
3901 assert!(names(&with).contains(&"save_note"), "got: {with}");
3902 }
3903
3904 #[test]
3908 fn recall_advertised_only_with_a_source() {
3909 fn names(defs: &serde_json::Value) -> Vec<&str> {
3910 defs.as_array()
3911 .unwrap()
3912 .iter()
3913 .filter_map(|d| d["function"]["name"].as_str())
3914 .collect()
3915 }
3916 let base = tool_definitions();
3917 assert!(!names(&base).contains(&"recall"), "got: {base}");
3918 let without = merged_tool_definitions(
3919 &NoMcp, false, false, false, false, false, false, false, false, false,
3920 );
3921 assert!(!names(&without).contains(&"recall"));
3922 let with = merged_tool_definitions(
3923 &NoMcp, false, true, false, false, false, false, false, false, false,
3924 );
3925 assert!(names(&with).contains(&"recall"), "got: {with}");
3926 let both = merged_tool_definitions(
3928 &NoMcp, true, true, false, false, false, false, false, false, false,
3929 );
3930 assert!(names(&both).contains(&"save_note"));
3931 assert!(names(&both).contains(&"recall"));
3932 }
3933
3934 #[test]
3938 fn memory_fetch_advertised_only_with_a_source() {
3939 fn names(defs: &serde_json::Value) -> Vec<&str> {
3940 defs.as_array()
3941 .unwrap()
3942 .iter()
3943 .filter_map(|d| d["function"]["name"].as_str())
3944 .collect()
3945 }
3946 let base = tool_definitions();
3947 assert!(!names(&base).contains(&"memory_fetch"), "got: {base}");
3948 let without = merged_tool_definitions(
3950 &NoMcp, false, false, false, false, false, false, false, false, false,
3951 );
3952 assert!(!names(&without).contains(&"memory_fetch"));
3953 let with = merged_tool_definitions(
3955 &NoMcp, false, false, true, false, false, false, false, false, false,
3956 );
3957 assert!(names(&with).contains(&"memory_fetch"), "got: {with}");
3958 let all = merged_tool_definitions(
3960 &NoMcp, true, true, true, false, false, false, false, false, false,
3961 );
3962 assert!(names(&all).contains(&"save_note"));
3963 assert!(names(&all).contains(&"recall"));
3964 assert!(names(&all).contains(&"memory_fetch"));
3965 }
3966
3967 #[test]
3970 fn hallucination_detection_coverage() {
3971 assert!(is_hallucination(
3973 "run_command",
3974 &serde_json::json!({"command": "list_dir ."})
3975 ));
3976 assert!(!is_hallucination(
3978 "run_command",
3979 &serde_json::json!({"command": "cargo test"})
3980 ));
3981 assert!(is_hallucination(
3983 "definitely_not_a_real_tool",
3984 &serde_json::json!({})
3985 ));
3986 assert!(!is_hallucination(
3988 "my_server__some_tool",
3989 &serde_json::json!({})
3990 ));
3991 for t in [
3993 "list_dir",
3994 "read_file",
3995 "write_file",
3996 "use_skill",
3997 "web_fetch",
3998 "save_note",
3999 "recall",
4000 ] {
4001 assert!(!is_hallucination(t, &serde_json::json!({"path": "."})));
4002 }
4003 }
4004
4005 #[test]
4010 fn run_command_redirect_lets_git_network_ops_through() {
4011 for cmd in [
4013 "git push origin fix/foo",
4014 "git push",
4015 "git fetch origin",
4016 "git pull",
4017 "git clone https://example.com/r.git",
4018 ] {
4019 assert_eq!(run_command_redirect(cmd), None, "{cmd} must fall through");
4020 }
4021 for cmd in [
4023 "git status",
4024 "git log --oneline",
4025 "git add .",
4026 "git commit -m x",
4027 ] {
4028 assert_eq!(
4029 run_command_redirect(cmd),
4030 Some("git"),
4031 "{cmd} must redirect"
4032 );
4033 }
4034 assert_eq!(run_command_redirect("read_file foo.txt"), Some("read_file"));
4036 assert_eq!(run_command_redirect("list_dir ."), Some("list_dir"));
4037 assert_eq!(run_command_redirect("cargo test"), None);
4038 assert_eq!(run_command_redirect("gh pr create --fill"), None);
4039 assert_eq!(run_command_redirect(""), None);
4040 }
4041
4042 #[test]
4045 fn is_hallucination_allows_git_network_ops() {
4046 assert!(!is_hallucination(
4047 "run_command",
4048 &serde_json::json!({"command": "git push origin fix/foo"})
4049 ));
4050 assert!(!is_hallucination(
4051 "run_command",
4052 &serde_json::json!({"command": "git fetch"})
4053 ));
4054 assert!(is_hallucination(
4055 "run_command",
4056 &serde_json::json!({"command": "git status"})
4057 ));
4058 }
4059
4060 #[test]
4063 fn pr_creation_url_extracts_github_and_gitlab() {
4064 let github = "remote: Create a pull request for 'fix/foo' on GitHub by visiting:\n\
4065 remote: https://github.com/OWNER/REPO/pull/new/fix/foo\n";
4066 assert_eq!(
4067 pr_creation_url(github),
4068 Some("https://github.com/OWNER/REPO/pull/new/fix/foo")
4069 );
4070 let gitlab = "remote: To create a merge request for topic, visit:\n\
4071 remote: https://gitlab.com/g/p/-/merge_requests/new?x=topic\n";
4072 assert_eq!(
4073 pr_creation_url(gitlab),
4074 Some("https://gitlab.com/g/p/-/merge_requests/new?x=topic")
4075 );
4076 assert_eq!(pr_creation_url("Already up to date.\n"), None);
4078 assert_eq!(
4079 pr_creation_url("see https://github.com/OWNER/REPO/issues/1"),
4080 None
4081 );
4082 }
4083
4084 #[test]
4088 fn shell_envelope_output_appends_pr_hint_on_push() {
4089 let push = serde_json::json!({
4090 "exit_code": 0,
4091 "stdout": "",
4092 "stderr": "remote: Create a pull request for 'fix/foo' on GitHub by visiting:\n\
4093 remote: https://github.com/OWNER/REPO/pull/new/fix/foo\n",
4094 });
4095 let out = shell_envelope_output(&push, 50, false, false, None);
4096 assert!(out.contains("gh pr create --fill"), "hint missing: {out}");
4097 assert!(
4098 out.contains("https://github.com/OWNER/REPO/pull/new/fix/foo"),
4099 "url dropped: {out}"
4100 );
4101
4102 let plain = serde_json::json!({ "exit_code": 0, "stdout": "hello\n", "stderr": "" });
4104 let out = shell_envelope_output(&plain, 50, false, false, None);
4105 assert!(!out.contains("gh pr create"), "spurious hint: {out}");
4106 assert_eq!(out, "hello\n");
4107 }
4108
4109 #[test]
4110 fn shell_envelope_output_spills_full_output_before_head_tail_cap() {
4111 let full = format!(
4112 "HEAD_ONLY_MARKER\n{}\nMIDDLE_ONLY_MARKER\n{}\nTAIL_ONLY_MARKER\n",
4113 "alpha\n".repeat(10_000),
4114 "omega\n".repeat(10_000)
4115 );
4116 let envelope = serde_json::json!({
4117 "exit_code": 0,
4118 "stdout": full,
4119 "stderr": "",
4120 });
4121 let store = spill::SessionSpillStore::default();
4122 let out = shell_envelope_output(&envelope, 50, false, true, Some(&store));
4123
4124 assert!(out.contains("HEAD_ONLY_MARKER"), "head dropped: {out}");
4125 assert!(out.contains("TAIL_ONLY_MARKER"), "tail dropped: {out}");
4126 assert!(
4127 out.contains("memory_fetch(\"spill:s0\")"),
4128 "spill handle missing: {out}"
4129 );
4130 assert!(
4131 out.contains("grep=\"<pattern>\""),
4132 "search affordance missing: {out}"
4133 );
4134 let stored = store.fetch("s0").expect("full output stored");
4135 assert!(
4136 stored.contains("MIDDLE_ONLY_MARKER"),
4137 "spilled payload was capped before storage"
4138 );
4139 assert!(stored.ends_with("TAIL_ONLY_MARKER\n"));
4140 }
4141
4142 #[test]
4143 fn envelope_denied_reads_structured_flag_only() {
4144 assert!(envelope_denied(&serde_json::json!({"denied": true})));
4145 assert!(!envelope_denied(&serde_json::json!({"denied": false})));
4146 assert!(!envelope_denied(&serde_json::json!({})));
4147 assert!(!envelope_denied(&serde_json::json!({"denied": "yes"})));
4149 }
4150
4151 #[test]
4152 fn envelope_denial_reason_joins_or_falls_back() {
4153 let multi = serde_json::json!({
4154 "denials": [
4155 {"kind": "exec", "target": "rm", "reason": "exec rm denied"},
4156 {"kind": "open", "target": "/etc/shadow", "reason": "open denied"}
4157 ]
4158 });
4159 assert_eq!(
4160 envelope_denial_reason(&multi),
4161 "exec rm denied; open denied"
4162 );
4163 let generic = "denied: the capability leash refused an operation";
4165 assert_eq!(envelope_denial_reason(&serde_json::json!({})), generic);
4166 assert_eq!(
4167 envelope_denial_reason(&serde_json::json!({"denials": []})),
4168 generic
4169 );
4170 assert_eq!(
4172 envelope_denial_reason(&serde_json::json!({"denials": [{"kind": "exec"}]})),
4173 generic
4174 );
4175 }
4176
4177 #[test]
4178 fn exec_allowlist_name_takes_basename() {
4179 assert_eq!(exec_allowlist_name("env"), "env");
4180 assert_eq!(exec_allowlist_name("/usr/bin/env"), "env");
4181 assert_eq!(exec_allowlist_name("/usr/bin/"), "bin");
4182 assert_eq!(exec_allowlist_name("C:\\tools\\env.exe"), "env.exe");
4183 }
4184
4185 #[test]
4191 fn exec_denial_target_label_is_the_bare_command_not_the_reason() {
4192 let one = serde_json::json!({
4193 "denied": true,
4194 "denials": [{
4195 "kind": "exec",
4196 "target": "export",
4197 "reason": "exec of \"export\" is not within the granted authority"
4198 }]
4199 });
4200 let label = exec_denial_target_label(&one);
4201 assert_eq!(label, "export");
4202 assert!(!label.contains("is not within the granted authority"));
4205 let multi = serde_json::json!({
4208 "denials": [
4209 {"kind": "exec", "target": "export", "reason": "r"},
4210 {"kind": "exec", "target": "set", "reason": "r"}
4211 ]
4212 });
4213 assert_eq!(exec_denial_target_label(&multi), "export, set");
4214 assert_eq!(
4215 exec_denial_target_label(&serde_json::json!({})),
4216 "a command"
4217 );
4218 }
4219
4220 #[test]
4221 fn host_of_url_extracts_hosts_conservatively() {
4222 assert_eq!(host_of_url("https://docs.rs/serde"), Some("docs.rs".into()));
4223 assert_eq!(host_of_url("http://Docs.RS"), Some("docs.rs".into()));
4224 assert_eq!(
4225 host_of_url("https://user:pw@example.com:8443/p?q#f"),
4226 Some("example.com".into())
4227 );
4228 assert_eq!(host_of_url("https://[::1]:8080/x"), Some("::1".into()));
4229 assert_eq!(host_of_url("not a url"), None);
4232 assert_eq!(host_of_url("ftp://example.com"), None);
4233 assert_eq!(host_of_url("https:///path-only"), None);
4234 }
4235
4236 #[test]
4237 fn exec_denial_requests_lifts_only_pure_exec_envelopes() {
4238 let exec_only = serde_json::json!({
4241 "denied": true,
4242 "denials": [
4243 {"kind": "exec", "target": "/usr/bin/npm", "reason": "exec npm denied"},
4244 {"kind": "exec", "target": "node", "reason": "exec node denied"}
4245 ]
4246 });
4247 let reqs = exec_denial_requests(&exec_only).expect("promptable");
4248 assert_eq!(reqs.len(), 2);
4249 assert_eq!(reqs[0].tool, "run_command");
4250 assert_eq!(reqs[0].kind, DenialKind::Exec);
4251 assert_eq!(
4252 reqs[0].target, "npm",
4253 "basename, same rule as the config hint"
4254 );
4255 assert_eq!(reqs[0].reason, "exec npm denied");
4256 assert_eq!(reqs[1].target, "node");
4257
4258 let mixed = serde_json::json!({
4261 "denials": [
4262 {"kind": "exec", "target": "npm", "reason": "r"},
4263 {"kind": "open", "target": "/etc/shadow", "reason": "r"}
4264 ]
4265 });
4266 assert!(exec_denial_requests(&mixed).is_none());
4267
4268 assert!(exec_denial_requests(&serde_json::json!({})).is_none());
4270 assert!(exec_denial_requests(&serde_json::json!({"denials": []})).is_none());
4271 let empty_target = serde_json::json!({
4272 "denials": [{"kind": "exec", "target": "", "reason": "r"}]
4273 });
4274 assert!(exec_denial_requests(&empty_target).is_none());
4275 let no_target = serde_json::json!({
4276 "denials": [{"kind": "exec", "reason": "r"}]
4277 });
4278 assert!(exec_denial_requests(&no_target).is_none());
4279 }
4280
4281 #[test]
4285 fn net_denial_requests_lifts_only_pure_net_envelopes() {
4286 let net_only = serde_json::json!({
4287 "denied": true,
4288 "denials": [
4289 {"kind": "net", "target": "github.com", "reason": "net does not permit 'github.com'"},
4290 {"kind": "net", "target": "api.github.com", "reason": "net does not permit 'api.github.com'"}
4291 ]
4292 });
4293 let reqs = net_denial_requests(&net_only).expect("promptable");
4294 assert_eq!(reqs.len(), 2);
4295 assert_eq!(reqs[0].tool, "run_command");
4296 assert_eq!(reqs[0].kind, DenialKind::Net);
4297 assert_eq!(
4298 reqs[0].target, "github.com",
4299 "host verbatim, not a basename"
4300 );
4301 assert_eq!(reqs[0].reason, "net does not permit 'github.com'");
4302 assert_eq!(reqs[1].target, "api.github.com");
4303
4304 let mixed = serde_json::json!({
4306 "denials": [
4307 {"kind": "net", "target": "github.com", "reason": "r"},
4308 {"kind": "exec", "target": "npm", "reason": "r"}
4309 ]
4310 });
4311 assert!(net_denial_requests(&mixed).is_none());
4312 let exec_only = serde_json::json!({"denials": [{"kind": "exec", "target": "npm"}]});
4314 assert!(net_denial_requests(&exec_only).is_none());
4315 assert!(net_denial_requests(&serde_json::json!({"denials": []})).is_none());
4316 let empty_target = serde_json::json!({"denials": [{"kind": "net", "target": ""}]});
4317 assert!(net_denial_requests(&empty_target).is_none());
4318 }
4319
4320 #[test]
4323 fn denial_axis_label_is_net_only_for_pure_net() {
4324 let net = serde_json::json!({"denials": [{"kind": "net", "target": "github.com"}]});
4325 assert_eq!(denial_axis_label(&net), "net");
4326 let exec = serde_json::json!({"denials": [{"kind": "exec", "target": "rm"}]});
4327 assert_eq!(denial_axis_label(&exec), "exec");
4328 let mixed = serde_json::json!({
4329 "denials": [{"kind": "net", "target": "h"}, {"kind": "exec", "target": "rm"}]
4330 });
4331 assert_eq!(denial_axis_label(&mixed), "exec", "mixed defaults to exec");
4332 assert_eq!(denial_axis_label(&serde_json::json!({})), "exec");
4333 }
4334
4335 #[test]
4336 fn tui_permits_path_prefix_semantics() {
4337 use crate::caveats::Scope;
4338 assert!(tui_permits_path(&Scope::All, "/anything/at/all"));
4339 assert!(!tui_permits_path(&Scope::<String>::none(), "/ws/file"));
4340 let only = Scope::only(["/ws".to_string()]);
4341 assert!(tui_permits_path(&only, "/ws/sub/file.rs"));
4342 assert!(tui_permits_path(&only, "/ws"), "the workspace root itself");
4343 assert!(!tui_permits_path(&only, "/elsewhere/file.rs"));
4344 assert!(
4347 !tui_permits_path(&only, "/ws/../etc/passwd"),
4348 "`..` traversal escapes the workspace"
4349 );
4350 assert!(
4351 !tui_permits_path(&only, "/ws/../../etc/passwd"),
4352 "repeated `..` traversal escapes the workspace"
4353 );
4354 assert!(
4356 !tui_permits_path(&only, "/ws-secret/file.rs"),
4357 "sibling-prefix collision escapes the workspace"
4358 );
4359 assert!(tui_permits_path(&only, "/ws/sub/../file.rs"));
4361 }
4362
4363 #[cfg(unix)]
4376 #[test]
4377 fn tui_permits_path_symlink_escape_is_the_known_residual() {
4378 use crate::caveats::Scope;
4379 let outside = tempfile::TempDir::new().unwrap();
4380 std::fs::write(outside.path().join("secret"), b"x").unwrap();
4381 let ws = tempfile::TempDir::new().unwrap();
4382 std::os::unix::fs::symlink(outside.path(), ws.path().join("link")).unwrap();
4384
4385 let only = Scope::only([ws.path().to_string_lossy().into_owned()]);
4386
4387 let via_link = ws.path().join("link").join("secret");
4389 assert!(
4392 tui_permits_path(&only, &via_link.to_string_lossy()),
4393 "string gate permits a symlinked escape — known residual (#522)"
4394 );
4395
4396 let dotdot = ws.path().join("..").join("etc").join("passwd");
4400 assert!(
4401 !tui_permits_path(&only, &dotdot.to_string_lossy()),
4402 "`..` escape is denied even though symlink escape is not"
4403 );
4404 }
4405
4406 #[test]
4409 fn git_tool_advertised_only_with_the_presence_gate() {
4410 fn names(defs: &serde_json::Value) -> Vec<&str> {
4411 defs.as_array()
4412 .unwrap()
4413 .iter()
4414 .filter_map(|d| d["function"]["name"].as_str())
4415 .collect()
4416 }
4417 let with = merged_tool_definitions(
4418 &NoMcp, false, false, false, true, false, false, false, false, false,
4419 );
4420 assert!(names(&with).contains(&"git"), "with_git advertises git");
4421 let without = merged_tool_definitions(
4422 &NoMcp, false, false, false, false, false, false, false, false, false,
4423 );
4424 assert!(!names(&without).contains(&"git"), "no git without the gate");
4425 let team = merged_tool_definitions(
4427 &NoMcp, false, false, false, false, true, false, false, false, false,
4428 );
4429 assert!(
4430 names(&team).contains(&"crew") && names(&team).contains(&"compose_roster"),
4431 "with_team advertises crew + compose_roster"
4432 );
4433 assert!(
4434 !names(&without).contains(&"crew"),
4435 "no crew without the gate"
4436 );
4437 let scratch = merged_tool_definitions(
4439 &NoMcp, false, false, false, false, false, true, false, false, false,
4440 );
4441 for t in ["state_set", "state_get", "state_clear"] {
4442 assert!(
4443 names(&scratch).contains(&t),
4444 "{t} advertised with_scratchpad"
4445 );
4446 assert!(!names(&without).contains(&t), "{t} hidden without the gate");
4447 assert!(
4448 !is_hallucination(t, &serde_json::json!({})),
4449 "{t} is a real tool"
4450 );
4451 }
4452 let code = merged_tool_definitions(
4454 &NoMcp, false, false, false, false, false, false, true, false, false,
4455 );
4456 assert!(
4457 names(&code).contains(&"code_search"),
4458 "code_search advertised"
4459 );
4460 assert!(
4461 !names(&without).contains(&"code_search"),
4462 "code_search hidden without the gate"
4463 );
4464 assert!(!is_hallucination("code_search", &serde_json::json!({})));
4465 let exp = merged_tool_definitions(
4467 &NoMcp, false, false, false, false, false, false, false, true, false,
4468 );
4469 for t in ["experience_record", "experience_recall"] {
4470 assert!(names(&exp).contains(&t), "{t} advertised with_experiential");
4471 assert!(!names(&without).contains(&t), "{t} hidden without the gate");
4472 assert!(
4473 !is_hallucination(t, &serde_json::json!({})),
4474 "{t} is a real tool"
4475 );
4476 }
4477 let sched = merged_tool_definitions(
4480 &NoMcp, false, false, false, false, false, false, false, false, true,
4481 );
4482 for t in ["update_plan", "plan_get"] {
4483 assert!(names(&sched).contains(&t), "{t} advertised with_scheduled");
4484 assert!(!names(&without).contains(&t), "{t} hidden without the gate");
4485 assert!(
4486 !is_hallucination(t, &serde_json::json!({})),
4487 "{t} is a real tool"
4488 );
4489 }
4490 }
4491
4492 #[tokio::test]
4493 async fn state_tools_dispatch_only_with_a_store() {
4494 use crate::agentic::scratchpad::{ScratchpadStore, SessionScratchpadStore};
4495 let caveats = crate::caveats::Caveats::top();
4496 let args = serde_json::json!({ "key": "k", "value": "v" });
4497 let none = execute_tool(
4499 "state_set",
4500 &args,
4501 ".",
4502 false,
4503 20,
4504 &caveats,
4505 &mut NoMcp,
4506 None,
4507 None,
4508 None,
4509 None,
4510 None,
4511 None,
4512 None,
4513 None,
4514 None,
4515 None,
4516 None,
4517 None,
4518 )
4519 .await;
4520 assert!(none.starts_with("unknown tool: state_set"), "{none}");
4521 let store = SessionScratchpadStore::default();
4523 let set = execute_tool(
4524 "state_set",
4525 &args,
4526 ".",
4527 false,
4528 20,
4529 &caveats,
4530 &mut NoMcp,
4531 None,
4532 None,
4533 None,
4534 None,
4535 None,
4536 None,
4537 None,
4538 None,
4539 Some(&store as &dyn ScratchpadStore),
4540 None,
4541 None,
4542 None,
4543 )
4544 .await;
4545 assert_eq!(set, "stored: k");
4546 assert_eq!(store.get("k").as_deref(), Some("v"));
4547 }
4548
4549 #[tokio::test]
4550 async fn code_search_dispatch_only_with_a_searcher() {
4551 use crate::agentic::semantic::{CodeSearch, Embedder, SessionSemanticIndex};
4552 struct E;
4553 #[async_trait::async_trait]
4554 impl Embedder for E {
4555 async fn embed(&self, _t: &str) -> anyhow::Result<Vec<f32>> {
4556 Ok(vec![1.0])
4557 }
4558 }
4559 let caveats = crate::caveats::Caveats::top();
4560 let args = serde_json::json!({ "query": "find it" });
4561 let none = execute_tool(
4563 "code_search",
4564 &args,
4565 ".",
4566 false,
4567 20,
4568 &caveats,
4569 &mut NoMcp,
4570 None,
4571 None,
4572 None,
4573 None,
4574 None,
4575 None,
4576 None,
4577 None,
4578 None,
4579 None,
4580 None,
4581 None,
4582 )
4583 .await;
4584 assert!(none.starts_with("unknown tool: code_search"), "{none}");
4585 let idx = SessionSemanticIndex::default();
4587 let search = CodeSearch {
4588 embedder: &E,
4589 index: &idx,
4590 top_k: 1,
4591 };
4592 let out = execute_tool(
4593 "code_search",
4594 &args,
4595 ".",
4596 false,
4597 20,
4598 &caveats,
4599 &mut NoMcp,
4600 None,
4601 None,
4602 None,
4603 None,
4604 None,
4605 None,
4606 None,
4607 None,
4608 None,
4609 Some(search),
4610 None,
4611 None,
4612 )
4613 .await;
4614 assert!(out.contains("no code matched"), "{out}");
4615 }
4616
4617 #[tokio::test]
4618 async fn experiential_dispatch_only_with_a_store() {
4619 use crate::agentic::experiential::{ExperienceStore, SessionExperienceStore};
4620 let caveats = crate::caveats::Caveats::top();
4621 let args = serde_json::json!({
4622 "task": "ci flake", "outcome": "fixed", "lesson": "pin the seed for the fuzz test"
4623 });
4624 for name in ["experience_record", "experience_recall"] {
4626 let out = execute_tool(
4627 name, &args, ".", false, 20, &caveats, &mut NoMcp, None, None, None, None, None,
4628 None, None, None, None, None, None, None,
4629 )
4630 .await;
4631 assert!(out.starts_with(&format!("unknown tool: {name}")), "{out}");
4632 }
4633 let store = SessionExperienceStore::default();
4635 let out = execute_tool(
4636 "experience_record",
4637 &args,
4638 ".",
4639 false,
4640 20,
4641 &caveats,
4642 &mut NoMcp,
4643 None,
4644 None,
4645 None,
4646 None,
4647 None,
4648 None,
4649 None,
4650 None,
4651 None,
4652 None,
4653 Some(&store as &dyn ExperienceStore),
4654 None,
4655 )
4656 .await;
4657 assert_eq!(out, "recorded experience");
4658 assert_eq!(store.count(), 1);
4659 }
4660
4661 #[tokio::test]
4662 async fn scheduled_dispatch_only_with_a_ledger() {
4663 use crate::agentic::scheduled::{SessionStepLedger, StepLedger};
4664 let caveats = crate::caveats::Caveats::top();
4665 let args = serde_json::json!({ "plan": [
4666 { "step": "a", "status": "in_progress" },
4667 { "step": "b", "status": "pending" },
4668 ] });
4669 for name in ["update_plan", "plan_get"] {
4672 let out = execute_tool(
4673 name, &args, ".", false, 20, &caveats, &mut NoMcp, None, None, None, None, None,
4674 None, None, None, None, None, None, None,
4675 )
4676 .await;
4677 assert!(out.starts_with(&format!("unknown tool: {name}")), "{out}");
4678 }
4679 let ledger = SessionStepLedger::default();
4681 let out = execute_tool(
4682 "update_plan",
4683 &args,
4684 ".",
4685 false,
4686 20,
4687 &caveats,
4688 &mut NoMcp,
4689 None,
4690 None,
4691 None,
4692 None,
4693 None,
4694 None,
4695 None,
4696 None,
4697 None,
4698 None,
4699 None,
4700 Some(&ledger as &dyn StepLedger),
4701 )
4702 .await;
4703 assert!(out.starts_with("<plan>\n"), "{out}");
4704 assert_eq!(ledger.count(), 2);
4705 let got = execute_tool(
4707 "plan_get",
4708 &serde_json::json!({}),
4709 ".",
4710 false,
4711 20,
4712 &caveats,
4713 &mut NoMcp,
4714 None,
4715 None,
4716 None,
4717 None,
4718 None,
4719 None,
4720 None,
4721 None,
4722 None,
4723 None,
4724 None,
4725 Some(&ledger as &dyn StepLedger),
4726 )
4727 .await;
4728 assert!(got.starts_with("<plan>\n"), "{got}");
4729 assert_eq!(ledger.count(), 2, "plan_get is read-only");
4730 }
4731
4732 #[tokio::test]
4733 async fn resume_context_dispatch_degrades_without_a_recall_source() {
4734 let caveats = crate::caveats::Caveats::top();
4737 let out = execute_tool(
4738 "resume_context",
4739 &serde_json::json!({}),
4740 ".",
4741 false,
4742 20,
4743 &caveats,
4744 &mut NoMcp,
4745 None, None, None, None, None, None, None, None, None, None, None, None, )
4758 .await;
4759 assert!(
4760 out.contains("no conversation history available this session"),
4761 "{out}"
4762 );
4763 assert!(!out.starts_with("unknown tool"), "{out}");
4764 }
4765
4766 #[test]
4767 fn run_build_check_reports_pass_fail_and_spawn_error() {
4768 let ws = tempfile::TempDir::new().unwrap();
4769 let ws_str = ws.path().to_string_lossy();
4770 assert_eq!(
4771 run_build_check(passing_build_check_cmd(), &ws_str),
4772 " ✓ build check passed"
4773 );
4774 let failed = run_build_check(&failing_build_check_cmd("boom"), &ws_str);
4775 assert!(failed.contains("✗ build check failed"), "got: {failed}");
4776 assert!(failed.contains("boom"), "stderr excerpt shown: {failed}");
4777 let err = run_build_check(passing_build_check_cmd(), "/definitely/not/a/dir");
4779 assert!(err.contains("⚠ build check could not run"), "got: {err}");
4780 }
4781}
4782
4783#[cfg(test)]
4788mod execute_tool_branch_tests {
4789 use super::super::NoMcp;
4790 use super::*;
4791 use crate::caveats::{Caveats, CountBound, Scope};
4792
4793 fn caveats_rw(ws: &std::path::Path) -> Caveats {
4796 Caveats {
4797 fs_read: Scope::All,
4798 fs_write: Scope::only([ws.to_string_lossy().into_owned()]),
4799 exec: Scope::none(),
4800 net: Scope::none(),
4801 max_calls: CountBound::Unlimited,
4802 valid_for_generation: Scope::All,
4803 }
4804 }
4805
4806 struct StubGit;
4811 impl crate::agentic::GitTool for StubGit {
4812 fn dispatch(
4813 &self,
4814 op: &str,
4815 _args: &serde_json::Value,
4816 caps: &crate::git_caveats::GitCaveats,
4817 ) -> Result<String, String> {
4818 match op {
4819 "status" => Ok("on branch main (HEAD abc123)".to_string()),
4820 "commit" if !caps.permits_commit() => {
4821 Err("capability denied: git commit not permitted".to_string())
4822 }
4823 "commit" => Ok("committed abc123: msg".to_string()),
4824 other => Err(format!("unknown git op '{other}'")),
4825 }
4826 }
4827 }
4828
4829 async fn run_git(
4830 op: &str,
4831 caveats: &Caveats,
4832 git: Option<&dyn crate::agentic::GitTool>,
4833 ) -> String {
4834 let ws = tempfile::TempDir::new().unwrap();
4835 execute_tool(
4836 "git",
4837 &serde_json::json!({ "op": op }),
4838 &ws.path().to_string_lossy(),
4839 false,
4840 20,
4841 caveats,
4842 &mut NoMcp,
4843 None,
4844 None,
4845 None,
4846 None,
4847 None,
4848 None,
4849 git,
4850 None, None, None, None, None, )
4856 .await
4857 }
4858
4859 #[tokio::test]
4860 async fn git_arm_dispatches_when_injected() {
4861 let ws = tempfile::TempDir::new().unwrap();
4862 let out = run_git("status", &caveats_rw(ws.path()), Some(&StubGit)).await;
4863 assert!(out.contains("on branch main"), "got: {out}");
4864 }
4865
4866 #[tokio::test]
4867 async fn git_arm_surfaces_denials_from_projected_caveats() {
4868 let ws = tempfile::TempDir::new().unwrap();
4870 let read_only = Caveats {
4871 fs_write: Scope::none(),
4872 ..caveats_rw(ws.path())
4873 };
4874 let out = run_git("commit", &read_only, Some(&StubGit)).await;
4875 assert!(
4876 out.contains("error:") && out.contains("commit"),
4877 "got: {out}"
4878 );
4879 let out = run_git("status", &read_only, Some(&StubGit)).await;
4881 assert!(out.contains("on branch main"), "got: {out}");
4882 }
4883
4884 #[tokio::test]
4885 async fn git_arm_unknown_op_is_an_error_not_a_panic() {
4886 let ws = tempfile::TempDir::new().unwrap();
4887 let out = run_git("frobnicate", &caveats_rw(ws.path()), Some(&StubGit)).await;
4888 assert!(
4889 out.contains("error:") && out.contains("unknown git op"),
4890 "got: {out}"
4891 );
4892 }
4893
4894 #[tokio::test]
4895 async fn git_arm_without_injection_is_unknown_tool() {
4896 let ws = tempfile::TempDir::new().unwrap();
4897 let out = run_git("status", &caveats_rw(ws.path()), None).await;
4898 assert!(out.contains("unknown tool: git"), "got: {out}");
4899 }
4900
4901 struct StubCrew;
4904 #[async_trait::async_trait]
4905 impl crate::agentic::CrewRunner for StubCrew {
4906 async fn dispatch(
4907 &self,
4908 op: &str,
4909 _args: &serde_json::Value,
4910 _caveats: &Caveats,
4911 ) -> Result<String, String> {
4912 match op {
4913 "compose_roster" => Ok("proposed roster: planner <- qwen3-coder:30b".to_string()),
4914 "crew" => Ok("crew ran: diff +1/-0, status PASS".to_string()),
4915 other => Err(format!("unknown op: {other}")),
4916 }
4917 }
4918 }
4919
4920 async fn run_crew_tool(
4921 name: &str,
4922 args: serde_json::Value,
4923 crew: Option<&dyn crate::agentic::CrewRunner>,
4924 ) -> String {
4925 let ws = tempfile::TempDir::new().unwrap();
4926 execute_tool(
4927 name,
4928 &args,
4929 &ws.path().to_string_lossy(),
4930 false,
4931 20,
4932 &caveats_rw(ws.path()),
4933 &mut NoMcp,
4934 None, None, None, None, None, None, None, crew,
4942 None, None, None, None, )
4947 .await
4948 }
4949
4950 #[tokio::test]
4951 async fn crew_arm_dispatches_when_injected() {
4952 let out = run_crew_tool(
4953 "crew",
4954 serde_json::json!({ "task": "do X" }),
4955 Some(&StubCrew),
4956 )
4957 .await;
4958 assert!(
4959 out.contains("crew ran") && out.contains("PASS"),
4960 "got: {out}"
4961 );
4962 let out = run_crew_tool(
4963 "compose_roster",
4964 serde_json::json!({ "mode": "crew" }),
4965 Some(&StubCrew),
4966 )
4967 .await;
4968 assert!(out.contains("proposed roster"), "got: {out}");
4969 }
4970
4971 #[tokio::test]
4976 async fn crew_arm_without_injection_coaches_recovery() {
4977 for name in ["crew", "compose_roster"] {
4978 let out = run_crew_tool(name, serde_json::json!({ "task": "x" }), None).await;
4979 assert!(out.contains("NEWT_TEAM"), "{name}: {out}");
4980 assert!(out.contains("read_file"), "{name}: {out}");
4981 assert!(!out.contains("unknown tool"), "{name}: {out}");
4982 }
4983 }
4984
4985 #[test]
4988 fn crew_off_recovery_result_names_gate_and_alternative() {
4989 let out = crew_off_recovery_result("crew");
4990 assert!(out.contains("'crew'"), "{out}");
4991 assert!(out.contains("NEWT_TEAM"), "{out}");
4992 assert!(out.contains("write_file"), "{out}");
4994 assert!(!out.contains("unknown tool"), "{out}");
4995 }
4996
4997 #[test]
5002 fn classify_gated_off_reach_only_fires_for_off_crew_names() {
5003 for name in ["crew", "compose_roster"] {
5004 assert_eq!(
5005 classify_gated_off_reach(name, false),
5006 Some(crate::PhantomResolution::GatedOff(
5007 "crew/team surface off (NEWT_TEAM)".into()
5008 )),
5009 "{name} OFF should record GatedOff"
5010 );
5011 assert_eq!(
5012 classify_gated_off_reach(name, true),
5013 None,
5014 "{name} ON dispatches normally — no phantom"
5015 );
5016 }
5017 assert_eq!(classify_gated_off_reach("read_file", false), None);
5019 assert_eq!(classify_gated_off_reach("read_file", true), None);
5020 }
5021
5022 #[test]
5026 fn crew_names_stay_real_and_unflagged_by_existing_seams() {
5027 for name in ["crew", "compose_roster"] {
5028 assert!(
5029 !is_hallucination(name, &serde_json::json!({ "task": "x" })),
5030 "{name} must stay a real tool name"
5031 );
5032 assert_eq!(
5033 classify_phantom_reach(name, &serde_json::json!({ "task": "x" }), "ok", true),
5034 None,
5035 "{name} must not be flagged by classify_phantom_reach"
5036 );
5037 }
5038 }
5039
5040 async fn run_find(args: serde_json::Value, ws: &std::path::Path) -> String {
5045 run_tool("find", args, ws, &caveats_rw(ws), None).await
5046 }
5047
5048 fn touch(root: &std::path::Path, rel: &str) {
5049 let p = root.join(rel);
5050 std::fs::create_dir_all(p.parent().unwrap()).unwrap();
5051 std::fs::write(p, b"x").unwrap();
5052 }
5053
5054 #[tokio::test]
5060 async fn find_locates_file_by_name_issue_496() {
5061 let ws = tempfile::TempDir::new().unwrap();
5062 touch(ws.path(), "newt-core/src/pyo3_module.rs");
5063 touch(ws.path(), "newt-data/src/other.rs");
5064 touch(ws.path(), "docs/pyo3_module.md"); let out = run_find(serde_json::json!({ "name": "pyo3_module.rs" }), ws.path()).await;
5066 assert_eq!(out, "newt-core/src/pyo3_module.rs", "got: {out}");
5067 }
5068
5069 #[tokio::test]
5073 async fn find_glob_type_and_maxdepth_together() {
5074 let ws = tempfile::TempDir::new().unwrap();
5075 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();
5080 let out = run_find(
5081 serde_json::json!({
5082 "path": "examples", "name": "*.py", "type": "f", "max_depth": 2
5083 }),
5084 ws.path(),
5085 )
5086 .await;
5087 assert_eq!(out, "examples/a.py\nexamples/sub/b.py", "got: {out}");
5090 }
5091
5092 #[tokio::test]
5094 async fn find_output_is_sorted() {
5095 let ws = tempfile::TempDir::new().unwrap();
5096 for f in ["m.txt", "a.txt", "z.txt", "c.txt"] {
5097 touch(ws.path(), f);
5098 }
5099 let out = run_find(serde_json::json!({ "name": "*.txt" }), ws.path()).await;
5100 let lines: Vec<&str> = out.lines().collect();
5101 assert_eq!(
5102 lines,
5103 vec!["a.txt", "c.txt", "m.txt", "z.txt"],
5104 "got: {out}"
5105 );
5106 }
5107
5108 #[tokio::test]
5110 async fn find_type_filter() {
5111 let ws = tempfile::TempDir::new().unwrap();
5112 touch(ws.path(), "pkg/file.rs");
5113 std::fs::create_dir_all(ws.path().join("pkg/sub")).unwrap();
5114 let dirs = run_find(serde_json::json!({ "type": "d" }), ws.path()).await;
5115 assert!(
5116 dirs.contains("pkg") && dirs.contains("pkg/sub"),
5117 "got: {dirs}"
5118 );
5119 assert!(!dirs.contains("file.rs"), "dirs-only leaked a file: {dirs}");
5120 let files = run_find(serde_json::json!({ "type": "f" }), ws.path()).await;
5121 assert!(files.contains("pkg/file.rs"), "got: {files}");
5122 assert!(
5123 !files.lines().any(|l| l == "pkg" || l == "pkg/sub"),
5124 "files-only leaked a dir: {files}"
5125 );
5126 }
5127
5128 #[tokio::test]
5131 async fn find_gitignore_and_default_skips() {
5132 let ws = tempfile::TempDir::new().unwrap();
5133 std::fs::write(ws.path().join(".gitignore"), "ignored.txt\n").unwrap();
5134 touch(ws.path(), "kept.txt");
5135 touch(ws.path(), "ignored.txt");
5136 touch(ws.path(), "target/build_artifact.txt");
5137 touch(ws.path(), "node_modules/dep.txt");
5138
5139 let on = run_find(serde_json::json!({ "name": "*.txt" }), ws.path()).await;
5140 assert!(on.contains("kept.txt"), "got: {on}");
5141 assert!(!on.contains("ignored.txt"), "gitignore not honoured: {on}");
5142 assert!(!on.contains("target/"), "target not skipped: {on}");
5143 assert!(
5144 !on.contains("node_modules/"),
5145 "node_modules not skipped: {on}"
5146 );
5147
5148 let off = run_find(
5149 serde_json::json!({ "name": "*.txt", "respect_gitignore": false }),
5150 ws.path(),
5151 )
5152 .await;
5153 assert!(off.contains("ignored.txt"), "opt-out should show it: {off}");
5154 assert!(off.contains("target/build_artifact.txt"), "got: {off}");
5155 }
5156
5157 #[tokio::test]
5159 async fn find_max_results_caps_and_notes_truncation() {
5160 let ws = tempfile::TempDir::new().unwrap();
5161 for i in 0..10 {
5162 touch(ws.path(), &format!("f{i}.txt"));
5163 }
5164 let out = run_find(
5165 serde_json::json!({ "name": "*.txt", "max_results": 3 }),
5166 ws.path(),
5167 )
5168 .await;
5169 let body: Vec<&str> = out.lines().filter(|l| l.ends_with(".txt")).collect();
5170 assert_eq!(body.len(), 3, "should cap at 3: {out}");
5171 assert!(out.contains("truncated at 3"), "got: {out}");
5172 }
5173
5174 #[tokio::test]
5176 async fn find_missing_root_and_no_matches() {
5177 let ws = tempfile::TempDir::new().unwrap();
5178 touch(ws.path(), "a.txt");
5179 let missing = run_find(serde_json::json!({ "path": "does/not/exist" }), ws.path()).await;
5180 assert!(missing.starts_with("error:"), "got: {missing}");
5181 let empty = run_find(serde_json::json!({ "name": "*.nope" }), ws.path()).await;
5182 assert_eq!(empty, "no matches", "got: {empty}");
5183 }
5184
5185 #[tokio::test]
5188 async fn find_denied_without_fs_read() {
5189 let ws = tempfile::TempDir::new().unwrap();
5190 touch(ws.path(), "secret.txt");
5191 let denied = Caveats {
5192 fs_read: Scope::none(),
5193 ..caveats_rw(ws.path())
5194 };
5195 let out = run_tool(
5196 "find",
5197 serde_json::json!({ "name": "*" }),
5198 ws.path(),
5199 &denied,
5200 None,
5201 )
5202 .await;
5203 assert!(out.starts_with("capability denied"), "got: {out}");
5204 }
5205
5206 #[tokio::test]
5209 async fn find_refuses_root_outside_workspace() {
5210 let parent = tempfile::TempDir::new().unwrap();
5211 std::fs::write(parent.path().join("outside.txt"), b"x").unwrap();
5212 let ws = parent.path().join("ws");
5213 std::fs::create_dir_all(&ws).unwrap();
5214 let out = run_find(serde_json::json!({ "path": ".." }), &ws).await;
5217 assert!(out.starts_with("capability denied"), "got: {out}");
5218 }
5219
5220 #[tokio::test]
5224 async fn find_empty_name_matches_everything() {
5225 let ws = tempfile::TempDir::new().unwrap();
5226 touch(ws.path(), "a.txt");
5227 touch(ws.path(), "sub/b.rs");
5228 let out = run_find(serde_json::json!({ "name": "" }), ws.path()).await;
5229 for expected in ["a.txt", "sub", "sub/b.rs"] {
5230 assert!(
5231 out.lines().any(|l| l == expected),
5232 "empty name should match `{expected}`: {out}"
5233 );
5234 }
5235 }
5236
5237 #[tokio::test]
5241 async fn find_hidden_entries_gated_by_respect_gitignore() {
5242 let ws = tempfile::TempDir::new().unwrap();
5243 touch(ws.path(), "visible.txt");
5244 touch(ws.path(), ".hidden.txt");
5245 touch(ws.path(), ".config/secret.txt");
5246
5247 let default = run_find(serde_json::json!({ "name": "*" }), ws.path()).await;
5248 assert!(
5249 default.lines().any(|l| l == "visible.txt"),
5250 "got: {default}"
5251 );
5252 assert!(
5253 !default.contains(".hidden") && !default.contains(".config"),
5254 "hidden entries must be skipped by default: {default}"
5255 );
5256
5257 let all = run_find(
5258 serde_json::json!({ "name": "*", "respect_gitignore": false }),
5259 ws.path(),
5260 )
5261 .await;
5262 assert!(all.contains(".hidden.txt"), "opt-out should show it: {all}");
5263 assert!(all.contains(".config/secret.txt"), "got: {all}");
5264 }
5265
5266 #[cfg(unix)]
5270 #[tokio::test]
5271 async fn find_does_not_follow_symlinks_out_of_workspace() {
5272 let outside = tempfile::TempDir::new().unwrap();
5273 std::fs::write(outside.path().join("secret.txt"), b"x").unwrap();
5274 let ws = tempfile::TempDir::new().unwrap();
5275 touch(ws.path(), "inside.txt");
5276 std::os::unix::fs::symlink(outside.path(), ws.path().join("link")).unwrap();
5277
5278 let leaked = run_find(serde_json::json!({ "name": "secret.txt" }), ws.path()).await;
5280 assert_eq!(
5281 leaked, "no matches",
5282 "symlink was followed out of ws: {leaked}"
5283 );
5284 let found = run_find(serde_json::json!({ "name": "inside.txt" }), ws.path()).await;
5286 assert_eq!(found, "inside.txt", "got: {found}");
5287 }
5288
5289 #[test]
5290 fn glob_to_regex_anchors_and_escapes() {
5291 let re = glob_to_regex("*.py", true).unwrap();
5293 assert!(re.is_match("foo.py"));
5294 assert!(!re.is_match("foo.pyc")); assert!(!re.is_match("fooxpy")); assert!(glob_to_regex("a?c", true).unwrap().is_match("abc"));
5298 assert!(!glob_to_regex("a?c", true).unwrap().is_match("ac"));
5299 assert!(glob_to_regex("readme.md", false)
5300 .unwrap()
5301 .is_match("README.MD"));
5302 assert!(!glob_to_regex("readme.md", true)
5303 .unwrap()
5304 .is_match("README.MD"));
5305 }
5306
5307 async fn run_tool(
5308 name: &str,
5309 args: serde_json::Value,
5310 ws: &std::path::Path,
5311 caveats: &Caveats,
5312 build_check: Option<&str>,
5313 ) -> String {
5314 execute_tool(
5315 name,
5316 &args,
5317 &ws.to_string_lossy(),
5318 false,
5319 20,
5320 caveats,
5321 &mut NoMcp,
5322 build_check,
5323 None,
5324 None,
5325 None, None,
5327 None,
5328 None, None, None, None, None, None, )
5335 .await
5336 }
5337
5338 #[tokio::test]
5339 async fn edit_file_replaces_unique_match_and_reports_delta() {
5340 let ws = tempfile::TempDir::new().unwrap();
5341 std::fs::write(ws.path().join("f.txt"), "hello world\nsecond line\n").unwrap();
5342 let caveats = caveats_rw(ws.path());
5343 let out = run_tool(
5344 "edit_file",
5345 serde_json::json!({
5346 "path": "f.txt",
5347 "old_string": "world",
5348 "new_string": "rust\nand more"
5349 }),
5350 ws.path(),
5351 &caveats,
5352 None,
5353 )
5354 .await;
5355 assert!(out.starts_with("edited f.txt (+1 lines"), "got: {out}");
5356 assert_eq!(
5357 std::fs::read_to_string(ws.path().join("f.txt")).unwrap(),
5358 "hello rust\nand more\nsecond line\n"
5359 );
5360 }
5361
5362 #[tokio::test]
5363 async fn edit_file_rejects_empty_missing_and_ambiguous_old_string() {
5364 let ws = tempfile::TempDir::new().unwrap();
5365 std::fs::write(ws.path().join("f.txt"), "dup\ndup\n").unwrap();
5366 let caveats = caveats_rw(ws.path());
5367
5368 let out = run_tool(
5369 "edit_file",
5370 serde_json::json!({"path": "f.txt", "old_string": "", "new_string": "x"}),
5371 ws.path(),
5372 &caveats,
5373 None,
5374 )
5375 .await;
5376 assert!(out.contains("old_string must not be empty"), "got: {out}");
5377
5378 let out = run_tool(
5379 "edit_file",
5380 serde_json::json!({"path": "f.txt", "old_string": "absent", "new_string": "x"}),
5381 ws.path(),
5382 &caveats,
5383 None,
5384 )
5385 .await;
5386 assert!(out.contains("old_string not found in f.txt"), "got: {out}");
5387 assert!(out.contains("do not guess again"), "got: {out}");
5390 assert!(
5391 out.contains("dup"),
5392 "miss error must include the file content: {out}"
5393 );
5394
5395 let out = run_tool(
5396 "edit_file",
5397 serde_json::json!({"path": "f.txt", "old_string": "dup", "new_string": "x"}),
5398 ws.path(),
5399 &caveats,
5400 None,
5401 )
5402 .await;
5403 assert!(out.contains("matches 2 locations"), "got: {out}");
5404 assert_eq!(
5406 std::fs::read_to_string(ws.path().join("f.txt")).unwrap(),
5407 "dup\ndup\n"
5408 );
5409 }
5410
5411 #[tokio::test]
5412 async fn edit_file_denied_outside_fs_write_scope_and_missing_file() {
5413 let ws = tempfile::TempDir::new().unwrap();
5414 let caveats = Caveats {
5415 fs_write: Scope::none(),
5416 ..caveats_rw(ws.path())
5417 };
5418 let out = run_tool(
5419 "edit_file",
5420 serde_json::json!({"path": "f.txt", "old_string": "a", "new_string": "b"}),
5421 ws.path(),
5422 &caveats,
5423 None,
5424 )
5425 .await;
5426 assert!(
5427 out.contains("capability denied: fs_write"),
5428 "denied before any fs access, got: {out}"
5429 );
5430
5431 let caveats = caveats_rw(ws.path());
5432 let out = run_tool(
5433 "edit_file",
5434 serde_json::json!({"path": "missing.txt", "old_string": "a", "new_string": "b"}),
5435 ws.path(),
5436 &caveats,
5437 None,
5438 )
5439 .await;
5440 assert!(out.contains("error reading missing.txt"), "got: {out}");
5441 }
5442
5443 #[tokio::test]
5444 async fn edit_file_appends_build_check_result() {
5445 let ws = tempfile::TempDir::new().unwrap();
5446 std::fs::write(ws.path().join("f.txt"), "old\n").unwrap();
5447 let caveats = caveats_rw(ws.path());
5448 let out = run_tool(
5449 "edit_file",
5450 serde_json::json!({"path": "f.txt", "old_string": "old", "new_string": "new"}),
5451 ws.path(),
5452 &caveats,
5453 Some(passing_build_check_cmd()),
5454 )
5455 .await;
5456 assert!(out.contains("✓ build check passed"), "got: {out}");
5457
5458 let failing_check = failing_build_check_cmd("broke");
5459 let out = run_tool(
5460 "edit_file",
5461 serde_json::json!({"path": "f.txt", "old_string": "new", "new_string": "newer"}),
5462 ws.path(),
5463 &caveats,
5464 Some(&failing_check),
5465 )
5466 .await;
5467 assert!(out.contains("✗ build check failed"), "got: {out}");
5468 assert!(out.contains("broke"), "model sees the failure text: {out}");
5469 }
5470
5471 #[tokio::test]
5472 async fn write_file_shrink_guard_refuses_large_deletion() {
5473 let ws = tempfile::TempDir::new().unwrap();
5474 let big: String = (0..100).map(|i| format!("line {i}\n")).collect();
5475 std::fs::write(ws.path().join("big.txt"), &big).unwrap();
5476 let caveats = caveats_rw(ws.path());
5477 let out = run_tool(
5478 "write_file",
5479 serde_json::json!({"path": "big.txt", "content": "tiny\n"}),
5480 ws.path(),
5481 &caveats,
5482 None,
5483 )
5484 .await;
5485 assert!(
5486 out.contains("would shrink big.txt from 100 → 1 lines"),
5487 "got: {out}"
5488 );
5489 assert!(out.contains("edit_file"), "points at the safer tool: {out}");
5490 assert_eq!(
5492 std::fs::read_to_string(ws.path().join("big.txt")).unwrap(),
5493 big
5494 );
5495 }
5496
5497 #[tokio::test]
5498 async fn write_file_creates_parent_directories() {
5499 let ws = tempfile::TempDir::new().unwrap();
5500 let caveats = caveats_rw(ws.path());
5501 let out = run_tool(
5502 "write_file",
5503 serde_json::json!({"path": "a/b/c.txt", "content": "nested"}),
5504 ws.path(),
5505 &caveats,
5506 None,
5507 )
5508 .await;
5509 assert!(out.starts_with("wrote a/b/c.txt"), "got: {out}");
5510 assert_eq!(
5511 std::fs::read_to_string(ws.path().join("a/b/c.txt")).unwrap(),
5512 "nested"
5513 );
5514 }
5515
5516 #[tokio::test]
5517 async fn read_file_denial_and_missing_file_errors() {
5518 let ws = tempfile::TempDir::new().unwrap();
5519 std::fs::write(ws.path().join("secret.txt"), "x").unwrap();
5520 let denied = Caveats {
5521 fs_read: Scope::none(),
5522 ..caveats_rw(ws.path())
5523 };
5524 let out = run_tool(
5525 "read_file",
5526 serde_json::json!({"path": "secret.txt"}),
5527 ws.path(),
5528 &denied,
5529 None,
5530 )
5531 .await;
5532 assert!(out.contains("capability denied: fs_read"), "got: {out}");
5533
5534 let caveats = caveats_rw(ws.path());
5535 let out = run_tool(
5536 "read_file",
5537 serde_json::json!({"path": "nope.txt"}),
5538 ws.path(),
5539 &caveats,
5540 None,
5541 )
5542 .await;
5543 assert!(out.contains("error reading nope.txt"), "got: {out}");
5544 }
5545
5546 #[tokio::test]
5547 async fn list_dir_denial_and_missing_dir_errors() {
5548 let ws = tempfile::TempDir::new().unwrap();
5549 let denied = Caveats {
5550 fs_read: Scope::none(),
5551 ..caveats_rw(ws.path())
5552 };
5553 let out = run_tool(
5554 "list_dir",
5555 serde_json::json!({"path": "."}),
5556 ws.path(),
5557 &denied,
5558 None,
5559 )
5560 .await;
5561 assert!(out.contains("capability denied: fs_read"), "got: {out}");
5562
5563 let caveats = caveats_rw(ws.path());
5564 let out = run_tool(
5565 "list_dir",
5566 serde_json::json!({"path": "not-a-dir"}),
5567 ws.path(),
5568 &caveats,
5569 None,
5570 )
5571 .await;
5572 assert!(out.starts_with("error:"), "got: {out}");
5573 }
5574
5575 #[tokio::test]
5576 async fn unknown_tool_name_is_reported_not_executed() {
5577 let ws = tempfile::TempDir::new().unwrap();
5578 let caveats = caveats_rw(ws.path());
5579 let out = run_tool(
5580 "definitely_not_a_tool",
5581 serde_json::json!({}),
5582 ws.path(),
5583 &caveats,
5584 None,
5585 )
5586 .await;
5587 assert!(
5590 out.starts_with("unknown tool: definitely_not_a_tool"),
5591 "got: {out}"
5592 );
5593 assert!(out.contains("Available tools include:"), "got: {out}");
5594 }
5595
5596 #[test]
5599 fn alias_rewrites_shell_names_to_run_command() {
5600 for n in [
5601 "execute",
5602 "exec",
5603 "bash",
5604 "shell",
5605 "sh",
5606 "zsh",
5607 "terminal",
5608 "run_shell_command",
5609 "shell_command",
5610 "system",
5611 ] {
5612 assert!(
5613 matches!(
5614 resolve_tool_alias(n),
5615 Some(AliasOutcome::Rewrite("run_command"))
5616 ),
5617 "{n} should rewrite to run_command"
5618 );
5619 }
5620 }
5621
5622 #[test]
5623 fn alias_corrects_edit_and_create_names() {
5624 for n in [
5625 "str_replace_editor",
5626 "str_replace",
5627 "apply_patch",
5628 "edit",
5629 "replace_in_file",
5630 ] {
5631 let Some(AliasOutcome::Correct(msg)) = resolve_tool_alias(n) else {
5632 panic!("{n} should produce a Correct outcome");
5633 };
5634 assert!(msg.contains("edit_file"), "{n}: {msg}");
5635 assert!(msg.contains("write_file"), "{n}: {msg}");
5636 }
5637 for n in ["create_file", "new_file", "touch"] {
5638 let Some(AliasOutcome::Correct(msg)) = resolve_tool_alias(n) else {
5639 panic!("{n} should produce a Correct outcome");
5640 };
5641 assert!(msg.contains("write_file"), "{n}: {msg}");
5642 }
5643 }
5644
5645 #[test]
5646 fn alias_coaches_mkdir_to_write_file() {
5647 for n in [
5651 "mkdir",
5652 "make_dir",
5653 "makedirs",
5654 "mkdirs",
5655 "create_dir",
5656 "create_directory",
5657 ] {
5658 let Some(AliasOutcome::Correct(msg)) = resolve_tool_alias(n) else {
5659 panic!("{n} should produce a Correct outcome");
5660 };
5661 assert!(msg.contains("write_file"), "{n}: {msg}");
5662 assert!(msg.contains("create_dir_all"), "{n}: {msg}");
5663 }
5664 let Some(AliasOutcome::Correct(msg)) = resolve_tool_alias("touch") else {
5667 panic!("touch should still be a create-file Correct outcome");
5668 };
5669 assert!(msg.contains("write_file"), "touch: {msg}");
5670 }
5671
5672 #[test]
5673 fn alias_passes_through_real_and_mcp_names() {
5674 for n in [
5675 "run_command",
5676 "read_file",
5677 "write_file",
5678 "edit_file",
5679 "git",
5680 "update_plan",
5681 "plan_get",
5682 "server__do_thing",
5683 ] {
5684 assert!(
5685 resolve_tool_alias(n).is_none(),
5686 "{n} must dispatch unchanged"
5687 );
5688 }
5689 }
5690
5691 #[test]
5694 fn alias_corrects_plan_names_to_update_plan() {
5695 for n in [
5696 "enter_plan",
5697 "enter_plan_mode",
5698 "plan_mode",
5699 "start_plan",
5700 "begin_plan",
5701 "make_plan",
5702 "create_plan",
5703 "plan",
5704 "planning",
5705 "todo",
5706 "todos",
5707 "todo_write",
5708 ] {
5709 let Some(AliasOutcome::Correct(msg)) = resolve_tool_alias(n) else {
5710 panic!("{n} should produce a Correct outcome");
5711 };
5712 assert!(msg.contains("update_plan"), "{n}: {msg}");
5713 }
5714 for n in [
5716 "next_step",
5717 "complete_step",
5718 "finish_step",
5719 "mark_done",
5720 "step_done",
5721 ] {
5722 let Some(AliasOutcome::Correct(msg)) = resolve_tool_alias(n) else {
5723 panic!("{n} should produce a Correct outcome");
5724 };
5725 assert!(msg.contains("update_plan"), "{n}: {msg}");
5726 assert!(msg.contains("completed"), "{n}: {msg}");
5727 }
5728 assert!(
5731 resolve_tool_alias("update_plan").is_none(),
5732 "update_plan must dispatch as the real tool, not a self-alias"
5733 );
5734 }
5735
5736 #[test]
5737 fn alias_rewrites_plan_read_names_to_plan_get() {
5738 for n in [
5739 "get_plan",
5740 "show_plan",
5741 "read_plan",
5742 "current_plan",
5743 "what_was_i_doing",
5744 ] {
5745 assert!(
5746 matches!(
5747 resolve_tool_alias(n),
5748 Some(AliasOutcome::Rewrite("plan_get"))
5749 ),
5750 "{n} should rewrite to plan_get"
5751 );
5752 }
5753 }
5754
5755 #[test]
5756 fn alias_rewrites_resume_reaches_to_resume_context() {
5757 for n in [
5760 "resume",
5761 "where_were_we",
5762 "where_did_we_leave_off",
5763 "catch_me_up",
5764 "recap",
5765 ] {
5766 assert!(
5767 matches!(
5768 resolve_tool_alias(n),
5769 Some(AliasOutcome::Rewrite("resume_context"))
5770 ),
5771 "{n} should rewrite to resume_context"
5772 );
5773 }
5774 assert!(
5778 resolve_tool_alias("resume_context").is_none(),
5779 "the real tool name must return None, not a self-Rewrite"
5780 );
5781 assert!(
5783 matches!(
5784 resolve_tool_alias("what_was_i_doing"),
5785 Some(AliasOutcome::Rewrite("plan_get"))
5786 ),
5787 "what_was_i_doing must stay → plan_get"
5788 );
5789 }
5790
5791 #[test]
5792 fn alias_corrects_crew_names_and_flags_team_gating() {
5793 for n in [
5794 "delegate",
5795 "spawn_agent",
5796 "subagent",
5797 "sub_agent",
5798 "crew_dispatch",
5799 "run_crew",
5800 "dispatch_crew",
5801 "fork_agent",
5802 "assign",
5803 "team",
5804 ] {
5805 let Some(AliasOutcome::Correct(msg)) = resolve_tool_alias(n) else {
5806 panic!("{n} should produce a Correct outcome");
5807 };
5808 assert!(msg.contains("compose_roster"), "{n}: {msg}");
5810 assert!(msg.contains("crew"), "{n}: {msg}");
5811 assert!(msg.contains("/team"), "{n}: {msg}");
5813 assert!(
5814 msg.contains("human enables") || msg.contains("cannot turn it on yourself"),
5815 "crew correction must not imply the model can invoke it: {msg}"
5816 );
5817 }
5818 }
5819
5820 #[test]
5821 fn alias_corrects_workflow_names_to_plan_plus_crew() {
5822 for n in ["workflow", "run_workflow", "start_workflow", "pipeline"] {
5823 let Some(AliasOutcome::Correct(msg)) = resolve_tool_alias(n) else {
5824 panic!("{n} should produce a Correct outcome");
5825 };
5826 assert!(msg.contains("no workflow tool"), "{n}: {msg}");
5827 assert!(msg.contains("update_plan"), "{n}: {msg}");
5828 }
5829 }
5830
5831 #[test]
5832 fn levenshtein_matches_known_distances() {
5833 assert_eq!(levenshtein("kitten", "sitting"), 3);
5834 assert_eq!(levenshtein("read_file", "read_file"), 0);
5835 assert_eq!(levenshtein("read_fil", "read_file"), 1);
5836 assert_eq!(levenshtein("", "abc"), 3);
5837 }
5838
5839 #[test]
5840 fn nearest_tool_name_suggests_close_only() {
5841 assert_eq!(nearest_tool_name("read_fil"), Some("read_file"));
5842 assert_eq!(nearest_tool_name("edit_fil"), Some("edit_file"));
5843 assert_eq!(nearest_tool_name("memory_fetchh"), Some("memory_fetch"));
5844 assert_eq!(nearest_tool_name("definitely_not_a_tool"), None);
5845 }
5846
5847 #[test]
5848 fn unknown_tool_message_names_catalog_and_suggestion() {
5849 let m = unknown_tool_message("read_fil");
5850 assert!(m.starts_with("unknown tool: read_fil"), "{m}");
5851 assert!(m.contains("Did you mean 'read_file'"), "{m}");
5852 assert!(m.contains("Available tools include:"), "{m}");
5853
5854 let m2 = unknown_tool_message("zzzzzzzzzzzz");
5855 assert!(m2.starts_with("unknown tool: zzzzzzzzzzzz"), "{m2}");
5856 assert!(!m2.contains("Did you mean"), "{m2}");
5857 assert!(m2.contains("Available tools include:"), "{m2}");
5858 }
5859
5860 #[tokio::test]
5864 async fn execute_tool_corrects_str_replace_editor_alias() {
5865 let ws = tempfile::TempDir::new().unwrap();
5866 let caveats = caveats_rw(ws.path());
5867 let out = run_tool(
5868 "str_replace_editor",
5869 serde_json::json!({"command": "str_replace", "path": "f.txt"}),
5870 ws.path(),
5871 &caveats,
5872 None,
5873 )
5874 .await;
5875 assert!(out.contains("edit_file"), "got: {out}");
5876 assert!(!out.starts_with("unknown tool"), "got: {out}");
5877 }
5878
5879 struct MockGate {
5884 allow: bool,
5885 base: Caveats,
5886 asks: Vec<(String, String)>,
5887 }
5888
5889 impl MockGate {
5890 fn new(allow: bool, base: &Caveats) -> Self {
5891 Self {
5892 allow,
5893 base: base.clone(),
5894 asks: Vec::new(),
5895 }
5896 }
5897 }
5898
5899 impl super::PermissionGate for MockGate {
5900 fn ask(&mut self, requests: &[super::PermissionRequest]) -> super::PermissionDecision {
5901 for r in requests {
5902 self.asks
5903 .push((r.tool.clone(), format!("{}:{}", r.kind.as_str(), r.target)));
5904 }
5905 if self.allow {
5906 let grants: Vec<_> = requests
5907 .iter()
5908 .map(|r| (r.kind, r.target.clone()))
5909 .collect();
5910 super::PermissionDecision::Allow(crate::agentic::widen_caveats(&self.base, &grants))
5911 } else {
5912 super::PermissionDecision::Deny
5913 }
5914 }
5915 fn ask_question(&mut self, _question: &str) -> Option<String> {
5918 None
5919 }
5920 }
5921
5922 #[allow(clippy::too_many_arguments)]
5923 async fn run_tool_gated(
5924 name: &str,
5925 args: serde_json::Value,
5926 ws: &std::path::Path,
5927 caveats: &Caveats,
5928 gate: &mut MockGate,
5929 ) -> String {
5930 execute_tool(
5931 name,
5932 &args,
5933 &ws.to_string_lossy(),
5934 false,
5935 20,
5936 caveats,
5937 &mut NoMcp,
5938 None,
5939 None,
5940 None,
5941 None, Some(gate),
5943 None,
5944 None, None, None, None, None, None, )
5951 .await
5952 }
5953
5954 #[test]
5957 fn exec_denial_is_recoverable_not_a_dead_end() {
5958 let envelope = serde_json::json!({
5964 "denied": true,
5965 "denials": [{
5966 "kind": "exec",
5967 "target": "mkdir",
5968 "reason": "exec of \"mkdir\" is not within the granted authority"
5969 }]
5970 });
5971 let out = denied_run_command_result(&envelope, false);
5972 assert!(out.starts_with("capability denied:"), "got: {out}");
5973 assert!(out.contains("request_permissions"), "got: {out}");
5974 assert!(
5978 !out.contains("extra_exec"),
5979 "the model message must not carry the stale config hint: {out}"
5980 );
5981 }
5982
5983 #[test]
5991 fn run_command_denial_is_single_level_not_nested() {
5992 let envelope = serde_json::json!({
5993 "denied": true,
5994 "denials": [{
5995 "kind": "exec",
5996 "target": "export",
5997 "reason": "exec of \"export\" is not within the granted authority"
5998 }]
5999 });
6000 let out = denied_run_command_result(&envelope, false);
6001 assert_eq!(
6003 out.matches("capability denied:").count(),
6004 1,
6005 "exactly one denial level: {out}"
6006 );
6007 assert!(!out.contains("add it via"), "stale config hint: {out}");
6009 assert!(!out.contains("extra_exec"), "stale config hint: {out}");
6010 assert!(
6012 !out.contains("does not permit 'exec of"),
6013 "nested denial sentence: {out}"
6014 );
6015 assert!(
6017 out.contains("exec of \"export\" is not within the granted authority"),
6018 "got: {out}"
6019 );
6020 assert!(out.contains("request_permissions"), "got: {out}");
6021 }
6022
6023 #[test]
6024 fn parse_capability_maps_synonyms_and_rejects_unknown() {
6025 assert_eq!(parse_capability("exec"), Some(DenialKind::Exec));
6026 assert_eq!(parse_capability("shell"), Some(DenialKind::Exec));
6027 assert_eq!(parse_capability("FS_READ"), Some(DenialKind::FsRead));
6028 assert_eq!(parse_capability("write"), Some(DenialKind::FsWrite));
6029 assert_eq!(parse_capability("network"), Some(DenialKind::Net));
6030 assert_eq!(parse_capability("gpu"), None);
6031 assert_eq!(parse_capability(""), None);
6032 }
6033
6034 #[test]
6035 fn request_permissions_grant_deny_and_no_gate() {
6036 let base = Caveats::top();
6037
6038 let mut gate = MockGate::new(true, &base);
6041 let out = execute_request_permissions(
6042 &serde_json::json!({"capability": "exec", "target": "mkdir", "reason": "make a dir"}),
6043 Some(&mut gate),
6044 false,
6045 20,
6046 );
6047 assert!(out.starts_with("granted:"), "got: {out}");
6048 assert!(out.contains("Retry the original operation"), "got: {out}");
6049 assert_eq!(gate.asks.len(), 1);
6050 assert_eq!(
6051 gate.asks[0],
6052 ("request_permissions".to_string(), "exec:mkdir".to_string())
6053 );
6054
6055 let mut gate = MockGate::new(false, &base);
6057 let out = execute_request_permissions(
6058 &serde_json::json!({"capability": "fs_write", "target": "/tmp/x", "reason": "w"}),
6059 Some(&mut gate),
6060 false,
6061 20,
6062 );
6063 assert!(out.starts_with("denied:"), "got: {out}");
6064 assert!(out.contains("different approach"), "got: {out}");
6065
6066 let out = execute_request_permissions(
6069 &serde_json::json!({"capability": "net", "target": "docs.rs", "reason": "fetch"}),
6070 None,
6071 false,
6072 20,
6073 );
6074 assert!(out.contains("no operator available"), "got: {out}");
6075 }
6076
6077 #[test]
6078 fn request_permissions_coaches_bad_inputs() {
6079 let out = execute_request_permissions(
6081 &serde_json::json!({"capability": "gpu", "target": "x", "reason": "y"}),
6082 None,
6083 false,
6084 20,
6085 );
6086 assert!(out.contains("unknown capability"), "got: {out}");
6087 assert!(out.contains("fs_read"), "got: {out}");
6088 let out = execute_request_permissions(
6090 &serde_json::json!({"capability": "exec", "reason": "y"}),
6091 None,
6092 false,
6093 20,
6094 );
6095 assert!(out.contains("'target' is required"), "got: {out}");
6096 }
6097
6098 #[test]
6099 fn request_permissions_is_a_real_tool_not_a_phantom() {
6100 assert!(resolve_tool_alias("request_permissions").is_none());
6102 assert!(ALL_TOOL_NAMES.contains(&"request_permissions"));
6103 assert!(classify_phantom_reach(
6104 "request_permissions",
6105 &serde_json::json!({"capability": "exec", "target": "mkdir", "reason": "r"}),
6106 "granted: the operator allowed exec for 'mkdir'.",
6107 true,
6108 )
6109 .is_none());
6110 }
6111
6112 struct AskGate {
6117 answer: Option<String>,
6118 asked: Vec<String>,
6119 }
6120 impl AskGate {
6121 fn new(answer: Option<&str>) -> Self {
6122 Self {
6123 answer: answer.map(str::to_string),
6124 asked: Vec::new(),
6125 }
6126 }
6127 }
6128 impl super::PermissionGate for AskGate {
6129 fn ask(&mut self, _requests: &[super::PermissionRequest]) -> super::PermissionDecision {
6130 super::PermissionDecision::Deny
6131 }
6132 fn ask_question(&mut self, question: &str) -> Option<String> {
6133 self.asked.push(question.to_string());
6134 self.answer.clone()
6135 }
6136 }
6137
6138 #[test]
6139 fn request_user_input_returns_the_human_answer() {
6140 let mut gate = AskGate::new(Some("postgres"));
6143 let out = execute_request_user_input(
6144 &serde_json::json!({"question": "which database should I target?"}),
6145 Some(&mut gate),
6146 false,
6147 20,
6148 );
6149 assert_eq!(out, "postgres");
6150 assert_eq!(
6151 gate.asked,
6152 vec!["which database should I target?".to_string()]
6153 );
6154 }
6155
6156 #[test]
6157 fn request_user_input_no_gate_reports_headless_never_hangs() {
6158 let out = execute_request_user_input(
6162 &serde_json::json!({"question": "are you sure?"}),
6163 None,
6164 false,
6165 20,
6166 );
6167 assert_eq!(out, HEADLESS_NO_HUMAN);
6168 assert!(out.contains("no human available"), "got: {out}");
6169 }
6170
6171 #[test]
6172 fn request_user_input_gate_with_no_human_reports_headless() {
6173 let mut gate = AskGate::new(None);
6176 let out = execute_request_user_input(
6177 &serde_json::json!({"question": "pick one"}),
6178 Some(&mut gate),
6179 false,
6180 20,
6181 );
6182 assert_eq!(out, HEADLESS_NO_HUMAN);
6183 }
6184
6185 #[test]
6186 fn request_user_input_requires_a_question() {
6187 let mut gate = AskGate::new(Some("unused"));
6189 let out = execute_request_user_input(
6190 &serde_json::json!({"question": " "}),
6191 Some(&mut gate),
6192 false,
6193 20,
6194 );
6195 assert!(out.contains("'question' is required"), "got: {out}");
6196 assert!(
6197 gate.asked.is_empty(),
6198 "gate not consulted for a blank question"
6199 );
6200 }
6201
6202 #[test]
6203 fn request_user_input_is_a_real_tool_not_a_phantom() {
6204 assert!(resolve_tool_alias("request_user_input").is_none());
6207 assert!(ALL_TOOL_NAMES.contains(&"request_user_input"));
6208 assert!(classify_phantom_reach(
6209 "request_user_input",
6210 &serde_json::json!({"question": "which db?"}),
6211 "postgres",
6212 true,
6213 )
6214 .is_none());
6215 let defs = merged_tool_definitions(
6217 &NoMcp, false, false, false, false, false, false, false, false, false,
6218 );
6219 let names: Vec<&str> = defs
6220 .as_array()
6221 .unwrap()
6222 .iter()
6223 .filter_map(|d| d["function"]["name"].as_str())
6224 .collect();
6225 assert!(names.contains(&"request_user_input"), "got: {names:?}");
6226 }
6227
6228 #[test]
6229 fn ask_verbs_rewrite_to_request_user_input() {
6230 for verb in [
6232 "ask_user",
6233 "ask_human",
6234 "prompt_user",
6235 "get_user_input",
6236 "ask_question",
6237 "clarify",
6238 "ask",
6239 ] {
6240 match resolve_tool_alias(verb) {
6241 Some(AliasOutcome::Rewrite(c)) => {
6242 assert_eq!(c, "request_user_input", "verb: {verb}");
6243 }
6244 _ => panic!("expected Rewrite(request_user_input) for {verb}"),
6245 }
6246 }
6247 }
6248
6249 #[tokio::test]
6250 async fn request_user_input_dispatches_through_execute_tool() {
6251 let ws = tempfile::TempDir::new().unwrap();
6254 let caveats = Caveats::top();
6255 let mut gate = AskGate::new(Some("the answer"));
6256 let out = execute_tool(
6257 "request_user_input",
6258 &serde_json::json!({"question": "what now?"}),
6259 &ws.path().to_string_lossy(),
6260 false,
6261 20,
6262 &caveats,
6263 &mut NoMcp,
6264 None,
6265 None,
6266 None,
6267 None, Some(&mut gate),
6269 None,
6270 None, None, None, None, None, None, )
6277 .await;
6278 assert_eq!(out, "the answer");
6279 assert_eq!(gate.asked, vec!["what now?".to_string()]);
6280 }
6281
6282 #[test]
6283 fn get_context_remaining_is_a_real_tool_not_a_phantom() {
6284 assert!(resolve_tool_alias("get_context_remaining").is_none());
6287 assert!(ALL_TOOL_NAMES.contains(&"get_context_remaining"));
6288 assert!(classify_phantom_reach(
6289 "get_context_remaining",
6290 &serde_json::json!({}),
6291 "Context budget: ~10 tokens used of an input ceiling of ~80 (80% of num_ctx 100).",
6292 true,
6293 )
6294 .is_none());
6295 let defs = merged_tool_definitions(
6297 &NoMcp, false, false, false, false, false, false, false, false, false,
6298 );
6299 assert!(defs
6300 .as_array()
6301 .unwrap()
6302 .iter()
6303 .any(|d| d["function"]["name"] == "get_context_remaining"));
6304 }
6305
6306 #[test]
6307 fn budget_verbs_rewrite_to_get_context_remaining() {
6308 for n in [
6311 "context_remaining",
6312 "tokens_left",
6313 "remaining_tokens",
6314 "budget",
6315 "how_much_context",
6316 "context_budget",
6317 "token_budget",
6318 ] {
6319 assert!(
6320 matches!(
6321 resolve_tool_alias(n),
6322 Some(AliasOutcome::Rewrite("get_context_remaining"))
6323 ),
6324 "{n} must rewrite to get_context_remaining"
6325 );
6326 assert!(
6328 is_context_remaining_call(n),
6329 "{n} must be recognized as a budget call by the loop"
6330 );
6331 }
6332 assert!(is_context_remaining_call("get_context_remaining"));
6334 assert!(resolve_tool_alias("get_context_remaining").is_none());
6335 assert!(!is_context_remaining_call("read_file"));
6337 }
6338
6339 #[tokio::test]
6344 async fn no_gate_denials_are_bit_for_bit_unchanged() {
6345 let ws = tempfile::TempDir::new().unwrap();
6346 std::fs::write(ws.path().join("secret.txt"), "x").unwrap();
6347 let denied = Caveats {
6348 fs_read: Scope::none(),
6349 fs_write: Scope::none(),
6350 ..caveats_rw(ws.path())
6351 };
6352 let out = run_tool(
6353 "read_file",
6354 serde_json::json!({"path": "secret.txt"}),
6355 ws.path(),
6356 &denied,
6357 None,
6358 )
6359 .await;
6360 assert_eq!(out, denied_fs_result("fs_read", "secret.txt"));
6361 let out = run_tool(
6362 "list_dir",
6363 serde_json::json!({"path": "."}),
6364 ws.path(),
6365 &denied,
6366 None,
6367 )
6368 .await;
6369 assert_eq!(out, denied_fs_result("fs_read", "."));
6370 let out = run_tool(
6371 "write_file",
6372 serde_json::json!({"path": "a.txt", "content": "c"}),
6373 ws.path(),
6374 &denied,
6375 None,
6376 )
6377 .await;
6378 assert_eq!(out, denied_fs_result("fs_write", "a.txt"));
6379 let out = run_tool(
6380 "edit_file",
6381 serde_json::json!({"path": "a.txt", "old_string": "a", "new_string": "b"}),
6382 ws.path(),
6383 &denied,
6384 None,
6385 )
6386 .await;
6387 assert_eq!(out, denied_fs_result("fs_write", "a.txt"));
6388 assert!(out.contains("request_permissions"), "got: {out}");
6390 }
6391
6392 #[tokio::test]
6396 async fn gate_allow_turns_fs_read_denial_into_the_real_result() {
6397 let ws = tempfile::TempDir::new().unwrap();
6398 std::fs::write(ws.path().join("secret.txt"), "the contents").unwrap();
6399 let denied = Caveats {
6400 fs_read: Scope::none(),
6401 ..caveats_rw(ws.path())
6402 };
6403 let mut gate = MockGate::new(true, &denied);
6404 let out = run_tool_gated(
6405 "read_file",
6406 serde_json::json!({"path": "secret.txt"}),
6407 ws.path(),
6408 &denied,
6409 &mut gate,
6410 )
6411 .await;
6412 assert_eq!(out, "the contents");
6413 let full = ws.path().join("secret.txt").to_string_lossy().into_owned();
6414 assert_eq!(
6415 gate.asks,
6416 vec![("read_file".to_string(), format!("fs_read:{full}"))]
6417 );
6418 }
6419
6420 #[tokio::test]
6423 async fn gate_deny_keeps_the_standard_denial_bit_for_bit() {
6424 let ws = tempfile::TempDir::new().unwrap();
6425 std::fs::write(ws.path().join("secret.txt"), "x").unwrap();
6426 let denied = Caveats {
6427 fs_read: Scope::none(),
6428 ..caveats_rw(ws.path())
6429 };
6430 let mut gate = MockGate::new(false, &denied);
6431 let gated = run_tool_gated(
6432 "read_file",
6433 serde_json::json!({"path": "secret.txt"}),
6434 ws.path(),
6435 &denied,
6436 &mut gate,
6437 )
6438 .await;
6439 let ungated = run_tool(
6440 "read_file",
6441 serde_json::json!({"path": "secret.txt"}),
6442 ws.path(),
6443 &denied,
6444 None,
6445 )
6446 .await;
6447 assert_eq!(gated, ungated);
6448 assert_eq!(gated, denied_fs_result("fs_read", "secret.txt"));
6449 assert_eq!(gate.asks.len(), 1, "the human was asked exactly once");
6450 }
6451
6452 #[tokio::test]
6454 async fn gate_allow_turns_fs_write_denials_into_real_writes() {
6455 let ws = tempfile::TempDir::new().unwrap();
6456 std::fs::write(ws.path().join("f.txt"), "old\n").unwrap();
6457 let denied = Caveats {
6458 fs_write: Scope::none(),
6459 ..caveats_rw(ws.path())
6460 };
6461 let mut gate = MockGate::new(true, &denied);
6462 let out = run_tool_gated(
6463 "write_file",
6464 serde_json::json!({"path": "new.txt", "content": "fresh"}),
6465 ws.path(),
6466 &denied,
6467 &mut gate,
6468 )
6469 .await;
6470 assert!(out.starts_with("wrote new.txt"), "got: {out}");
6471 assert_eq!(
6472 std::fs::read_to_string(ws.path().join("new.txt")).unwrap(),
6473 "fresh"
6474 );
6475 let out = run_tool_gated(
6476 "edit_file",
6477 serde_json::json!({"path": "f.txt", "old_string": "old", "new_string": "new"}),
6478 ws.path(),
6479 &denied,
6480 &mut gate,
6481 )
6482 .await;
6483 assert!(out.starts_with("edited f.txt"), "got: {out}");
6484 assert_eq!(gate.asks.len(), 2);
6485 assert_eq!(gate.asks[0].0, "write_file");
6486 assert!(
6487 gate.asks[1].1.starts_with("fs_write:"),
6488 "got: {:?}",
6489 gate.asks[1]
6490 );
6491 }
6492
6493 #[tokio::test]
6495 async fn gate_allow_turns_list_dir_denial_into_the_listing() {
6496 let ws = tempfile::TempDir::new().unwrap();
6497 std::fs::write(ws.path().join("seen.txt"), "x").unwrap();
6498 let denied = Caveats {
6499 fs_read: Scope::none(),
6500 ..caveats_rw(ws.path())
6501 };
6502 let mut gate = MockGate::new(true, &denied);
6503 let out = run_tool_gated(
6504 "list_dir",
6505 serde_json::json!({"path": "."}),
6506 ws.path(),
6507 &denied,
6508 &mut gate,
6509 )
6510 .await;
6511 assert!(out.contains("seen.txt"), "got: {out}");
6512 }
6513
6514 #[tokio::test]
6518 async fn gate_allow_without_real_coverage_is_still_denied() {
6519 struct LyingGate;
6520 impl super::PermissionGate for LyingGate {
6521 fn ask(&mut self, _requests: &[super::PermissionRequest]) -> super::PermissionDecision {
6522 super::PermissionDecision::Allow(Caveats {
6524 fs_read: Scope::none(),
6525 fs_write: Scope::none(),
6526 exec: Scope::none(),
6527 net: Scope::none(),
6528 max_calls: CountBound::Unlimited,
6529 valid_for_generation: Scope::All,
6530 })
6531 }
6532 fn ask_question(&mut self, _question: &str) -> Option<String> {
6533 None
6534 }
6535 }
6536 let ws = tempfile::TempDir::new().unwrap();
6537 std::fs::write(ws.path().join("secret.txt"), "x").unwrap();
6538 let denied = Caveats {
6539 fs_read: Scope::none(),
6540 ..caveats_rw(ws.path())
6541 };
6542 let mut gate = LyingGate;
6543 let out = execute_tool(
6544 "read_file",
6545 &serde_json::json!({"path": "secret.txt"}),
6546 &ws.path().to_string_lossy(),
6547 false,
6548 20,
6549 &denied,
6550 &mut NoMcp,
6551 None,
6552 None,
6553 None,
6554 None, Some(&mut gate),
6556 None,
6557 None, None, None, None, None, None, )
6564 .await;
6565 assert_eq!(out, denied_fs_result("fs_read", "secret.txt"));
6566 }
6567
6568 #[tokio::test]
6573 async fn web_fetch_gate_deny_dispatches_under_original_caveats() {
6574 let ws = tempfile::TempDir::new().unwrap();
6575 let caveats = caveats_rw(ws.path()); let mut gate = MockGate::new(false, &caveats);
6577 let out = run_tool_gated(
6578 "web_fetch",
6579 serde_json::json!({"url": "https://denied.example.com:8443/page"}),
6580 ws.path(),
6581 &caveats,
6582 &mut gate,
6583 )
6584 .await;
6585 assert!(out.starts_with("error:"), "leash denial surfaces: {out}");
6586 assert_eq!(
6587 gate.asks,
6588 vec![(
6589 "web_fetch".to_string(),
6590 "net:denied.example.com".to_string()
6591 )]
6592 );
6593 }
6594
6595 #[tokio::test]
6599 async fn web_fetch_github_denial_consults_permission_gate() {
6600 let ws = tempfile::TempDir::new().unwrap();
6601 let caveats = caveats_rw(ws.path()); let mut gate = MockGate::new(false, &caveats);
6603 let out = run_tool_gated(
6604 "web_fetch",
6605 serde_json::json!({"url": "https://github.com/openai/codex"}),
6606 ws.path(),
6607 &caveats,
6608 &mut gate,
6609 )
6610 .await;
6611 assert!(out.starts_with("error:"), "leash denial surfaces: {out}");
6612 assert_eq!(
6613 gate.asks,
6614 vec![("web_fetch".to_string(), "net:github.com".to_string())]
6615 );
6616 }
6617
6618 #[tokio::test]
6621 async fn web_fetch_unparseable_url_never_prompts() {
6622 let ws = tempfile::TempDir::new().unwrap();
6623 let caveats = caveats_rw(ws.path());
6624 let mut gate = MockGate::new(true, &caveats);
6625 let out = run_tool_gated(
6626 "web_fetch",
6627 serde_json::json!({"url": "not-a-url"}),
6628 ws.path(),
6629 &caveats,
6630 &mut gate,
6631 )
6632 .await;
6633 assert!(out.starts_with("error:"), "got: {out}");
6634 assert!(gate.asks.is_empty(), "no prompt for an unparseable URL");
6635 }
6636
6637 #[tokio::test]
6640 async fn save_note_without_sink_is_unknown_tool() {
6641 let ws = tempfile::TempDir::new().unwrap();
6642 let caveats = caveats_rw(ws.path());
6643 let out = run_tool(
6645 "save_note",
6646 serde_json::json!({"action": "add", "text": "a fact"}),
6647 ws.path(),
6648 &caveats,
6649 None,
6650 )
6651 .await;
6652 assert!(out.starts_with("unknown tool: save_note"), "got: {out}");
6653 }
6654
6655 #[tokio::test]
6656 async fn save_note_with_sink_routes_through_execute_tool() {
6657 use crate::agentic::note_sink::tests::MockSink;
6658 let ws = tempfile::TempDir::new().unwrap();
6659 let caveats = caveats_rw(ws.path());
6660 let mut sink = MockSink::default();
6661 let out = execute_tool(
6662 "save_note",
6663 &serde_json::json!({"action": "add", "text": "workspace builds with just check"}),
6664 &ws.path().to_string_lossy(),
6665 false,
6666 20,
6667 &caveats,
6668 &mut NoMcp,
6669 None,
6670 Some(&mut sink),
6671 None,
6672 None, None,
6674 None,
6675 None, None, None, None, None, None, )
6682 .await;
6683 assert_eq!(sink.calls, vec!["add:workspace builds with just check"]);
6684 assert!(
6685 out.starts_with("note saved: workspace builds"),
6686 "got: {out}"
6687 );
6688 }
6689
6690 #[tokio::test]
6693 async fn recall_without_source_is_unknown_tool() {
6694 let ws = tempfile::TempDir::new().unwrap();
6695 let caveats = caveats_rw(ws.path());
6696 let out = run_tool(
6698 "recall",
6699 serde_json::json!({"query": "tokio panic"}),
6700 ws.path(),
6701 &caveats,
6702 None,
6703 )
6704 .await;
6705 assert!(out.starts_with("unknown tool: recall"), "got: {out}");
6706 }
6707
6708 #[tokio::test]
6709 async fn recall_with_source_routes_through_execute_tool() {
6710 use crate::agentic::recall::tests::{hit, MockSource};
6711 let ws = tempfile::TempDir::new().unwrap();
6712 let caveats = caveats_rw(ws.path());
6713 let source = MockSource {
6714 hits: vec![hit(
6715 "123456789012-abcd",
6716 "past work",
6717 3,
6718 ">>>tokio<<< panic",
6719 )],
6720 ..Default::default()
6721 };
6722 let out = execute_tool(
6723 "recall",
6724 &serde_json::json!({"query": "tokio panic"}),
6725 &ws.path().to_string_lossy(),
6726 false,
6727 20,
6728 &caveats,
6729 &mut NoMcp,
6730 None,
6731 None,
6732 Some(&source),
6733 None, None,
6735 None,
6736 None, None, None, None, None, None, )
6743 .await;
6744 assert_eq!(
6745 *source.calls.lock().unwrap(),
6746 vec![("tokio panic".to_string(), 5)]
6747 );
6748 assert!(out.contains("«tokio» panic"), "got: {out}");
6749 assert!(out.contains("past work"), "got: {out}");
6750 }
6751
6752 #[tokio::test]
6758 async fn memory_fetch_without_source_is_unknown_tool() {
6759 let ws = tempfile::TempDir::new().unwrap();
6760 let caveats = caveats_rw(ws.path());
6761 let out = run_tool(
6763 "memory_fetch",
6764 serde_json::json!({"address": "note:1"}),
6765 ws.path(),
6766 &caveats,
6767 None,
6768 )
6769 .await;
6770 assert!(out.starts_with("unknown tool: memory_fetch"), "got: {out}");
6771 }
6772
6773 #[tokio::test]
6777 async fn memory_fetch_with_source_routes_through_execute_tool() {
6778 use crate::agentic::memory_fetch::tests::MockSource;
6779 use crate::agentic::MemAddr;
6780 let ws = tempfile::TempDir::new().unwrap();
6781 let caveats = caveats_rw(ws.path());
6782 let source = MockSource {
6783 body: Some("the exact note body".to_string()),
6784 ..Default::default()
6785 };
6786 let out = execute_tool(
6787 "memory_fetch",
6788 &serde_json::json!({"address": "note:1"}),
6789 &ws.path().to_string_lossy(),
6790 false,
6791 20,
6792 &caveats,
6793 &mut NoMcp,
6794 None,
6795 None,
6796 None,
6797 Some(&source),
6798 None,
6799 None,
6800 None, None, None, None, None, None, )
6807 .await;
6808 assert_eq!(out, "the exact note body");
6809 assert_eq!(
6810 *source.calls.lock().unwrap(),
6811 vec![MemAddr::Note { id: "1".into() }]
6812 );
6813 }
6814}
6815
6816#[cfg(test)]
6823mod disable_ocap_tests {
6824 use super::super::NoMcp;
6825 use super::*;
6826 use crate::caveats::{Caveats, CountBound, Scope};
6827 use tokio::sync::{Mutex, MutexGuard};
6828
6829 static ENV_LOCK: Mutex<()> = Mutex::const_new(());
6835
6836 async fn env_lock() -> MutexGuard<'static, ()> {
6837 ENV_LOCK.lock().await
6838 }
6839
6840 struct EnvVar {
6844 key: &'static str,
6845 saved: Option<String>,
6846 }
6847
6848 impl EnvVar {
6849 fn set(key: &'static str, value: &str) -> Self {
6850 let saved = std::env::var(key).ok();
6851 std::env::set_var(key, value);
6852 Self { key, saved }
6853 }
6854
6855 fn unset(key: &'static str) -> Self {
6856 let saved = std::env::var(key).ok();
6857 std::env::remove_var(key);
6858 Self { key, saved }
6859 }
6860 }
6861
6862 impl Drop for EnvVar {
6863 fn drop(&mut self) {
6864 match self.saved.take() {
6865 Some(v) => std::env::set_var(self.key, v),
6866 None => std::env::remove_var(self.key),
6867 }
6868 }
6869 }
6870
6871 fn caveats_no_exec(ws: &std::path::Path) -> Caveats {
6874 Caveats {
6875 fs_read: Scope::only([ws.to_string_lossy().into_owned()]),
6876 fs_write: Scope::only([ws.to_string_lossy().into_owned()]),
6877 exec: Scope::none(),
6878 net: Scope::none(),
6879 max_calls: CountBound::Unlimited,
6880 valid_for_generation: Scope::All,
6881 }
6882 }
6883
6884 async fn run_tool(
6885 name: &str,
6886 args: serde_json::Value,
6887 ws: &std::path::Path,
6888 caveats: &Caveats,
6889 ) -> String {
6890 run_tool_with_floor(name, args, ws, caveats, None).await
6891 }
6892
6893 async fn run_tool_with_floor(
6898 name: &str,
6899 args: serde_json::Value,
6900 ws: &std::path::Path,
6901 caveats: &Caveats,
6902 exec_floor: Option<&Scope<String>>,
6903 ) -> String {
6904 execute_tool(
6905 name,
6906 &args,
6907 &ws.to_string_lossy(),
6908 false,
6909 20,
6910 caveats,
6911 &mut NoMcp,
6912 None,
6913 None,
6914 None,
6915 None, None,
6917 exec_floor,
6918 None, None, None, None, None, None, )
6925 .await
6926 }
6927
6928 #[test]
6933 fn ocap_disabled_requires_exactly_1() {
6934 let _l = ENV_LOCK.blocking_lock();
6935 {
6936 let _unset = EnvVar::unset("NEWT_DISABLE_OCAP");
6937 assert!(!ocap_disabled(), "absent ⇒ confinement stays on");
6938 }
6939 for (value, expected) in [
6940 ("1", true),
6941 ("0", false),
6942 ("", false),
6943 ("true", false),
6944 ("yes", false),
6945 ("YOLO", false),
6946 ] {
6947 let _set = EnvVar::set("NEWT_DISABLE_OCAP", value);
6948 assert_eq!(
6949 ocap_disabled(),
6950 expected,
6951 "NEWT_DISABLE_OCAP={value:?} must read as {expected}"
6952 );
6953 }
6954 }
6955
6956 #[test]
6960 fn full_access_requested_requires_exactly_1() {
6961 let _l = ENV_LOCK.blocking_lock();
6962 {
6963 let _unset = EnvVar::unset("NEWT_FULL_ACCESS");
6964 assert!(!full_access_requested(), "absent ⇒ configured preset rules");
6965 }
6966 for (value, expected) in [
6967 ("1", true),
6968 ("0", false),
6969 ("", false),
6970 ("true", false),
6971 ("yes", false),
6972 ("FULL", false),
6973 ] {
6974 let _set = EnvVar::set("NEWT_FULL_ACCESS", value);
6975 assert_eq!(
6976 full_access_requested(),
6977 expected,
6978 "NEWT_FULL_ACCESS={value:?} must read as {expected}"
6979 );
6980 }
6981 }
6982
6983 #[tokio::test]
6990 async fn flag_off_run_command_keeps_the_confined_dispatch_verbatim() {
6991 let _l = env_lock().await;
6992 let _off = EnvVar::unset("NEWT_DISABLE_OCAP");
6993 let ws = tempfile::TempDir::new().unwrap();
6994 let caveats = caveats_no_exec(ws.path());
6995 let out = run_tool(
6996 "run_command",
6997 serde_json::json!({"command": "echo hi"}),
6998 ws.path(),
6999 &caveats,
7000 )
7001 .await;
7002 assert!(
7003 out.contains("capability denied"),
7004 "flag off ⇒ the confined dispatch must govern (deny) the command, got: {out}"
7005 );
7006 }
7007
7008 #[cfg(unix)]
7012 #[tokio::test]
7013 async fn yolo_runs_the_denied_command_on_the_host_shell() {
7014 let _l = env_lock().await;
7015 let _on = EnvVar::set("NEWT_DISABLE_OCAP", "1");
7016 let ws = tempfile::TempDir::new().unwrap();
7017 let caveats = caveats_no_exec(ws.path());
7018 let out = run_tool(
7019 "run_command",
7020 serde_json::json!({"command": "echo yolo-ok"}),
7021 ws.path(),
7022 &caveats,
7023 )
7024 .await;
7025 assert_eq!(out, "yolo-ok\n");
7026
7027 let out = run_tool(
7029 "run_command",
7030 serde_json::json!({"command": "exit 3"}),
7031 ws.path(),
7032 &caveats,
7033 )
7034 .await;
7035 assert_eq!(out, "(exit 3)");
7036 }
7037
7038 #[cfg(unix)]
7050 #[tokio::test]
7051 async fn run_command_output_over_budget_is_token_capped() {
7052 let _l = env_lock().await;
7053 let _on = EnvVar::set("NEWT_DISABLE_OCAP", "1");
7054 let ws = tempfile::TempDir::new().unwrap();
7055 let caveats = caveats_no_exec(ws.path());
7056 let out = run_tool(
7058 "run_command",
7059 serde_json::json!({"command": "seq 1 60000"}),
7060 ws.path(),
7061 &caveats,
7062 )
7063 .await;
7064 assert!(
7065 out.len() < 41_500,
7066 "model-facing output capped near the ~40k-char budget, got {} bytes",
7067 out.len()
7068 );
7069 assert!(
7070 out.contains("chars elided (head+tail shown"),
7071 "carries the head+tail elision marker: {:?}",
7072 &out[..out.len().min(400)]
7073 );
7074 assert!(
7076 out.starts_with("1\n2\n3\n"),
7077 "head preserved: {:?}",
7078 &out[..out.len().min(160)]
7079 );
7080 assert!(
7084 out.trim_end().ends_with("60000"),
7085 "tail preserved, not dropped by the cap: {:?}",
7086 &out[out.len().saturating_sub(160)..]
7087 );
7088 }
7089
7090 #[test]
7094 fn exec_floor_permits_covers_each_branch() {
7095 use crate::caveats::Scope;
7096 assert!(exec_floor_permits(None, "rm -rf /"));
7098 let only_echo = Scope::only(["echo".to_string()]);
7100 assert!(exec_floor_permits(Some(&only_echo), ""));
7101 assert!(exec_floor_permits(Some(&only_echo), "echo hi"));
7103 assert!(!exec_floor_permits(Some(&only_echo), "rm hi"));
7105 assert!(!exec_floor_permits(Some(&only_echo), "echo hi && rm x"));
7107 assert!(!exec_floor_permits(Some(&only_echo), "echo a | tee b"));
7108 assert!(!exec_floor_permits(Some(&only_echo), "echo $(rm x)"));
7109 let all: Scope<String> = Scope::All;
7111 assert!(exec_floor_permits(Some(&all), "anything goes"));
7112 assert!(!exec_floor_permits(Some(&all), "anything; sneaky"));
7113 }
7114
7115 #[test]
7120 fn exec_floor_refuses_every_metacharacter_form() {
7121 use crate::caveats::Scope;
7122 let echo = Scope::only(["echo".to_string()]);
7123 let attacks = [
7126 "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", ];
7143 for a in attacks {
7144 assert!(
7145 !exec_floor_permits(Some(&echo), a),
7146 "metacharacter form must NOT bypass the floor: {a:?}"
7147 );
7148 }
7149 let leading_token_attacks = [
7152 "rm -rf /tmp/x", "FOO=bar rm x", "/bin/echo ok", " rm x", "env rm x", "bash -c rm", ];
7159 for a in leading_token_attacks {
7160 assert!(
7161 !exec_floor_permits(Some(&echo), a),
7162 "out-of-floor leading token must be refused: {a:?}"
7163 );
7164 }
7165 assert!(exec_floor_permits(Some(&echo), "echo hello world"));
7169 assert!(exec_floor_permits(Some(&echo), "echo -n trailing"));
7170 }
7171
7172 #[tokio::test]
7179 async fn floor_blocks_disable_ocap_for_a_denied_exec() {
7180 let _l = env_lock().await;
7181 let _on = EnvVar::set("NEWT_DISABLE_OCAP", "1");
7182 let ws = tempfile::TempDir::new().unwrap();
7183 let caveats = caveats_no_exec(ws.path());
7184 let floor = crate::NamedPermissionPreset {
7186 readonly: true,
7187 ..Default::default()
7188 }
7189 .clamp();
7190 let out = run_tool_with_floor(
7191 "run_command",
7192 serde_json::json!({"command": "echo should-not-run"}),
7193 ws.path(),
7194 &caveats,
7195 Some(&floor.exec),
7196 )
7197 .await;
7198 assert_ne!(out, "should-not-run\n", "the floor must block the bypass");
7201 assert!(
7202 out.contains("capability denied"),
7203 "fell to confined dispatch and was denied, got: {out}"
7204 );
7205 }
7206
7207 #[cfg(unix)]
7211 #[tokio::test]
7212 async fn floor_allows_disable_ocap_for_an_in_floor_exec() {
7213 let _l = env_lock().await;
7214 let _on = EnvVar::set("NEWT_DISABLE_OCAP", "1");
7215 let ws = tempfile::TempDir::new().unwrap();
7216 let caveats = caveats_no_exec(ws.path());
7217 let floor = crate::NamedPermissionPreset {
7219 readonly: true,
7220 exec_allow: vec!["echo".to_string()],
7221 ..Default::default()
7222 }
7223 .clamp();
7224 let out = run_tool_with_floor(
7225 "run_command",
7226 serde_json::json!({"command": "echo in-floor-ok"}),
7227 ws.path(),
7228 &caveats,
7229 Some(&floor.exec),
7230 )
7231 .await;
7232 assert_eq!(out, "in-floor-ok\n", "in-floor command runs unconfined");
7233 }
7234
7235 #[tokio::test]
7240 async fn floor_refuses_bypass_for_a_compound_command() {
7241 let _l = env_lock().await;
7242 let _on = EnvVar::set("NEWT_DISABLE_OCAP", "1");
7243 let ws = tempfile::TempDir::new().unwrap();
7244 let caveats = caveats_no_exec(ws.path());
7245 let floor = crate::NamedPermissionPreset {
7247 readonly: true,
7248 exec_allow: vec!["echo".to_string()],
7249 ..Default::default()
7250 }
7251 .clamp();
7252 let out = run_tool_with_floor(
7253 "run_command",
7254 serde_json::json!({"command": "echo ok && rm -rf /tmp/x"}),
7255 ws.path(),
7256 &caveats,
7257 Some(&floor.exec),
7258 )
7259 .await;
7260 assert_ne!(out, "ok\n", "a compound command must not bypass the floor");
7261 assert!(
7264 out.contains("capability denied"),
7265 "fell to confined dispatch and was denied, got: {out}"
7266 );
7267 }
7268
7269 #[cfg(unix)]
7273 #[tokio::test]
7274 async fn no_floor_keeps_disable_ocap_bit_for_bit() {
7275 let _l = env_lock().await;
7276 let _on = EnvVar::set("NEWT_DISABLE_OCAP", "1");
7277 let ws = tempfile::TempDir::new().unwrap();
7278 let caveats = caveats_no_exec(ws.path());
7279 let out = run_tool_with_floor(
7280 "run_command",
7281 serde_json::json!({"command": "echo no-floor-ok"}),
7282 ws.path(),
7283 &caveats,
7284 None,
7285 )
7286 .await;
7287 assert_eq!(out, "no-floor-ok\n", "no floor ⇒ bypass unchanged");
7288 }
7289
7290 #[cfg(unix)]
7295 #[tokio::test]
7296 async fn host_shell_envelope_matches_the_bridle_shape() {
7297 let ws = tempfile::TempDir::new().unwrap();
7298 let envelope = host_shell_dispatch(
7299 "echo out; echo err >&2; exit 3",
7300 &ws.path().to_string_lossy(),
7301 )
7302 .await
7303 .expect("host shell runs");
7304 assert_eq!(envelope["exit_code"], 3);
7305 assert_eq!(envelope["stdout"], "out\n");
7306 assert_eq!(envelope["stderr"], "err\n");
7307 assert_eq!(envelope["sandbox_kind"], "none");
7308 assert!(envelope.get("denied").is_none(), "got: {envelope}");
7311 assert!(envelope.get("denials").is_none(), "got: {envelope}");
7312 assert!(!envelope_denied(&envelope));
7313 assert_eq!(
7315 shell_envelope_output(&envelope, 20, false, false, None),
7316 "out\nerr\n"
7317 );
7318 }
7319
7320 #[test]
7321 fn decode_shell_stream_preserves_valid_utf8() {
7322 let text = "// ── Model — test ──\n";
7323 assert_eq!(decode_shell_stream(text.as_bytes()), text);
7324 }
7325
7326 #[test]
7327 fn decode_shell_stream_repairs_bsd_cat_v_utf8_notation() {
7328 let cat_v = b"\xe2M-^TM-^@ \xe2M-^@M-^T\n";
7333 assert_eq!(decode_shell_stream(cat_v), "─ —\n");
7334 }
7335
7336 #[test]
7337 fn decode_shell_stream_repairs_two_byte_bsd_cat_v_notation() {
7338 let cat_v = b"caf\xc3M-)\n";
7340 assert_eq!(decode_shell_stream(cat_v), "café\n");
7341 }
7342
7343 #[cfg(unix)]
7349 #[tokio::test]
7350 async fn yolo_keeps_the_venv_prefix_logic() {
7351 let _l = env_lock().await;
7352 let _on = EnvVar::set("NEWT_DISABLE_OCAP", "1");
7353 let _venv = EnvVar::set("NEWT_VENV", "/opt/fake-venv");
7354 let _virtual = EnvVar::unset("VIRTUAL_ENV");
7355 let _paths = EnvVar::unset("NEWT_EXEC_PATHS");
7356 let ws = tempfile::TempDir::new().unwrap();
7357 let caveats = caveats_no_exec(ws.path());
7358 let out = run_tool(
7359 "run_command",
7360 serde_json::json!({"command": "echo \"$VIRTUAL_ENV\""}),
7361 ws.path(),
7362 &caveats,
7363 )
7364 .await;
7365 assert_eq!(out, "/opt/fake-venv\n");
7366 }
7367
7368 #[tokio::test]
7375 async fn confined_dispatch_uses_env_seam_not_export_prefix_783() {
7376 let _l = env_lock().await;
7377 let _venv = EnvVar::set("NEWT_VENV", "/opt/fake-venv");
7378 let _virtual = EnvVar::unset("VIRTUAL_ENV");
7379 let _paths = EnvVar::unset("NEWT_EXEC_PATHS");
7380
7381 let cmd = "hostname; sw_vers 2>/dev/null | head -1; uname -s";
7383 let args = confined_dispatch_args(cmd, "/work/dir");
7384
7385 assert_eq!(args["cmd"], cmd);
7387 assert!(
7388 !args["cmd"]
7389 .as_str()
7390 .expect("cmd is a string")
7391 .contains("export "),
7392 "confined cmd must not carry an export prefix: {args}"
7393 );
7394 assert_eq!(args["cwd"], "/work/dir");
7395
7396 assert_eq!(args["env"]["VIRTUAL_ENV"], "/opt/fake-venv");
7398 let path = args["env"]["PATH"].as_str().expect("PATH in the env seam");
7399 assert!(
7400 path.starts_with("/opt/fake-venv/bin"),
7401 "venv bin must be prepended to PATH: {path}"
7402 );
7403 }
7404
7405 #[tokio::test]
7408 async fn confined_dispatch_env_seam_empty_without_venv_783() {
7409 let _l = env_lock().await;
7410 let _venv = EnvVar::unset("NEWT_VENV");
7411 let _virtual = EnvVar::unset("VIRTUAL_ENV");
7412 let _paths = EnvVar::unset("NEWT_EXEC_PATHS");
7413
7414 let args = confined_dispatch_args("ls -la", "/work/dir");
7415 assert_eq!(args["cmd"], "ls -la");
7416 assert_eq!(
7417 args["env"],
7418 serde_json::json!({}),
7419 "no venv inputs ⇒ empty env map: {args}"
7420 );
7421 }
7422
7423 #[tokio::test]
7427 async fn yolo_keeps_the_fs_workspace_fence() {
7428 let _l = env_lock().await;
7429 let _on = EnvVar::set("NEWT_DISABLE_OCAP", "1");
7430 let ws = tempfile::TempDir::new().unwrap();
7431 let caveats = caveats_no_exec(ws.path());
7432 let escape = "/definitely-outside-the-fence/escape.txt";
7433 let out = run_tool(
7434 "write_file",
7435 serde_json::json!({"path": escape, "content": "nope"}),
7436 ws.path(),
7437 &caveats,
7438 )
7439 .await;
7440 assert_eq!(out, denied_fs_result("fs_write", escape));
7441 assert!(!std::path::Path::new(escape).exists());
7442
7443 let out = run_tool(
7444 "read_file",
7445 serde_json::json!({"path": "/etc/hostname"}),
7446 ws.path(),
7447 &caveats,
7448 )
7449 .await;
7450 assert_eq!(out, denied_fs_result("fs_read", "/etc/hostname"));
7451 }
7452
7453 #[cfg(unix)]
7458 #[tokio::test]
7459 async fn yolo_never_consults_the_permission_gate_for_exec() {
7460 struct PanicGate;
7461 impl super::PermissionGate for PanicGate {
7462 fn ask(&mut self, requests: &[super::PermissionRequest]) -> super::PermissionDecision {
7463 panic!("yolo exec must never prompt, but the gate was asked: {requests:?}");
7464 }
7465 fn ask_question(&mut self, question: &str) -> Option<String> {
7466 panic!("yolo exec must never prompt, but the gate was asked: {question:?}");
7467 }
7468 }
7469 let _l = env_lock().await;
7470 let _on = EnvVar::set("NEWT_DISABLE_OCAP", "1");
7471 let ws = tempfile::TempDir::new().unwrap();
7472 let caveats = caveats_no_exec(ws.path());
7473 let mut gate = PanicGate;
7474 let out = execute_tool(
7475 "run_command",
7476 &serde_json::json!({"command": "echo no-prompt"}),
7477 &ws.path().to_string_lossy(),
7478 false,
7479 20,
7480 &caveats,
7481 &mut NoMcp,
7482 None,
7483 None,
7484 None,
7485 None, Some(&mut gate),
7487 None,
7488 None, None, None, None, None, None, )
7495 .await;
7496 assert_eq!(out, "no-prompt\n");
7497 }
7498
7499 #[tokio::test]
7502 async fn yolo_keeps_the_tool_name_corrective_guard() {
7503 let _l = env_lock().await;
7504 let _on = EnvVar::set("NEWT_DISABLE_OCAP", "1");
7505 let ws = tempfile::TempDir::new().unwrap();
7506 let caveats = caveats_no_exec(ws.path());
7507 let out = run_tool(
7508 "run_command",
7509 serde_json::json!({"command": "read_file foo.txt"}),
7510 ws.path(),
7511 &caveats,
7512 )
7513 .await;
7514 assert!(out.contains("is a tool, not a shell command"), "got: {out}");
7515 }
7516
7517 struct RoutingStubGit;
7523 impl crate::agentic::GitTool for RoutingStubGit {
7524 fn dispatch(
7525 &self,
7526 op: &str,
7527 _args: &serde_json::Value,
7528 _caps: &crate::git_caveats::GitCaveats,
7529 ) -> Result<String, String> {
7530 match op {
7531 "status" => Ok("on branch main (routed via git built-in)".to_string()),
7532 other => Err(format!("unexpected routed git op '{other}'")),
7533 }
7534 }
7535 }
7536
7537 async fn run_routed_with_git(command: &str, ws: &std::path::Path, caveats: &Caveats) -> String {
7538 execute_tool(
7539 "run_command",
7540 &serde_json::json!({ "command": command }),
7541 &ws.to_string_lossy(),
7542 false,
7543 20,
7544 caveats,
7545 &mut NoMcp,
7546 None,
7547 None,
7548 None,
7549 None, None, None, Some(&RoutingStubGit as &dyn crate::agentic::GitTool),
7553 None, None, None, None, None, )
7559 .await
7560 }
7561
7562 #[test]
7567 fn routing_disabled_requires_exactly_1_and_is_independent_of_ocap() {
7568 let _l = ENV_LOCK.blocking_lock();
7569 let _no_ocap = EnvVar::unset("NEWT_DISABLE_OCAP");
7570 {
7571 let _unset = EnvVar::unset("NEWT_NO_ROUTE");
7572 assert!(!routing_disabled(), "absent ⇒ routing stays on");
7573 }
7574 for (value, expected) in [("1", true), ("0", false), ("", false), ("true", false)] {
7575 let _set = EnvVar::set("NEWT_NO_ROUTE", value);
7576 assert_eq!(routing_disabled(), expected, "NEWT_NO_ROUTE={value:?}");
7577 assert!(
7579 !ocap_disabled(),
7580 "NEWT_NO_ROUTE must not imply --disable-ocap"
7581 );
7582 }
7583 let _unset_route = EnvVar::unset("NEWT_NO_ROUTE");
7585 let _on_ocap = EnvVar::set("NEWT_DISABLE_OCAP", "1");
7586 assert!(ocap_disabled());
7587 assert!(
7588 !routing_disabled(),
7589 "--disable-ocap must not imply --no-route"
7590 );
7591 }
7592
7593 #[tokio::test]
7599 async fn routed_cat_goes_through_the_fs_floor_not_a_bypass() {
7600 let _l = env_lock().await;
7601 let _route_on = EnvVar::unset("NEWT_NO_ROUTE");
7602 let _ocap_off = EnvVar::unset("NEWT_DISABLE_OCAP");
7603 let ws = tempfile::TempDir::new().unwrap();
7604 let caveats = caveats_no_exec(ws.path()); let out = run_tool(
7606 "run_command",
7607 serde_json::json!({ "command": "cat /etc/shadow" }),
7608 ws.path(),
7609 &caveats,
7610 )
7611 .await;
7612 assert!(
7613 out.contains("capability denied: fs_read does not permit")
7614 && out.contains("/etc/shadow"),
7615 "routed cat must hit the fs floor, not run unconfined; got: {out}"
7616 );
7617 }
7618
7619 #[tokio::test]
7624 async fn routed_git_status_dispatches_through_the_git_builtin() {
7625 let _l = env_lock().await;
7626 let _route_on = EnvVar::unset("NEWT_NO_ROUTE");
7627 let _ocap_off = EnvVar::unset("NEWT_DISABLE_OCAP");
7628 let ws = tempfile::TempDir::new().unwrap();
7629 let caveats = caveats_no_exec(ws.path());
7630 let out = run_routed_with_git("git status", ws.path(), &caveats).await;
7631 assert!(
7632 out.contains("routed via git built-in"),
7633 "git status must route to the governed git built-in; got: {out}"
7634 );
7635 }
7636
7637 #[tokio::test]
7641 async fn state_modifying_git_add_is_not_routed() {
7642 let _l = env_lock().await;
7643 let _route_on = EnvVar::unset("NEWT_NO_ROUTE");
7644 let _ocap_off = EnvVar::unset("NEWT_DISABLE_OCAP");
7645 let ws = tempfile::TempDir::new().unwrap();
7646 let caveats = caveats_no_exec(ws.path());
7647 let out = run_routed_with_git("git add a.txt", ws.path(), &caveats).await;
7648 assert!(
7649 !out.contains("routed"),
7650 "git add must NOT route to the git built-in; got: {out}"
7651 );
7652 assert!(out.contains("is a tool, not a shell command"), "got: {out}");
7655 }
7656
7657 #[tokio::test]
7663 async fn no_route_bypasses_routing_but_keeps_l3() {
7664 let _l = env_lock().await;
7665 let _route_off = EnvVar::set("NEWT_NO_ROUTE", "1");
7666 let _ocap_off = EnvVar::unset("NEWT_DISABLE_OCAP");
7667 assert!(routing_disabled() && !ocap_disabled(), "L2 off, L3 on");
7668 let ws = tempfile::TempDir::new().unwrap();
7669 let caveats = caveats_no_exec(ws.path());
7670 let out = run_tool(
7671 "run_command",
7672 serde_json::json!({ "command": "cat /etc/shadow" }),
7673 ws.path(),
7674 &caveats,
7675 )
7676 .await;
7677 assert!(
7679 !out.contains("fs_read does not permit"),
7680 "--no-route must not route to read_file; got: {out}"
7681 );
7682 assert!(
7685 out.contains("capability denied"),
7686 "the L3 confined dispatch must still gate the command; got: {out}"
7687 );
7688 }
7689}