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::report::{execute_render_report, render_report_tool_definition};
14use super::spill::{self, SpillStore};
15use crate::caveats::CaveatsExt as _;
16use std::sync::atomic::{AtomicUsize, Ordering};
17use std::sync::LazyLock;
18
19const DEFAULT_READ_LIMIT: usize = 2_000;
24
25const DEFAULT_MAX_OUTPUT_TOKENS: usize = 10_000;
33const DEFAULT_OUTPUT_HEAD_TOKENS: usize = 1_500;
34
35static MAX_OUTPUT_TOKENS: AtomicUsize = AtomicUsize::new(DEFAULT_MAX_OUTPUT_TOKENS);
44static OUTPUT_HEAD_TOKENS: AtomicUsize = AtomicUsize::new(DEFAULT_OUTPUT_HEAD_TOKENS);
45
46pub fn set_max_output_tokens(max_tokens: usize) {
50 MAX_OUTPUT_TOKENS.store(max_tokens, Ordering::Relaxed);
51}
52
53pub fn set_output_head_tokens(head_tokens: usize) {
57 OUTPUT_HEAD_TOKENS.store(head_tokens, Ordering::Relaxed);
58}
59
60fn max_output_tokens() -> usize {
63 MAX_OUTPUT_TOKENS.load(Ordering::Relaxed)
64}
65
66fn output_head_tokens() -> usize {
67 OUTPUT_HEAD_TOKENS.load(Ordering::Relaxed)
68}
69
70fn cap_model_output(text: &str, max_tokens: usize) -> String {
78 cap_model_output_with_handle(text, max_tokens, output_head_tokens(), None)
79}
80
81fn cap_model_output_with_handle(
82 text: &str,
83 max_tokens: usize,
84 head_tokens: usize,
85 spill_id: Option<&str>,
86) -> String {
87 if max_tokens == 0 {
88 return text.to_string();
89 }
90 let est = crate::tokens::TokenEstimation::default();
91 if est.tokens_for_chars(text.len()) <= max_tokens {
92 return text.to_string();
93 }
94 let max_chars = est.chars_for_tokens(max_tokens);
95 let head_tokens = head_tokens.min(max_tokens);
96 let head_chars = est.chars_for_tokens(head_tokens).min(max_chars);
97 let tail_chars = max_chars.saturating_sub(head_chars);
98 let total_chars = text.chars().count();
99 let shown_chars = head_chars.saturating_add(tail_chars).min(total_chars);
100 let elided = total_chars.saturating_sub(shown_chars);
101 let head = take_chars(text, head_chars);
102 let tail = take_tail_chars(text, tail_chars);
103 let marker = match spill_id {
104 Some(id) => format!(
105 "[… {elided} chars elided (head+tail shown). Full output: \
106 memory_fetch(\"spill:{id}\"); search it with \
107 memory_fetch(\"spill:{id}\", grep=\"<pattern>\") …]"
108 ),
109 None => format!(
110 "[… {elided} chars elided (head+tail shown; ~{max_tokens} token budget). \
111 Narrow the command or use a more specific grep/filter if needed …]"
112 ),
113 };
114 format!("{head}\n\n{marker}\n\n{tail}")
115}
116
117fn take_chars(text: &str, max_chars: usize) -> String {
118 text.chars().take(max_chars).collect()
119}
120
121fn take_tail_chars(text: &str, max_chars: usize) -> String {
122 let mut chars: Vec<char> = text.chars().rev().take(max_chars).collect();
123 chars.reverse();
124 chars.into_iter().collect()
125}
126
127fn paginate_read(
137 contents: &str,
138 offset: Option<usize>,
139 limit: Option<usize>,
140 max_output_tokens: usize,
141) -> String {
142 let max_chars = if max_output_tokens == 0 {
143 usize::MAX
144 } else {
145 crate::tokens::TokenEstimation::default().chars_for_tokens(max_output_tokens)
146 };
147 let total = contents.lines().count();
148 let start = offset.filter(|&o| o > 0).unwrap_or(1); let limit = limit.filter(|&l| l > 0).unwrap_or(DEFAULT_READ_LIMIT);
150 if start == 1 && limit >= total && contents.len() <= max_chars {
152 return contents.to_string();
153 }
154 let start0 = start - 1;
155 if start0 >= total {
156 return format!("(offset {start} is past end of file — {total} lines total)");
157 }
158 let window: Vec<&str> = contents.lines().skip(start0).take(limit).collect();
159 let end = start0 + window.len(); let mut body = window.join("\n");
161 let char_capped = body.len() > max_chars;
162 if char_capped {
163 let mut cut = max_chars;
164 while cut > 0 && !body.is_char_boundary(cut) {
165 cut -= 1;
166 }
167 body.truncate(cut);
168 }
169 let footer = if char_capped {
170 Some(format!(
171 "payload truncated to {max_chars} chars (~{max_output_tokens} tokens) from line \
172 {start}; call read_file with a higher offset (and/or smaller limit) to continue"
173 ))
174 } else if end < total {
175 Some(format!(
176 "showing lines {start}-{end} of {total}; \
177 call read_file with offset={} to continue",
178 end + 1
179 ))
180 } else {
181 None
182 };
183 match footer {
184 Some(f) => format!("{body}\n\n[{f}]"),
185 None => body,
186 }
187}
188
189pub fn tool_definitions() -> serde_json::Value {
190 serde_json::json!([
191 {
192 "type": "function",
193 "function": {
194 "name": "run_command",
195 "description": "Run a shell command in the workspace directory and return its output",
196 "parameters": {
197 "type": "object",
198 "properties": {
199 "command": { "type": "string", "description": "The shell command to run" }
200 },
201 "required": ["command"]
202 }
203 }
204 },
205 {
206 "type": "function",
207 "function": {
208 "name": "read_file",
209 "description": "Read a file in the workspace. Returns up to `limit` lines \
210 (default 2000) starting at 1-based `offset` (default 1). Large \
211 files come back with a footer pointing at the next window — read \
212 them in pages with offset/limit rather than all at once, or the \
213 full file can saturate the context window.",
214 "parameters": {
215 "type": "object",
216 "properties": {
217 "path": { "type": "string", "description": "File path relative to workspace root" },
218 "offset": { "type": "integer", "description": "1-based line number to start at (default 1)" },
219 "limit": { "type": "integer", "description": "Maximum number of lines to return (default 2000)" }
220 },
221 "required": ["path"]
222 }
223 }
224 },
225 {
226 "type": "function",
227 "function": {
228 "name": "write_file",
229 "description": "Write or overwrite a file in the workspace. \
230 WARNING: use edit_file instead when modifying an existing file — \
231 write_file replaces the entire contents and will fail if the new \
232 content is significantly shorter than the original (shrink guard). \
233 Only use write_file for new files or full rewrites you have \
234 explicitly generated in their entirety.",
235 "parameters": {
236 "type": "object",
237 "properties": {
238 "path": { "type": "string", "description": "File path relative to workspace root" },
239 "content": { "type": "string", "description": "The complete new file contents" }
240 },
241 "required": ["path", "content"]
242 }
243 }
244 },
245 {
246 "type": "function",
247 "function": {
248 "name": "edit_file",
249 "description": "Make a targeted edit to an existing file by replacing one exact \
250 string with another. Safer than write_file for modifying existing \
251 files — you only generate the change, not the whole file. \
252 Fails with a clear error if old_string is not found or matches \
253 multiple times (add more surrounding context to make it unique).",
254 "parameters": {
255 "type": "object",
256 "properties": {
257 "path": { "type": "string", "description": "File path relative to workspace root" },
258 "old_string": { "type": "string", "description": "Exact string to find and replace (must match exactly once)" },
259 "new_string": { "type": "string", "description": "Replacement string" }
260 },
261 "required": ["path", "old_string", "new_string"]
262 }
263 }
264 },
265 {
266 "type": "function",
267 "function": {
268 "name": "delete_file",
269 "description": "Delete one file in the workspace. Use this when a file should be \
270 removed entirely; it is governed by the same fs_write permission \
271 and operator prompt path as write_file and edit_file. Refuses \
272 directories, missing files, and paths outside the granted write scope.",
273 "parameters": {
274 "type": "object",
275 "properties": {
276 "path": { "type": "string", "description": "File path relative to workspace root" }
277 },
278 "required": ["path"]
279 }
280 }
281 },
282 {
283 "type": "function",
284 "function": {
285 "name": "list_dir",
286 "description": "List files in a directory",
287 "parameters": {
288 "type": "object",
289 "properties": {
290 "path": { "type": "string", "description": "Directory path relative to workspace root (use '.' for root)" }
291 },
292 "required": ["path"]
293 }
294 }
295 },
296 {
297 "type": "function",
298 "function": {
299 "name": "find",
300 "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.",
301 "parameters": {
302 "type": "object",
303 "properties": {
304 "path": { "type": "string", "description": "Directory to search under, relative to workspace root. Default '.' (the whole workspace)." },
305 "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." },
306 "type": { "type": "string", "enum": ["f", "d", "any"], "description": "Restrict to files ('f'), directories ('d'), or both ('any', the default)." },
307 "max_depth": { "type": "integer", "description": "Maximum directory depth below `path` (1 = immediate children only). Omit for unlimited." },
308 "max_results": { "type": "integer", "description": "Cap on the number of matches returned. Default 1000; output notes when truncated." },
309 "respect_gitignore": { "type": "boolean", "description": "When true (default) skip .gitignored paths plus .git/target/node_modules/hidden dirs. Set false to search everything." },
310 "case_sensitive": { "type": "boolean", "description": "Case-sensitive basename match. Default true." }
311 },
312 "required": []
313 }
314 }
315 },
316 {
317 "type": "function",
318 "function": {
319 "name": "use_skill",
320 "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.",
321 "parameters": {
322 "type": "object",
323 "properties": {
324 "name": { "type": "string", "description": "The skill name as shown in the 'Available skills' index" }
325 },
326 "required": ["name"]
327 }
328 }
329 },
330 {
331 "type": "function",
332 "function": {
333 "name": "web_fetch",
334 "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.",
335 "parameters": {
336 "type": "object",
337 "properties": {
338 "url": { "type": "string", "description": "The http(s) URL to fetch" },
339 "max_bytes": { "type": "integer", "description": "Optional cap on bytes downloaded (default 5 MiB, max 25 MiB)" }
340 },
341 "required": ["url"]
342 }
343 }
344 },
345 {
346 "type": "function",
347 "function": {
348 "name": "request_permissions",
349 "description": "Ask the operator to GRANT a capability you were denied — the \
350 capability-grant path (#721). Call this AFTER a `capability denied` \
351 result to request authority you don't currently have. If an operator \
352 is present they may allow it; then retry the original operation. In a \
353 headless session (no operator) you'll be told the capability must be \
354 configured by the owner — change approach. This requests AUTHORITY \
355 (it mints a capability grant); it is NOT a way to ask the user a \
356 free-text question.",
357 "parameters": {
358 "type": "object",
359 "properties": {
360 "capability": { "type": "string", "enum": ["exec", "fs_read", "fs_write", "net"], "description": "Which capability axis to request" },
361 "target": { "type": "string", "description": "What to grant: a command name (exec), a path (fs_read/fs_write), or a host (net)" },
362 "reason": { "type": "string", "description": "Why you need it — shown to the operator deciding" }
363 },
364 "required": ["capability", "target", "reason"]
365 }
366 }
367 }
368 ])
369}
370
371fn request_user_input_tool_definition() -> serde_json::Value {
379 serde_json::json!({
380 "type": "function",
381 "function": {
382 "name": "request_user_input",
383 "description": "Ask the human operator a free-text question and get \
384 their answer. Use this to resolve genuine ambiguity \
385 instead of guessing or narrating (e.g. 'which database \
386 should I target?', 'is this the file you meant?'). This \
387 asks for INFORMATION, not authority — to request a \
388 capability you were denied, use request_permissions \
389 instead. In a headless session (no operator) you'll be \
390 told no human is available — then proceed with your best \
391 judgment and state your assumption explicitly.",
392 "parameters": {
393 "type": "object",
394 "properties": {
395 "question": { "type": "string", "description": "The free-text question to ask the human" }
396 },
397 "required": ["question"]
398 }
399 }
400 })
401}
402
403pub fn lifecycle_tool_definition() -> serde_json::Value {
414 let phases: Vec<&str> = crate::tooling::Phase::ALL
415 .iter()
416 .map(|p| p.as_str())
417 .collect();
418 serde_json::json!({
419 "type": "function",
420 "function": {
421 "name": "lifecycle",
422 "description": "Run a named project lifecycle phase using THIS repo's \
423 configured command instead of guessing a raw shell command. \
424 Phases: setup (resolve deps / prepare a checkout), format \
425 (auto-format the tree), lint (static analysis), test (run the \
426 tests), check (the full gate a change must pass), clean (remove \
427 build artifacts). The command is resolved from \
428 .newt/config.toml [lifecycle] and the repo's tooling packs \
429 (Rust / Python / PyO3 / Go / custom), so `check` runs the RIGHT \
430 gate for this project. Prefer this over run_command for build / \
431 test / format / lint / check work so the project's own \
432 conventions are honored uniformly across build systems. Use \
433 action=list to see the resolved command without running it.",
434 "parameters": {
435 "type": "object",
436 "properties": {
437 "phase": {
438 "type": "string",
439 "enum": phases,
440 "description": "The lifecycle phase to run."
441 },
442 "action": {
443 "type": "string",
444 "enum": ["run", "list"],
445 "description": "run (default) executes the phase's resolved command; \
446 list returns the command without running it."
447 }
448 },
449 "required": ["phase"]
450 }
451 }
452 })
453}
454
455#[allow(clippy::too_many_arguments)] pub(crate) fn merged_tool_definitions(
469 mcp: &dyn McpTools,
470 with_save_note: bool,
471 with_recall: bool,
472 with_memory_fetch: bool,
473 with_git: bool,
474 with_team: bool,
475 with_scratchpad: bool,
476 with_code_search: bool,
477 with_experiential: bool,
478 with_scheduled: bool,
479) -> serde_json::Value {
480 let mut defs = match tool_definitions() {
481 serde_json::Value::Array(a) => a,
482 other => vec![other],
483 };
484 for spec in EXTENDED_TOOL_REGISTRY {
494 if gate_satisfied(
495 spec.gate,
496 with_save_note,
497 with_recall,
498 with_memory_fetch,
499 with_git,
500 with_team,
501 with_scratchpad,
502 with_code_search,
503 with_experiential,
504 with_scheduled,
505 ) {
506 defs.push((spec.definition)());
507 }
508 }
509 defs.extend(mcp.tool_defs());
510 serde_json::Value::Array(defs)
511}
512
513fn is_always_on_tool(name: &str) -> bool {
518 EXTENDED_TOOL_REGISTRY
519 .iter()
520 .any(|spec| spec.gate == Gate::Always && spec.name == name)
521}
522
523pub fn persona_tool_allowed(name: &str, allow: &[String]) -> bool {
534 allow.iter().any(|t| t == name) || is_always_on_tool(name)
535}
536
537fn tool_def_name(def: &serde_json::Value) -> Option<&str> {
540 def.get("function")
541 .and_then(|f| f.get("name"))
542 .and_then(|n| n.as_str())
543}
544
545pub fn filter_advertised_tools(
556 defs: serde_json::Value,
557 allow: Option<&[String]>,
558) -> serde_json::Value {
559 let Some(allow) = allow else { return defs };
560 let serde_json::Value::Array(arr) = defs else {
561 return defs;
562 };
563 serde_json::Value::Array(
564 arr.into_iter()
565 .filter(|def| match tool_def_name(def) {
566 Some(name) => persona_tool_allowed(name, allow),
567 None => true,
568 })
569 .collect(),
570 )
571}
572
573fn persona_tool_denied_message(name: &str) -> String {
577 format!(
578 "Tool `{name}` is not available under the active persona: its `tools:` \
579 front-matter restricts which tools it may call. Choose one of the \
580 granted tools, or clear the persona (`/persona clear`) if broader \
581 access is genuinely required."
582 )
583}
584
585const DIRECT_TOOL_NAMES: &[&str] = &[
588 "list_dir",
589 "read_file",
590 "write_file",
591 "edit_file",
592 "delete_file",
593 "use_skill",
594 "web_fetch",
595 "find",
598 "git",
602];
603
604const GIT_PASSTHROUGH_SUBCOMMANDS: &[&str] = &["push", "fetch", "pull", "clone", "rm"];
615
616fn run_command_redirect(command: &str) -> Option<&'static str> {
626 let mut tokens = command.split_ascii_whitespace();
627 let first = tokens.next().unwrap_or("");
628 if first == "git" {
629 let sub = tokens.next().unwrap_or("");
630 if GIT_PASSTHROUGH_SUBCOMMANDS.contains(&sub) {
631 return None;
632 }
633 return Some("git");
634 }
635 DIRECT_TOOL_NAMES.iter().copied().find(|&t| t == first)
636}
637
638#[derive(Clone, Copy, PartialEq, Eq)]
653enum Gate {
654 Always,
655 SaveNote,
656 Recall,
657 MemoryFetch,
658 Git,
659 Team,
660 Scratchpad,
661 CodeSearch,
662 Experiential,
663 Scheduled,
664}
665
666struct ToolSpec {
668 name: &'static str,
670 definition: fn() -> serde_json::Value,
673 gate: Gate,
675}
676
677const EXTENDED_TOOL_REGISTRY: &[ToolSpec] = &[
681 ToolSpec {
684 name: "resume_context",
685 definition: super::resume::resume_context_tool_definition,
686 gate: Gate::Always,
687 },
688 ToolSpec {
689 name: "tool_search",
690 definition: super::tool_search::tool_search_tool_definition,
691 gate: Gate::Always,
692 },
693 ToolSpec {
694 name: "get_context_remaining",
695 definition: super::budget::get_context_remaining_tool_definition,
696 gate: Gate::Always,
697 },
698 ToolSpec {
699 name: "request_user_input",
700 definition: request_user_input_tool_definition,
701 gate: Gate::Always,
702 },
703 ToolSpec {
704 name: "lifecycle",
705 definition: lifecycle_tool_definition,
706 gate: Gate::Always,
707 },
708 ToolSpec {
712 name: "render_report",
713 definition: render_report_tool_definition,
714 gate: Gate::Always,
715 },
716 ToolSpec {
718 name: "save_note",
719 definition: save_note_tool_definition,
720 gate: Gate::SaveNote,
721 },
722 ToolSpec {
723 name: "recall",
724 definition: recall_tool_definition,
725 gate: Gate::Recall,
726 },
727 ToolSpec {
728 name: "memory_fetch",
729 definition: memory_fetch_tool_definition,
730 gate: Gate::MemoryFetch,
731 },
732 ToolSpec {
733 name: "git",
734 definition: super::git_tool::git_tool_definition,
735 gate: Gate::Git,
736 },
737 ToolSpec {
738 name: "compose_roster",
739 definition: super::crew_tool::compose_roster_tool_definition,
740 gate: Gate::Team,
741 },
742 ToolSpec {
743 name: "crew",
744 definition: super::crew_tool::crew_tool_definition,
745 gate: Gate::Team,
746 },
747 ToolSpec {
748 name: "state_set",
749 definition: super::scratchpad::state_set_tool_definition,
750 gate: Gate::Scratchpad,
751 },
752 ToolSpec {
753 name: "state_get",
754 definition: super::scratchpad::state_get_tool_definition,
755 gate: Gate::Scratchpad,
756 },
757 ToolSpec {
758 name: "state_clear",
759 definition: super::scratchpad::state_clear_tool_definition,
760 gate: Gate::Scratchpad,
761 },
762 ToolSpec {
763 name: "code_search",
764 definition: super::semantic::code_search_tool_definition,
765 gate: Gate::CodeSearch,
766 },
767 ToolSpec {
768 name: "experience_record",
769 definition: super::experiential::experience_record_tool_definition,
770 gate: Gate::Experiential,
771 },
772 ToolSpec {
773 name: "experience_recall",
774 definition: super::experiential::experience_recall_tool_definition,
775 gate: Gate::Experiential,
776 },
777 ToolSpec {
778 name: "update_plan",
779 definition: super::scheduled::update_plan_tool_definition,
780 gate: Gate::Scheduled,
781 },
782 ToolSpec {
783 name: "plan_get",
784 definition: super::scheduled::plan_get_tool_definition,
785 gate: Gate::Scheduled,
786 },
787];
788
789const BASE_TOOL_NAMES: &[&str] = &[
795 "run_command",
796 "read_file",
797 "write_file",
798 "edit_file",
799 "delete_file",
800 "list_dir",
801 "find",
802 "use_skill",
803 "web_fetch",
804 "request_permissions",
805];
806
807static ALL_TOOL_NAMES: LazyLock<Vec<&'static str>> = LazyLock::new(|| {
814 BASE_TOOL_NAMES
815 .iter()
816 .copied()
817 .chain(EXTENDED_TOOL_REGISTRY.iter().map(|s| s.name))
818 .collect()
819});
820
821pub(crate) fn known_builtin_tool_name(tool_name: &str) -> bool {
825 ALL_TOOL_NAMES.contains(&tool_name)
826}
827
828#[allow(clippy::too_many_arguments)] fn gate_satisfied(
832 gate: Gate,
833 with_save_note: bool,
834 with_recall: bool,
835 with_memory_fetch: bool,
836 with_git: bool,
837 with_team: bool,
838 with_scratchpad: bool,
839 with_code_search: bool,
840 with_experiential: bool,
841 with_scheduled: bool,
842) -> bool {
843 match gate {
844 Gate::Always => true,
845 Gate::SaveNote => with_save_note,
846 Gate::Recall => with_recall,
847 Gate::MemoryFetch => with_memory_fetch,
848 Gate::Git => with_git,
849 Gate::Team => with_team,
850 Gate::Scratchpad => with_scratchpad,
851 Gate::CodeSearch => with_code_search,
852 Gate::Experiential => with_experiential,
853 Gate::Scheduled => with_scheduled,
854 }
855}
856
857pub(crate) fn is_hallucination(tool_name: &str, args: &serde_json::Value) -> bool {
861 if tool_name == "run_command" {
862 let cmd = args["command"].as_str().unwrap_or("");
863 return run_command_redirect(cmd).is_some();
866 }
867 if tool_name.contains("__") {
869 return false;
870 }
871 !ALL_TOOL_NAMES.contains(&tool_name)
874}
875
876pub(crate) enum AliasOutcome {
883 Rewrite(&'static str),
886 Correct(String),
889}
890
891pub(crate) fn resolve_tool_alias(name: &str) -> Option<AliasOutcome> {
894 match name {
895 "execute" | "exec" | "bash" | "shell" | "sh" | "zsh" | "terminal" | "run_shell_command"
899 | "shell_command" | "system" => Some(AliasOutcome::Rewrite("run_command")),
900 "run_phase" | "run_lifecycle" | "lifecycle_run" => Some(AliasOutcome::Rewrite("lifecycle")),
904 "str_replace_editor" | "str_replace" | "str-replace-editor" | "apply_patch" | "edit"
906 | "editor" | "replace_in_file" | "search_replace" => Some(AliasOutcome::Correct(format!(
907 "'{name}' is not a newt tool. To change an existing file, call \
908 edit_file with {{\"path\", \"old_string\", \"new_string\"}} \
909 (replaces one exact occurrence). For a new file or a full \
910 rewrite, call write_file with {{\"path\", \"content\"}}."
911 ))),
912 "create_file" | "new_file" | "createfile" | "add_file" | "touch" => {
914 Some(AliasOutcome::Correct(format!(
915 "'{name}' is not a newt tool. To create or overwrite a file, call \
916 write_file with {{\"path\", \"content\"}}. To change part of an \
917 existing file, call edit_file with \
918 {{\"path\", \"old_string\", \"new_string\"}}."
919 )))
920 }
921 "remove_file" | "delete" | "remove" | "unlink" | "rm_file" => {
926 Some(AliasOutcome::Correct(format!(
927 "'{name}' is not a newt tool. To remove one file, call delete_file \
928 with {{\"path\"}}. It is governed by fs_write permissions and \
929 prompts the operator when a grant is needed."
930 )))
931 }
932 "mkdir" | "make_dir" | "makedirs" | "mkdirs" | "create_dir" | "create_directory" => {
941 Some(AliasOutcome::Correct(
942 "newt has no mkdir/touch tool — call write_file; it creates parent \
943 directories automatically (create_dir_all). For an empty file, call \
944 write_file with empty content."
945 .to_string(),
946 ))
947 }
948 "cat" | "open_file" | "view_file" | "view" | "open" => {
950 Some(AliasOutcome::Correct(format!(
951 "'{name}' is not a newt tool. To read a file, call read_file with \
952 {{\"path\"}}. To list a directory, call list_dir with {{\"path\"}}."
953 )))
954 }
955 "enter_plan" | "enter_plan_mode" | "plan_mode" | "start_plan" | "begin_plan"
963 | "make_plan" | "create_plan" | "plan" | "planning" | "todo" | "todos" | "todo_write" => {
964 Some(AliasOutcome::Correct(format!(
965 "'{name}' is not a newt tool. To start or revise your plan, call update_plan with \
966 {{\"plan\":[{{\"step\",\"status\"}}]}} — send the full ordered list each time, \
967 each step's status one of pending/in_progress/completed (exactly one \
968 in_progress)."
969 )))
970 }
971 "next_step" | "complete_step" | "finish_step" | "mark_done" | "step_done" => {
975 Some(AliasOutcome::Correct(format!(
976 "'{name}' is not a newt tool. To advance your plan, call update_plan with the \
977 full plan and mark the finished step \"completed\" (and the next one \
978 \"in_progress\")."
979 )))
980 }
981 "get_plan" | "show_plan" | "read_plan" | "current_plan" | "what_was_i_doing" => {
986 Some(AliasOutcome::Rewrite("plan_get"))
987 }
988 "resume" | "where_were_we" | "where_did_we_leave_off" | "catch_me_up" | "recap" => {
994 Some(AliasOutcome::Rewrite("resume_context"))
995 }
996 "delegate" | "spawn_agent" | "subagent" | "sub_agent" | "crew_dispatch" | "run_crew"
1001 | "dispatch_crew" | "fork_agent" | "assign" | "team" => {
1002 Some(AliasOutcome::Correct(format!(
1003 "'{name}' is not a newt tool. Crew/team delegation is only available once the \
1004 human enables /team this session — you cannot turn it on yourself. When /team \
1005 is on, compose_roster ({{\"mode\"}}) proposes a roster and crew ({{\"task\"}}) \
1006 dispatches it."
1007 )))
1008 }
1009 "find_tool" | "search_tools" | "list_tools" | "which_tool" | "available_tools"
1015 | "what_tools" | "tools" => Some(AliasOutcome::Rewrite("tool_search")),
1016 "workflow" | "run_workflow" | "start_workflow" | "pipeline" => Some(AliasOutcome::Correct(
1019 "newt has no workflow tool; sequence the work with update_plan (the full ordered \
1020 plan with statuses), or delegate subtasks via crew/team (needs /team)."
1021 .to_string(),
1022 )),
1023 "context_remaining" | "tokens_left" | "remaining_tokens" | "budget"
1029 | "how_much_context" | "context_budget" | "token_budget" => {
1030 Some(AliasOutcome::Rewrite("get_context_remaining"))
1031 }
1032 "ask_user" | "ask_human" | "prompt_user" | "get_user_input" | "ask_question"
1039 | "clarify" | "ask" => Some(AliasOutcome::Rewrite("request_user_input")),
1040 _ => None,
1041 }
1042}
1043
1044pub(crate) fn is_context_remaining_call(name: &str) -> bool {
1050 name == "get_context_remaining"
1051 || matches!(
1052 resolve_tool_alias(name),
1053 Some(AliasOutcome::Rewrite("get_context_remaining"))
1054 )
1055}
1056
1057pub(crate) fn classify_phantom_reach(
1067 name: &str,
1068 args: &serde_json::Value,
1069 result: &str,
1070 ok: bool,
1071) -> Option<crate::PhantomResolution> {
1072 let _ = ok;
1073 match resolve_tool_alias(name) {
1076 Some(AliasOutcome::Rewrite(canonical)) => {
1077 return Some(crate::PhantomResolution::Rewrite(canonical.to_string()))
1078 }
1079 Some(AliasOutcome::Correct(msg)) => return Some(crate::PhantomResolution::Correct(msg)),
1080 None => {}
1081 }
1082 if is_hallucination(name, args) {
1084 return Some(crate::PhantomResolution::Unknown);
1085 }
1086 let r = result.trim_start();
1090 if name == "state_get" && r.starts_with("no such key") {
1091 return Some(crate::PhantomResolution::RealToolMiss(
1092 "state_get on an unset key".into(),
1093 ));
1094 }
1095 if name == "recall" && r.starts_with("no matches in past conversations") {
1096 return Some(crate::PhantomResolution::RealToolMiss(
1097 "recall returned no matches".into(),
1098 ));
1099 }
1100 None
1101}
1102
1103pub(crate) fn classify_gated_off_reach(
1119 name: &str,
1120 advertise_team: bool,
1121) -> Option<crate::PhantomResolution> {
1122 if !advertise_team && (name == "crew" || name == "compose_roster") {
1123 return Some(crate::PhantomResolution::GatedOff(
1124 "crew/team surface off (NEWT_TEAM)".into(),
1125 ));
1126 }
1127 None
1128}
1129
1130fn levenshtein(a: &str, b: &str) -> usize {
1133 let a: Vec<char> = a.chars().collect();
1134 let b: Vec<char> = b.chars().collect();
1135 let mut prev: Vec<usize> = (0..=b.len()).collect();
1136 let mut cur = vec![0usize; b.len() + 1];
1137 for (i, ca) in a.iter().enumerate() {
1138 cur[0] = i + 1;
1139 for (j, cb) in b.iter().enumerate() {
1140 let cost = usize::from(ca != cb);
1141 cur[j + 1] = (prev[j + 1] + 1).min(cur[j] + 1).min(prev[j] + cost);
1142 }
1143 std::mem::swap(&mut prev, &mut cur);
1144 }
1145 prev[b.len()]
1146}
1147
1148fn nearest_tool_name(name: &str) -> Option<&'static str> {
1152 let threshold = (name.chars().count() / 3).max(1);
1153 ALL_TOOL_NAMES
1154 .iter()
1155 .map(|&t| (levenshtein(name, t), t))
1156 .filter(|(d, _)| *d <= threshold)
1157 .min_by_key(|(d, _)| *d)
1158 .map(|(_, t)| t)
1159}
1160
1161fn unknown_tool_message(name: &str) -> String {
1166 const BASE: &str =
1167 "run_command, read_file, write_file, edit_file, delete_file, list_dir, find, use_skill, web_fetch";
1168 match nearest_tool_name(name) {
1169 Some(sugg) => format!(
1170 "unknown tool: {name}. Did you mean '{sugg}'? Available tools include: \
1171 {BASE} (plus git and any memory/plan tools enabled this session)."
1172 ),
1173 None => format!(
1174 "unknown tool: {name}. Available tools include: {BASE} (plus git and \
1175 any memory/plan tools enabled this session)."
1176 ),
1177 }
1178}
1179
1180pub fn venv_cmd_prefix() -> Option<String> {
1190 let venv = std::env::var("NEWT_VENV")
1191 .or_else(|_| std::env::var("VIRTUAL_ENV"))
1192 .ok();
1193 let exec_paths = std::env::var("NEWT_EXEC_PATHS").ok();
1194
1195 if venv.is_none() && exec_paths.is_none() {
1196 return None;
1197 }
1198
1199 let q = |s: &str| format!("'{}'", s.replace('\'', r"'\''"));
1201
1202 let mut path_dirs: Vec<String> = Vec::new();
1204 let mut prefix = String::new();
1205
1206 if let Some(ref venv) = venv {
1207 let venv_bin = format!("{venv}/bin");
1208 prefix.push_str(&format!("export VIRTUAL_ENV={}; ", q(venv)));
1209 path_dirs.push(venv_bin);
1210 }
1211 if let Some(ref paths) = exec_paths {
1212 for dir in paths.split(':') {
1213 if !dir.is_empty() {
1214 path_dirs.push(dir.to_string());
1215 }
1216 }
1217 }
1218
1219 if !path_dirs.is_empty() {
1220 let quoted: Vec<String> = path_dirs.iter().map(|d| q(d)).collect();
1221 prefix.push_str(&format!("export PATH={}:\"$PATH\"; ", quoted.join(":")));
1222 }
1223
1224 if prefix.is_empty() {
1225 None
1226 } else {
1227 Some(prefix)
1228 }
1229}
1230
1231fn venv_env_map() -> std::collections::BTreeMap<String, String> {
1248 let mut map = std::collections::BTreeMap::new();
1249
1250 for var in shell_env_passthrough() {
1260 if let Ok(val) = std::env::var(&var) {
1261 map.insert(var, val);
1262 }
1263 }
1264 map.insert("SHELL".to_string(), shell_engine().as_str().to_string());
1267
1268 let venv = std::env::var("NEWT_VENV")
1269 .or_else(|_| std::env::var("VIRTUAL_ENV"))
1270 .ok();
1271 let exec_paths = std::env::var("NEWT_EXEC_PATHS").ok();
1272
1273 let mut path_dirs: Vec<String> = Vec::new();
1276 if let Some(ref venv) = venv {
1277 map.insert("VIRTUAL_ENV".to_string(), venv.clone());
1278 path_dirs.push(format!("{venv}/bin"));
1279 }
1280 if let Some(ref paths) = exec_paths {
1281 for dir in paths.split(':') {
1282 if !dir.is_empty() {
1283 path_dirs.push(dir.to_string());
1284 }
1285 }
1286 }
1287
1288 if !path_dirs.is_empty() {
1289 let prepend = path_dirs.join(":");
1290 let path = match std::env::var("PATH") {
1291 Ok(inherited) if !inherited.is_empty() => format!("{prepend}:{inherited}"),
1292 _ => prepend,
1293 };
1294 map.insert("PATH".to_string(), path);
1295 }
1296
1297 map
1298}
1299
1300fn shell_env_passthrough() -> Vec<String> {
1304 match std::env::var("NEWT_SHELL_ENV_PASSTHROUGH") {
1305 Ok(s) if !s.trim().is_empty() => s
1306 .split(':')
1307 .filter(|v| !v.is_empty())
1308 .map(str::to_string)
1309 .collect(),
1310 _ => crate::config::shell_env_passthrough_default(),
1311 }
1312}
1313
1314fn confined_dispatch_args(cmd: &str, workspace: &str) -> serde_json::Value {
1322 serde_json::json!({
1323 "cmd": cmd,
1324 "cwd": workspace,
1325 "env": venv_env_map(),
1326 })
1327}
1328
1329fn shell_engine() -> crate::ShellEngine {
1338 if let Some(engine) = std::env::var("NEWT_SHELL_ENGINE")
1339 .ok()
1340 .and_then(|s| s.parse::<crate::ShellEngine>().ok())
1341 {
1342 return engine;
1343 }
1344 if full_access_requested() {
1349 crate::full_access_default_engine()
1350 } else {
1351 crate::ShellEngine::default()
1352 }
1353}
1354
1355fn bridle_registry() -> agent_bridle::Registry {
1362 use std::sync::Arc;
1363 let shell: Arc<dyn agent_bridle::Tool> = match shell_engine() {
1364 crate::ShellEngine::SafeSubset => Arc::new(agent_bridle::ShellTool::new()),
1365 crate::ShellEngine::Host => Arc::new(agent_bridle::HostShellTool::new()),
1366 crate::ShellEngine::Brush => {
1367 #[cfg(windows)]
1371 {
1372 use std::sync::Once;
1373 static WARN: Once = Once::new();
1374 WARN.call_once(|| {
1375 tracing::warn!(
1376 "using the 'brush' shell engine on Windows: run_command runs a \
1377 bash-in-Rust shell for internal-tooling compatibility. Native \
1378 PowerShell/cmd code paths are a FUTURE release — not written yet \
1379 (we are opinionated Linux developers who occasionally use a \
1380 MacBook). Bash-isms work; Windows-native shell semantics do not."
1381 );
1382 });
1383 }
1384 Arc::new(agent_bridle::BrushShellTool::new())
1385 }
1386 };
1387 agent_bridle::Registry::builder()
1388 .tool(shell)
1389 .tool(Arc::new(agent_bridle::WebFetchTool::new()))
1390 .build()
1391}
1392
1393pub fn ocap_disabled() -> bool {
1421 std::env::var("NEWT_DISABLE_OCAP").is_ok_and(|v| v == "1")
1422}
1423
1424pub fn full_access_requested() -> bool {
1441 std::env::var("NEWT_FULL_ACCESS").is_ok_and(|v| v == "1")
1442}
1443
1444pub fn routing_disabled() -> bool {
1460 std::env::var("NEWT_NO_ROUTE").is_ok_and(|v| v == "1")
1461}
1462
1463fn exec_floor_permits(floor: Option<&crate::caveats::Scope<String>>, cmd: &str) -> bool {
1486 use crate::caveats::ScopeExt as _;
1487 let Some(scope) = floor else {
1488 return true; };
1490 const SHELL_META: &[char] = &['&', '|', ';', '`', '$', '\n', '>', '<', '(', ')'];
1494 if cmd.contains(SHELL_META) {
1495 return false;
1496 }
1497 match cmd.split_ascii_whitespace().next() {
1498 None => true,
1500 Some(prog) => scope.permits(&prog.to_string()),
1501 }
1502}
1503
1504#[allow(clippy::too_many_arguments)]
1521async fn exec_confined_command(
1522 cmd: &str,
1523 workspace: &str,
1524 color: bool,
1525 tool_output_lines: usize,
1526 caveats: &crate::caveats::Caveats,
1527 exec_floor: Option<&crate::caveats::Scope<String>>,
1528 permission_gate: Option<&mut dyn PermissionGate>,
1529 tool_offload: bool,
1530 spill_store: Option<&dyn SpillStore>,
1531) -> String {
1532 let cmd_with_venv = match venv_cmd_prefix() {
1540 Some(prefix) => format!("{prefix}{cmd}"),
1541 None => cmd.to_string(),
1542 };
1543
1544 if ocap_disabled() && exec_floor_permits(exec_floor, cmd) {
1553 return match host_shell_dispatch(&cmd_with_venv, workspace).await {
1554 Ok(envelope) => shell_envelope_output(
1555 &envelope,
1556 tool_output_lines,
1557 color,
1558 tool_offload,
1559 spill_store,
1560 ),
1561 Err(e) => format!("error: {e}"),
1562 };
1563 }
1564
1565 let dispatch_args = confined_dispatch_args(cmd, workspace);
1568 match bridle_registry()
1569 .dispatch("shell", dispatch_args.clone(), caveats)
1570 .await
1571 {
1572 Ok(envelope) if envelope_denied(&envelope) => {
1581 if let Some(gate) = permission_gate {
1586 if let Some(requests) =
1590 exec_denial_requests(&envelope).or_else(|| net_denial_requests(&envelope))
1591 {
1592 if let PermissionDecision::Allow(widened) = gate.ask(&requests) {
1593 return match bridle_registry()
1594 .dispatch("shell", dispatch_args, &widened)
1595 .await
1596 {
1597 Ok(env2) if envelope_denied(&env2) => {
1598 denied_run_command_result(&env2, color)
1599 }
1600 Ok(env2) => shell_envelope_output(
1601 &env2,
1602 tool_output_lines,
1603 color,
1604 tool_offload,
1605 spill_store,
1606 ),
1607 Err(e) => format!("error: {e}"),
1608 };
1609 }
1610 }
1611 }
1612 denied_run_command_result(&envelope, color)
1613 }
1614 Ok(envelope) => shell_envelope_output(
1615 &envelope,
1616 tool_output_lines,
1617 color,
1618 tool_offload,
1619 spill_store,
1620 ),
1621 Err(e) => format!("error: {e}"),
1624 }
1625}
1626
1627async fn host_shell_dispatch(cmd: &str, cwd: &str) -> std::io::Result<serde_json::Value> {
1628 let run = host_shell_output(cmd, cwd).await?;
1629 Ok(serde_json::json!({
1630 "exit_code": run.exit_code,
1631 "stdout": decode_shell_stream(&run.stdout),
1632 "stderr": decode_shell_stream(&run.stderr),
1633 "timed_out": run.timed_out,
1636 "sandbox_kind": "none",
1639 }))
1640}
1641
1642struct HostShellRun {
1646 exit_code: i64,
1647 stdout: Vec<u8>,
1648 stderr: Vec<u8>,
1649 timed_out: bool,
1650}
1651
1652fn host_exec_timeout() -> std::time::Duration {
1661 const DEFAULT_SECS: u64 = 120;
1662 let secs = std::env::var("NEWT_HOST_EXEC_TIMEOUT_SECS")
1663 .ok()
1664 .and_then(|raw| raw.trim().parse::<u64>().ok())
1665 .filter(|&n| n > 0)
1666 .unwrap_or(DEFAULT_SECS);
1667 std::time::Duration::from_secs(secs)
1668}
1669
1670fn decode_shell_stream(bytes: &[u8]) -> String {
1671 match std::str::from_utf8(bytes) {
1672 Ok(text) => text.to_string(),
1673 Err(_) => repair_bsd_cat_v_utf8(bytes)
1674 .unwrap_or_else(|| String::from_utf8_lossy(bytes).into_owned()),
1675 }
1676}
1677
1678fn repair_bsd_cat_v_utf8(bytes: &[u8]) -> Option<String> {
1686 let mut repaired = Vec::with_capacity(bytes.len());
1687 let mut changed = false;
1688 let mut i = 0;
1689 while i < bytes.len() {
1690 let lead = bytes[i];
1691 let Some(cont_count) = utf8_continuation_count(lead) else {
1692 repaired.push(lead);
1693 i += 1;
1694 continue;
1695 };
1696
1697 let mut seq = Vec::with_capacity(cont_count + 1);
1698 seq.push(lead);
1699 let mut j = i + 1;
1700 let mut ok = true;
1701 for _ in 0..cont_count {
1702 match parse_cat_v_meta_byte(bytes, j) {
1703 Some((cont, next)) if (0x80..=0xbf).contains(&cont) => {
1704 seq.push(cont);
1705 j = next;
1706 }
1707 _ => {
1708 ok = false;
1709 break;
1710 }
1711 }
1712 }
1713
1714 if ok && std::str::from_utf8(&seq).is_ok() {
1715 repaired.extend_from_slice(&seq);
1716 changed = true;
1717 i = j;
1718 } else {
1719 repaired.push(lead);
1720 i += 1;
1721 }
1722 }
1723
1724 changed.then(|| String::from_utf8(repaired).ok()).flatten()
1725}
1726
1727fn utf8_continuation_count(lead: u8) -> Option<usize> {
1728 match lead {
1729 0xc2..=0xdf => Some(1),
1730 0xe0..=0xef => Some(2),
1731 0xf0..=0xf4 => Some(3),
1732 _ => None,
1733 }
1734}
1735
1736fn parse_cat_v_meta_byte(bytes: &[u8], start: usize) -> Option<(u8, usize)> {
1737 if start + 2 > bytes.len() || &bytes[start..start + 2] != b"M-" {
1738 return None;
1739 }
1740 let pos = start + 2;
1741 match bytes.get(pos).copied()? {
1742 b'^' => {
1743 let c = bytes.get(pos + 1).copied()?;
1744 let low = if c == b'?' {
1745 0x7f
1746 } else if (b'@'..=b'_').contains(&c) {
1747 c - b'@'
1748 } else {
1749 return None;
1750 };
1751 Some((low | 0x80, pos + 2))
1752 }
1753 c if (0x20..=0x7e).contains(&c) => Some((c | 0x80, pos + 1)),
1754 _ => None,
1755 }
1756}
1757
1758#[cfg(not(windows))]
1771async fn host_shell_output(cmd: &str, cwd: &str) -> std::io::Result<HostShellRun> {
1772 use std::process::Stdio;
1773
1774 fn shell(program: &str, cmd: &str, cwd: &str) -> tokio::process::Command {
1775 let mut c = tokio::process::Command::new(program);
1776 c.arg("-c")
1777 .arg(cmd)
1778 .current_dir(cwd)
1779 .stdin(Stdio::null())
1782 .stdout(Stdio::piped())
1783 .stderr(Stdio::piped())
1784 .process_group(0)
1788 .kill_on_drop(true);
1791 c
1792 }
1793
1794 async fn run_one(child: tokio::process::Child) -> std::io::Result<HostShellRun> {
1795 let timeout = host_exec_timeout();
1798 match tokio::time::timeout(timeout, child.wait_with_output()).await {
1799 Ok(Ok(output)) => Ok(HostShellRun {
1800 exit_code: output.status.code().unwrap_or(-1) as i64,
1801 stdout: output.stdout,
1802 stderr: output.stderr,
1803 timed_out: false,
1804 }),
1805 Ok(Err(e)) => Err(e),
1806 Err(_elapsed) => Ok(HostShellRun {
1807 exit_code: 124,
1808 stdout: Vec::new(),
1809 stderr: format!(
1810 "command exceeded {}s host-shell timeout and was killed\n",
1811 timeout.as_secs()
1812 )
1813 .into_bytes(),
1814 timed_out: true,
1815 }),
1816 }
1817 }
1818
1819 match shell("bash", cmd, cwd).spawn() {
1820 Ok(child) => run_one(child).await,
1821 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
1822 run_one(shell("sh", cmd, cwd).spawn()?).await
1823 }
1824 Err(e) => Err(e),
1825 }
1826}
1827
1828#[cfg(windows)]
1832async fn host_shell_output(cmd: &str, cwd: &str) -> std::io::Result<HostShellRun> {
1833 use std::process::Stdio;
1834
1835 let child = tokio::process::Command::new("cmd")
1836 .args(["/C", cmd])
1837 .current_dir(cwd)
1838 .stdin(Stdio::null())
1839 .stdout(Stdio::piped())
1840 .stderr(Stdio::piped())
1841 .kill_on_drop(true)
1842 .spawn()?;
1843
1844 let timeout = host_exec_timeout();
1845 match tokio::time::timeout(timeout, child.wait_with_output()).await {
1846 Ok(Ok(output)) => Ok(HostShellRun {
1847 exit_code: output.status.code().unwrap_or(-1) as i64,
1848 stdout: output.stdout,
1849 stderr: output.stderr,
1850 timed_out: false,
1851 }),
1852 Ok(Err(e)) => Err(e),
1853 Err(_elapsed) => Ok(HostShellRun {
1854 exit_code: 124,
1855 stdout: Vec::new(),
1856 stderr: format!(
1857 "command exceeded {}s host-shell timeout and was killed\r\n",
1858 timeout.as_secs()
1859 )
1860 .into_bytes(),
1861 timed_out: true,
1862 }),
1863 }
1864}
1865
1866fn lexically_normalize(path: &str) -> std::path::PathBuf {
1874 use std::path::{Component, PathBuf};
1875 let mut out = PathBuf::new();
1876 for comp in std::path::Path::new(path).components() {
1877 match comp {
1878 Component::CurDir => {}
1879 Component::ParentDir => {
1880 if !out.pop() {
1882 out.push(comp.as_os_str());
1883 }
1884 }
1885 other => out.push(other.as_os_str()),
1886 }
1887 }
1888 out
1889}
1890
1891pub(crate) fn tui_permits_path(scope: &crate::caveats::Scope<String>, full_path: &str) -> bool {
1903 match scope {
1904 crate::caveats::Scope::All => true,
1905 crate::caveats::Scope::Only(set) if set.is_empty() => false,
1906 crate::caveats::Scope::Only(set) => {
1907 let candidate = lexically_normalize(full_path);
1908 set.iter()
1909 .any(|root| candidate.starts_with(lexically_normalize(root)))
1910 }
1911 }
1912}
1913
1914fn confirm_unrestricted_fs_mutation(
1918 caveats: &crate::caveats::Caveats,
1919 gate: &mut Option<&mut dyn PermissionGate>,
1920 question: &str,
1921) -> bool {
1922 if !matches!(caveats.fs_write, crate::caveats::Scope::All) {
1923 return true;
1924 }
1925 if ocap_disabled() {
1926 return true;
1927 }
1928 match gate {
1929 Some(g) => g
1930 .ask_question(question)
1931 .is_some_and(|answer| answer.trim().eq_ignore_ascii_case("y")),
1932 None => false,
1933 }
1934}
1935
1936pub(crate) fn run_build_check(cmd: &str, workspace: &str) -> String {
1939 let result = build_check_shell(cmd).current_dir(workspace).output();
1940 match result {
1941 Ok(out) if out.status.success() => " ✓ build check passed".to_string(),
1942 Ok(out) => {
1943 let stderr = String::from_utf8_lossy(&out.stderr);
1944 let stdout = String::from_utf8_lossy(&out.stdout);
1945 let combined = format!("{stdout}{stderr}");
1946 let excerpt: String = combined.lines().take(8).collect::<Vec<_>>().join("\n");
1947 format!(" ✗ build check failed:\n{excerpt}")
1948 }
1949 Err(e) => format!(" ⚠ build check could not run: {e}"),
1950 }
1951}
1952
1953#[cfg(windows)]
1954fn build_check_shell(cmd: &str) -> std::process::Command {
1955 let mut shell = std::process::Command::new("cmd");
1956 shell.args(["/C", cmd]);
1957 shell
1958}
1959
1960#[cfg(not(windows))]
1961fn build_check_shell(cmd: &str) -> std::process::Command {
1962 let mut shell = std::process::Command::new("sh");
1963 shell.args(["-c", cmd]);
1964 shell
1965}
1966
1967#[cfg(all(test, windows))]
1968fn passing_build_check_cmd() -> &'static str {
1969 "exit /B 0"
1970}
1971
1972#[cfg(all(test, not(windows)))]
1973fn passing_build_check_cmd() -> &'static str {
1974 "true"
1975}
1976
1977#[cfg(all(test, windows))]
1978fn failing_build_check_cmd(message: &str) -> String {
1979 format!("echo {message} 1>&2 & exit /B 1")
1980}
1981
1982#[cfg(all(test, not(windows)))]
1983fn failing_build_check_cmd(message: &str) -> String {
1984 format!("echo {message} >&2; exit 1")
1985}
1986
1987fn envelope_denied(envelope: &serde_json::Value) -> bool {
1995 envelope
1996 .get("denied")
1997 .and_then(serde_json::Value::as_bool)
1998 .unwrap_or(false)
1999}
2000
2001fn envelope_denial_reason(envelope: &serde_json::Value) -> String {
2005 let reasons: Vec<String> = envelope
2006 .get("denials")
2007 .and_then(serde_json::Value::as_array)
2008 .map(|arr| {
2009 arr.iter()
2010 .filter_map(|d| d.get("reason").and_then(serde_json::Value::as_str))
2011 .map(str::to_string)
2012 .collect()
2013 })
2014 .unwrap_or_default();
2015 if reasons.is_empty() {
2016 "denied: the capability leash refused an operation".to_string()
2017 } else {
2018 reasons.join("; ")
2019 }
2020}
2021
2022fn exec_allowlist_name(target: &str) -> &str {
2027 target
2028 .rsplit(['/', '\\'])
2029 .find(|part| !part.is_empty())
2030 .unwrap_or(target)
2031}
2032
2033const DENIAL_RECOVERY_HINT: &str =
2043 "This is outside your granted authority — call request_permissions with the \
2044 capability, a target, and a reason to ask the operator to grant it, or take \
2045 a different approach that stays within your current authority.";
2046
2047const CREW_OFF_RECOVERY_HINT: &str =
2057 "the crew/team surface is not enabled this session (the operator launches it \
2058 with NEWT_TEAM). Accomplish this yourself with the available tools \
2059 (read_file/write_file/edit_file/run_command/...), or ask the operator to \
2060 enable a crew.";
2061
2062fn crew_off_recovery_result(name: &str) -> String {
2066 format!("'{name}' is unavailable: {CREW_OFF_RECOVERY_HINT}")
2067}
2068
2069fn denied_fs_result(kind: &str, path: &str) -> String {
2075 format!("capability denied: {kind} does not permit '{path}'. {DENIAL_RECOVERY_HINT}")
2076}
2077
2078fn denied_run_command_result(envelope: &serde_json::Value, color: bool) -> String {
2101 print_denied(
2105 denial_axis_label(envelope),
2106 &exec_denial_target_label(envelope),
2107 color,
2108 );
2109 format!(
2111 "capability denied: {}. {DENIAL_RECOVERY_HINT}",
2112 envelope_denial_reason(envelope)
2113 )
2114}
2115
2116fn exec_denial_target_label(envelope: &serde_json::Value) -> String {
2122 let targets: Vec<&str> = envelope
2123 .get("denials")
2124 .and_then(serde_json::Value::as_array)
2125 .map(|arr| {
2126 arr.iter()
2127 .filter_map(|d| d.get("target").and_then(serde_json::Value::as_str))
2128 .filter(|t| !t.is_empty())
2129 .collect()
2130 })
2131 .unwrap_or_default();
2132 if targets.is_empty() {
2133 "a command".to_string()
2134 } else {
2135 targets.join(", ")
2136 }
2137}
2138
2139fn shell_envelope_output(
2143 envelope: &serde_json::Value,
2144 tool_output_lines: usize,
2145 color: bool,
2146 tool_offload: bool,
2147 spill_store: Option<&dyn SpillStore>,
2148) -> String {
2149 let stdout = envelope
2150 .get("stdout")
2151 .and_then(serde_json::Value::as_str)
2152 .unwrap_or("");
2153 let stderr = envelope
2154 .get("stderr")
2155 .and_then(serde_json::Value::as_str)
2156 .unwrap_or("");
2157 let out = format!("{stdout}{stderr}");
2158 print_tool_output(&out, tool_output_lines, color);
2160 if out.trim().is_empty() {
2161 let code = envelope
2162 .get("exit_code")
2163 .and_then(serde_json::Value::as_i64)
2164 .unwrap_or(-1);
2165 format!("(exit {code})")
2166 } else {
2167 let max_tokens = max_output_tokens();
2172 let est = crate::tokens::TokenEstimation::default();
2173 let over_model_budget = max_tokens != 0 && est.tokens_for_chars(out.len()) > max_tokens;
2174 let over_spill_budget = out.chars().count() > spill::TOOL_RESULT_SPILL_CAP;
2175 let should_spill =
2176 max_tokens != 0 && tool_offload && (over_model_budget || over_spill_budget);
2177 let capped = if should_spill {
2178 match spill_store {
2179 Some(store) => {
2180 let (id, redacted) = spill::store_redacted_full(&out, store);
2181 let teaser_tokens =
2182 est.tokens_for_chars(spill::TOOL_RESULT_SPILL_CAP.saturating_sub(512));
2183 cap_model_output_with_handle(
2184 &redacted,
2185 max_tokens.min(teaser_tokens),
2186 output_head_tokens(),
2187 Some(&id),
2188 )
2189 }
2190 None => cap_model_output(&out, max_tokens),
2191 }
2192 } else {
2193 cap_model_output(&out, max_tokens)
2194 };
2195 match pr_creation_url(&out) {
2201 Some(url) => format!("{capped}{}", pr_next_step_hint(url)),
2202 None => capped,
2203 }
2204 }
2205}
2206
2207fn pr_creation_url(output: &str) -> Option<&str> {
2214 output.split_whitespace().find(|tok| {
2215 tok.starts_with("https://")
2216 && (tok.contains("/pull/new/")
2217 || tok.contains("/merge_requests/new")
2218 || tok.contains("/compare/"))
2219 })
2220}
2221
2222fn pr_next_step_hint(url: &str) -> String {
2227 format!(
2228 "\n\n[newt] A branch was pushed. To open a pull request now, call \
2229 run_command with `gh pr create --fill` (the `gh` CLI is available; the \
2230 `git` tool cannot push or open PRs). Or open this URL: {url}"
2231 )
2232}
2233
2234fn exec_denial_requests(envelope: &serde_json::Value) -> Option<Vec<PermissionRequest>> {
2242 let denials = envelope.get("denials")?.as_array()?;
2243 if denials.is_empty() {
2244 return None;
2245 }
2246 let mut requests = Vec::with_capacity(denials.len());
2247 for d in denials {
2248 if d.get("kind")?.as_str()? != "exec" {
2249 return None;
2250 }
2251 let target = d.get("target")?.as_str().filter(|t| !t.is_empty())?;
2252 requests.push(PermissionRequest {
2253 tool: "run_command".to_string(),
2254 kind: DenialKind::Exec,
2255 target: exec_allowlist_name(target).to_string(),
2256 reason: d
2257 .get("reason")
2258 .and_then(serde_json::Value::as_str)
2259 .unwrap_or_default()
2260 .to_string(),
2261 });
2262 }
2263 Some(requests)
2264}
2265
2266fn net_denial_requests(envelope: &serde_json::Value) -> Option<Vec<PermissionRequest>> {
2277 let denials = envelope.get("denials")?.as_array()?;
2278 if denials.is_empty() {
2279 return None;
2280 }
2281 let mut requests = Vec::with_capacity(denials.len());
2282 for d in denials {
2283 if d.get("kind")?.as_str()? != "net" {
2284 return None;
2285 }
2286 let host = d.get("target")?.as_str().filter(|t| !t.is_empty())?;
2287 requests.push(PermissionRequest {
2288 tool: "run_command".to_string(),
2289 kind: DenialKind::Net,
2290 target: host.to_string(),
2291 reason: d
2292 .get("reason")
2293 .and_then(serde_json::Value::as_str)
2294 .unwrap_or_default()
2295 .to_string(),
2296 });
2297 }
2298 Some(requests)
2299}
2300
2301fn denial_axis_label(envelope: &serde_json::Value) -> &'static str {
2305 let all_net = envelope
2306 .get("denials")
2307 .and_then(serde_json::Value::as_array)
2308 .filter(|arr| !arr.is_empty())
2309 .is_some_and(|arr| {
2310 arr.iter()
2311 .all(|d| d.get("kind").and_then(serde_json::Value::as_str) == Some("net"))
2312 });
2313 if all_net {
2314 "net"
2315 } else {
2316 "exec"
2317 }
2318}
2319
2320fn fs_gate_allows(
2324 gate: &mut dyn PermissionGate,
2325 tool: &str,
2326 kind: DenialKind,
2327 full_path: &str,
2328 axis: impl Fn(&crate::caveats::Caveats) -> &crate::caveats::Scope<String>,
2329) -> bool {
2330 let request = PermissionRequest {
2331 tool: tool.to_string(),
2332 kind,
2333 target: full_path.to_string(),
2334 reason: format!("{} does not permit '{full_path}'", kind.as_str()),
2335 };
2336 match gate.ask(std::slice::from_ref(&request)) {
2337 PermissionDecision::Allow(widened) => tui_permits_path(axis(&widened), full_path),
2338 PermissionDecision::Deny => false,
2339 }
2340}
2341
2342fn is_git_write_denial(out: &str) -> bool {
2349 out.contains("capability denied: git ") && out.contains("not permitted")
2350}
2351
2352fn git_gate_allows(gate: &mut dyn PermissionGate, op: &str) -> bool {
2360 let request = PermissionRequest {
2361 tool: "git".to_string(),
2362 kind: DenialKind::GitWrite,
2363 target: op.to_string(),
2364 reason: format!("git {op} is outside the granted git-write authority"),
2365 };
2366 matches!(
2367 gate.ask(std::slice::from_ref(&request)),
2368 PermissionDecision::Allow(_)
2369 )
2370}
2371
2372fn parse_capability(s: &str) -> Option<DenialKind> {
2378 match s.trim().to_ascii_lowercase().as_str() {
2379 "exec" | "run" | "run_command" | "command" | "shell" => Some(DenialKind::Exec),
2380 "fs_read" | "fs-read" | "read" | "read_file" => Some(DenialKind::FsRead),
2381 "fs_write" | "fs-write" | "write" | "write_file" => Some(DenialKind::FsWrite),
2382 "net" | "network" | "web" | "web_fetch" => Some(DenialKind::Net),
2383 _ => None,
2384 }
2385}
2386
2387fn execute_request_permissions(
2403 args: &serde_json::Value,
2404 gate: Option<&mut dyn PermissionGate>,
2405 color: bool,
2406 tool_output_lines: usize,
2407) -> String {
2408 let capability = args["capability"].as_str().unwrap_or("").trim();
2409 let target = args["target"].as_str().unwrap_or("").trim();
2410 let reason = args["reason"].as_str().unwrap_or("").trim();
2411 print_tool_call("request_permissions", capability, color);
2412
2413 let Some(kind) = parse_capability(capability) else {
2414 let out = format!(
2415 "request_permissions: unknown capability '{capability}'. Use one of: \
2416 exec, fs_read, fs_write, net."
2417 );
2418 print_tool_output(&out, tool_output_lines, color);
2419 return out;
2420 };
2421 if target.is_empty() {
2422 let out = "request_permissions: 'target' is required — the command name (exec), \
2423 the path (fs_read/fs_write), or the host (net)."
2424 .to_string();
2425 print_tool_output(&out, tool_output_lines, color);
2426 return out;
2427 }
2428
2429 let request = PermissionRequest {
2430 tool: "request_permissions".to_string(),
2431 kind,
2432 target: target.to_string(),
2433 reason: if reason.is_empty() {
2434 format!("model requested {capability} for '{target}'")
2435 } else {
2436 reason.to_string()
2437 },
2438 };
2439
2440 let out = match gate {
2441 Some(g) => match g.ask(std::slice::from_ref(&request)) {
2446 PermissionDecision::Allow(_widened) => format!(
2447 "granted: the operator allowed {capability} for '{target}'. \
2448 Retry the original operation now."
2449 ),
2450 PermissionDecision::Deny => format!(
2451 "denied: the operator declined {capability} for '{target}'. \
2452 Do not retry it — take a different approach."
2453 ),
2454 },
2455 None => format!(
2459 "no operator available to grant {capability} for '{target}' — this session \
2460 has no interactive permission gate (headless / eval / piped). The capability \
2461 must be configured by the owner (e.g. [tui.permissions] in newt config); \
2462 take a different approach for now."
2463 ),
2464 };
2465 print_tool_output(&out, tool_output_lines, color);
2466 out
2467}
2468
2469const HEADLESS_NO_HUMAN: &str = "no human available this session (running headless) \
2474 — proceed with your best judgment or state your assumption explicitly.";
2475
2476fn execute_request_user_input(
2489 args: &serde_json::Value,
2490 gate: Option<&mut dyn PermissionGate>,
2491 color: bool,
2492 tool_output_lines: usize,
2493) -> String {
2494 let question = args["question"].as_str().unwrap_or("").trim();
2495 print_tool_call("request_user_input", question, color);
2496
2497 if question.is_empty() {
2498 let out = "request_user_input: 'question' is required — the free-text \
2499 question to ask the human."
2500 .to_string();
2501 print_tool_output(&out, tool_output_lines, color);
2502 return out;
2503 }
2504
2505 let out = match gate.and_then(|g| g.ask_question(question)) {
2509 Some(answer) => answer,
2510 None => HEADLESS_NO_HUMAN.to_string(),
2511 };
2512 print_tool_output(&out, tool_output_lines, color);
2513 out
2514}
2515
2516pub(crate) fn host_of_url(url: &str) -> Option<String> {
2521 let rest = url
2522 .strip_prefix("https://")
2523 .or_else(|| url.strip_prefix("http://"))?;
2524 let authority = rest.split(['/', '?', '#']).next()?;
2525 let host_port = authority.rsplit('@').next()?;
2526 let host = if let Some(stripped) = host_port.strip_prefix('[') {
2528 stripped.split(']').next()?
2529 } else {
2530 host_port.split(':').next()?
2531 };
2532 if host.is_empty() {
2533 None
2534 } else {
2535 Some(host.to_ascii_lowercase())
2536 }
2537}
2538
2539#[derive(Clone, Copy, PartialEq, Eq, Debug)]
2541enum FindType {
2542 Files,
2543 Dirs,
2544 Any,
2545}
2546
2547struct FindOpts<'a> {
2549 name: Option<&'a str>,
2551 type_filter: FindType,
2552 max_depth: Option<usize>,
2555 max_results: usize,
2557 respect_gitignore: bool,
2559 case_sensitive: bool,
2560}
2561
2562fn find_detail(path: &str, opts: &FindOpts) -> String {
2567 let mut parts: Vec<String> = Vec::new();
2568 if let Some(name) = opts.name {
2569 parts.push(format!("name={name}"));
2570 }
2571 match opts.type_filter {
2572 FindType::Files => parts.push("type=f".to_string()),
2573 FindType::Dirs => parts.push("type=d".to_string()),
2574 FindType::Any => {}
2575 }
2576 if let Some(d) = opts.max_depth {
2577 parts.push(format!("depth={d}"));
2578 }
2579 if opts.max_results != 1000 {
2581 parts.push(format!("max={}", opts.max_results));
2582 }
2583 if !opts.respect_gitignore {
2584 parts.push("no-gitignore".to_string());
2585 }
2586 if !opts.case_sensitive {
2587 parts.push("icase".to_string());
2588 }
2589 if parts.is_empty() {
2590 path.to_string()
2591 } else {
2592 format!("{path} ({})", parts.join(", "))
2593 }
2594}
2595
2596fn glob_to_regex(glob: &str, case_sensitive: bool) -> Result<regex::Regex, String> {
2600 let mut re = String::with_capacity(glob.len() + 8);
2601 if !case_sensitive {
2602 re.push_str("(?i)");
2603 }
2604 re.push('^');
2605 for ch in glob.chars() {
2606 match ch {
2607 '*' => re.push_str(".*"),
2608 '?' => re.push('.'),
2609 '.' | '+' | '(' | ')' | '|' | '[' | ']' | '{' | '}' | '^' | '$' | '\\' => {
2611 re.push('\\');
2612 re.push(ch);
2613 }
2614 other => re.push(other),
2615 }
2616 }
2617 re.push('$');
2618 regex::Regex::new(&re).map_err(|e| format!("invalid name pattern: {e}"))
2619}
2620
2621fn find_walk(
2627 root: &std::path::Path,
2628 workspace_root: &std::path::Path,
2629 opts: &FindOpts<'_>,
2630) -> Result<(Vec<String>, bool), String> {
2631 let pattern = match opts.name {
2632 Some(g) if !g.is_empty() => Some(glob_to_regex(g, opts.case_sensitive)?),
2633 _ => None,
2634 };
2635
2636 let mut builder = ignore::WalkBuilder::new(root);
2637 builder
2638 .hidden(opts.respect_gitignore)
2639 .ignore(opts.respect_gitignore)
2640 .git_ignore(opts.respect_gitignore)
2641 .git_global(opts.respect_gitignore)
2642 .git_exclude(opts.respect_gitignore)
2643 .parents(opts.respect_gitignore)
2644 .require_git(false)
2647 .follow_links(false);
2648 if let Some(d) = opts.max_depth {
2649 builder.max_depth(Some(d));
2650 }
2651 if opts.respect_gitignore {
2657 let mut ob = ignore::overrides::OverrideBuilder::new(root);
2658 if ob.add("!target/").is_ok() && ob.add("!node_modules/").is_ok() {
2661 if let Ok(ov) = ob.build() {
2662 builder.overrides(ov);
2663 }
2664 }
2665 }
2666
2667 let mut out: Vec<String> = Vec::new();
2668 let mut truncated = false;
2669 for result in builder.build() {
2670 let entry = match result {
2671 Ok(e) => e,
2672 Err(_) => continue,
2674 };
2675 if entry.depth() == 0 {
2677 continue;
2678 }
2679 let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false);
2680 match opts.type_filter {
2681 FindType::Files if is_dir => continue,
2682 FindType::Dirs if !is_dir => continue,
2683 _ => {}
2684 }
2685 if let Some(re) = &pattern {
2686 let base = entry.file_name().to_string_lossy();
2687 if !re.is_match(&base) {
2688 continue;
2689 }
2690 }
2691 if out.len() >= opts.max_results {
2692 truncated = true;
2693 break;
2694 }
2695 let rel = entry
2696 .path()
2697 .strip_prefix(workspace_root)
2698 .unwrap_or_else(|_| entry.path());
2699 out.push(rel.to_string_lossy().replace('\\', "/"));
2700 }
2701 out.sort();
2702 out.dedup();
2703 Ok((out, truncated))
2704}
2705
2706#[allow(clippy::too_many_arguments)]
2747pub async fn execute_tool(
2748 name: &str,
2749 args: &serde_json::Value,
2750 workspace: &str,
2751 color: bool,
2752 tool_output_lines: usize,
2753 caveats: &crate::caveats::Caveats,
2754 mcp: &mut dyn McpTools,
2755 build_check_cmd: Option<&str>,
2756 note_sink: Option<&mut dyn NoteSink>,
2757 recall_source: Option<&dyn RecallSource>,
2758 memory_source: Option<&dyn MemorySource>,
2759 permission_gate: Option<&mut dyn PermissionGate>,
2760 exec_floor: Option<&crate::caveats::Scope<String>>,
2761 git_tool: Option<&dyn GitTool>,
2762 crew_runner: Option<&dyn CrewRunner>,
2763 scratchpad_store: Option<&dyn super::scratchpad::ScratchpadStore>,
2764 code_search: Option<super::semantic::CodeSearch<'_>>,
2765 experience_store: Option<&dyn super::experiential::ExperienceStore>,
2766 step_ledger: Option<&dyn super::scheduled::StepLedger>,
2767) -> String {
2768 execute_tool_with_offload(
2769 name,
2770 args,
2771 workspace,
2772 color,
2773 tool_output_lines,
2774 caveats,
2775 mcp,
2776 build_check_cmd,
2777 note_sink,
2778 recall_source,
2779 memory_source,
2780 permission_gate,
2781 exec_floor,
2782 git_tool,
2783 crew_runner,
2784 scratchpad_store,
2785 code_search,
2786 experience_store,
2787 step_ledger,
2788 false,
2789 None,
2790 None,
2793 )
2794 .await
2795}
2796
2797#[allow(clippy::too_many_arguments)]
2798pub async fn execute_tool_with_offload(
2799 name: &str,
2800 args: &serde_json::Value,
2801 workspace: &str,
2802 color: bool,
2803 tool_output_lines: usize,
2804 caveats: &crate::caveats::Caveats,
2805 mcp: &mut dyn McpTools,
2806 build_check_cmd: Option<&str>,
2807 note_sink: Option<&mut dyn NoteSink>,
2808 recall_source: Option<&dyn RecallSource>,
2809 memory_source: Option<&dyn MemorySource>,
2810 mut permission_gate: Option<&mut dyn PermissionGate>,
2811 exec_floor: Option<&crate::caveats::Scope<String>>,
2812 git_tool: Option<&dyn GitTool>,
2813 crew_runner: Option<&dyn CrewRunner>,
2814 scratchpad_store: Option<&dyn super::scratchpad::ScratchpadStore>,
2815 code_search: Option<super::semantic::CodeSearch<'_>>,
2816 experience_store: Option<&dyn super::experiential::ExperienceStore>,
2817 step_ledger: Option<&dyn super::scheduled::StepLedger>,
2818 tool_offload: bool,
2819 spill_store: Option<&dyn SpillStore>,
2820 persona_tools: Option<&[String]>,
2826) -> String {
2827 if let Some(denied) = super::deny::deny_check(name, args) {
2835 print_tool_call(name, &args.to_string(), color);
2836 print_tool_output(&denied.reason, tool_output_lines, color);
2837 return denied.reason;
2838 }
2839
2840 if let Some(allow) = persona_tools {
2851 let canonical = match resolve_tool_alias(name) {
2852 Some(AliasOutcome::Rewrite(canonical)) => canonical,
2853 _ => name,
2854 };
2855 if !persona_tool_allowed(canonical, allow) && !mcp.handles(name) {
2856 let msg = persona_tool_denied_message(canonical);
2857 print_tool_call(name, &args.to_string(), color);
2858 print_tool_output(&msg, tool_output_lines, color);
2859 return msg;
2860 }
2861 }
2862
2863 if mcp.handles(name) {
2872 if let Some(allow) = persona_tools {
2873 if !persona_tool_allowed(name, allow) {
2874 let request = PermissionRequest {
2875 tool: name.to_string(),
2876 kind: DenialKind::RemoteTool,
2877 target: name.to_string(),
2878 reason: format!(
2879 "remote tool `{name}` is outside the active persona's tool allow-list"
2880 ),
2881 };
2882 let granted = match permission_gate {
2885 Some(gate) => {
2886 matches!(gate.ask(&[request]), PermissionDecision::Allow(_))
2887 }
2888 None => false,
2891 };
2892 if !granted {
2893 let msg = persona_tool_denied_message(name);
2894 print_tool_call(name, &args.to_string(), color);
2895 print_tool_output(&msg, tool_output_lines, color);
2896 return msg;
2897 }
2898 }
2899 }
2900 print_tool_call(name, &args.to_string(), color);
2901 let out = mcp.call(name, args).await;
2902 print_tool_output(&out, tool_output_lines, color);
2903 return out;
2904 }
2905
2906 let name = match resolve_tool_alias(name) {
2912 Some(AliasOutcome::Rewrite(canonical)) => canonical,
2913 Some(AliasOutcome::Correct(msg)) => return msg,
2914 None => name,
2915 };
2916
2917 let routed: Option<(&'static str, serde_json::Value)> =
2932 if name == "run_command" && !routing_disabled() {
2933 let command = args.get("command").and_then(|v| v.as_str()).unwrap_or("");
2934 let decision = super::routing::RouteTable::builtin().classify(command);
2935 if let Some(line) = super::routing::audit_line(command, &decision) {
2938 tracing::debug!(target: "newt::routing", "{line}");
2939 }
2940 match decision {
2941 super::routing::RouteDecision::Route { tool, args } => Some((tool, args)),
2942 super::routing::RouteDecision::Exec => None,
2943 }
2944 } else {
2945 None
2946 };
2947 let (name, args): (&str, &serde_json::Value) = match &routed {
2948 Some((tool, routed_args)) => (*tool, routed_args),
2949 None => (name, args),
2950 };
2951
2952 match name {
2953 "save_note" => match note_sink {
2958 Some(sink) => execute_save_note(args, sink, color, tool_output_lines),
2959 None => "unknown tool: save_note (no note store in this session)".to_string(),
2962 },
2963
2964 "recall" => match recall_source {
2968 Some(source) => execute_recall(args, source, color, tool_output_lines),
2969 None => "unknown tool: recall (no conversation store in this session)".to_string(),
2972 },
2973
2974 "memory_fetch" => match memory_source {
2979 Some(source) => execute_memory_fetch(args, source, color, tool_output_lines),
2980 None => "unknown tool: memory_fetch (no memory source in this session)".to_string(),
2983 },
2984
2985 "state_set" => match scratchpad_store {
2988 Some(s) => super::scratchpad::execute_state_set(args, s, color, tool_output_lines),
2989 None => "unknown tool: state_set (no scratchpad in this session)".to_string(),
2990 },
2991 "state_get" => match scratchpad_store {
2992 Some(s) => super::scratchpad::execute_state_get(args, s, color, tool_output_lines),
2993 None => "unknown tool: state_get (no scratchpad in this session)".to_string(),
2994 },
2995 "state_clear" => match scratchpad_store {
2996 Some(s) => super::scratchpad::execute_state_clear(s, color, tool_output_lines),
2997 None => "unknown tool: state_clear (no scratchpad in this session)".to_string(),
2998 },
2999
3000 "code_search" => match code_search {
3003 Some(search) => {
3004 super::semantic::execute_code_search(args, search, color, tool_output_lines).await
3005 }
3006 None => {
3007 "unknown tool: code_search (semantic retrieval is off this session)".to_string()
3008 }
3009 },
3010
3011 "experience_record" => match experience_store {
3014 Some(s) => {
3015 super::experiential::execute_experience_record(args, s, color, tool_output_lines)
3016 }
3017 None => "unknown tool: experience_record (experiential memory is off)".to_string(),
3018 },
3019 "experience_recall" => match experience_store {
3020 Some(s) => super::experiential::execute_experience_recall(
3021 args,
3022 s,
3023 super::experiential::EXPERIENCE_TOP_K,
3024 color,
3025 tool_output_lines,
3026 ),
3027 None => "unknown tool: experience_recall (experiential memory is off)".to_string(),
3028 },
3029
3030 "update_plan" => match step_ledger {
3034 Some(l) => super::scheduled::execute_update_plan(args, l, color, tool_output_lines),
3035 None => "unknown tool: update_plan (scheduled planning is off)".to_string(),
3036 },
3037 "plan_get" => match step_ledger {
3040 Some(l) => super::scheduled::execute_plan_get(l, color, tool_output_lines),
3041 None => "unknown tool: plan_get (scheduled planning is off)".to_string(),
3042 },
3043
3044 "resume_context" => super::resume::execute_resume_context(
3050 recall_source,
3051 step_ledger,
3052 scratchpad_store,
3053 color,
3054 tool_output_lines,
3055 ),
3056
3057 "request_permissions" => {
3063 execute_request_permissions(args, permission_gate, color, tool_output_lines)
3064 }
3065
3066 "request_user_input" => {
3074 execute_request_user_input(args, permission_gate, color, tool_output_lines)
3075 }
3076
3077 "tool_search" => {
3087 let query = args.get("query").and_then(|v| v.as_str()).unwrap_or("");
3088 print_tool_call("tool_search", query, color);
3089 let catalog = filter_advertised_tools(
3092 merged_tool_definitions(
3093 &*mcp,
3094 note_sink.is_some(),
3095 recall_source.is_some(),
3096 memory_source.is_some(),
3097 git_tool.is_some(),
3098 crew_runner.is_some(),
3099 scratchpad_store.is_some(),
3100 code_search.is_some(),
3101 experience_store.is_some(),
3102 step_ledger.is_some(),
3103 ),
3104 persona_tools,
3105 );
3106 let out = super::tool_search::execute_tool_search(query, &catalog);
3107 print_tool_output(&out, tool_output_lines, color);
3108 out
3109 }
3110
3111 "git" => match git_tool {
3117 Some(tool) => {
3118 let gc = crate::git_caveats::GitCaveats::from_session(caveats);
3119 let op = args.get("op").and_then(|v| v.as_str()).unwrap_or("");
3120 print_tool_call("git", op, color);
3121 let mut out = match tool.dispatch(op, args, &gc) {
3122 Ok(rendered) => rendered,
3123 Err(e) => format!("error: {e}"),
3126 };
3127 if is_git_write_denial(&out) {
3135 let granted = permission_gate
3136 .as_deref_mut()
3137 .is_some_and(|gate| git_gate_allows(gate, op));
3138 if granted {
3139 out = match tool.dispatch(op, args, &crate::git_caveats::GitCaveats::top())
3140 {
3141 Ok(rendered) => rendered,
3142 Err(e) => format!("error: {e}"),
3143 };
3144 }
3145 }
3146 print_tool_output(&out, tool_output_lines, color);
3147 out
3148 }
3149 None => "unknown tool: git (no git surface in this session)".to_string(),
3150 },
3151
3152 "compose_roster" | "crew" => match crew_runner {
3159 Some(runner) => {
3160 print_tool_call(name, &args.to_string(), color);
3161 let out = match runner.dispatch(name, args, caveats).await {
3162 Ok(rendered) => rendered,
3163 Err(e) => format!("error: {e}"),
3164 };
3165 print_tool_output(&out, tool_output_lines, color);
3166 out
3167 }
3168 None => crew_off_recovery_result(name),
3171 },
3172
3173 "run_command" => {
3174 let cmd = args["command"].as_str().unwrap_or("");
3175
3176 if let Some(tool) = run_command_redirect(cmd) {
3182 return format!(
3183 "error: '{tool}' is a tool, not a shell command. \
3184 Call it as a separate tool invocation — \
3185 do not pass '{tool}' as a command argument to run_command."
3186 );
3187 }
3188
3189 print_tool_call("run_command", cmd, color);
3190
3191 exec_confined_command(
3196 cmd,
3197 workspace,
3198 color,
3199 tool_output_lines,
3200 caveats,
3201 exec_floor,
3202 permission_gate,
3203 tool_offload,
3204 spill_store,
3205 )
3206 .await
3207 }
3208
3209 "render_report" => execute_render_report(args, color),
3218
3219 "lifecycle" => {
3220 let phase_key = args.get("phase").and_then(|v| v.as_str()).unwrap_or("");
3221 let Some(phase) = crate::tooling::Phase::from_key(phase_key) else {
3222 let valid = crate::tooling::Phase::ALL
3223 .iter()
3224 .map(|p| p.as_str())
3225 .collect::<Vec<_>>()
3226 .join(", ");
3227 return format!(
3228 "error: unknown lifecycle phase '{phase_key}'. Valid phases: {valid}."
3229 );
3230 };
3231 let action = args.get("action").and_then(|v| v.as_str()).unwrap_or("run");
3232 let cmds =
3237 crate::tooling::resolved_phase_commands(std::path::Path::new(workspace), phase);
3238 if cmds.is_empty() {
3239 return format!(
3240 "no command configured for lifecycle phase '{}'. Set it in \
3241 .newt/config.toml [lifecycle] or a tooling pack; `lifecycle` \
3242 only runs commands the project declares.",
3243 phase.as_str()
3244 );
3245 }
3246 let joined = cmds.join(" && ");
3247 match action {
3248 "list" => format!("lifecycle {} → {joined}", phase.as_str()),
3249 "run" => {
3250 print_tool_call(
3251 "lifecycle",
3252 &format!("{} → {joined}", phase.as_str()),
3253 color,
3254 );
3255 exec_confined_command(
3256 &joined,
3257 workspace,
3258 color,
3259 tool_output_lines,
3260 caveats,
3261 exec_floor,
3262 permission_gate,
3263 tool_offload,
3264 spill_store,
3265 )
3266 .await
3267 }
3268 other => format!(
3269 "error: unknown lifecycle action '{other}'. Use 'run' (default) or 'list'."
3270 ),
3271 }
3272 }
3273
3274 "read_file" => {
3275 let path = args["path"].as_str().unwrap_or("");
3276 let full = std::path::Path::new(workspace).join(path);
3277 let full_str = full.to_string_lossy();
3278 if !tui_permits_path(&caveats.fs_read, &full_str) {
3279 let allowed = permission_gate.is_some_and(|gate| {
3282 fs_gate_allows(gate, "read_file", DenialKind::FsRead, &full_str, |c| {
3283 &c.fs_read
3284 })
3285 });
3286 if !allowed {
3287 let msg = denied_fs_result("fs_read", path);
3288 print_denied("fs_read", path, color);
3289 return msg;
3290 }
3291 }
3292 print_tool_call("read_file", path, color);
3293 match std::fs::read_to_string(&full) {
3294 Ok(contents) => {
3295 let offset = args["offset"].as_u64().map(|n| n as usize);
3299 let limit = args["limit"].as_u64().map(|n| n as usize);
3300 let out = paginate_read(&contents, offset, limit, max_output_tokens());
3303 print_tool_output(&out, tool_output_lines, color);
3304 out
3305 }
3306 Err(e) => format!("error reading {path}: {e}"),
3307 }
3308 }
3309
3310 "write_file" => {
3311 let path = args["path"].as_str().unwrap_or("");
3312 let content = args["content"].as_str().unwrap_or("");
3313 let full = std::path::Path::new(workspace).join(path);
3314 let full_str = full.to_string_lossy();
3315 if !tui_permits_path(&caveats.fs_write, &full_str) {
3316 let allowed = permission_gate.as_deref_mut().is_some_and(|gate| {
3321 fs_gate_allows(gate, "write_file", DenialKind::FsWrite, &full_str, |c| {
3322 &c.fs_write
3323 })
3324 });
3325 if !allowed {
3326 let msg = denied_fs_result("fs_write", path);
3327 print_denied("fs_write", path, color);
3328 return msg;
3329 }
3330 }
3331
3332 if let Ok(existing) = std::fs::read_to_string(&full) {
3337 let orig_lines = existing.lines().count();
3338 let new_lines = content.lines().count();
3339 let removed = orig_lines.saturating_sub(new_lines);
3340 if removed > 30 && new_lines < orig_lines * 7 / 10 {
3341 let pct = removed * 100 / orig_lines.max(1);
3342 let msg = format!(
3343 "error: write_file would shrink {path} from {orig_lines} → {new_lines} lines \
3344 (-{pct}%). This is likely unintentional. Use edit_file to make targeted \
3345 changes, or ensure your content includes the full file."
3346 );
3347 print_denied("shrink-guard", path, color);
3348 return msg;
3349 }
3350 }
3351
3352 print_tool_call(
3353 "write_file",
3354 &format!("{path} ({} bytes)", content.len()),
3355 color,
3356 );
3357
3358 let preview: String = content.lines().take(20).collect::<Vec<_>>().join("\n");
3360 let has_more = content.lines().count() > 20;
3361 print_tool_output(
3362 &format!("{preview}{}", if has_more { "\n…" } else { "" }),
3363 tool_output_lines,
3364 color,
3365 );
3366
3367 let confirmed = confirm_unrestricted_fs_mutation(
3372 caveats,
3373 &mut permission_gate,
3374 "Write this file? [y/N]",
3375 );
3376
3377 if confirmed {
3378 let full = std::path::Path::new(workspace).join(path);
3379 if let Some(parent) = full.parent() {
3380 let _ = std::fs::create_dir_all(parent);
3381 }
3382 match std::fs::write(&full, content) {
3383 Ok(_) => {
3384 let line_count = content.lines().count();
3385 println!("✓ wrote {path} ({line_count} lines)");
3386 let check = build_check_cmd
3387 .map(|cmd| run_build_check(cmd, workspace))
3388 .unwrap_or_default();
3389 format!("wrote {path} ({line_count} lines){check}")
3390 }
3391 Err(e) => format!("error writing {path}: {e}"),
3392 }
3393 } else {
3394 println!("skipped");
3395 format!("user declined to write {path}")
3396 }
3397 }
3398
3399 "delete_file" => {
3400 let path = args["path"].as_str().unwrap_or("");
3401 if path.trim().is_empty() {
3402 return "error: path is required".to_string();
3403 }
3404 let full = std::path::Path::new(workspace).join(path);
3405 let full_str = full.to_string_lossy();
3406 if !tui_permits_path(&caveats.fs_write, &full_str) {
3407 let allowed = permission_gate.as_deref_mut().is_some_and(|gate| {
3412 fs_gate_allows(gate, "delete_file", DenialKind::FsWrite, &full_str, |c| {
3413 &c.fs_write
3414 })
3415 });
3416 if !allowed {
3417 let msg = denied_fs_result("fs_write", path);
3418 print_denied("fs_write", path, color);
3419 return msg;
3420 }
3421 }
3422
3423 let meta = match std::fs::symlink_metadata(&full) {
3424 Ok(meta) => meta,
3425 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
3426 return format!("error deleting {path}: file does not exist");
3427 }
3428 Err(e) => return format!("error deleting {path}: {e}"),
3429 };
3430 if meta.file_type().is_dir() {
3431 return format!("error deleting {path}: delete_file refuses directories");
3432 }
3433
3434 print_tool_call("delete_file", path, color);
3435 let confirmed = confirm_unrestricted_fs_mutation(
3436 caveats,
3437 &mut permission_gate,
3438 "Delete this file? [y/N]",
3439 );
3440
3441 if !confirmed {
3442 println!("skipped");
3443 return format!("user declined to delete {path}");
3444 }
3445
3446 match std::fs::remove_file(&full) {
3447 Ok(_) => {
3448 println!("✓ deleted {path}");
3449 let check = build_check_cmd
3450 .map(|cmd| run_build_check(cmd, workspace))
3451 .unwrap_or_default();
3452 format!("deleted {path}{check}")
3453 }
3454 Err(e) => format!("error deleting {path}: {e}"),
3455 }
3456 }
3457
3458 "edit_file" => {
3459 let path = args["path"].as_str().unwrap_or("");
3460 let old_string = args["old_string"].as_str().unwrap_or("");
3461 let new_string = args["new_string"].as_str().unwrap_or("");
3462 let full = std::path::Path::new(workspace).join(path);
3463 let full_str = full.to_string_lossy();
3464 if !tui_permits_path(&caveats.fs_write, &full_str) {
3465 let allowed = permission_gate.is_some_and(|gate| {
3467 fs_gate_allows(gate, "edit_file", DenialKind::FsWrite, &full_str, |c| {
3468 &c.fs_write
3469 })
3470 });
3471 if !allowed {
3472 let msg = denied_fs_result("fs_write", path);
3473 print_denied("fs_write", path, color);
3474 return msg;
3475 }
3476 }
3477 if old_string.is_empty() {
3478 return "error: old_string must not be empty — use write_file to create new files"
3479 .to_string();
3480 }
3481 let existing = match std::fs::read_to_string(&full) {
3482 Ok(s) => s,
3483 Err(e) => return format!("error reading {path}: {e}"),
3484 };
3485 let count = existing.matches(old_string).count();
3486 if count == 0 {
3487 const HEAD: usize = 40;
3493 let total = existing.lines().count();
3494 let head: String = existing
3495 .lines()
3496 .take(HEAD)
3497 .map(|l| format!(" {l}"))
3498 .collect::<Vec<_>>()
3499 .join("\n");
3500 let more = if total > HEAD {
3501 format!("\n … ({} more line(s))", total - HEAD)
3502 } else {
3503 String::new()
3504 };
3505 return format!(
3506 "error: old_string not found in {path} — do not guess again. Copy the \
3507 EXACT text (including leading whitespace) from the contents below, then \
3508 retry. To add a header/first line, set old_string to the shown first \
3509 line and put your header + that line in new_string; to create a new \
3510 file use write_file.\n--- {path} (first {shown} of {total} line(s)) ---\n{head}{more}",
3511 shown = total.min(HEAD),
3512 );
3513 }
3514 if count > 1 {
3515 return format!(
3516 "error: old_string matches {count} locations in {path}. \
3517 Add more surrounding context to make it unique."
3518 );
3519 }
3520 let updated = existing.replacen(old_string, new_string, 1);
3521 let old_lines = existing.lines().count();
3522 let new_lines = updated.lines().count();
3523 let delta = new_lines as i64 - old_lines as i64;
3524 let delta_str = if delta >= 0 {
3525 format!("+{delta}")
3526 } else {
3527 format!("{delta}")
3528 };
3529 print_tool_call("edit_file", &format!("{path} ({delta_str} lines)"), color);
3530 match std::fs::write(&full, &updated) {
3531 Ok(_) => {
3532 println!("✓ edited {path} ({delta_str} lines, now {new_lines} total)");
3533 let check = build_check_cmd
3534 .map(|cmd| run_build_check(cmd, workspace))
3535 .unwrap_or_default();
3536 format!("edited {path} ({delta_str} lines, now {new_lines} total){check}")
3537 }
3538 Err(e) => format!("error writing {path}: {e}"),
3539 }
3540 }
3541
3542 "list_dir" => {
3543 let path = args["path"].as_str().unwrap_or(".");
3544 let full = std::path::Path::new(workspace).join(path);
3545 let full_str = full.to_string_lossy();
3546 if !tui_permits_path(&caveats.fs_read, &full_str) {
3547 let allowed = permission_gate.is_some_and(|gate| {
3549 fs_gate_allows(gate, "list_dir", DenialKind::FsRead, &full_str, |c| {
3550 &c.fs_read
3551 })
3552 });
3553 if !allowed {
3554 let msg = denied_fs_result("fs_read", path);
3555 print_denied("fs_read", path, color);
3556 return msg;
3557 }
3558 }
3559 print_tool_call("list_dir", path, color);
3560 match std::fs::read_dir(&full) {
3561 Ok(entries) => {
3562 let mut names: Vec<String> = entries
3563 .flatten()
3564 .map(|e| e.file_name().to_string_lossy().into_owned())
3565 .collect();
3566 names.sort();
3567 let listing = names.join("\n");
3568 print_tool_output(&listing, tool_output_lines, color);
3569 listing
3570 }
3571 Err(e) => format!("error: {e}"),
3572 }
3573 }
3574
3575 "find" => {
3580 let path = args["path"].as_str().unwrap_or(".");
3581 let full = std::path::Path::new(workspace).join(path);
3582 let full_str = full.to_string_lossy();
3583 if !tui_permits_path(&caveats.fs_read, &full_str) {
3584 let allowed = permission_gate.is_some_and(|gate| {
3585 fs_gate_allows(gate, "find", DenialKind::FsRead, &full_str, |c| &c.fs_read)
3586 });
3587 if !allowed {
3588 let msg = denied_fs_result("fs_read", path);
3589 print_denied("fs_read", path, color);
3590 return msg;
3591 }
3592 }
3593 let opts = FindOpts {
3594 name: args["name"].as_str(),
3595 type_filter: match args["type"].as_str() {
3596 Some("f") => FindType::Files,
3597 Some("d") => FindType::Dirs,
3598 _ => FindType::Any,
3599 },
3600 max_depth: args["max_depth"].as_u64().map(|d| d as usize),
3601 max_results: args["max_results"]
3602 .as_u64()
3603 .map(|m| m as usize)
3604 .unwrap_or(1000),
3605 respect_gitignore: args["respect_gitignore"].as_bool().unwrap_or(true),
3606 case_sensitive: args["case_sensitive"].as_bool().unwrap_or(true),
3607 };
3608 print_tool_call("find", &find_detail(path, &opts), color);
3611 if !full.exists() {
3612 return format!("error: no such path '{path}'");
3613 }
3614 if let (Ok(ws_canon), Ok(root_canon)) = (
3618 std::path::Path::new(workspace).canonicalize(),
3619 full.canonicalize(),
3620 ) {
3621 if !root_canon.starts_with(&ws_canon) {
3622 let msg = denied_fs_result("fs_read", path);
3623 print_denied("fs_read", path, color);
3624 return msg;
3625 }
3626 }
3627 match find_walk(&full, std::path::Path::new(workspace), &opts) {
3628 Ok((hits, truncated)) => {
3629 let mut listing = if hits.is_empty() {
3630 "no matches".to_string()
3631 } else {
3632 hits.join("\n")
3633 };
3634 if truncated {
3635 listing
3636 .push_str(&format!("\n… (truncated at {} matches)", opts.max_results));
3637 }
3638 print_tool_output(&listing, tool_output_lines, color);
3639 listing
3640 }
3641 Err(e) => format!("error: {e}"),
3642 }
3643 }
3644
3645 "use_skill" => {
3646 let skill_name = args["name"].as_str().unwrap_or("");
3647 print_tool_call("use_skill", skill_name, color);
3648 let dirs = crate::Config::resolve()
3656 .map(|c| c.skill_search_dirs())
3657 .unwrap_or_default();
3658 match newt_skills::load_body_from(&dirs, skill_name) {
3659 Ok(body) => {
3660 print_tool_output(&body, tool_output_lines, color);
3661 body
3662 }
3663 Err(e) => format!("error: {e}"),
3664 }
3665 }
3666
3667 "web_fetch" => {
3668 let url = args["url"].as_str().unwrap_or("");
3669 print_tool_call("web_fetch", url, color);
3670
3671 let mut fetch_args = serde_json::json!({ "url": url });
3678 if let Some(max_bytes) = args.get("max_bytes").and_then(serde_json::Value::as_u64) {
3679 fetch_args["max_bytes"] = serde_json::json!(max_bytes);
3680 }
3681 let widened_for_net = match (permission_gate, host_of_url(url)) {
3687 (Some(gate), Some(host)) if !caveats.permits_net(&host) => {
3688 let request = PermissionRequest {
3689 tool: "web_fetch".to_string(),
3690 kind: DenialKind::Net,
3691 target: host.clone(),
3692 reason: format!("net does not permit '{host}'"),
3693 };
3694 match gate.ask(std::slice::from_ref(&request)) {
3695 PermissionDecision::Allow(widened) => Some(widened),
3696 PermissionDecision::Deny => None,
3697 }
3698 }
3699 _ => None,
3700 };
3701 let effective_caveats = widened_for_net.as_ref().unwrap_or(caveats);
3702 match agent_bridle::registry()
3703 .dispatch("web_fetch", fetch_args, effective_caveats)
3704 .await
3705 {
3706 Ok(result) => {
3707 let markdown = result
3708 .get("markdown")
3709 .and_then(serde_json::Value::as_str)
3710 .unwrap_or("");
3711 let title = result
3712 .get("title")
3713 .and_then(serde_json::Value::as_str)
3714 .unwrap_or("");
3715 let final_url = result
3716 .get("final_url")
3717 .and_then(serde_json::Value::as_str)
3718 .unwrap_or(url);
3719 let out = if title.is_empty() {
3720 format!("{final_url}\n\n{markdown}")
3721 } else {
3722 format!("# {title}\n{final_url}\n\n{markdown}")
3723 };
3724 print_tool_output(&out, tool_output_lines, color);
3725 out
3726 }
3727 Err(e) => format!("error: {e}"),
3730 }
3731 }
3732
3733 other => unknown_tool_message(other),
3734 }
3735}
3736
3737pub(crate) fn tool_result_ok(result: &str) -> bool {
3745 let r = result.trim_start();
3746 !(r.starts_with("error:")
3747 || r.starts_with("capability denied:")
3748 || r.starts_with("unknown tool"))
3749}
3750
3751#[cfg(test)]
3752mod tests {
3753 use super::*;
3754 use crate::agentic::NoMcp;
3755
3756 #[test]
3759 fn classify_phantom_rewrite_alias() {
3760 let got = classify_phantom_reach("bash", &serde_json::json!({"command": "ls"}), "ok", true);
3762 assert_eq!(
3763 got,
3764 Some(crate::PhantomResolution::Rewrite("run_command".into()))
3765 );
3766 }
3767
3768 #[test]
3769 fn classify_phantom_correct_alias() {
3770 let got = classify_phantom_reach(
3772 "str_replace_editor",
3773 &serde_json::json!({}),
3774 "ignored",
3775 false,
3776 );
3777 match got {
3778 Some(crate::PhantomResolution::Correct(msg)) => {
3779 assert!(msg.contains("edit_file"), "guidance names the tool: {msg}");
3780 }
3781 other => panic!("expected Correct, got {other:?}"),
3782 }
3783 }
3784
3785 #[test]
3786 fn classify_phantom_unknown_name() {
3787 let got = classify_phantom_reach(
3791 "summon_kraken",
3792 &serde_json::json!({}),
3793 "unknown tool: summon_kraken",
3794 false,
3795 );
3796 assert_eq!(got, Some(crate::PhantomResolution::Unknown));
3797 }
3798
3799 #[test]
3800 fn classify_phantom_plan_alias_is_correct() {
3801 let got = classify_phantom_reach("make_plan", &serde_json::json!({}), "ignored", false);
3805 match got {
3806 Some(crate::PhantomResolution::Correct(msg)) => {
3807 assert!(
3808 msg.contains("update_plan"),
3809 "guidance names the tool: {msg}"
3810 );
3811 }
3812 other => panic!("expected Correct, got {other:?}"),
3813 }
3814 }
3815
3816 #[test]
3817 fn classify_phantom_state_get_miss() {
3818 let got = classify_phantom_reach(
3820 "state_get",
3821 &serde_json::json!({"key": "nope"}),
3822 "no such key: nope",
3823 true,
3824 );
3825 assert_eq!(
3826 got,
3827 Some(crate::PhantomResolution::RealToolMiss(
3828 "state_get on an unset key".into()
3829 ))
3830 );
3831 }
3832
3833 #[test]
3834 fn classify_phantom_recall_miss() {
3835 let got = classify_phantom_reach(
3837 "recall",
3838 &serde_json::json!({"query": "zzz"}),
3839 "no matches in past conversations for \"zzz\" — try different keywords",
3840 true,
3841 );
3842 assert_eq!(
3843 got,
3844 Some(crate::PhantomResolution::RealToolMiss(
3845 "recall returned no matches".into()
3846 ))
3847 );
3848 }
3849
3850 #[test]
3851 fn classify_phantom_resume_reach_is_a_rewrite() {
3852 let got = classify_phantom_reach("where_were_we", &serde_json::json!({}), "ignored", false);
3855 assert_eq!(
3856 got,
3857 Some(crate::PhantomResolution::Rewrite("resume_context".into()))
3858 );
3859 }
3860
3861 #[test]
3862 fn classify_phantom_real_success_is_none() {
3863 let got = classify_phantom_reach(
3865 "read_file",
3866 &serde_json::json!({"path": "src/lib.rs"}),
3867 "line 1\nline 2\n",
3868 true,
3869 );
3870 assert_eq!(got, None);
3871 }
3872
3873 #[test]
3876 fn tool_search_is_a_real_tool_name() {
3877 assert!(ALL_TOOL_NAMES.contains(&"tool_search"));
3880 }
3881
3882 #[test]
3883 fn discovery_verbs_alias_to_tool_search() {
3884 for verb in [
3887 "find_tool",
3888 "search_tools",
3889 "list_tools",
3890 "which_tool",
3891 "available_tools",
3892 "what_tools",
3893 "tools",
3894 ] {
3895 match resolve_tool_alias(verb) {
3896 Some(AliasOutcome::Rewrite(c)) => assert_eq!(c, "tool_search", "verb: {verb}"),
3897 other => panic!(
3898 "expected Rewrite(tool_search) for {verb}, got something else: {}",
3899 other.is_some()
3900 ),
3901 }
3902 }
3903 }
3904
3905 #[test]
3906 fn tool_search_is_not_an_alias_of_itself() {
3907 assert!(resolve_tool_alias("tool_search").is_none());
3909 }
3910
3911 #[test]
3912 fn classify_phantom_discovery_reach_is_a_rewrite() {
3913 let got = classify_phantom_reach("find_tool", &serde_json::json!({}), "ignored", false);
3916 assert_eq!(
3917 got,
3918 Some(crate::PhantomResolution::Rewrite("tool_search".into()))
3919 );
3920 }
3921
3922 #[test]
3923 fn classify_phantom_tool_search_real_call_is_none() {
3924 let got = classify_phantom_reach(
3926 "tool_search",
3927 &serde_json::json!({"query": "read"}),
3928 "Tools matching \"read\":\n- read_file — Read a file",
3929 true,
3930 );
3931 assert_eq!(got, None);
3932 }
3933
3934 #[test]
3935 fn tool_search_is_not_a_hallucination() {
3936 assert!(!is_hallucination(
3937 "tool_search",
3938 &serde_json::json!({"query": "x"})
3939 ));
3940 }
3941
3942 #[test]
3945 fn paginate_read_caps_a_large_file_to_the_default_window() {
3946 let body: String = (1..=15_057)
3949 .map(|n| format!("line {n}"))
3950 .collect::<Vec<_>>()
3951 .join("\n");
3952 let out = paginate_read(&body, None, None, DEFAULT_MAX_OUTPUT_TOKENS);
3953 let lines: Vec<&str> = out.lines().collect();
3954 assert_eq!(lines[0], "line 1");
3955 assert_eq!(lines[1999], "line 2000");
3956 assert!(
3957 !out.contains("line 2001"),
3958 "window stops at 2000: {:?}",
3959 &out[..40]
3960 );
3961 assert!(out.contains("of 15057"), "footer names the total");
3962 assert!(
3963 out.contains("offset=2001"),
3964 "footer points at the next window"
3965 );
3966 }
3967
3968 #[test]
3969 fn paginate_read_offset_and_limit_return_just_that_window() {
3970 let body: String = (1..=100)
3971 .map(|n| format!("L{n}"))
3972 .collect::<Vec<_>>()
3973 .join("\n");
3974 let out = paginate_read(&body, Some(10), Some(5), DEFAULT_MAX_OUTPUT_TOKENS);
3975 assert!(out.starts_with("L10\nL11\nL12\nL13\nL14"), "{out:?}");
3976 assert!(out.contains("offset=15"), "continues at line 15: {out:?}");
3977 }
3978
3979 #[test]
3980 fn paginate_read_small_file_is_returned_verbatim_without_a_footer() {
3981 assert_eq!(
3983 paginate_read("a\nb\nc\n", None, None, DEFAULT_MAX_OUTPUT_TOKENS),
3984 "a\nb\nc\n"
3985 );
3986 }
3987
3988 #[test]
3989 fn paginate_read_char_backstop_tracks_the_token_budget() {
3990 let budget = 1_000;
3995 let max_chars = crate::tokens::TokenEstimation::default().chars_for_tokens(budget);
3996 let body = "x".repeat(50_000);
3997 let out = paginate_read(&body, None, None, budget);
3998 assert!(
3999 out.len() < max_chars + 300,
4000 "char-capped to the token budget (~{max_chars} chars): {} bytes",
4001 out.len()
4002 );
4003 assert!(out.contains("truncated"), "marks the truncation");
4004 assert!(
4005 out.contains("~1000 tokens"),
4006 "footer names the token budget: {out:?}"
4007 );
4008
4009 let wide = paginate_read(&body, None, None, 4_000);
4012 assert!(
4013 wide.len() > out.len(),
4014 "a wider token budget keeps more chars: {} vs {}",
4015 wide.len(),
4016 out.len()
4017 );
4018 }
4019
4020 #[test]
4021 fn paginate_read_zero_budget_disables_the_char_backstop() {
4022 let body = "y".repeat(500_000);
4025 let out = paginate_read(&body, None, None, 0);
4026 assert_eq!(out, body, "zero budget = no char backstop");
4027 }
4028
4029 #[test]
4030 fn paginate_read_offset_past_end_is_a_clear_message() {
4031 let out = paginate_read("a\nb", Some(99), None, DEFAULT_MAX_OUTPUT_TOKENS);
4032 assert!(out.contains("past end"), "{out:?}");
4033 }
4034
4035 #[test]
4038 fn cap_model_output_passes_small_output_through_unchanged() {
4039 let small = "hello\nworld\n";
4041 assert_eq!(cap_model_output(small, DEFAULT_MAX_OUTPUT_TOKENS), small);
4042 }
4043
4044 #[test]
4045 fn cap_model_output_truncates_over_budget_as_head_tail() {
4046 let big = format!("HEAD_MARKER\n{}\nTAIL_MARKER", "middle\n".repeat(20_000));
4047 let out = cap_model_output_with_handle(&big, 1_000, 100, None);
4048 assert!(out.len() < big.len(), "must shrink: {} bytes", out.len());
4049 assert!(out.contains("HEAD_MARKER"), "head dropped: {out:?}");
4050 assert!(out.contains("TAIL_MARKER"), "tail dropped: {out:?}");
4051 assert!(out.contains("head+tail shown"), "marker present: {out:?}");
4052 assert!(
4053 !out.contains(&"middle\n".repeat(1_000)),
4054 "middle should be elided"
4055 );
4056 }
4057
4058 #[test]
4059 fn cap_model_output_truncates_at_a_char_boundary() {
4060 let budget = 10; let body = "é".repeat(1_000); let out = cap_model_output(&body, budget);
4065 assert!(out.is_char_boundary(out.len()), "valid boundary");
4066 assert!(
4067 out.chars()
4068 .all(|c| c == 'é' || !c.is_control() || c == '\n'),
4069 "no split char: {out:?}"
4070 );
4071 }
4072
4073 #[test]
4074 fn cap_model_output_zero_budget_is_no_cap() {
4075 let body = "z".repeat(500_000);
4076 assert_eq!(cap_model_output(&body, 0), body);
4077 }
4078
4079 #[test]
4080 fn token_to_char_math_uses_the_default_four_chars_per_token() {
4081 let est = crate::tokens::TokenEstimation::default();
4084 assert_eq!(est.chars_for_tokens(DEFAULT_MAX_OUTPUT_TOKENS), 40_000);
4085 }
4086
4087 #[test]
4088 fn find_detail_bare_path_has_no_filters() {
4089 let opts = FindOpts {
4090 name: None,
4091 type_filter: FindType::Any,
4092 max_depth: None,
4093 max_results: 1000,
4094 respect_gitignore: true,
4095 case_sensitive: true,
4096 };
4097 assert_eq!(find_detail(".", &opts), ".");
4098 }
4099
4100 #[test]
4101 fn find_detail_shows_only_non_default_filters() {
4102 let opts = FindOpts {
4103 name: Some("*.rs"),
4104 type_filter: FindType::Files,
4105 max_depth: Some(2),
4106 max_results: 50,
4107 respect_gitignore: false,
4108 case_sensitive: false,
4109 };
4110 assert_eq!(
4111 find_detail("src", &opts),
4112 "src (name=*.rs, type=f, depth=2, max=50, no-gitignore, icase)"
4113 );
4114 }
4115
4116 #[test]
4117 fn find_detail_omits_each_default_independently() {
4118 let opts = FindOpts {
4119 name: None,
4120 type_filter: FindType::Dirs,
4121 max_depth: None,
4122 max_results: 1000,
4123 respect_gitignore: true,
4124 case_sensitive: true,
4125 };
4126 assert_eq!(find_detail(".", &opts), ". (type=d)");
4127 }
4128
4129 #[test]
4130 fn use_skill_tool_is_advertised_in_definitions() {
4131 let defs = tool_definitions();
4132 let names: Vec<&str> = defs
4133 .as_array()
4134 .unwrap()
4135 .iter()
4136 .filter_map(|d| d["function"]["name"].as_str())
4137 .collect();
4138 assert!(names.contains(&"use_skill"), "got: {names:?}");
4139 }
4140
4141 #[test]
4142 fn merged_tool_definitions_with_empty_mcp_is_builtin_set() {
4143 let merged = merged_tool_definitions(
4144 &NoMcp, false, false, false, false, false, false, false, false, false,
4145 );
4146 let names: Vec<&str> = merged
4147 .as_array()
4148 .unwrap()
4149 .iter()
4150 .filter_map(|d| d["function"]["name"].as_str())
4151 .collect();
4152 assert_eq!(
4153 names,
4154 vec![
4155 "run_command",
4156 "read_file",
4157 "write_file",
4158 "edit_file",
4159 "delete_file",
4160 "list_dir",
4161 "find",
4162 "use_skill",
4163 "web_fetch",
4164 "request_permissions",
4167 "resume_context",
4170 "tool_search",
4173 "get_context_remaining",
4176 "request_user_input",
4179 "lifecycle",
4183 "render_report",
4187 ]
4188 );
4189 }
4190
4191 #[test]
4196 fn persona_allow_list_filters_the_advertised_catalog() {
4197 let full =
4198 merged_tool_definitions(&NoMcp, true, true, true, true, true, true, true, true, true);
4199 let name_set = |v: &serde_json::Value| -> Vec<String> {
4200 v.as_array()
4201 .unwrap()
4202 .iter()
4203 .filter_map(|d| d["function"]["name"].as_str().map(str::to_owned))
4204 .collect()
4205 };
4206 assert_eq!(
4208 name_set(&filter_advertised_tools(full.clone(), None)),
4209 name_set(&full),
4210 "None must be a no-op"
4211 );
4212 let allow = vec!["read_file".to_string()];
4215 let got = name_set(&filter_advertised_tools(full, Some(&allow)));
4216 assert!(got.iter().any(|n| n == "read_file"), "granted tool kept");
4217 for denied in [
4218 "write_file",
4219 "edit_file",
4220 "delete_file",
4221 "run_command",
4222 "list_dir",
4223 ] {
4224 assert!(
4225 !got.iter().any(|n| n == denied),
4226 "{denied} must be filtered out"
4227 );
4228 }
4229 for infra in [
4230 "resume_context",
4231 "tool_search",
4232 "get_context_remaining",
4233 "request_user_input",
4234 "lifecycle",
4235 ] {
4236 assert!(
4237 got.iter().any(|n| n == infra),
4238 "{infra} is always-on and must survive any persona"
4239 );
4240 }
4241 }
4242
4243 #[test]
4248 fn persona_tool_allowed_admits_named_and_always_on_only() {
4249 let allow = vec!["read_file".to_string()];
4250 assert!(persona_tool_allowed("read_file", &allow), "named → allowed");
4251 assert!(
4252 persona_tool_allowed("request_user_input", &allow),
4253 "always-on infra → allowed even when unlisted"
4254 );
4255 assert!(
4256 !persona_tool_allowed("write_file", &allow),
4257 "unlisted non-infra → denied"
4258 );
4259 assert!(
4260 !persona_tool_allowed("delete_file", &allow),
4261 "unlisted non-infra → denied"
4262 );
4263 }
4264
4265 #[tokio::test]
4271 async fn executor_refuses_tools_outside_the_persona_allow_list() {
4272 let ws = tempfile::TempDir::new().unwrap();
4273 let caveats = crate::caveats::Caveats::top();
4274 let allow = vec!["read_file".to_string()];
4275 let target = ws.path().join("blocked.txt");
4278 let args = serde_json::json!({
4279 "path": target.to_string_lossy(),
4280 "content": "should never be written",
4281 });
4282 let out = call_offload("write_file", &args, &ws, &caveats, Some(&allow)).await;
4283 assert!(
4284 out.contains("not available under the active persona"),
4285 "expected persona refusal, got: {out}"
4286 );
4287 assert!(!target.exists(), "a denied write must not touch the fs");
4288 let infra = call_offload(
4290 "get_context_remaining",
4291 &serde_json::json!({}),
4292 &ws,
4293 &caveats,
4294 Some(&allow),
4295 )
4296 .await;
4297 assert!(
4298 !infra.contains("not available under the active persona"),
4299 "always-on infra must not be refused: {infra}"
4300 );
4301 }
4302
4303 #[tokio::test]
4309 async fn executor_enforces_the_absolute_deny_list() {
4310 let ws = tempfile::TempDir::new().unwrap();
4311 let caveats = crate::caveats::Caveats::top(); let denied = call_offload(
4313 "run_command",
4314 &serde_json::json!({ "command": "ssh host 'uptime'" }),
4315 &ws,
4316 &caveats,
4317 None, )
4319 .await;
4320 assert!(
4321 denied.contains("absolute deny-list"),
4322 "ssh must hit the deny-list, got: {denied}"
4323 );
4324 let ok = call_offload(
4326 "run_command",
4327 &serde_json::json!({ "command": "echo coaching" }),
4328 &ws,
4329 &caveats,
4330 None,
4331 )
4332 .await;
4333 assert!(
4334 !ok.contains("absolute deny-list"),
4335 "an ordinary command must not be denied, got: {ok}"
4336 );
4337 }
4338
4339 async fn call_offload(
4342 name: &str,
4343 args: &serde_json::Value,
4344 ws: &tempfile::TempDir,
4345 caveats: &crate::caveats::Caveats,
4346 persona_tools: Option<&[String]>,
4347 ) -> String {
4348 execute_tool_with_offload(
4349 name,
4350 args,
4351 &ws.path().to_string_lossy(),
4352 false,
4353 20,
4354 caveats,
4355 &mut NoMcp,
4356 None, None, None, None, None, None, None, None, None, None, None, None, false, None, persona_tools,
4371 )
4372 .await
4373 }
4374
4375 #[test]
4379 fn registry_specs_match_their_definition_names() {
4380 for spec in EXTENDED_TOOL_REGISTRY {
4381 let def = (spec.definition)();
4382 assert_eq!(
4383 def["function"]["name"].as_str(),
4384 Some(spec.name),
4385 "ToolSpec name {:?} != definition name",
4386 spec.name
4387 );
4388 }
4389 }
4390
4391 #[test]
4394 fn builtin_tool_names_are_unique() {
4395 let mut seen = std::collections::HashSet::new();
4396 for name in ALL_TOOL_NAMES.iter() {
4397 assert!(seen.insert(*name), "duplicate built-in tool name: {name}");
4398 }
4399 }
4400
4401 #[test]
4407 fn advertised_set_matches_all_tool_names_both_directions() {
4408 let all =
4409 merged_tool_definitions(&NoMcp, true, true, true, true, true, true, true, true, true);
4410 let advertised: std::collections::HashSet<&str> = all
4411 .as_array()
4412 .unwrap()
4413 .iter()
4414 .filter_map(|d| d["function"]["name"].as_str())
4415 .collect();
4416 let names: std::collections::HashSet<&str> = ALL_TOOL_NAMES.iter().copied().collect();
4417 for a in &advertised {
4419 assert!(
4420 names.contains(a),
4421 "advertised but not in ALL_TOOL_NAMES: {a}"
4422 );
4423 }
4424 for n in &names {
4426 assert!(
4427 advertised.contains(n),
4428 "in ALL_TOOL_NAMES but never advertised: {n}"
4429 );
4430 }
4431 }
4432
4433 #[test]
4436 fn base_tool_names_match_tool_definitions() {
4437 let defs = tool_definitions();
4438 let base: Vec<&str> = defs
4439 .as_array()
4440 .unwrap()
4441 .iter()
4442 .filter_map(|d| d["function"]["name"].as_str())
4443 .collect();
4444 assert_eq!(base, BASE_TOOL_NAMES);
4445 }
4446
4447 #[test]
4453 fn lifecycle_is_a_real_tool_name_not_a_hallucination() {
4454 assert!(
4455 ALL_TOOL_NAMES.contains(&"lifecycle"),
4456 "lifecycle must be a real tool name"
4457 );
4458 assert!(
4459 !is_hallucination("lifecycle", &serde_json::json!({"phase": "test"})),
4460 "a real lifecycle call must not be flagged as a hallucination"
4461 );
4462 }
4463
4464 #[test]
4465 fn lifecycle_definition_enum_matches_phase_vocabulary() {
4466 let def = lifecycle_tool_definition();
4469 assert_eq!(def["function"]["name"], "lifecycle");
4470 let enum_vals: Vec<&str> = def["function"]["parameters"]["properties"]["phase"]["enum"]
4471 .as_array()
4472 .unwrap()
4473 .iter()
4474 .map(|v| v.as_str().unwrap())
4475 .collect();
4476 let vocab: Vec<&str> = crate::tooling::Phase::ALL
4477 .iter()
4478 .map(|p| p.as_str())
4479 .collect();
4480 assert_eq!(enum_vals, vocab);
4481 }
4482
4483 #[test]
4484 fn run_phase_aliases_route_to_lifecycle() {
4485 for a in ["run_phase", "run_lifecycle", "lifecycle_run"] {
4486 assert!(
4487 matches!(
4488 resolve_tool_alias(a),
4489 Some(AliasOutcome::Rewrite("lifecycle"))
4490 ),
4491 "{a} should rewrite to lifecycle"
4492 );
4493 }
4494 assert!(resolve_tool_alias("lifecycle").is_none());
4496 }
4497
4498 #[tokio::test]
4499 async fn lifecycle_unknown_phase_lists_valid_phases() {
4500 let caveats = crate::caveats::Caveats::top();
4503 let args = serde_json::json!({ "phase": "deploy" });
4504 let out = execute_tool(
4505 "lifecycle",
4506 &args,
4507 ".",
4508 false,
4509 20,
4510 &caveats,
4511 &mut NoMcp,
4512 None,
4513 None,
4514 None,
4515 None,
4516 None,
4517 None,
4518 None,
4519 None,
4520 None,
4521 None,
4522 None,
4523 None,
4524 )
4525 .await;
4526 assert!(
4527 out.starts_with("error: unknown lifecycle phase 'deploy'"),
4528 "{out}"
4529 );
4530 assert!(out.contains("check"), "should name valid phases: {out}");
4531 }
4532
4533 #[test]
4537 fn save_note_advertised_only_with_a_sink() {
4538 fn names(defs: &serde_json::Value) -> Vec<&str> {
4539 defs.as_array()
4540 .unwrap()
4541 .iter()
4542 .filter_map(|d| d["function"]["name"].as_str())
4543 .collect()
4544 }
4545 let base = tool_definitions();
4547 assert!(!names(&base).contains(&"save_note"), "got: {base}");
4548 let without = merged_tool_definitions(
4550 &NoMcp, false, false, false, false, false, false, false, false, false,
4551 );
4552 assert!(!names(&without).contains(&"save_note"));
4553 let with = merged_tool_definitions(
4555 &NoMcp, true, false, false, false, false, false, false, false, false,
4556 );
4557 assert!(names(&with).contains(&"save_note"), "got: {with}");
4558 }
4559
4560 #[test]
4564 fn recall_advertised_only_with_a_source() {
4565 fn names(defs: &serde_json::Value) -> Vec<&str> {
4566 defs.as_array()
4567 .unwrap()
4568 .iter()
4569 .filter_map(|d| d["function"]["name"].as_str())
4570 .collect()
4571 }
4572 let base = tool_definitions();
4573 assert!(!names(&base).contains(&"recall"), "got: {base}");
4574 let without = merged_tool_definitions(
4575 &NoMcp, false, false, false, false, false, false, false, false, false,
4576 );
4577 assert!(!names(&without).contains(&"recall"));
4578 let with = merged_tool_definitions(
4579 &NoMcp, false, true, false, false, false, false, false, false, false,
4580 );
4581 assert!(names(&with).contains(&"recall"), "got: {with}");
4582 let both = merged_tool_definitions(
4584 &NoMcp, true, true, false, false, false, false, false, false, false,
4585 );
4586 assert!(names(&both).contains(&"save_note"));
4587 assert!(names(&both).contains(&"recall"));
4588 }
4589
4590 #[test]
4594 fn memory_fetch_advertised_only_with_a_source() {
4595 fn names(defs: &serde_json::Value) -> Vec<&str> {
4596 defs.as_array()
4597 .unwrap()
4598 .iter()
4599 .filter_map(|d| d["function"]["name"].as_str())
4600 .collect()
4601 }
4602 let base = tool_definitions();
4603 assert!(!names(&base).contains(&"memory_fetch"), "got: {base}");
4604 let without = merged_tool_definitions(
4606 &NoMcp, false, false, false, false, false, false, false, false, false,
4607 );
4608 assert!(!names(&without).contains(&"memory_fetch"));
4609 let with = merged_tool_definitions(
4611 &NoMcp, false, false, true, false, false, false, false, false, false,
4612 );
4613 assert!(names(&with).contains(&"memory_fetch"), "got: {with}");
4614 let all = merged_tool_definitions(
4616 &NoMcp, true, true, true, false, false, false, false, false, false,
4617 );
4618 assert!(names(&all).contains(&"save_note"));
4619 assert!(names(&all).contains(&"recall"));
4620 assert!(names(&all).contains(&"memory_fetch"));
4621 }
4622
4623 #[test]
4626 fn hallucination_detection_coverage() {
4627 assert!(is_hallucination(
4629 "run_command",
4630 &serde_json::json!({"command": "list_dir ."})
4631 ));
4632 assert!(!is_hallucination(
4634 "run_command",
4635 &serde_json::json!({"command": "cargo test"})
4636 ));
4637 assert!(is_hallucination(
4639 "definitely_not_a_real_tool",
4640 &serde_json::json!({})
4641 ));
4642 assert!(!is_hallucination(
4644 "my_server__some_tool",
4645 &serde_json::json!({})
4646 ));
4647 for t in [
4649 "list_dir",
4650 "read_file",
4651 "write_file",
4652 "edit_file",
4653 "delete_file",
4654 "use_skill",
4655 "web_fetch",
4656 "save_note",
4657 "recall",
4658 ] {
4659 assert!(!is_hallucination(t, &serde_json::json!({"path": "."})));
4660 }
4661 }
4662
4663 #[test]
4667 fn run_command_redirect_lets_git_network_ops_through() {
4668 for cmd in [
4670 "git push origin fix/foo",
4671 "git push",
4672 "git fetch origin",
4673 "git pull",
4674 "git clone https://example.com/r.git",
4675 "git rm src/cockpit.rs",
4676 ] {
4677 assert_eq!(run_command_redirect(cmd), None, "{cmd} must fall through");
4678 }
4679 for cmd in [
4681 "git status",
4682 "git log --oneline",
4683 "git add .",
4684 "git commit -m x",
4685 ] {
4686 assert_eq!(
4687 run_command_redirect(cmd),
4688 Some("git"),
4689 "{cmd} must redirect"
4690 );
4691 }
4692 assert_eq!(run_command_redirect("read_file foo.txt"), Some("read_file"));
4694 assert_eq!(run_command_redirect("list_dir ."), Some("list_dir"));
4695 assert_eq!(run_command_redirect("cargo test"), None);
4696 assert_eq!(run_command_redirect("gh pr create --fill"), None);
4697 assert_eq!(run_command_redirect(""), None);
4698 }
4699
4700 #[test]
4703 fn is_hallucination_allows_git_network_ops() {
4704 assert!(!is_hallucination(
4705 "run_command",
4706 &serde_json::json!({"command": "git push origin fix/foo"})
4707 ));
4708 assert!(!is_hallucination(
4709 "run_command",
4710 &serde_json::json!({"command": "git fetch"})
4711 ));
4712 assert!(!is_hallucination(
4713 "run_command",
4714 &serde_json::json!({"command": "git rm src/cockpit.rs"})
4715 ));
4716 assert!(is_hallucination(
4717 "run_command",
4718 &serde_json::json!({"command": "git status"})
4719 ));
4720 }
4721
4722 #[test]
4725 fn pr_creation_url_extracts_github_and_gitlab() {
4726 let github = "remote: Create a pull request for 'fix/foo' on GitHub by visiting:\n\
4727 remote: https://github.com/OWNER/REPO/pull/new/fix/foo\n";
4728 assert_eq!(
4729 pr_creation_url(github),
4730 Some("https://github.com/OWNER/REPO/pull/new/fix/foo")
4731 );
4732 let gitlab = "remote: To create a merge request for topic, visit:\n\
4733 remote: https://gitlab.com/g/p/-/merge_requests/new?x=topic\n";
4734 assert_eq!(
4735 pr_creation_url(gitlab),
4736 Some("https://gitlab.com/g/p/-/merge_requests/new?x=topic")
4737 );
4738 assert_eq!(pr_creation_url("Already up to date.\n"), None);
4740 assert_eq!(
4741 pr_creation_url("see https://github.com/OWNER/REPO/issues/1"),
4742 None
4743 );
4744 }
4745
4746 #[test]
4750 fn shell_envelope_output_appends_pr_hint_on_push() {
4751 let push = serde_json::json!({
4752 "exit_code": 0,
4753 "stdout": "",
4754 "stderr": "remote: Create a pull request for 'fix/foo' on GitHub by visiting:\n\
4755 remote: https://github.com/OWNER/REPO/pull/new/fix/foo\n",
4756 });
4757 let out = shell_envelope_output(&push, 50, false, false, None);
4758 assert!(out.contains("gh pr create --fill"), "hint missing: {out}");
4759 assert!(
4760 out.contains("https://github.com/OWNER/REPO/pull/new/fix/foo"),
4761 "url dropped: {out}"
4762 );
4763
4764 let plain = serde_json::json!({ "exit_code": 0, "stdout": "hello\n", "stderr": "" });
4766 let out = shell_envelope_output(&plain, 50, false, false, None);
4767 assert!(!out.contains("gh pr create"), "spurious hint: {out}");
4768 assert_eq!(out, "hello\n");
4769 }
4770
4771 #[test]
4772 fn shell_envelope_output_spills_full_output_before_head_tail_cap() {
4773 let full = format!(
4774 "HEAD_ONLY_MARKER\n{}\nMIDDLE_ONLY_MARKER\n{}\nTAIL_ONLY_MARKER\n",
4775 "alpha\n".repeat(10_000),
4776 "omega\n".repeat(10_000)
4777 );
4778 let envelope = serde_json::json!({
4779 "exit_code": 0,
4780 "stdout": full,
4781 "stderr": "",
4782 });
4783 let store = spill::SessionSpillStore::default();
4784 let out = shell_envelope_output(&envelope, 50, false, true, Some(&store));
4785
4786 assert!(out.contains("HEAD_ONLY_MARKER"), "head dropped: {out}");
4787 assert!(out.contains("TAIL_ONLY_MARKER"), "tail dropped: {out}");
4788 assert!(
4789 out.contains("memory_fetch(\"spill:s0\")"),
4790 "spill handle missing: {out}"
4791 );
4792 assert!(
4793 out.contains("grep=\"<pattern>\""),
4794 "search affordance missing: {out}"
4795 );
4796 let stored = store.fetch("s0").expect("full output stored");
4797 assert!(
4798 stored.contains("MIDDLE_ONLY_MARKER"),
4799 "spilled payload was capped before storage"
4800 );
4801 assert!(stored.ends_with("TAIL_ONLY_MARKER\n"));
4802 }
4803
4804 #[test]
4805 fn envelope_denied_reads_structured_flag_only() {
4806 assert!(envelope_denied(&serde_json::json!({"denied": true})));
4807 assert!(!envelope_denied(&serde_json::json!({"denied": false})));
4808 assert!(!envelope_denied(&serde_json::json!({})));
4809 assert!(!envelope_denied(&serde_json::json!({"denied": "yes"})));
4811 }
4812
4813 #[test]
4814 fn envelope_denial_reason_joins_or_falls_back() {
4815 let multi = serde_json::json!({
4816 "denials": [
4817 {"kind": "exec", "target": "rm", "reason": "exec rm denied"},
4818 {"kind": "open", "target": "/etc/shadow", "reason": "open denied"}
4819 ]
4820 });
4821 assert_eq!(
4822 envelope_denial_reason(&multi),
4823 "exec rm denied; open denied"
4824 );
4825 let generic = "denied: the capability leash refused an operation";
4827 assert_eq!(envelope_denial_reason(&serde_json::json!({})), generic);
4828 assert_eq!(
4829 envelope_denial_reason(&serde_json::json!({"denials": []})),
4830 generic
4831 );
4832 assert_eq!(
4834 envelope_denial_reason(&serde_json::json!({"denials": [{"kind": "exec"}]})),
4835 generic
4836 );
4837 }
4838
4839 #[test]
4840 fn exec_allowlist_name_takes_basename() {
4841 assert_eq!(exec_allowlist_name("env"), "env");
4842 assert_eq!(exec_allowlist_name("/usr/bin/env"), "env");
4843 assert_eq!(exec_allowlist_name("/usr/bin/"), "bin");
4844 assert_eq!(exec_allowlist_name("C:\\tools\\env.exe"), "env.exe");
4845 }
4846
4847 #[test]
4853 fn exec_denial_target_label_is_the_bare_command_not_the_reason() {
4854 let one = serde_json::json!({
4855 "denied": true,
4856 "denials": [{
4857 "kind": "exec",
4858 "target": "export",
4859 "reason": "exec of \"export\" is not within the granted authority"
4860 }]
4861 });
4862 let label = exec_denial_target_label(&one);
4863 assert_eq!(label, "export");
4864 assert!(!label.contains("is not within the granted authority"));
4867 let multi = serde_json::json!({
4870 "denials": [
4871 {"kind": "exec", "target": "export", "reason": "r"},
4872 {"kind": "exec", "target": "set", "reason": "r"}
4873 ]
4874 });
4875 assert_eq!(exec_denial_target_label(&multi), "export, set");
4876 assert_eq!(
4877 exec_denial_target_label(&serde_json::json!({})),
4878 "a command"
4879 );
4880 }
4881
4882 #[test]
4883 fn host_of_url_extracts_hosts_conservatively() {
4884 assert_eq!(host_of_url("https://docs.rs/serde"), Some("docs.rs".into()));
4885 assert_eq!(host_of_url("http://Docs.RS"), Some("docs.rs".into()));
4886 assert_eq!(
4887 host_of_url("https://user:pw@example.com:8443/p?q#f"),
4888 Some("example.com".into())
4889 );
4890 assert_eq!(host_of_url("https://[::1]:8080/x"), Some("::1".into()));
4891 assert_eq!(host_of_url("not a url"), None);
4894 assert_eq!(host_of_url("ftp://example.com"), None);
4895 assert_eq!(host_of_url("https:///path-only"), None);
4896 }
4897
4898 #[test]
4899 fn exec_denial_requests_lifts_only_pure_exec_envelopes() {
4900 let exec_only = serde_json::json!({
4903 "denied": true,
4904 "denials": [
4905 {"kind": "exec", "target": "/usr/bin/npm", "reason": "exec npm denied"},
4906 {"kind": "exec", "target": "node", "reason": "exec node denied"}
4907 ]
4908 });
4909 let reqs = exec_denial_requests(&exec_only).expect("promptable");
4910 assert_eq!(reqs.len(), 2);
4911 assert_eq!(reqs[0].tool, "run_command");
4912 assert_eq!(reqs[0].kind, DenialKind::Exec);
4913 assert_eq!(
4914 reqs[0].target, "npm",
4915 "basename, same rule as the config hint"
4916 );
4917 assert_eq!(reqs[0].reason, "exec npm denied");
4918 assert_eq!(reqs[1].target, "node");
4919
4920 let mixed = serde_json::json!({
4923 "denials": [
4924 {"kind": "exec", "target": "npm", "reason": "r"},
4925 {"kind": "open", "target": "/etc/shadow", "reason": "r"}
4926 ]
4927 });
4928 assert!(exec_denial_requests(&mixed).is_none());
4929
4930 assert!(exec_denial_requests(&serde_json::json!({})).is_none());
4932 assert!(exec_denial_requests(&serde_json::json!({"denials": []})).is_none());
4933 let empty_target = serde_json::json!({
4934 "denials": [{"kind": "exec", "target": "", "reason": "r"}]
4935 });
4936 assert!(exec_denial_requests(&empty_target).is_none());
4937 let no_target = serde_json::json!({
4938 "denials": [{"kind": "exec", "reason": "r"}]
4939 });
4940 assert!(exec_denial_requests(&no_target).is_none());
4941 }
4942
4943 #[test]
4947 fn net_denial_requests_lifts_only_pure_net_envelopes() {
4948 let net_only = serde_json::json!({
4949 "denied": true,
4950 "denials": [
4951 {"kind": "net", "target": "github.com", "reason": "net does not permit 'github.com'"},
4952 {"kind": "net", "target": "api.github.com", "reason": "net does not permit 'api.github.com'"}
4953 ]
4954 });
4955 let reqs = net_denial_requests(&net_only).expect("promptable");
4956 assert_eq!(reqs.len(), 2);
4957 assert_eq!(reqs[0].tool, "run_command");
4958 assert_eq!(reqs[0].kind, DenialKind::Net);
4959 assert_eq!(
4960 reqs[0].target, "github.com",
4961 "host verbatim, not a basename"
4962 );
4963 assert_eq!(reqs[0].reason, "net does not permit 'github.com'");
4964 assert_eq!(reqs[1].target, "api.github.com");
4965
4966 let mixed = serde_json::json!({
4968 "denials": [
4969 {"kind": "net", "target": "github.com", "reason": "r"},
4970 {"kind": "exec", "target": "npm", "reason": "r"}
4971 ]
4972 });
4973 assert!(net_denial_requests(&mixed).is_none());
4974 let exec_only = serde_json::json!({"denials": [{"kind": "exec", "target": "npm"}]});
4976 assert!(net_denial_requests(&exec_only).is_none());
4977 assert!(net_denial_requests(&serde_json::json!({"denials": []})).is_none());
4978 let empty_target = serde_json::json!({"denials": [{"kind": "net", "target": ""}]});
4979 assert!(net_denial_requests(&empty_target).is_none());
4980 }
4981
4982 #[test]
4985 fn denial_axis_label_is_net_only_for_pure_net() {
4986 let net = serde_json::json!({"denials": [{"kind": "net", "target": "github.com"}]});
4987 assert_eq!(denial_axis_label(&net), "net");
4988 let exec = serde_json::json!({"denials": [{"kind": "exec", "target": "rm"}]});
4989 assert_eq!(denial_axis_label(&exec), "exec");
4990 let mixed = serde_json::json!({
4991 "denials": [{"kind": "net", "target": "h"}, {"kind": "exec", "target": "rm"}]
4992 });
4993 assert_eq!(denial_axis_label(&mixed), "exec", "mixed defaults to exec");
4994 assert_eq!(denial_axis_label(&serde_json::json!({})), "exec");
4995 }
4996
4997 #[test]
4998 fn tui_permits_path_prefix_semantics() {
4999 use crate::caveats::Scope;
5000 assert!(tui_permits_path(&Scope::All, "/anything/at/all"));
5001 assert!(!tui_permits_path(&Scope::<String>::none(), "/ws/file"));
5002 let only = Scope::only(["/ws".to_string()]);
5003 assert!(tui_permits_path(&only, "/ws/sub/file.rs"));
5004 assert!(tui_permits_path(&only, "/ws"), "the workspace root itself");
5005 assert!(!tui_permits_path(&only, "/elsewhere/file.rs"));
5006 assert!(
5009 !tui_permits_path(&only, "/ws/../etc/passwd"),
5010 "`..` traversal escapes the workspace"
5011 );
5012 assert!(
5013 !tui_permits_path(&only, "/ws/../../etc/passwd"),
5014 "repeated `..` traversal escapes the workspace"
5015 );
5016 assert!(
5018 !tui_permits_path(&only, "/ws-secret/file.rs"),
5019 "sibling-prefix collision escapes the workspace"
5020 );
5021 assert!(tui_permits_path(&only, "/ws/sub/../file.rs"));
5023 }
5024
5025 #[cfg(unix)]
5038 #[test]
5039 fn tui_permits_path_symlink_escape_is_the_known_residual() {
5040 use crate::caveats::Scope;
5041 let outside = tempfile::TempDir::new().unwrap();
5042 std::fs::write(outside.path().join("secret"), b"x").unwrap();
5043 let ws = tempfile::TempDir::new().unwrap();
5044 std::os::unix::fs::symlink(outside.path(), ws.path().join("link")).unwrap();
5046
5047 let only = Scope::only([ws.path().to_string_lossy().into_owned()]);
5048
5049 let via_link = ws.path().join("link").join("secret");
5051 assert!(
5054 tui_permits_path(&only, &via_link.to_string_lossy()),
5055 "string gate permits a symlinked escape — known residual (#522)"
5056 );
5057
5058 let dotdot = ws.path().join("..").join("etc").join("passwd");
5062 assert!(
5063 !tui_permits_path(&only, &dotdot.to_string_lossy()),
5064 "`..` escape is denied even though symlink escape is not"
5065 );
5066 }
5067
5068 #[test]
5071 fn git_tool_advertised_only_with_the_presence_gate() {
5072 fn names(defs: &serde_json::Value) -> Vec<&str> {
5073 defs.as_array()
5074 .unwrap()
5075 .iter()
5076 .filter_map(|d| d["function"]["name"].as_str())
5077 .collect()
5078 }
5079 let with = merged_tool_definitions(
5080 &NoMcp, false, false, false, true, false, false, false, false, false,
5081 );
5082 assert!(names(&with).contains(&"git"), "with_git advertises git");
5083 let without = merged_tool_definitions(
5084 &NoMcp, false, false, false, false, false, false, false, false, false,
5085 );
5086 assert!(!names(&without).contains(&"git"), "no git without the gate");
5087 let team = merged_tool_definitions(
5089 &NoMcp, false, false, false, false, true, false, false, false, false,
5090 );
5091 assert!(
5092 names(&team).contains(&"crew") && names(&team).contains(&"compose_roster"),
5093 "with_team advertises crew + compose_roster"
5094 );
5095 assert!(
5096 !names(&without).contains(&"crew"),
5097 "no crew without the gate"
5098 );
5099 let scratch = merged_tool_definitions(
5101 &NoMcp, false, false, false, false, false, true, false, false, false,
5102 );
5103 for t in ["state_set", "state_get", "state_clear"] {
5104 assert!(
5105 names(&scratch).contains(&t),
5106 "{t} advertised with_scratchpad"
5107 );
5108 assert!(!names(&without).contains(&t), "{t} hidden without the gate");
5109 assert!(
5110 !is_hallucination(t, &serde_json::json!({})),
5111 "{t} is a real tool"
5112 );
5113 }
5114 let code = merged_tool_definitions(
5116 &NoMcp, false, false, false, false, false, false, true, false, false,
5117 );
5118 assert!(
5119 names(&code).contains(&"code_search"),
5120 "code_search advertised"
5121 );
5122 assert!(
5123 !names(&without).contains(&"code_search"),
5124 "code_search hidden without the gate"
5125 );
5126 assert!(!is_hallucination("code_search", &serde_json::json!({})));
5127 let exp = merged_tool_definitions(
5129 &NoMcp, false, false, false, false, false, false, false, true, false,
5130 );
5131 for t in ["experience_record", "experience_recall"] {
5132 assert!(names(&exp).contains(&t), "{t} advertised with_experiential");
5133 assert!(!names(&without).contains(&t), "{t} hidden without the gate");
5134 assert!(
5135 !is_hallucination(t, &serde_json::json!({})),
5136 "{t} is a real tool"
5137 );
5138 }
5139 let sched = merged_tool_definitions(
5142 &NoMcp, false, false, false, false, false, false, false, false, true,
5143 );
5144 for t in ["update_plan", "plan_get"] {
5145 assert!(names(&sched).contains(&t), "{t} advertised with_scheduled");
5146 assert!(!names(&without).contains(&t), "{t} hidden without the gate");
5147 assert!(
5148 !is_hallucination(t, &serde_json::json!({})),
5149 "{t} is a real tool"
5150 );
5151 }
5152 }
5153
5154 #[tokio::test]
5155 async fn state_tools_dispatch_only_with_a_store() {
5156 use crate::agentic::scratchpad::{ScratchpadStore, SessionScratchpadStore};
5157 let caveats = crate::caveats::Caveats::top();
5158 let args = serde_json::json!({ "key": "k", "value": "v" });
5159 let none = execute_tool(
5161 "state_set",
5162 &args,
5163 ".",
5164 false,
5165 20,
5166 &caveats,
5167 &mut NoMcp,
5168 None,
5169 None,
5170 None,
5171 None,
5172 None,
5173 None,
5174 None,
5175 None,
5176 None,
5177 None,
5178 None,
5179 None,
5180 )
5181 .await;
5182 assert!(none.starts_with("unknown tool: state_set"), "{none}");
5183 let store = SessionScratchpadStore::default();
5185 let set = execute_tool(
5186 "state_set",
5187 &args,
5188 ".",
5189 false,
5190 20,
5191 &caveats,
5192 &mut NoMcp,
5193 None,
5194 None,
5195 None,
5196 None,
5197 None,
5198 None,
5199 None,
5200 None,
5201 Some(&store as &dyn ScratchpadStore),
5202 None,
5203 None,
5204 None,
5205 )
5206 .await;
5207 assert_eq!(set, "stored: k");
5208 assert_eq!(store.get("k").as_deref(), Some("v"));
5209 }
5210
5211 #[tokio::test]
5212 async fn code_search_dispatch_only_with_a_searcher() {
5213 use crate::agentic::semantic::{CodeSearch, Embedder, SessionSemanticIndex};
5214 struct E;
5215 #[async_trait::async_trait]
5216 impl Embedder for E {
5217 async fn embed(&self, _t: &str) -> anyhow::Result<Vec<f32>> {
5218 Ok(vec![1.0])
5219 }
5220 }
5221 let caveats = crate::caveats::Caveats::top();
5222 let args = serde_json::json!({ "query": "find it" });
5223 let none = execute_tool(
5225 "code_search",
5226 &args,
5227 ".",
5228 false,
5229 20,
5230 &caveats,
5231 &mut NoMcp,
5232 None,
5233 None,
5234 None,
5235 None,
5236 None,
5237 None,
5238 None,
5239 None,
5240 None,
5241 None,
5242 None,
5243 None,
5244 )
5245 .await;
5246 assert!(none.starts_with("unknown tool: code_search"), "{none}");
5247 let idx = SessionSemanticIndex::default();
5249 let search = CodeSearch {
5250 embedder: &E,
5251 index: &idx,
5252 top_k: 1,
5253 };
5254 let out = execute_tool(
5255 "code_search",
5256 &args,
5257 ".",
5258 false,
5259 20,
5260 &caveats,
5261 &mut NoMcp,
5262 None,
5263 None,
5264 None,
5265 None,
5266 None,
5267 None,
5268 None,
5269 None,
5270 None,
5271 Some(search),
5272 None,
5273 None,
5274 )
5275 .await;
5276 assert!(out.contains("no code matched"), "{out}");
5277 }
5278
5279 #[tokio::test]
5280 async fn experiential_dispatch_only_with_a_store() {
5281 use crate::agentic::experiential::{ExperienceStore, SessionExperienceStore};
5282 let caveats = crate::caveats::Caveats::top();
5283 let args = serde_json::json!({
5284 "task": "ci flake", "outcome": "fixed", "lesson": "pin the seed for the fuzz test"
5285 });
5286 for name in ["experience_record", "experience_recall"] {
5288 let out = execute_tool(
5289 name, &args, ".", false, 20, &caveats, &mut NoMcp, None, None, None, None, None,
5290 None, None, None, None, None, None, None,
5291 )
5292 .await;
5293 assert!(out.starts_with(&format!("unknown tool: {name}")), "{out}");
5294 }
5295 let store = SessionExperienceStore::default();
5297 let out = execute_tool(
5298 "experience_record",
5299 &args,
5300 ".",
5301 false,
5302 20,
5303 &caveats,
5304 &mut NoMcp,
5305 None,
5306 None,
5307 None,
5308 None,
5309 None,
5310 None,
5311 None,
5312 None,
5313 None,
5314 None,
5315 Some(&store as &dyn ExperienceStore),
5316 None,
5317 )
5318 .await;
5319 assert_eq!(out, "recorded experience");
5320 assert_eq!(store.count(), 1);
5321 }
5322
5323 #[tokio::test]
5324 async fn scheduled_dispatch_only_with_a_ledger() {
5325 use crate::agentic::scheduled::{SessionStepLedger, StepLedger};
5326 let caveats = crate::caveats::Caveats::top();
5327 let args = serde_json::json!({ "plan": [
5328 { "step": "a", "status": "in_progress" },
5329 { "step": "b", "status": "pending" },
5330 ] });
5331 for name in ["update_plan", "plan_get"] {
5334 let out = execute_tool(
5335 name, &args, ".", false, 20, &caveats, &mut NoMcp, None, None, None, None, None,
5336 None, None, None, None, None, None, None,
5337 )
5338 .await;
5339 assert!(out.starts_with(&format!("unknown tool: {name}")), "{out}");
5340 }
5341 let ledger = SessionStepLedger::default();
5343 let out = execute_tool(
5344 "update_plan",
5345 &args,
5346 ".",
5347 false,
5348 20,
5349 &caveats,
5350 &mut NoMcp,
5351 None,
5352 None,
5353 None,
5354 None,
5355 None,
5356 None,
5357 None,
5358 None,
5359 None,
5360 None,
5361 None,
5362 Some(&ledger as &dyn StepLedger),
5363 )
5364 .await;
5365 assert!(out.starts_with("<plan>\n"), "{out}");
5366 assert_eq!(ledger.count(), 2);
5367 let got = execute_tool(
5369 "plan_get",
5370 &serde_json::json!({}),
5371 ".",
5372 false,
5373 20,
5374 &caveats,
5375 &mut NoMcp,
5376 None,
5377 None,
5378 None,
5379 None,
5380 None,
5381 None,
5382 None,
5383 None,
5384 None,
5385 None,
5386 None,
5387 Some(&ledger as &dyn StepLedger),
5388 )
5389 .await;
5390 assert!(got.starts_with("<plan>\n"), "{got}");
5391 assert_eq!(ledger.count(), 2, "plan_get is read-only");
5392 }
5393
5394 #[tokio::test]
5395 async fn resume_context_dispatch_degrades_without_a_recall_source() {
5396 let caveats = crate::caveats::Caveats::top();
5399 let out = execute_tool(
5400 "resume_context",
5401 &serde_json::json!({}),
5402 ".",
5403 false,
5404 20,
5405 &caveats,
5406 &mut NoMcp,
5407 None, None, None, None, None, None, None, None, None, None, None, None, )
5420 .await;
5421 assert!(
5422 out.contains("no conversation history available this session"),
5423 "{out}"
5424 );
5425 assert!(!out.starts_with("unknown tool"), "{out}");
5426 }
5427
5428 #[test]
5429 fn run_build_check_reports_pass_fail_and_spawn_error() {
5430 let ws = tempfile::TempDir::new().unwrap();
5431 let ws_str = ws.path().to_string_lossy();
5432 assert_eq!(
5433 run_build_check(passing_build_check_cmd(), &ws_str),
5434 " ✓ build check passed"
5435 );
5436 let failed = run_build_check(&failing_build_check_cmd("boom"), &ws_str);
5437 assert!(failed.contains("✗ build check failed"), "got: {failed}");
5438 assert!(failed.contains("boom"), "stderr excerpt shown: {failed}");
5439 let err = run_build_check(passing_build_check_cmd(), "/definitely/not/a/dir");
5441 assert!(err.contains("⚠ build check could not run"), "got: {err}");
5442 }
5443}
5444
5445#[cfg(test)]
5450mod execute_tool_branch_tests {
5451 use super::super::NoMcp;
5452 use super::*;
5453 use crate::caveats::{Caveats, CountBound, Scope};
5454
5455 fn caveats_rw(ws: &std::path::Path) -> Caveats {
5458 Caveats {
5459 fs_read: Scope::All,
5460 fs_write: Scope::only([ws.to_string_lossy().into_owned()]),
5461 exec: Scope::none(),
5462 net: Scope::none(),
5463 max_calls: CountBound::Unlimited,
5464 valid_for_generation: Scope::All,
5465 }
5466 }
5467
5468 struct StubGit;
5473 impl crate::agentic::GitTool for StubGit {
5474 fn dispatch(
5475 &self,
5476 op: &str,
5477 _args: &serde_json::Value,
5478 caps: &crate::git_caveats::GitCaveats,
5479 ) -> Result<String, String> {
5480 match op {
5481 "status" => Ok("on branch main (HEAD abc123)".to_string()),
5482 "commit" if !caps.permits_commit() => {
5483 Err("capability denied: git commit not permitted".to_string())
5484 }
5485 "commit" => Ok("committed abc123: msg".to_string()),
5486 other => Err(format!("unknown git op '{other}'")),
5487 }
5488 }
5489 }
5490
5491 async fn run_git(
5492 op: &str,
5493 caveats: &Caveats,
5494 git: Option<&dyn crate::agentic::GitTool>,
5495 ) -> String {
5496 let ws = tempfile::TempDir::new().unwrap();
5497 execute_tool(
5498 "git",
5499 &serde_json::json!({ "op": op }),
5500 &ws.path().to_string_lossy(),
5501 false,
5502 20,
5503 caveats,
5504 &mut NoMcp,
5505 None,
5506 None,
5507 None,
5508 None,
5509 None,
5510 None,
5511 git,
5512 None, None, None, None, None, )
5518 .await
5519 }
5520
5521 #[tokio::test]
5522 async fn git_arm_dispatches_when_injected() {
5523 let ws = tempfile::TempDir::new().unwrap();
5524 let out = run_git("status", &caveats_rw(ws.path()), Some(&StubGit)).await;
5525 assert!(out.contains("on branch main"), "got: {out}");
5526 }
5527
5528 #[tokio::test]
5529 async fn git_arm_surfaces_denials_from_projected_caveats() {
5530 let ws = tempfile::TempDir::new().unwrap();
5532 let read_only = Caveats {
5533 fs_write: Scope::none(),
5534 ..caveats_rw(ws.path())
5535 };
5536 let out = run_git("commit", &read_only, Some(&StubGit)).await;
5537 assert!(
5538 out.contains("error:") && out.contains("commit"),
5539 "got: {out}"
5540 );
5541 let out = run_git("status", &read_only, Some(&StubGit)).await;
5543 assert!(out.contains("on branch main"), "got: {out}");
5544 }
5545
5546 #[allow(clippy::too_many_arguments)]
5549 async fn run_git_gated(
5550 op: &str,
5551 caveats: &Caveats,
5552 git: &dyn crate::agentic::GitTool,
5553 gate: &mut MockGate,
5554 ) -> String {
5555 let ws = tempfile::TempDir::new().unwrap();
5556 execute_tool(
5557 "git",
5558 &serde_json::json!({ "op": op }),
5559 &ws.path().to_string_lossy(),
5560 false,
5561 20,
5562 caveats,
5563 &mut NoMcp,
5564 None,
5565 None,
5566 None,
5567 None,
5568 Some(gate), None, Some(git), None, None, None, None, None, )
5577 .await
5578 }
5579
5580 #[tokio::test]
5585 async fn git_write_denial_routes_through_gate_and_commits_on_allow() {
5586 let ws = tempfile::TempDir::new().unwrap();
5587 let read_only = Caveats {
5588 fs_write: Scope::none(),
5589 ..caveats_rw(ws.path())
5590 };
5591 let mut gate = MockGate::new(true, &read_only);
5592 let out = run_git_gated("commit", &read_only, &StubGit, &mut gate).await;
5593 assert!(
5594 out.contains("committed"),
5595 "gate-granted commit should land: {out}"
5596 );
5597 assert!(
5598 gate.asks
5599 .iter()
5600 .any(|(t, k)| t == "git" && k.starts_with("git_write:commit")),
5601 "a git_write grant was requested: {:?}",
5602 gate.asks
5603 );
5604 }
5605
5606 #[tokio::test]
5608 async fn git_write_denied_when_operator_declines() {
5609 let ws = tempfile::TempDir::new().unwrap();
5610 let read_only = Caveats {
5611 fs_write: Scope::none(),
5612 ..caveats_rw(ws.path())
5613 };
5614 let mut gate = MockGate::new(false, &read_only);
5615 let out = run_git_gated("commit", &read_only, &StubGit, &mut gate).await;
5616 assert!(
5617 out.contains("capability denied: git commit not permitted"),
5618 "a declined git write stays denied: {out}"
5619 );
5620 }
5621
5622 #[tokio::test]
5625 async fn git_read_is_never_gated() {
5626 let ws = tempfile::TempDir::new().unwrap();
5627 let read_only = Caveats {
5628 fs_write: Scope::none(),
5629 ..caveats_rw(ws.path())
5630 };
5631 let mut gate = MockGate::new(false, &read_only);
5632 let out = run_git_gated("status", &read_only, &StubGit, &mut gate).await;
5633 assert!(
5634 out.contains("on branch main"),
5635 "read op runs ungated: {out}"
5636 );
5637 assert!(gate.asks.is_empty(), "a read must not consult the gate");
5638 }
5639
5640 #[tokio::test]
5641 async fn git_arm_unknown_op_is_an_error_not_a_panic() {
5642 let ws = tempfile::TempDir::new().unwrap();
5643 let out = run_git("frobnicate", &caveats_rw(ws.path()), Some(&StubGit)).await;
5644 assert!(
5645 out.contains("error:") && out.contains("unknown git op"),
5646 "got: {out}"
5647 );
5648 }
5649
5650 #[tokio::test]
5651 async fn git_arm_without_injection_is_unknown_tool() {
5652 let ws = tempfile::TempDir::new().unwrap();
5653 let out = run_git("status", &caveats_rw(ws.path()), None).await;
5654 assert!(out.contains("unknown tool: git"), "got: {out}");
5655 }
5656
5657 struct StubCrew;
5660 #[async_trait::async_trait]
5661 impl crate::agentic::CrewRunner for StubCrew {
5662 async fn dispatch(
5663 &self,
5664 op: &str,
5665 _args: &serde_json::Value,
5666 _caveats: &Caveats,
5667 ) -> Result<String, String> {
5668 match op {
5669 "compose_roster" => Ok("proposed roster: planner <- qwen3-coder:30b".to_string()),
5670 "crew" => Ok("crew ran: diff +1/-0, status PASS".to_string()),
5671 other => Err(format!("unknown op: {other}")),
5672 }
5673 }
5674 }
5675
5676 async fn run_crew_tool(
5677 name: &str,
5678 args: serde_json::Value,
5679 crew: Option<&dyn crate::agentic::CrewRunner>,
5680 ) -> String {
5681 let ws = tempfile::TempDir::new().unwrap();
5682 execute_tool(
5683 name,
5684 &args,
5685 &ws.path().to_string_lossy(),
5686 false,
5687 20,
5688 &caveats_rw(ws.path()),
5689 &mut NoMcp,
5690 None, None, None, None, None, None, None, crew,
5698 None, None, None, None, )
5703 .await
5704 }
5705
5706 #[tokio::test]
5707 async fn crew_arm_dispatches_when_injected() {
5708 let out = run_crew_tool(
5709 "crew",
5710 serde_json::json!({ "task": "do X" }),
5711 Some(&StubCrew),
5712 )
5713 .await;
5714 assert!(
5715 out.contains("crew ran") && out.contains("PASS"),
5716 "got: {out}"
5717 );
5718 let out = run_crew_tool(
5719 "compose_roster",
5720 serde_json::json!({ "mode": "crew" }),
5721 Some(&StubCrew),
5722 )
5723 .await;
5724 assert!(out.contains("proposed roster"), "got: {out}");
5725 }
5726
5727 #[tokio::test]
5732 async fn crew_arm_without_injection_coaches_recovery() {
5733 for name in ["crew", "compose_roster"] {
5734 let out = run_crew_tool(name, serde_json::json!({ "task": "x" }), None).await;
5735 assert!(out.contains("NEWT_TEAM"), "{name}: {out}");
5736 assert!(out.contains("read_file"), "{name}: {out}");
5737 assert!(!out.contains("unknown tool"), "{name}: {out}");
5738 }
5739 }
5740
5741 #[test]
5744 fn crew_off_recovery_result_names_gate_and_alternative() {
5745 let out = crew_off_recovery_result("crew");
5746 assert!(out.contains("'crew'"), "{out}");
5747 assert!(out.contains("NEWT_TEAM"), "{out}");
5748 assert!(out.contains("write_file"), "{out}");
5750 assert!(!out.contains("unknown tool"), "{out}");
5751 }
5752
5753 #[test]
5758 fn classify_gated_off_reach_only_fires_for_off_crew_names() {
5759 for name in ["crew", "compose_roster"] {
5760 assert_eq!(
5761 classify_gated_off_reach(name, false),
5762 Some(crate::PhantomResolution::GatedOff(
5763 "crew/team surface off (NEWT_TEAM)".into()
5764 )),
5765 "{name} OFF should record GatedOff"
5766 );
5767 assert_eq!(
5768 classify_gated_off_reach(name, true),
5769 None,
5770 "{name} ON dispatches normally — no phantom"
5771 );
5772 }
5773 assert_eq!(classify_gated_off_reach("read_file", false), None);
5775 assert_eq!(classify_gated_off_reach("read_file", true), None);
5776 }
5777
5778 #[test]
5782 fn crew_names_stay_real_and_unflagged_by_existing_seams() {
5783 for name in ["crew", "compose_roster"] {
5784 assert!(
5785 !is_hallucination(name, &serde_json::json!({ "task": "x" })),
5786 "{name} must stay a real tool name"
5787 );
5788 assert_eq!(
5789 classify_phantom_reach(name, &serde_json::json!({ "task": "x" }), "ok", true),
5790 None,
5791 "{name} must not be flagged by classify_phantom_reach"
5792 );
5793 }
5794 }
5795
5796 async fn run_find(args: serde_json::Value, ws: &std::path::Path) -> String {
5801 run_tool("find", args, ws, &caveats_rw(ws), None).await
5802 }
5803
5804 fn touch(root: &std::path::Path, rel: &str) {
5805 let p = root.join(rel);
5806 std::fs::create_dir_all(p.parent().unwrap()).unwrap();
5807 std::fs::write(p, b"x").unwrap();
5808 }
5809
5810 #[tokio::test]
5816 async fn find_locates_file_by_name_issue_496() {
5817 let ws = tempfile::TempDir::new().unwrap();
5818 touch(ws.path(), "newt-core/src/pyo3_module.rs");
5819 touch(ws.path(), "newt-data/src/other.rs");
5820 touch(ws.path(), "docs/pyo3_module.md"); let out = run_find(serde_json::json!({ "name": "pyo3_module.rs" }), ws.path()).await;
5822 assert_eq!(out, "newt-core/src/pyo3_module.rs", "got: {out}");
5823 }
5824
5825 #[tokio::test]
5829 async fn find_glob_type_and_maxdepth_together() {
5830 let ws = tempfile::TempDir::new().unwrap();
5831 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();
5836 let out = run_find(
5837 serde_json::json!({
5838 "path": "examples", "name": "*.py", "type": "f", "max_depth": 2
5839 }),
5840 ws.path(),
5841 )
5842 .await;
5843 assert_eq!(out, "examples/a.py\nexamples/sub/b.py", "got: {out}");
5846 }
5847
5848 #[tokio::test]
5850 async fn find_output_is_sorted() {
5851 let ws = tempfile::TempDir::new().unwrap();
5852 for f in ["m.txt", "a.txt", "z.txt", "c.txt"] {
5853 touch(ws.path(), f);
5854 }
5855 let out = run_find(serde_json::json!({ "name": "*.txt" }), ws.path()).await;
5856 let lines: Vec<&str> = out.lines().collect();
5857 assert_eq!(
5858 lines,
5859 vec!["a.txt", "c.txt", "m.txt", "z.txt"],
5860 "got: {out}"
5861 );
5862 }
5863
5864 #[tokio::test]
5866 async fn find_type_filter() {
5867 let ws = tempfile::TempDir::new().unwrap();
5868 touch(ws.path(), "pkg/file.rs");
5869 std::fs::create_dir_all(ws.path().join("pkg/sub")).unwrap();
5870 let dirs = run_find(serde_json::json!({ "type": "d" }), ws.path()).await;
5871 assert!(
5872 dirs.contains("pkg") && dirs.contains("pkg/sub"),
5873 "got: {dirs}"
5874 );
5875 assert!(!dirs.contains("file.rs"), "dirs-only leaked a file: {dirs}");
5876 let files = run_find(serde_json::json!({ "type": "f" }), ws.path()).await;
5877 assert!(files.contains("pkg/file.rs"), "got: {files}");
5878 assert!(
5879 !files.lines().any(|l| l == "pkg" || l == "pkg/sub"),
5880 "files-only leaked a dir: {files}"
5881 );
5882 }
5883
5884 #[tokio::test]
5887 async fn find_gitignore_and_default_skips() {
5888 let ws = tempfile::TempDir::new().unwrap();
5889 std::fs::write(ws.path().join(".gitignore"), "ignored.txt\n").unwrap();
5890 touch(ws.path(), "kept.txt");
5891 touch(ws.path(), "ignored.txt");
5892 touch(ws.path(), "target/build_artifact.txt");
5893 touch(ws.path(), "node_modules/dep.txt");
5894
5895 let on = run_find(serde_json::json!({ "name": "*.txt" }), ws.path()).await;
5896 assert!(on.contains("kept.txt"), "got: {on}");
5897 assert!(!on.contains("ignored.txt"), "gitignore not honoured: {on}");
5898 assert!(!on.contains("target/"), "target not skipped: {on}");
5899 assert!(
5900 !on.contains("node_modules/"),
5901 "node_modules not skipped: {on}"
5902 );
5903
5904 let off = run_find(
5905 serde_json::json!({ "name": "*.txt", "respect_gitignore": false }),
5906 ws.path(),
5907 )
5908 .await;
5909 assert!(off.contains("ignored.txt"), "opt-out should show it: {off}");
5910 assert!(off.contains("target/build_artifact.txt"), "got: {off}");
5911 }
5912
5913 #[tokio::test]
5915 async fn find_max_results_caps_and_notes_truncation() {
5916 let ws = tempfile::TempDir::new().unwrap();
5917 for i in 0..10 {
5918 touch(ws.path(), &format!("f{i}.txt"));
5919 }
5920 let out = run_find(
5921 serde_json::json!({ "name": "*.txt", "max_results": 3 }),
5922 ws.path(),
5923 )
5924 .await;
5925 let body: Vec<&str> = out.lines().filter(|l| l.ends_with(".txt")).collect();
5926 assert_eq!(body.len(), 3, "should cap at 3: {out}");
5927 assert!(out.contains("truncated at 3"), "got: {out}");
5928 }
5929
5930 #[tokio::test]
5932 async fn find_missing_root_and_no_matches() {
5933 let ws = tempfile::TempDir::new().unwrap();
5934 touch(ws.path(), "a.txt");
5935 let missing = run_find(serde_json::json!({ "path": "does/not/exist" }), ws.path()).await;
5936 assert!(missing.starts_with("error:"), "got: {missing}");
5937 let empty = run_find(serde_json::json!({ "name": "*.nope" }), ws.path()).await;
5938 assert_eq!(empty, "no matches", "got: {empty}");
5939 }
5940
5941 #[tokio::test]
5944 async fn find_denied_without_fs_read() {
5945 let ws = tempfile::TempDir::new().unwrap();
5946 touch(ws.path(), "secret.txt");
5947 let denied = Caveats {
5948 fs_read: Scope::none(),
5949 ..caveats_rw(ws.path())
5950 };
5951 let out = run_tool(
5952 "find",
5953 serde_json::json!({ "name": "*" }),
5954 ws.path(),
5955 &denied,
5956 None,
5957 )
5958 .await;
5959 assert!(out.starts_with("capability denied"), "got: {out}");
5960 }
5961
5962 #[tokio::test]
5965 async fn find_refuses_root_outside_workspace() {
5966 let parent = tempfile::TempDir::new().unwrap();
5967 std::fs::write(parent.path().join("outside.txt"), b"x").unwrap();
5968 let ws = parent.path().join("ws");
5969 std::fs::create_dir_all(&ws).unwrap();
5970 let out = run_find(serde_json::json!({ "path": ".." }), &ws).await;
5973 assert!(out.starts_with("capability denied"), "got: {out}");
5974 }
5975
5976 #[tokio::test]
5980 async fn find_empty_name_matches_everything() {
5981 let ws = tempfile::TempDir::new().unwrap();
5982 touch(ws.path(), "a.txt");
5983 touch(ws.path(), "sub/b.rs");
5984 let out = run_find(serde_json::json!({ "name": "" }), ws.path()).await;
5985 for expected in ["a.txt", "sub", "sub/b.rs"] {
5986 assert!(
5987 out.lines().any(|l| l == expected),
5988 "empty name should match `{expected}`: {out}"
5989 );
5990 }
5991 }
5992
5993 #[tokio::test]
5997 async fn find_hidden_entries_gated_by_respect_gitignore() {
5998 let ws = tempfile::TempDir::new().unwrap();
5999 touch(ws.path(), "visible.txt");
6000 touch(ws.path(), ".hidden.txt");
6001 touch(ws.path(), ".config/secret.txt");
6002
6003 let default = run_find(serde_json::json!({ "name": "*" }), ws.path()).await;
6004 assert!(
6005 default.lines().any(|l| l == "visible.txt"),
6006 "got: {default}"
6007 );
6008 assert!(
6009 !default.contains(".hidden") && !default.contains(".config"),
6010 "hidden entries must be skipped by default: {default}"
6011 );
6012
6013 let all = run_find(
6014 serde_json::json!({ "name": "*", "respect_gitignore": false }),
6015 ws.path(),
6016 )
6017 .await;
6018 assert!(all.contains(".hidden.txt"), "opt-out should show it: {all}");
6019 assert!(all.contains(".config/secret.txt"), "got: {all}");
6020 }
6021
6022 #[cfg(unix)]
6026 #[tokio::test]
6027 async fn find_does_not_follow_symlinks_out_of_workspace() {
6028 let outside = tempfile::TempDir::new().unwrap();
6029 std::fs::write(outside.path().join("secret.txt"), b"x").unwrap();
6030 let ws = tempfile::TempDir::new().unwrap();
6031 touch(ws.path(), "inside.txt");
6032 std::os::unix::fs::symlink(outside.path(), ws.path().join("link")).unwrap();
6033
6034 let leaked = run_find(serde_json::json!({ "name": "secret.txt" }), ws.path()).await;
6036 assert_eq!(
6037 leaked, "no matches",
6038 "symlink was followed out of ws: {leaked}"
6039 );
6040 let found = run_find(serde_json::json!({ "name": "inside.txt" }), ws.path()).await;
6042 assert_eq!(found, "inside.txt", "got: {found}");
6043 }
6044
6045 #[test]
6046 fn glob_to_regex_anchors_and_escapes() {
6047 let re = glob_to_regex("*.py", true).unwrap();
6049 assert!(re.is_match("foo.py"));
6050 assert!(!re.is_match("foo.pyc")); assert!(!re.is_match("fooxpy")); assert!(glob_to_regex("a?c", true).unwrap().is_match("abc"));
6054 assert!(!glob_to_regex("a?c", true).unwrap().is_match("ac"));
6055 assert!(glob_to_regex("readme.md", false)
6056 .unwrap()
6057 .is_match("README.MD"));
6058 assert!(!glob_to_regex("readme.md", true)
6059 .unwrap()
6060 .is_match("README.MD"));
6061 }
6062
6063 async fn run_tool(
6064 name: &str,
6065 args: serde_json::Value,
6066 ws: &std::path::Path,
6067 caveats: &Caveats,
6068 build_check: Option<&str>,
6069 ) -> String {
6070 execute_tool(
6071 name,
6072 &args,
6073 &ws.to_string_lossy(),
6074 false,
6075 20,
6076 caveats,
6077 &mut NoMcp,
6078 build_check,
6079 None,
6080 None,
6081 None, None,
6083 None,
6084 None, None, None, None, None, None, )
6091 .await
6092 }
6093
6094 #[tokio::test]
6095 async fn edit_file_replaces_unique_match_and_reports_delta() {
6096 let ws = tempfile::TempDir::new().unwrap();
6097 std::fs::write(ws.path().join("f.txt"), "hello world\nsecond line\n").unwrap();
6098 let caveats = caveats_rw(ws.path());
6099 let out = run_tool(
6100 "edit_file",
6101 serde_json::json!({
6102 "path": "f.txt",
6103 "old_string": "world",
6104 "new_string": "rust\nand more"
6105 }),
6106 ws.path(),
6107 &caveats,
6108 None,
6109 )
6110 .await;
6111 assert!(out.starts_with("edited f.txt (+1 lines"), "got: {out}");
6112 assert_eq!(
6113 std::fs::read_to_string(ws.path().join("f.txt")).unwrap(),
6114 "hello rust\nand more\nsecond line\n"
6115 );
6116 }
6117
6118 #[tokio::test]
6119 async fn edit_file_rejects_empty_missing_and_ambiguous_old_string() {
6120 let ws = tempfile::TempDir::new().unwrap();
6121 std::fs::write(ws.path().join("f.txt"), "dup\ndup\n").unwrap();
6122 let caveats = caveats_rw(ws.path());
6123
6124 let out = run_tool(
6125 "edit_file",
6126 serde_json::json!({"path": "f.txt", "old_string": "", "new_string": "x"}),
6127 ws.path(),
6128 &caveats,
6129 None,
6130 )
6131 .await;
6132 assert!(out.contains("old_string must not be empty"), "got: {out}");
6133
6134 let out = run_tool(
6135 "edit_file",
6136 serde_json::json!({"path": "f.txt", "old_string": "absent", "new_string": "x"}),
6137 ws.path(),
6138 &caveats,
6139 None,
6140 )
6141 .await;
6142 assert!(out.contains("old_string not found in f.txt"), "got: {out}");
6143 assert!(out.contains("do not guess again"), "got: {out}");
6146 assert!(
6147 out.contains("dup"),
6148 "miss error must include the file content: {out}"
6149 );
6150
6151 let out = run_tool(
6152 "edit_file",
6153 serde_json::json!({"path": "f.txt", "old_string": "dup", "new_string": "x"}),
6154 ws.path(),
6155 &caveats,
6156 None,
6157 )
6158 .await;
6159 assert!(out.contains("matches 2 locations"), "got: {out}");
6160 assert_eq!(
6162 std::fs::read_to_string(ws.path().join("f.txt")).unwrap(),
6163 "dup\ndup\n"
6164 );
6165 }
6166
6167 #[tokio::test]
6168 async fn edit_file_denied_outside_fs_write_scope_and_missing_file() {
6169 let ws = tempfile::TempDir::new().unwrap();
6170 let caveats = Caveats {
6171 fs_write: Scope::none(),
6172 ..caveats_rw(ws.path())
6173 };
6174 let out = run_tool(
6175 "edit_file",
6176 serde_json::json!({"path": "f.txt", "old_string": "a", "new_string": "b"}),
6177 ws.path(),
6178 &caveats,
6179 None,
6180 )
6181 .await;
6182 assert!(
6183 out.contains("capability denied: fs_write"),
6184 "denied before any fs access, got: {out}"
6185 );
6186
6187 let caveats = caveats_rw(ws.path());
6188 let out = run_tool(
6189 "edit_file",
6190 serde_json::json!({"path": "missing.txt", "old_string": "a", "new_string": "b"}),
6191 ws.path(),
6192 &caveats,
6193 None,
6194 )
6195 .await;
6196 assert!(out.contains("error reading missing.txt"), "got: {out}");
6197 }
6198
6199 #[tokio::test]
6200 async fn edit_file_appends_build_check_result() {
6201 let ws = tempfile::TempDir::new().unwrap();
6202 std::fs::write(ws.path().join("f.txt"), "old\n").unwrap();
6203 let caveats = caveats_rw(ws.path());
6204 let out = run_tool(
6205 "edit_file",
6206 serde_json::json!({"path": "f.txt", "old_string": "old", "new_string": "new"}),
6207 ws.path(),
6208 &caveats,
6209 Some(passing_build_check_cmd()),
6210 )
6211 .await;
6212 assert!(out.contains("✓ build check passed"), "got: {out}");
6213
6214 let failing_check = failing_build_check_cmd("broke");
6215 let out = run_tool(
6216 "edit_file",
6217 serde_json::json!({"path": "f.txt", "old_string": "new", "new_string": "newer"}),
6218 ws.path(),
6219 &caveats,
6220 Some(&failing_check),
6221 )
6222 .await;
6223 assert!(out.contains("✗ build check failed"), "got: {out}");
6224 assert!(out.contains("broke"), "model sees the failure text: {out}");
6225 }
6226
6227 #[tokio::test]
6228 async fn write_file_shrink_guard_refuses_large_deletion() {
6229 let ws = tempfile::TempDir::new().unwrap();
6230 let big: String = (0..100).map(|i| format!("line {i}\n")).collect();
6231 std::fs::write(ws.path().join("big.txt"), &big).unwrap();
6232 let caveats = caveats_rw(ws.path());
6233 let out = run_tool(
6234 "write_file",
6235 serde_json::json!({"path": "big.txt", "content": "tiny\n"}),
6236 ws.path(),
6237 &caveats,
6238 None,
6239 )
6240 .await;
6241 assert!(
6242 out.contains("would shrink big.txt from 100 → 1 lines"),
6243 "got: {out}"
6244 );
6245 assert!(out.contains("edit_file"), "points at the safer tool: {out}");
6246 assert_eq!(
6248 std::fs::read_to_string(ws.path().join("big.txt")).unwrap(),
6249 big
6250 );
6251 }
6252
6253 #[tokio::test]
6254 async fn write_file_creates_parent_directories() {
6255 let ws = tempfile::TempDir::new().unwrap();
6256 let caveats = caveats_rw(ws.path());
6257 let out = run_tool(
6258 "write_file",
6259 serde_json::json!({"path": "a/b/c.txt", "content": "nested"}),
6260 ws.path(),
6261 &caveats,
6262 None,
6263 )
6264 .await;
6265 assert!(out.starts_with("wrote a/b/c.txt"), "got: {out}");
6266 assert_eq!(
6267 std::fs::read_to_string(ws.path().join("a/b/c.txt")).unwrap(),
6268 "nested"
6269 );
6270 }
6271
6272 #[tokio::test]
6273 async fn delete_file_removes_one_file_and_appends_build_check() {
6274 let ws = tempfile::TempDir::new().unwrap();
6275 std::fs::write(ws.path().join("old.rs"), "fn main() {}\n").unwrap();
6276 let caveats = caveats_rw(ws.path());
6277 let out = run_tool(
6278 "delete_file",
6279 serde_json::json!({"path": "old.rs"}),
6280 ws.path(),
6281 &caveats,
6282 Some(passing_build_check_cmd()),
6283 )
6284 .await;
6285 assert!(out.starts_with("deleted old.rs"), "got: {out}");
6286 assert!(out.contains("✓ build check passed"), "got: {out}");
6287 assert!(
6288 !ws.path().join("old.rs").exists(),
6289 "delete_file must remove the target file"
6290 );
6291 }
6292
6293 #[tokio::test]
6294 async fn delete_file_denies_missing_files_directories_and_fs_write_misses() {
6295 let ws = tempfile::TempDir::new().unwrap();
6296 std::fs::write(ws.path().join("secret.txt"), "x").unwrap();
6297 std::fs::create_dir(ws.path().join("dir")).unwrap();
6298
6299 let denied = Caveats {
6300 fs_write: Scope::none(),
6301 ..caveats_rw(ws.path())
6302 };
6303 let out = run_tool(
6304 "delete_file",
6305 serde_json::json!({"path": "secret.txt"}),
6306 ws.path(),
6307 &denied,
6308 None,
6309 )
6310 .await;
6311 assert!(out.contains("capability denied: fs_write"), "got: {out}");
6312 assert!(
6313 ws.path().join("secret.txt").exists(),
6314 "denied delete must not remove the file"
6315 );
6316
6317 let caveats = caveats_rw(ws.path());
6318 let out = run_tool(
6319 "delete_file",
6320 serde_json::json!({"path": "missing.txt"}),
6321 ws.path(),
6322 &caveats,
6323 None,
6324 )
6325 .await;
6326 assert!(out.contains("file does not exist"), "got: {out}");
6327
6328 let out = run_tool(
6329 "delete_file",
6330 serde_json::json!({"path": "dir"}),
6331 ws.path(),
6332 &caveats,
6333 None,
6334 )
6335 .await;
6336 assert!(out.contains("refuses directories"), "got: {out}");
6337 assert!(ws.path().join("dir").is_dir(), "directory must remain");
6338 }
6339
6340 #[tokio::test]
6341 async fn read_file_denial_and_missing_file_errors() {
6342 let ws = tempfile::TempDir::new().unwrap();
6343 std::fs::write(ws.path().join("secret.txt"), "x").unwrap();
6344 let denied = Caveats {
6345 fs_read: Scope::none(),
6346 ..caveats_rw(ws.path())
6347 };
6348 let out = run_tool(
6349 "read_file",
6350 serde_json::json!({"path": "secret.txt"}),
6351 ws.path(),
6352 &denied,
6353 None,
6354 )
6355 .await;
6356 assert!(out.contains("capability denied: fs_read"), "got: {out}");
6357
6358 let caveats = caveats_rw(ws.path());
6359 let out = run_tool(
6360 "read_file",
6361 serde_json::json!({"path": "nope.txt"}),
6362 ws.path(),
6363 &caveats,
6364 None,
6365 )
6366 .await;
6367 assert!(out.contains("error reading nope.txt"), "got: {out}");
6368 }
6369
6370 #[tokio::test]
6371 async fn list_dir_denial_and_missing_dir_errors() {
6372 let ws = tempfile::TempDir::new().unwrap();
6373 let denied = Caveats {
6374 fs_read: Scope::none(),
6375 ..caveats_rw(ws.path())
6376 };
6377 let out = run_tool(
6378 "list_dir",
6379 serde_json::json!({"path": "."}),
6380 ws.path(),
6381 &denied,
6382 None,
6383 )
6384 .await;
6385 assert!(out.contains("capability denied: fs_read"), "got: {out}");
6386
6387 let caveats = caveats_rw(ws.path());
6388 let out = run_tool(
6389 "list_dir",
6390 serde_json::json!({"path": "not-a-dir"}),
6391 ws.path(),
6392 &caveats,
6393 None,
6394 )
6395 .await;
6396 assert!(out.starts_with("error:"), "got: {out}");
6397 }
6398
6399 #[tokio::test]
6400 async fn unknown_tool_name_is_reported_not_executed() {
6401 let ws = tempfile::TempDir::new().unwrap();
6402 let caveats = caveats_rw(ws.path());
6403 let out = run_tool(
6404 "definitely_not_a_tool",
6405 serde_json::json!({}),
6406 ws.path(),
6407 &caveats,
6408 None,
6409 )
6410 .await;
6411 assert!(
6414 out.starts_with("unknown tool: definitely_not_a_tool"),
6415 "got: {out}"
6416 );
6417 assert!(out.contains("Available tools include:"), "got: {out}");
6418 }
6419
6420 #[test]
6423 fn alias_rewrites_shell_names_to_run_command() {
6424 for n in [
6425 "execute",
6426 "exec",
6427 "bash",
6428 "shell",
6429 "sh",
6430 "zsh",
6431 "terminal",
6432 "run_shell_command",
6433 "shell_command",
6434 "system",
6435 ] {
6436 assert!(
6437 matches!(
6438 resolve_tool_alias(n),
6439 Some(AliasOutcome::Rewrite("run_command"))
6440 ),
6441 "{n} should rewrite to run_command"
6442 );
6443 }
6444 }
6445
6446 #[test]
6447 fn alias_corrects_edit_and_create_names() {
6448 for n in [
6449 "str_replace_editor",
6450 "str_replace",
6451 "apply_patch",
6452 "edit",
6453 "replace_in_file",
6454 ] {
6455 let Some(AliasOutcome::Correct(msg)) = resolve_tool_alias(n) else {
6456 panic!("{n} should produce a Correct outcome");
6457 };
6458 assert!(msg.contains("edit_file"), "{n}: {msg}");
6459 assert!(msg.contains("write_file"), "{n}: {msg}");
6460 }
6461 for n in ["create_file", "new_file", "touch"] {
6462 let Some(AliasOutcome::Correct(msg)) = resolve_tool_alias(n) else {
6463 panic!("{n} should produce a Correct outcome");
6464 };
6465 assert!(msg.contains("write_file"), "{n}: {msg}");
6466 }
6467 for n in ["remove_file", "delete", "remove", "unlink", "rm_file"] {
6468 let Some(AliasOutcome::Correct(msg)) = resolve_tool_alias(n) else {
6469 panic!("{n} should produce a Correct outcome");
6470 };
6471 assert!(msg.contains("delete_file"), "{n}: {msg}");
6472 assert!(msg.contains("fs_write"), "{n}: {msg}");
6473 }
6474 }
6475
6476 #[test]
6477 fn alias_coaches_mkdir_to_write_file() {
6478 for n in [
6482 "mkdir",
6483 "make_dir",
6484 "makedirs",
6485 "mkdirs",
6486 "create_dir",
6487 "create_directory",
6488 ] {
6489 let Some(AliasOutcome::Correct(msg)) = resolve_tool_alias(n) else {
6490 panic!("{n} should produce a Correct outcome");
6491 };
6492 assert!(msg.contains("write_file"), "{n}: {msg}");
6493 assert!(msg.contains("create_dir_all"), "{n}: {msg}");
6494 }
6495 let Some(AliasOutcome::Correct(msg)) = resolve_tool_alias("touch") else {
6498 panic!("touch should still be a create-file Correct outcome");
6499 };
6500 assert!(msg.contains("write_file"), "touch: {msg}");
6501 }
6502
6503 #[test]
6504 fn alias_passes_through_real_and_mcp_names() {
6505 for n in [
6506 "run_command",
6507 "read_file",
6508 "write_file",
6509 "edit_file",
6510 "delete_file",
6511 "git",
6512 "update_plan",
6513 "plan_get",
6514 "server__do_thing",
6515 ] {
6516 assert!(
6517 resolve_tool_alias(n).is_none(),
6518 "{n} must dispatch unchanged"
6519 );
6520 }
6521 }
6522
6523 #[test]
6526 fn alias_corrects_plan_names_to_update_plan() {
6527 for n in [
6528 "enter_plan",
6529 "enter_plan_mode",
6530 "plan_mode",
6531 "start_plan",
6532 "begin_plan",
6533 "make_plan",
6534 "create_plan",
6535 "plan",
6536 "planning",
6537 "todo",
6538 "todos",
6539 "todo_write",
6540 ] {
6541 let Some(AliasOutcome::Correct(msg)) = resolve_tool_alias(n) else {
6542 panic!("{n} should produce a Correct outcome");
6543 };
6544 assert!(msg.contains("update_plan"), "{n}: {msg}");
6545 }
6546 for n in [
6548 "next_step",
6549 "complete_step",
6550 "finish_step",
6551 "mark_done",
6552 "step_done",
6553 ] {
6554 let Some(AliasOutcome::Correct(msg)) = resolve_tool_alias(n) else {
6555 panic!("{n} should produce a Correct outcome");
6556 };
6557 assert!(msg.contains("update_plan"), "{n}: {msg}");
6558 assert!(msg.contains("completed"), "{n}: {msg}");
6559 }
6560 assert!(
6563 resolve_tool_alias("update_plan").is_none(),
6564 "update_plan must dispatch as the real tool, not a self-alias"
6565 );
6566 }
6567
6568 #[test]
6569 fn alias_rewrites_plan_read_names_to_plan_get() {
6570 for n in [
6571 "get_plan",
6572 "show_plan",
6573 "read_plan",
6574 "current_plan",
6575 "what_was_i_doing",
6576 ] {
6577 assert!(
6578 matches!(
6579 resolve_tool_alias(n),
6580 Some(AliasOutcome::Rewrite("plan_get"))
6581 ),
6582 "{n} should rewrite to plan_get"
6583 );
6584 }
6585 }
6586
6587 #[test]
6588 fn alias_rewrites_resume_reaches_to_resume_context() {
6589 for n in [
6592 "resume",
6593 "where_were_we",
6594 "where_did_we_leave_off",
6595 "catch_me_up",
6596 "recap",
6597 ] {
6598 assert!(
6599 matches!(
6600 resolve_tool_alias(n),
6601 Some(AliasOutcome::Rewrite("resume_context"))
6602 ),
6603 "{n} should rewrite to resume_context"
6604 );
6605 }
6606 assert!(
6610 resolve_tool_alias("resume_context").is_none(),
6611 "the real tool name must return None, not a self-Rewrite"
6612 );
6613 assert!(
6615 matches!(
6616 resolve_tool_alias("what_was_i_doing"),
6617 Some(AliasOutcome::Rewrite("plan_get"))
6618 ),
6619 "what_was_i_doing must stay → plan_get"
6620 );
6621 }
6622
6623 #[test]
6624 fn alias_corrects_crew_names_and_flags_team_gating() {
6625 for n in [
6626 "delegate",
6627 "spawn_agent",
6628 "subagent",
6629 "sub_agent",
6630 "crew_dispatch",
6631 "run_crew",
6632 "dispatch_crew",
6633 "fork_agent",
6634 "assign",
6635 "team",
6636 ] {
6637 let Some(AliasOutcome::Correct(msg)) = resolve_tool_alias(n) else {
6638 panic!("{n} should produce a Correct outcome");
6639 };
6640 assert!(msg.contains("compose_roster"), "{n}: {msg}");
6642 assert!(msg.contains("crew"), "{n}: {msg}");
6643 assert!(msg.contains("/team"), "{n}: {msg}");
6645 assert!(
6646 msg.contains("human enables") || msg.contains("cannot turn it on yourself"),
6647 "crew correction must not imply the model can invoke it: {msg}"
6648 );
6649 }
6650 }
6651
6652 #[test]
6653 fn alias_corrects_workflow_names_to_plan_plus_crew() {
6654 for n in ["workflow", "run_workflow", "start_workflow", "pipeline"] {
6655 let Some(AliasOutcome::Correct(msg)) = resolve_tool_alias(n) else {
6656 panic!("{n} should produce a Correct outcome");
6657 };
6658 assert!(msg.contains("no workflow tool"), "{n}: {msg}");
6659 assert!(msg.contains("update_plan"), "{n}: {msg}");
6660 }
6661 }
6662
6663 #[test]
6664 fn levenshtein_matches_known_distances() {
6665 assert_eq!(levenshtein("kitten", "sitting"), 3);
6666 assert_eq!(levenshtein("read_file", "read_file"), 0);
6667 assert_eq!(levenshtein("read_fil", "read_file"), 1);
6668 assert_eq!(levenshtein("", "abc"), 3);
6669 }
6670
6671 #[test]
6672 fn nearest_tool_name_suggests_close_only() {
6673 assert_eq!(nearest_tool_name("read_fil"), Some("read_file"));
6674 assert_eq!(nearest_tool_name("edit_fil"), Some("edit_file"));
6675 assert_eq!(nearest_tool_name("memory_fetchh"), Some("memory_fetch"));
6676 assert_eq!(nearest_tool_name("definitely_not_a_tool"), None);
6677 }
6678
6679 #[test]
6680 fn unknown_tool_message_names_catalog_and_suggestion() {
6681 let m = unknown_tool_message("read_fil");
6682 assert!(m.starts_with("unknown tool: read_fil"), "{m}");
6683 assert!(m.contains("Did you mean 'read_file'"), "{m}");
6684 assert!(m.contains("Available tools include:"), "{m}");
6685
6686 let m2 = unknown_tool_message("zzzzzzzzzzzz");
6687 assert!(m2.starts_with("unknown tool: zzzzzzzzzzzz"), "{m2}");
6688 assert!(!m2.contains("Did you mean"), "{m2}");
6689 assert!(m2.contains("Available tools include:"), "{m2}");
6690 }
6691
6692 #[tokio::test]
6696 async fn execute_tool_corrects_str_replace_editor_alias() {
6697 let ws = tempfile::TempDir::new().unwrap();
6698 let caveats = caveats_rw(ws.path());
6699 let out = run_tool(
6700 "str_replace_editor",
6701 serde_json::json!({"command": "str_replace", "path": "f.txt"}),
6702 ws.path(),
6703 &caveats,
6704 None,
6705 )
6706 .await;
6707 assert!(out.contains("edit_file"), "got: {out}");
6708 assert!(!out.starts_with("unknown tool"), "got: {out}");
6709 }
6710
6711 struct MockGate {
6716 allow: bool,
6717 base: Caveats,
6718 asks: Vec<(String, String)>,
6719 }
6720
6721 impl MockGate {
6722 fn new(allow: bool, base: &Caveats) -> Self {
6723 Self {
6724 allow,
6725 base: base.clone(),
6726 asks: Vec::new(),
6727 }
6728 }
6729 }
6730
6731 impl super::PermissionGate for MockGate {
6732 fn ask(&mut self, requests: &[super::PermissionRequest]) -> super::PermissionDecision {
6733 for r in requests {
6734 self.asks
6735 .push((r.tool.clone(), format!("{}:{}", r.kind.as_str(), r.target)));
6736 }
6737 if self.allow {
6738 let grants: Vec<_> = requests
6739 .iter()
6740 .map(|r| (r.kind, r.target.clone()))
6741 .collect();
6742 super::PermissionDecision::Allow(crate::agentic::widen_caveats(&self.base, &grants))
6743 } else {
6744 super::PermissionDecision::Deny
6745 }
6746 }
6747 fn ask_question(&mut self, _question: &str) -> Option<String> {
6750 None
6751 }
6752 }
6753
6754 #[allow(clippy::too_many_arguments)]
6755 async fn run_tool_gated(
6756 name: &str,
6757 args: serde_json::Value,
6758 ws: &std::path::Path,
6759 caveats: &Caveats,
6760 gate: &mut MockGate,
6761 ) -> String {
6762 execute_tool(
6763 name,
6764 &args,
6765 &ws.to_string_lossy(),
6766 false,
6767 20,
6768 caveats,
6769 &mut NoMcp,
6770 None,
6771 None,
6772 None,
6773 None, Some(gate),
6775 None,
6776 None, None, None, None, None, None, )
6783 .await
6784 }
6785
6786 struct OneRemoteTool {
6789 name: &'static str,
6790 called: bool,
6791 }
6792 impl OneRemoteTool {
6793 fn new(name: &'static str) -> Self {
6794 Self {
6795 name,
6796 called: false,
6797 }
6798 }
6799 }
6800 #[async_trait::async_trait]
6801 impl McpTools for OneRemoteTool {
6802 fn handles(&self, name: &str) -> bool {
6803 name == self.name
6804 }
6805 fn tool_defs(&self) -> Vec<serde_json::Value> {
6806 vec![serde_json::json!({
6807 "type": "function",
6808 "function": { "name": self.name, "description": "", "parameters": {} }
6809 })]
6810 }
6811 async fn call(&mut self, _name: &str, _args: &serde_json::Value) -> String {
6812 self.called = true;
6813 "remote-tool-ran".to_string()
6814 }
6815 }
6816
6817 async fn run_remote_gated(
6818 name: &str,
6819 ws: &std::path::Path,
6820 caveats: &Caveats,
6821 persona_tools: Option<&[String]>,
6822 mcp: &mut dyn McpTools,
6823 gate: Option<&mut MockGate>,
6824 ) -> String {
6825 let gate = gate.map(|g| g as &mut dyn super::PermissionGate);
6826 execute_tool_with_offload(
6827 name,
6828 &serde_json::json!({}),
6829 &ws.to_string_lossy(),
6830 false,
6831 20,
6832 caveats,
6833 mcp,
6834 None,
6835 None,
6836 None,
6837 None,
6838 gate,
6839 None,
6840 None,
6841 None,
6842 None,
6843 None,
6844 None,
6845 None,
6846 false,
6847 None,
6848 persona_tools,
6849 )
6850 .await
6851 }
6852
6853 #[tokio::test]
6858 async fn remote_tool_outside_allow_list_is_prompted_not_hard_vetoed() {
6859 let ws = tempfile::TempDir::new().unwrap();
6860 let caveats = crate::caveats::Caveats::top();
6861 let coach = vec!["read_file".to_string()]; let mut mcp = OneRemoteTool::new("incident__create");
6866 let mut gate = MockGate::new(false, &caveats);
6867 let out = run_remote_gated(
6868 "incident__create",
6869 ws.path(),
6870 &caveats,
6871 Some(&coach),
6872 &mut mcp,
6873 Some(&mut gate),
6874 )
6875 .await;
6876 assert!(!mcp.called, "denied remote tool must NOT dispatch");
6877 assert_eq!(gate.asks.len(), 1, "the human was prompted");
6878 assert_eq!(gate.asks[0].1, "remote_tool:incident__create");
6879 assert!(out.contains("persona"), "returns a denial: {out}");
6880
6881 let mut mcp = OneRemoteTool::new("incident__create");
6883 let mut gate = MockGate::new(true, &caveats);
6884 let out = run_remote_gated(
6885 "incident__create",
6886 ws.path(),
6887 &caveats,
6888 Some(&coach),
6889 &mut mcp,
6890 Some(&mut gate),
6891 )
6892 .await;
6893 assert!(mcp.called, "granted remote tool dispatches");
6894 assert_eq!(out, "remote-tool-ran");
6895
6896 let granted = vec!["incident__create".to_string()];
6898 let mut mcp = OneRemoteTool::new("incident__create");
6899 let mut gate = MockGate::new(false, &caveats); run_remote_gated(
6901 "incident__create",
6902 ws.path(),
6903 &caveats,
6904 Some(&granted),
6905 &mut mcp,
6906 Some(&mut gate),
6907 )
6908 .await;
6909 assert!(mcp.called, "allow-listed remote tool dispatches");
6910 assert!(
6911 gate.asks.is_empty(),
6912 "no prompt when the persona already grants it"
6913 );
6914
6915 let mut mcp = OneRemoteTool::new("incident__create");
6917 let out = run_remote_gated(
6918 "incident__create",
6919 ws.path(),
6920 &caveats,
6921 Some(&coach),
6922 &mut mcp,
6923 None,
6924 )
6925 .await;
6926 assert!(
6927 !mcp.called,
6928 "headless must fail closed for an ungranted remote tool"
6929 );
6930 assert!(out.contains("persona"), "headless denial: {out}");
6931 }
6932
6933 #[test]
6936 fn exec_denial_is_recoverable_not_a_dead_end() {
6937 let envelope = serde_json::json!({
6943 "denied": true,
6944 "denials": [{
6945 "kind": "exec",
6946 "target": "mkdir",
6947 "reason": "exec of \"mkdir\" is not within the granted authority"
6948 }]
6949 });
6950 let out = denied_run_command_result(&envelope, false);
6951 assert!(out.starts_with("capability denied:"), "got: {out}");
6952 assert!(out.contains("request_permissions"), "got: {out}");
6953 assert!(
6957 !out.contains("extra_exec"),
6958 "the model message must not carry the stale config hint: {out}"
6959 );
6960 }
6961
6962 #[test]
6970 fn run_command_denial_is_single_level_not_nested() {
6971 let envelope = serde_json::json!({
6972 "denied": true,
6973 "denials": [{
6974 "kind": "exec",
6975 "target": "export",
6976 "reason": "exec of \"export\" is not within the granted authority"
6977 }]
6978 });
6979 let out = denied_run_command_result(&envelope, false);
6980 assert_eq!(
6982 out.matches("capability denied:").count(),
6983 1,
6984 "exactly one denial level: {out}"
6985 );
6986 assert!(!out.contains("add it via"), "stale config hint: {out}");
6988 assert!(!out.contains("extra_exec"), "stale config hint: {out}");
6989 assert!(
6991 !out.contains("does not permit 'exec of"),
6992 "nested denial sentence: {out}"
6993 );
6994 assert!(
6996 out.contains("exec of \"export\" is not within the granted authority"),
6997 "got: {out}"
6998 );
6999 assert!(out.contains("request_permissions"), "got: {out}");
7000 }
7001
7002 #[test]
7003 fn parse_capability_maps_synonyms_and_rejects_unknown() {
7004 assert_eq!(parse_capability("exec"), Some(DenialKind::Exec));
7005 assert_eq!(parse_capability("shell"), Some(DenialKind::Exec));
7006 assert_eq!(parse_capability("FS_READ"), Some(DenialKind::FsRead));
7007 assert_eq!(parse_capability("write"), Some(DenialKind::FsWrite));
7008 assert_eq!(parse_capability("network"), Some(DenialKind::Net));
7009 assert_eq!(parse_capability("gpu"), None);
7010 assert_eq!(parse_capability(""), None);
7011 }
7012
7013 #[test]
7014 fn request_permissions_grant_deny_and_no_gate() {
7015 let base = Caveats::top();
7016
7017 let mut gate = MockGate::new(true, &base);
7020 let out = execute_request_permissions(
7021 &serde_json::json!({"capability": "exec", "target": "mkdir", "reason": "make a dir"}),
7022 Some(&mut gate),
7023 false,
7024 20,
7025 );
7026 assert!(out.starts_with("granted:"), "got: {out}");
7027 assert!(out.contains("Retry the original operation"), "got: {out}");
7028 assert_eq!(gate.asks.len(), 1);
7029 assert_eq!(
7030 gate.asks[0],
7031 ("request_permissions".to_string(), "exec:mkdir".to_string())
7032 );
7033
7034 let mut gate = MockGate::new(false, &base);
7036 let out = execute_request_permissions(
7037 &serde_json::json!({"capability": "fs_write", "target": "/tmp/x", "reason": "w"}),
7038 Some(&mut gate),
7039 false,
7040 20,
7041 );
7042 assert!(out.starts_with("denied:"), "got: {out}");
7043 assert!(out.contains("different approach"), "got: {out}");
7044
7045 let out = execute_request_permissions(
7048 &serde_json::json!({"capability": "net", "target": "docs.rs", "reason": "fetch"}),
7049 None,
7050 false,
7051 20,
7052 );
7053 assert!(out.contains("no operator available"), "got: {out}");
7054 }
7055
7056 #[test]
7057 fn request_permissions_coaches_bad_inputs() {
7058 let out = execute_request_permissions(
7060 &serde_json::json!({"capability": "gpu", "target": "x", "reason": "y"}),
7061 None,
7062 false,
7063 20,
7064 );
7065 assert!(out.contains("unknown capability"), "got: {out}");
7066 assert!(out.contains("fs_read"), "got: {out}");
7067 let out = execute_request_permissions(
7069 &serde_json::json!({"capability": "exec", "reason": "y"}),
7070 None,
7071 false,
7072 20,
7073 );
7074 assert!(out.contains("'target' is required"), "got: {out}");
7075 }
7076
7077 #[test]
7078 fn request_permissions_is_a_real_tool_not_a_phantom() {
7079 assert!(resolve_tool_alias("request_permissions").is_none());
7081 assert!(ALL_TOOL_NAMES.contains(&"request_permissions"));
7082 assert!(classify_phantom_reach(
7083 "request_permissions",
7084 &serde_json::json!({"capability": "exec", "target": "mkdir", "reason": "r"}),
7085 "granted: the operator allowed exec for 'mkdir'.",
7086 true,
7087 )
7088 .is_none());
7089 }
7090
7091 struct AskGate {
7096 answer: Option<String>,
7097 asked: Vec<String>,
7098 }
7099 impl AskGate {
7100 fn new(answer: Option<&str>) -> Self {
7101 Self {
7102 answer: answer.map(str::to_string),
7103 asked: Vec::new(),
7104 }
7105 }
7106 }
7107 impl super::PermissionGate for AskGate {
7108 fn ask(&mut self, _requests: &[super::PermissionRequest]) -> super::PermissionDecision {
7109 super::PermissionDecision::Deny
7110 }
7111 fn ask_question(&mut self, question: &str) -> Option<String> {
7112 self.asked.push(question.to_string());
7113 self.answer.clone()
7114 }
7115 }
7116
7117 #[test]
7118 fn request_user_input_returns_the_human_answer() {
7119 let mut gate = AskGate::new(Some("postgres"));
7122 let out = execute_request_user_input(
7123 &serde_json::json!({"question": "which database should I target?"}),
7124 Some(&mut gate),
7125 false,
7126 20,
7127 );
7128 assert_eq!(out, "postgres");
7129 assert_eq!(
7130 gate.asked,
7131 vec!["which database should I target?".to_string()]
7132 );
7133 }
7134
7135 #[test]
7136 fn request_user_input_no_gate_reports_headless_never_hangs() {
7137 let out = execute_request_user_input(
7141 &serde_json::json!({"question": "are you sure?"}),
7142 None,
7143 false,
7144 20,
7145 );
7146 assert_eq!(out, HEADLESS_NO_HUMAN);
7147 assert!(out.contains("no human available"), "got: {out}");
7148 }
7149
7150 #[test]
7151 fn request_user_input_gate_with_no_human_reports_headless() {
7152 let mut gate = AskGate::new(None);
7155 let out = execute_request_user_input(
7156 &serde_json::json!({"question": "pick one"}),
7157 Some(&mut gate),
7158 false,
7159 20,
7160 );
7161 assert_eq!(out, HEADLESS_NO_HUMAN);
7162 }
7163
7164 #[test]
7165 fn request_user_input_requires_a_question() {
7166 let mut gate = AskGate::new(Some("unused"));
7168 let out = execute_request_user_input(
7169 &serde_json::json!({"question": " "}),
7170 Some(&mut gate),
7171 false,
7172 20,
7173 );
7174 assert!(out.contains("'question' is required"), "got: {out}");
7175 assert!(
7176 gate.asked.is_empty(),
7177 "gate not consulted for a blank question"
7178 );
7179 }
7180
7181 #[test]
7182 fn request_user_input_is_a_real_tool_not_a_phantom() {
7183 assert!(resolve_tool_alias("request_user_input").is_none());
7186 assert!(ALL_TOOL_NAMES.contains(&"request_user_input"));
7187 assert!(classify_phantom_reach(
7188 "request_user_input",
7189 &serde_json::json!({"question": "which db?"}),
7190 "postgres",
7191 true,
7192 )
7193 .is_none());
7194 let defs = merged_tool_definitions(
7196 &NoMcp, false, false, false, false, false, false, false, false, false,
7197 );
7198 let names: Vec<&str> = defs
7199 .as_array()
7200 .unwrap()
7201 .iter()
7202 .filter_map(|d| d["function"]["name"].as_str())
7203 .collect();
7204 assert!(names.contains(&"request_user_input"), "got: {names:?}");
7205 }
7206
7207 #[test]
7208 fn ask_verbs_rewrite_to_request_user_input() {
7209 for verb in [
7211 "ask_user",
7212 "ask_human",
7213 "prompt_user",
7214 "get_user_input",
7215 "ask_question",
7216 "clarify",
7217 "ask",
7218 ] {
7219 match resolve_tool_alias(verb) {
7220 Some(AliasOutcome::Rewrite(c)) => {
7221 assert_eq!(c, "request_user_input", "verb: {verb}");
7222 }
7223 _ => panic!("expected Rewrite(request_user_input) for {verb}"),
7224 }
7225 }
7226 }
7227
7228 #[tokio::test]
7229 async fn request_user_input_dispatches_through_execute_tool() {
7230 let ws = tempfile::TempDir::new().unwrap();
7233 let caveats = Caveats::top();
7234 let mut gate = AskGate::new(Some("the answer"));
7235 let out = execute_tool(
7236 "request_user_input",
7237 &serde_json::json!({"question": "what now?"}),
7238 &ws.path().to_string_lossy(),
7239 false,
7240 20,
7241 &caveats,
7242 &mut NoMcp,
7243 None,
7244 None,
7245 None,
7246 None, Some(&mut gate),
7248 None,
7249 None, None, None, None, None, None, )
7256 .await;
7257 assert_eq!(out, "the answer");
7258 assert_eq!(gate.asked, vec!["what now?".to_string()]);
7259 }
7260
7261 #[test]
7262 fn get_context_remaining_is_a_real_tool_not_a_phantom() {
7263 assert!(resolve_tool_alias("get_context_remaining").is_none());
7266 assert!(ALL_TOOL_NAMES.contains(&"get_context_remaining"));
7267 assert!(classify_phantom_reach(
7268 "get_context_remaining",
7269 &serde_json::json!({}),
7270 "Context budget: ~10 tokens used of an input ceiling of ~80 (80% of num_ctx 100).",
7271 true,
7272 )
7273 .is_none());
7274 let defs = merged_tool_definitions(
7276 &NoMcp, false, false, false, false, false, false, false, false, false,
7277 );
7278 assert!(defs
7279 .as_array()
7280 .unwrap()
7281 .iter()
7282 .any(|d| d["function"]["name"] == "get_context_remaining"));
7283 }
7284
7285 #[test]
7286 fn budget_verbs_rewrite_to_get_context_remaining() {
7287 for n in [
7290 "context_remaining",
7291 "tokens_left",
7292 "remaining_tokens",
7293 "budget",
7294 "how_much_context",
7295 "context_budget",
7296 "token_budget",
7297 ] {
7298 assert!(
7299 matches!(
7300 resolve_tool_alias(n),
7301 Some(AliasOutcome::Rewrite("get_context_remaining"))
7302 ),
7303 "{n} must rewrite to get_context_remaining"
7304 );
7305 assert!(
7307 is_context_remaining_call(n),
7308 "{n} must be recognized as a budget call by the loop"
7309 );
7310 }
7311 assert!(is_context_remaining_call("get_context_remaining"));
7313 assert!(resolve_tool_alias("get_context_remaining").is_none());
7314 assert!(!is_context_remaining_call("read_file"));
7316 }
7317
7318 #[tokio::test]
7323 async fn no_gate_denials_are_bit_for_bit_unchanged() {
7324 let ws = tempfile::TempDir::new().unwrap();
7325 std::fs::write(ws.path().join("secret.txt"), "x").unwrap();
7326 let denied = Caveats {
7327 fs_read: Scope::none(),
7328 fs_write: Scope::none(),
7329 ..caveats_rw(ws.path())
7330 };
7331 let out = run_tool(
7332 "read_file",
7333 serde_json::json!({"path": "secret.txt"}),
7334 ws.path(),
7335 &denied,
7336 None,
7337 )
7338 .await;
7339 assert_eq!(out, denied_fs_result("fs_read", "secret.txt"));
7340 let out = run_tool(
7341 "list_dir",
7342 serde_json::json!({"path": "."}),
7343 ws.path(),
7344 &denied,
7345 None,
7346 )
7347 .await;
7348 assert_eq!(out, denied_fs_result("fs_read", "."));
7349 let out = run_tool(
7350 "write_file",
7351 serde_json::json!({"path": "a.txt", "content": "c"}),
7352 ws.path(),
7353 &denied,
7354 None,
7355 )
7356 .await;
7357 assert_eq!(out, denied_fs_result("fs_write", "a.txt"));
7358 let out = run_tool(
7359 "edit_file",
7360 serde_json::json!({"path": "a.txt", "old_string": "a", "new_string": "b"}),
7361 ws.path(),
7362 &denied,
7363 None,
7364 )
7365 .await;
7366 assert_eq!(out, denied_fs_result("fs_write", "a.txt"));
7367 let out = run_tool(
7368 "delete_file",
7369 serde_json::json!({"path": "secret.txt"}),
7370 ws.path(),
7371 &denied,
7372 None,
7373 )
7374 .await;
7375 assert_eq!(out, denied_fs_result("fs_write", "secret.txt"));
7376 assert!(out.contains("request_permissions"), "got: {out}");
7378 }
7379
7380 #[tokio::test]
7384 async fn gate_allow_turns_fs_read_denial_into_the_real_result() {
7385 let ws = tempfile::TempDir::new().unwrap();
7386 std::fs::write(ws.path().join("secret.txt"), "the contents").unwrap();
7387 let denied = Caveats {
7388 fs_read: Scope::none(),
7389 ..caveats_rw(ws.path())
7390 };
7391 let mut gate = MockGate::new(true, &denied);
7392 let out = run_tool_gated(
7393 "read_file",
7394 serde_json::json!({"path": "secret.txt"}),
7395 ws.path(),
7396 &denied,
7397 &mut gate,
7398 )
7399 .await;
7400 assert_eq!(out, "the contents");
7401 let full = ws.path().join("secret.txt").to_string_lossy().into_owned();
7402 assert_eq!(
7403 gate.asks,
7404 vec![("read_file".to_string(), format!("fs_read:{full}"))]
7405 );
7406 }
7407
7408 #[tokio::test]
7411 async fn gate_deny_keeps_the_standard_denial_bit_for_bit() {
7412 let ws = tempfile::TempDir::new().unwrap();
7413 std::fs::write(ws.path().join("secret.txt"), "x").unwrap();
7414 let denied = Caveats {
7415 fs_read: Scope::none(),
7416 ..caveats_rw(ws.path())
7417 };
7418 let mut gate = MockGate::new(false, &denied);
7419 let gated = run_tool_gated(
7420 "read_file",
7421 serde_json::json!({"path": "secret.txt"}),
7422 ws.path(),
7423 &denied,
7424 &mut gate,
7425 )
7426 .await;
7427 let ungated = run_tool(
7428 "read_file",
7429 serde_json::json!({"path": "secret.txt"}),
7430 ws.path(),
7431 &denied,
7432 None,
7433 )
7434 .await;
7435 assert_eq!(gated, ungated);
7436 assert_eq!(gated, denied_fs_result("fs_read", "secret.txt"));
7437 assert_eq!(gate.asks.len(), 1, "the human was asked exactly once");
7438 }
7439
7440 #[tokio::test]
7442 async fn gate_allow_turns_fs_write_denials_into_real_writes() {
7443 let ws = tempfile::TempDir::new().unwrap();
7444 std::fs::write(ws.path().join("f.txt"), "old\n").unwrap();
7445 std::fs::write(ws.path().join("stale.txt"), "remove me\n").unwrap();
7446 let denied = Caveats {
7447 fs_write: Scope::none(),
7448 ..caveats_rw(ws.path())
7449 };
7450 let mut gate = MockGate::new(true, &denied);
7451 let out = run_tool_gated(
7452 "write_file",
7453 serde_json::json!({"path": "new.txt", "content": "fresh"}),
7454 ws.path(),
7455 &denied,
7456 &mut gate,
7457 )
7458 .await;
7459 assert!(out.starts_with("wrote new.txt"), "got: {out}");
7460 assert_eq!(
7461 std::fs::read_to_string(ws.path().join("new.txt")).unwrap(),
7462 "fresh"
7463 );
7464 let out = run_tool_gated(
7465 "edit_file",
7466 serde_json::json!({"path": "f.txt", "old_string": "old", "new_string": "new"}),
7467 ws.path(),
7468 &denied,
7469 &mut gate,
7470 )
7471 .await;
7472 assert!(out.starts_with("edited f.txt"), "got: {out}");
7473 let out = run_tool_gated(
7474 "delete_file",
7475 serde_json::json!({"path": "stale.txt"}),
7476 ws.path(),
7477 &denied,
7478 &mut gate,
7479 )
7480 .await;
7481 assert!(out.starts_with("deleted stale.txt"), "got: {out}");
7482 assert!(
7483 !ws.path().join("stale.txt").exists(),
7484 "gate-approved delete must remove the file"
7485 );
7486 assert_eq!(gate.asks.len(), 3);
7487 assert_eq!(gate.asks[0].0, "write_file");
7488 assert!(
7489 gate.asks[1].1.starts_with("fs_write:"),
7490 "got: {:?}",
7491 gate.asks[1]
7492 );
7493 assert_eq!(gate.asks[2].0, "delete_file");
7494 }
7495
7496 #[tokio::test]
7498 async fn gate_allow_turns_list_dir_denial_into_the_listing() {
7499 let ws = tempfile::TempDir::new().unwrap();
7500 std::fs::write(ws.path().join("seen.txt"), "x").unwrap();
7501 let denied = Caveats {
7502 fs_read: Scope::none(),
7503 ..caveats_rw(ws.path())
7504 };
7505 let mut gate = MockGate::new(true, &denied);
7506 let out = run_tool_gated(
7507 "list_dir",
7508 serde_json::json!({"path": "."}),
7509 ws.path(),
7510 &denied,
7511 &mut gate,
7512 )
7513 .await;
7514 assert!(out.contains("seen.txt"), "got: {out}");
7515 }
7516
7517 #[tokio::test]
7521 async fn gate_allow_without_real_coverage_is_still_denied() {
7522 struct LyingGate;
7523 impl super::PermissionGate for LyingGate {
7524 fn ask(&mut self, _requests: &[super::PermissionRequest]) -> super::PermissionDecision {
7525 super::PermissionDecision::Allow(Caveats {
7527 fs_read: Scope::none(),
7528 fs_write: Scope::none(),
7529 exec: Scope::none(),
7530 net: Scope::none(),
7531 max_calls: CountBound::Unlimited,
7532 valid_for_generation: Scope::All,
7533 })
7534 }
7535 fn ask_question(&mut self, _question: &str) -> Option<String> {
7536 None
7537 }
7538 }
7539 let ws = tempfile::TempDir::new().unwrap();
7540 std::fs::write(ws.path().join("secret.txt"), "x").unwrap();
7541 let denied = Caveats {
7542 fs_read: Scope::none(),
7543 ..caveats_rw(ws.path())
7544 };
7545 let mut gate = LyingGate;
7546 let out = execute_tool(
7547 "read_file",
7548 &serde_json::json!({"path": "secret.txt"}),
7549 &ws.path().to_string_lossy(),
7550 false,
7551 20,
7552 &denied,
7553 &mut NoMcp,
7554 None,
7555 None,
7556 None,
7557 None, Some(&mut gate),
7559 None,
7560 None, None, None, None, None, None, )
7567 .await;
7568 assert_eq!(out, denied_fs_result("fs_read", "secret.txt"));
7569 }
7570
7571 #[tokio::test]
7576 async fn web_fetch_gate_deny_dispatches_under_original_caveats() {
7577 let ws = tempfile::TempDir::new().unwrap();
7578 let caveats = caveats_rw(ws.path()); let mut gate = MockGate::new(false, &caveats);
7580 let out = run_tool_gated(
7581 "web_fetch",
7582 serde_json::json!({"url": "https://denied.example.com:8443/page"}),
7583 ws.path(),
7584 &caveats,
7585 &mut gate,
7586 )
7587 .await;
7588 assert!(out.starts_with("error:"), "leash denial surfaces: {out}");
7589 assert_eq!(
7590 gate.asks,
7591 vec![(
7592 "web_fetch".to_string(),
7593 "net:denied.example.com".to_string()
7594 )]
7595 );
7596 }
7597
7598 #[tokio::test]
7602 async fn web_fetch_github_denial_consults_permission_gate() {
7603 let ws = tempfile::TempDir::new().unwrap();
7604 let caveats = caveats_rw(ws.path()); let mut gate = MockGate::new(false, &caveats);
7606 let out = run_tool_gated(
7607 "web_fetch",
7608 serde_json::json!({"url": "https://github.com/openai/codex"}),
7609 ws.path(),
7610 &caveats,
7611 &mut gate,
7612 )
7613 .await;
7614 assert!(out.starts_with("error:"), "leash denial surfaces: {out}");
7615 assert_eq!(
7616 gate.asks,
7617 vec![("web_fetch".to_string(), "net:github.com".to_string())]
7618 );
7619 }
7620
7621 #[tokio::test]
7624 async fn web_fetch_unparseable_url_never_prompts() {
7625 let ws = tempfile::TempDir::new().unwrap();
7626 let caveats = caveats_rw(ws.path());
7627 let mut gate = MockGate::new(true, &caveats);
7628 let out = run_tool_gated(
7629 "web_fetch",
7630 serde_json::json!({"url": "not-a-url"}),
7631 ws.path(),
7632 &caveats,
7633 &mut gate,
7634 )
7635 .await;
7636 assert!(out.starts_with("error:"), "got: {out}");
7637 assert!(gate.asks.is_empty(), "no prompt for an unparseable URL");
7638 }
7639
7640 #[tokio::test]
7643 async fn save_note_without_sink_is_unknown_tool() {
7644 let ws = tempfile::TempDir::new().unwrap();
7645 let caveats = caveats_rw(ws.path());
7646 let out = run_tool(
7648 "save_note",
7649 serde_json::json!({"action": "add", "text": "a fact"}),
7650 ws.path(),
7651 &caveats,
7652 None,
7653 )
7654 .await;
7655 assert!(out.starts_with("unknown tool: save_note"), "got: {out}");
7656 }
7657
7658 #[tokio::test]
7659 async fn save_note_with_sink_routes_through_execute_tool() {
7660 use crate::agentic::note_sink::tests::MockSink;
7661 let ws = tempfile::TempDir::new().unwrap();
7662 let caveats = caveats_rw(ws.path());
7663 let mut sink = MockSink::default();
7664 let out = execute_tool(
7665 "save_note",
7666 &serde_json::json!({"action": "add", "text": "workspace builds with just check"}),
7667 &ws.path().to_string_lossy(),
7668 false,
7669 20,
7670 &caveats,
7671 &mut NoMcp,
7672 None,
7673 Some(&mut sink),
7674 None,
7675 None, None,
7677 None,
7678 None, None, None, None, None, None, )
7685 .await;
7686 assert_eq!(sink.calls, vec!["add:workspace builds with just check"]);
7687 assert!(
7688 out.starts_with("note saved: workspace builds"),
7689 "got: {out}"
7690 );
7691 }
7692
7693 #[tokio::test]
7696 async fn recall_without_source_is_unknown_tool() {
7697 let ws = tempfile::TempDir::new().unwrap();
7698 let caveats = caveats_rw(ws.path());
7699 let out = run_tool(
7701 "recall",
7702 serde_json::json!({"query": "tokio panic"}),
7703 ws.path(),
7704 &caveats,
7705 None,
7706 )
7707 .await;
7708 assert!(out.starts_with("unknown tool: recall"), "got: {out}");
7709 }
7710
7711 #[tokio::test]
7712 async fn recall_with_source_routes_through_execute_tool() {
7713 use crate::agentic::recall::tests::{hit, MockSource};
7714 let ws = tempfile::TempDir::new().unwrap();
7715 let caveats = caveats_rw(ws.path());
7716 let source = MockSource {
7717 hits: vec![hit(
7718 "123456789012-abcd",
7719 "past work",
7720 3,
7721 ">>>tokio<<< panic",
7722 )],
7723 ..Default::default()
7724 };
7725 let out = execute_tool(
7726 "recall",
7727 &serde_json::json!({"query": "tokio panic"}),
7728 &ws.path().to_string_lossy(),
7729 false,
7730 20,
7731 &caveats,
7732 &mut NoMcp,
7733 None,
7734 None,
7735 Some(&source),
7736 None, None,
7738 None,
7739 None, None, None, None, None, None, )
7746 .await;
7747 assert_eq!(
7748 *source.calls.lock().unwrap(),
7749 vec![("tokio panic".to_string(), 5)]
7750 );
7751 assert!(out.contains("«tokio» panic"), "got: {out}");
7752 assert!(out.contains("past work"), "got: {out}");
7753 }
7754
7755 #[tokio::test]
7761 async fn memory_fetch_without_source_is_unknown_tool() {
7762 let ws = tempfile::TempDir::new().unwrap();
7763 let caveats = caveats_rw(ws.path());
7764 let out = run_tool(
7766 "memory_fetch",
7767 serde_json::json!({"address": "note:1"}),
7768 ws.path(),
7769 &caveats,
7770 None,
7771 )
7772 .await;
7773 assert!(out.starts_with("unknown tool: memory_fetch"), "got: {out}");
7774 }
7775
7776 #[tokio::test]
7780 async fn memory_fetch_with_source_routes_through_execute_tool() {
7781 use crate::agentic::memory_fetch::tests::MockSource;
7782 use crate::agentic::MemAddr;
7783 let ws = tempfile::TempDir::new().unwrap();
7784 let caveats = caveats_rw(ws.path());
7785 let source = MockSource {
7786 body: Some("the exact note body".to_string()),
7787 ..Default::default()
7788 };
7789 let out = execute_tool(
7790 "memory_fetch",
7791 &serde_json::json!({"address": "note:1"}),
7792 &ws.path().to_string_lossy(),
7793 false,
7794 20,
7795 &caveats,
7796 &mut NoMcp,
7797 None,
7798 None,
7799 None,
7800 Some(&source),
7801 None,
7802 None,
7803 None, None, None, None, None, None, )
7810 .await;
7811 assert_eq!(out, "the exact note body");
7812 assert_eq!(
7813 *source.calls.lock().unwrap(),
7814 vec![MemAddr::Note { id: "1".into() }]
7815 );
7816 }
7817}
7818
7819#[cfg(test)]
7826mod disable_ocap_tests {
7827 use super::super::NoMcp;
7828 use super::*;
7829 use crate::caveats::{Caveats, CountBound, Scope};
7830 use tokio::sync::{Mutex, MutexGuard};
7831
7832 static ENV_LOCK: Mutex<()> = Mutex::const_new(());
7838
7839 async fn env_lock() -> MutexGuard<'static, ()> {
7840 ENV_LOCK.lock().await
7841 }
7842
7843 struct EnvVar {
7847 key: &'static str,
7848 saved: Option<String>,
7849 }
7850
7851 impl EnvVar {
7852 fn set(key: &'static str, value: &str) -> Self {
7853 let saved = std::env::var(key).ok();
7854 std::env::set_var(key, value);
7855 Self { key, saved }
7856 }
7857
7858 fn unset(key: &'static str) -> Self {
7859 let saved = std::env::var(key).ok();
7860 std::env::remove_var(key);
7861 Self { key, saved }
7862 }
7863 }
7864
7865 impl Drop for EnvVar {
7866 fn drop(&mut self) {
7867 match self.saved.take() {
7868 Some(v) => std::env::set_var(self.key, v),
7869 None => std::env::remove_var(self.key),
7870 }
7871 }
7872 }
7873
7874 fn caveats_no_exec(ws: &std::path::Path) -> Caveats {
7877 Caveats {
7878 fs_read: Scope::only([ws.to_string_lossy().into_owned()]),
7879 fs_write: Scope::only([ws.to_string_lossy().into_owned()]),
7880 exec: Scope::none(),
7881 net: Scope::none(),
7882 max_calls: CountBound::Unlimited,
7883 valid_for_generation: Scope::All,
7884 }
7885 }
7886
7887 async fn run_tool(
7888 name: &str,
7889 args: serde_json::Value,
7890 ws: &std::path::Path,
7891 caveats: &Caveats,
7892 ) -> String {
7893 run_tool_with_floor(name, args, ws, caveats, None).await
7894 }
7895
7896 async fn run_tool_with_floor(
7901 name: &str,
7902 args: serde_json::Value,
7903 ws: &std::path::Path,
7904 caveats: &Caveats,
7905 exec_floor: Option<&Scope<String>>,
7906 ) -> String {
7907 execute_tool(
7908 name,
7909 &args,
7910 &ws.to_string_lossy(),
7911 false,
7912 20,
7913 caveats,
7914 &mut NoMcp,
7915 None,
7916 None,
7917 None,
7918 None, None,
7920 exec_floor,
7921 None, None, None, None, None, None, )
7928 .await
7929 }
7930
7931 #[test]
7936 fn ocap_disabled_requires_exactly_1() {
7937 let _l = ENV_LOCK.blocking_lock();
7938 {
7939 let _unset = EnvVar::unset("NEWT_DISABLE_OCAP");
7940 assert!(!ocap_disabled(), "absent ⇒ confinement stays on");
7941 }
7942 for (value, expected) in [
7943 ("1", true),
7944 ("0", false),
7945 ("", false),
7946 ("true", false),
7947 ("yes", false),
7948 ("YOLO", false),
7949 ] {
7950 let _set = EnvVar::set("NEWT_DISABLE_OCAP", value);
7951 assert_eq!(
7952 ocap_disabled(),
7953 expected,
7954 "NEWT_DISABLE_OCAP={value:?} must read as {expected}"
7955 );
7956 }
7957 }
7958
7959 #[test]
7963 fn full_access_requested_requires_exactly_1() {
7964 let _l = ENV_LOCK.blocking_lock();
7965 {
7966 let _unset = EnvVar::unset("NEWT_FULL_ACCESS");
7967 assert!(!full_access_requested(), "absent ⇒ configured preset rules");
7968 }
7969 for (value, expected) in [
7970 ("1", true),
7971 ("0", false),
7972 ("", false),
7973 ("true", false),
7974 ("yes", false),
7975 ("FULL", false),
7976 ] {
7977 let _set = EnvVar::set("NEWT_FULL_ACCESS", value);
7978 assert_eq!(
7979 full_access_requested(),
7980 expected,
7981 "NEWT_FULL_ACCESS={value:?} must read as {expected}"
7982 );
7983 }
7984 }
7985
7986 #[tokio::test]
7993 async fn flag_off_run_command_keeps_the_confined_dispatch_verbatim() {
7994 let _l = env_lock().await;
7995 let _off = EnvVar::unset("NEWT_DISABLE_OCAP");
7996 let ws = tempfile::TempDir::new().unwrap();
7997 let caveats = caveats_no_exec(ws.path());
7998 let out = run_tool(
7999 "run_command",
8000 serde_json::json!({"command": "echo hi"}),
8001 ws.path(),
8002 &caveats,
8003 )
8004 .await;
8005 assert!(
8006 out.contains("capability denied"),
8007 "flag off ⇒ the confined dispatch must govern (deny) the command, got: {out}"
8008 );
8009 }
8010
8011 #[cfg(unix)]
8015 #[tokio::test]
8016 async fn yolo_runs_the_denied_command_on_the_host_shell() {
8017 let _l = env_lock().await;
8018 let _on = EnvVar::set("NEWT_DISABLE_OCAP", "1");
8019 let ws = tempfile::TempDir::new().unwrap();
8020 let caveats = caveats_no_exec(ws.path());
8021 let out = run_tool(
8022 "run_command",
8023 serde_json::json!({"command": "echo yolo-ok"}),
8024 ws.path(),
8025 &caveats,
8026 )
8027 .await;
8028 assert_eq!(out, "yolo-ok\n");
8029
8030 let out = run_tool(
8032 "run_command",
8033 serde_json::json!({"command": "exit 3"}),
8034 ws.path(),
8035 &caveats,
8036 )
8037 .await;
8038 assert_eq!(out, "(exit 3)");
8039 }
8040
8041 #[cfg(unix)]
8053 #[tokio::test]
8054 async fn run_command_output_over_budget_is_token_capped() {
8055 let _l = env_lock().await;
8056 let _on = EnvVar::set("NEWT_DISABLE_OCAP", "1");
8057 let ws = tempfile::TempDir::new().unwrap();
8058 let caveats = caveats_no_exec(ws.path());
8059 let out = run_tool(
8061 "run_command",
8062 serde_json::json!({"command": "seq 1 60000"}),
8063 ws.path(),
8064 &caveats,
8065 )
8066 .await;
8067 assert!(
8068 out.len() < 41_500,
8069 "model-facing output capped near the ~40k-char budget, got {} bytes",
8070 out.len()
8071 );
8072 assert!(
8073 out.contains("chars elided (head+tail shown"),
8074 "carries the head+tail elision marker: {:?}",
8075 &out[..out.len().min(400)]
8076 );
8077 assert!(
8079 out.starts_with("1\n2\n3\n"),
8080 "head preserved: {:?}",
8081 &out[..out.len().min(160)]
8082 );
8083 assert!(
8087 out.trim_end().ends_with("60000"),
8088 "tail preserved, not dropped by the cap: {:?}",
8089 &out[out.len().saturating_sub(160)..]
8090 );
8091 }
8092
8093 #[test]
8097 fn exec_floor_permits_covers_each_branch() {
8098 use crate::caveats::Scope;
8099 assert!(exec_floor_permits(None, "rm -rf /"));
8101 let only_echo = Scope::only(["echo".to_string()]);
8103 assert!(exec_floor_permits(Some(&only_echo), ""));
8104 assert!(exec_floor_permits(Some(&only_echo), "echo hi"));
8106 assert!(!exec_floor_permits(Some(&only_echo), "rm hi"));
8108 assert!(!exec_floor_permits(Some(&only_echo), "echo hi && rm x"));
8110 assert!(!exec_floor_permits(Some(&only_echo), "echo a | tee b"));
8111 assert!(!exec_floor_permits(Some(&only_echo), "echo $(rm x)"));
8112 let all: Scope<String> = Scope::All;
8114 assert!(exec_floor_permits(Some(&all), "anything goes"));
8115 assert!(!exec_floor_permits(Some(&all), "anything; sneaky"));
8116 }
8117
8118 #[test]
8123 fn exec_floor_refuses_every_metacharacter_form() {
8124 use crate::caveats::Scope;
8125 let echo = Scope::only(["echo".to_string()]);
8126 let attacks = [
8129 "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", ];
8146 for a in attacks {
8147 assert!(
8148 !exec_floor_permits(Some(&echo), a),
8149 "metacharacter form must NOT bypass the floor: {a:?}"
8150 );
8151 }
8152 let leading_token_attacks = [
8155 "rm -rf /tmp/x", "FOO=bar rm x", "/bin/echo ok", " rm x", "env rm x", "bash -c rm", ];
8162 for a in leading_token_attacks {
8163 assert!(
8164 !exec_floor_permits(Some(&echo), a),
8165 "out-of-floor leading token must be refused: {a:?}"
8166 );
8167 }
8168 assert!(exec_floor_permits(Some(&echo), "echo hello world"));
8172 assert!(exec_floor_permits(Some(&echo), "echo -n trailing"));
8173 }
8174
8175 #[tokio::test]
8182 async fn floor_blocks_disable_ocap_for_a_denied_exec() {
8183 let _l = env_lock().await;
8184 let _on = EnvVar::set("NEWT_DISABLE_OCAP", "1");
8185 let ws = tempfile::TempDir::new().unwrap();
8186 let caveats = caveats_no_exec(ws.path());
8187 let floor = crate::NamedPermissionPreset {
8189 readonly: true,
8190 ..Default::default()
8191 }
8192 .clamp();
8193 let out = run_tool_with_floor(
8194 "run_command",
8195 serde_json::json!({"command": "echo should-not-run"}),
8196 ws.path(),
8197 &caveats,
8198 Some(&floor.exec),
8199 )
8200 .await;
8201 assert_ne!(out, "should-not-run\n", "the floor must block the bypass");
8204 assert!(
8205 out.contains("capability denied"),
8206 "fell to confined dispatch and was denied, got: {out}"
8207 );
8208 }
8209
8210 #[cfg(unix)]
8214 #[tokio::test]
8215 async fn floor_allows_disable_ocap_for_an_in_floor_exec() {
8216 let _l = env_lock().await;
8217 let _on = EnvVar::set("NEWT_DISABLE_OCAP", "1");
8218 let ws = tempfile::TempDir::new().unwrap();
8219 let caveats = caveats_no_exec(ws.path());
8220 let floor = crate::NamedPermissionPreset {
8222 readonly: true,
8223 exec_allow: vec!["echo".to_string()],
8224 ..Default::default()
8225 }
8226 .clamp();
8227 let out = run_tool_with_floor(
8228 "run_command",
8229 serde_json::json!({"command": "echo in-floor-ok"}),
8230 ws.path(),
8231 &caveats,
8232 Some(&floor.exec),
8233 )
8234 .await;
8235 assert_eq!(out, "in-floor-ok\n", "in-floor command runs unconfined");
8236 }
8237
8238 #[tokio::test]
8243 async fn floor_refuses_bypass_for_a_compound_command() {
8244 let _l = env_lock().await;
8245 let _on = EnvVar::set("NEWT_DISABLE_OCAP", "1");
8246 let ws = tempfile::TempDir::new().unwrap();
8247 let caveats = caveats_no_exec(ws.path());
8248 let floor = crate::NamedPermissionPreset {
8250 readonly: true,
8251 exec_allow: vec!["echo".to_string()],
8252 ..Default::default()
8253 }
8254 .clamp();
8255 let out = run_tool_with_floor(
8256 "run_command",
8257 serde_json::json!({"command": "echo ok && rm -rf /tmp/x"}),
8258 ws.path(),
8259 &caveats,
8260 Some(&floor.exec),
8261 )
8262 .await;
8263 assert_ne!(out, "ok\n", "a compound command must not bypass the floor");
8264 assert!(
8267 out.contains("capability denied"),
8268 "fell to confined dispatch and was denied, got: {out}"
8269 );
8270 }
8271
8272 #[cfg(unix)]
8276 #[tokio::test]
8277 async fn no_floor_keeps_disable_ocap_bit_for_bit() {
8278 let _l = env_lock().await;
8279 let _on = EnvVar::set("NEWT_DISABLE_OCAP", "1");
8280 let ws = tempfile::TempDir::new().unwrap();
8281 let caveats = caveats_no_exec(ws.path());
8282 let out = run_tool_with_floor(
8283 "run_command",
8284 serde_json::json!({"command": "echo no-floor-ok"}),
8285 ws.path(),
8286 &caveats,
8287 None,
8288 )
8289 .await;
8290 assert_eq!(out, "no-floor-ok\n", "no floor ⇒ bypass unchanged");
8291 }
8292
8293 #[cfg(unix)]
8298 #[tokio::test]
8299 async fn host_shell_envelope_matches_the_bridle_shape() {
8300 let ws = tempfile::TempDir::new().unwrap();
8301 let envelope = host_shell_dispatch(
8302 "echo out; echo err >&2; exit 3",
8303 &ws.path().to_string_lossy(),
8304 )
8305 .await
8306 .expect("host shell runs");
8307 assert_eq!(envelope["exit_code"], 3);
8308 assert_eq!(envelope["stdout"], "out\n");
8309 assert_eq!(envelope["stderr"], "err\n");
8310 assert_eq!(envelope["sandbox_kind"], "none");
8311 assert!(envelope.get("denied").is_none(), "got: {envelope}");
8314 assert!(envelope.get("denials").is_none(), "got: {envelope}");
8315 assert!(!envelope_denied(&envelope));
8316 assert_eq!(
8318 shell_envelope_output(&envelope, 20, false, false, None),
8319 "out\nerr\n"
8320 );
8321 }
8322
8323 #[test]
8324 fn decode_shell_stream_preserves_valid_utf8() {
8325 let text = "// ── Model — test ──\n";
8326 assert_eq!(decode_shell_stream(text.as_bytes()), text);
8327 }
8328
8329 #[test]
8330 fn decode_shell_stream_repairs_bsd_cat_v_utf8_notation() {
8331 let cat_v = b"\xe2M-^TM-^@ \xe2M-^@M-^T\n";
8336 assert_eq!(decode_shell_stream(cat_v), "─ —\n");
8337 }
8338
8339 #[test]
8340 fn decode_shell_stream_repairs_two_byte_bsd_cat_v_notation() {
8341 let cat_v = b"caf\xc3M-)\n";
8343 assert_eq!(decode_shell_stream(cat_v), "café\n");
8344 }
8345
8346 #[cfg(unix)]
8352 #[tokio::test]
8353 async fn yolo_keeps_the_venv_prefix_logic() {
8354 let _l = env_lock().await;
8355 let _on = EnvVar::set("NEWT_DISABLE_OCAP", "1");
8356 let _venv = EnvVar::set("NEWT_VENV", "/opt/fake-venv");
8357 let _virtual = EnvVar::unset("VIRTUAL_ENV");
8358 let _paths = EnvVar::unset("NEWT_EXEC_PATHS");
8359 let ws = tempfile::TempDir::new().unwrap();
8360 let caveats = caveats_no_exec(ws.path());
8361 let out = run_tool(
8362 "run_command",
8363 serde_json::json!({"command": "echo \"$VIRTUAL_ENV\""}),
8364 ws.path(),
8365 &caveats,
8366 )
8367 .await;
8368 assert_eq!(out, "/opt/fake-venv\n");
8369 }
8370
8371 #[tokio::test]
8375 async fn yolo_auto_confirms_unrestricted_write_and_delete_prompts() {
8376 let _l = env_lock().await;
8377 let _on = EnvVar::set("NEWT_DISABLE_OCAP", "1");
8378 let ws = tempfile::TempDir::new().unwrap();
8379 let caveats = Caveats::top();
8380
8381 let out = run_tool(
8382 "write_file",
8383 serde_json::json!({"path": "auto.txt", "content": "ok\n"}),
8384 ws.path(),
8385 &caveats,
8386 )
8387 .await;
8388 assert!(out.starts_with("wrote auto.txt"), "got: {out}");
8389 assert_eq!(
8390 std::fs::read_to_string(ws.path().join("auto.txt")).unwrap(),
8391 "ok\n"
8392 );
8393
8394 let out = run_tool(
8395 "delete_file",
8396 serde_json::json!({"path": "auto.txt"}),
8397 ws.path(),
8398 &caveats,
8399 )
8400 .await;
8401 assert!(out.starts_with("deleted auto.txt"), "got: {out}");
8402 assert!(
8403 !ws.path().join("auto.txt").exists(),
8404 "yolo-confirmed delete must remove the file"
8405 );
8406 }
8407
8408 #[tokio::test]
8412 async fn unrestricted_write_and_delete_confirm_through_permission_gate() {
8413 struct ConfirmGate {
8414 answer: Option<String>,
8415 questions: Vec<String>,
8416 }
8417 impl super::PermissionGate for ConfirmGate {
8418 fn ask(&mut self, _requests: &[super::PermissionRequest]) -> super::PermissionDecision {
8419 super::PermissionDecision::Deny
8420 }
8421 fn ask_question(&mut self, question: &str) -> Option<String> {
8422 self.questions.push(question.to_string());
8423 self.answer.clone()
8424 }
8425 }
8426
8427 let _l = env_lock().await;
8428 let _off = EnvVar::unset("NEWT_DISABLE_OCAP");
8429 let ws = tempfile::TempDir::new().unwrap();
8430 let caveats = Caveats::top();
8431 let mut gate = ConfirmGate {
8432 answer: Some("y".to_string()),
8433 questions: Vec::new(),
8434 };
8435
8436 let out = execute_tool(
8437 "write_file",
8438 &serde_json::json!({"path": "guarded.txt", "content": "ok\n"}),
8439 &ws.path().to_string_lossy(),
8440 false,
8441 20,
8442 &caveats,
8443 &mut NoMcp,
8444 None,
8445 None,
8446 None,
8447 None, Some(&mut gate),
8449 None,
8450 None, None, None, None, None, None, )
8457 .await;
8458 assert!(out.starts_with("wrote guarded.txt"), "got: {out}");
8459 assert_eq!(
8460 std::fs::read_to_string(ws.path().join("guarded.txt")).unwrap(),
8461 "ok\n"
8462 );
8463
8464 let out = execute_tool(
8465 "delete_file",
8466 &serde_json::json!({"path": "guarded.txt"}),
8467 &ws.path().to_string_lossy(),
8468 false,
8469 20,
8470 &caveats,
8471 &mut NoMcp,
8472 None,
8473 None,
8474 None,
8475 None, Some(&mut gate),
8477 None,
8478 None, None, None, None, None, None, )
8485 .await;
8486 assert!(out.starts_with("deleted guarded.txt"), "got: {out}");
8487 assert!(!ws.path().join("guarded.txt").exists());
8488 assert_eq!(
8489 gate.questions,
8490 vec![
8491 "Write this file? [y/N]".to_string(),
8492 "Delete this file? [y/N]".to_string()
8493 ]
8494 );
8495 }
8496
8497 #[tokio::test]
8504 async fn confined_dispatch_uses_env_seam_not_export_prefix_783() {
8505 let _l = env_lock().await;
8506 let _venv = EnvVar::set("NEWT_VENV", "/opt/fake-venv");
8507 let _virtual = EnvVar::unset("VIRTUAL_ENV");
8508 let _paths = EnvVar::unset("NEWT_EXEC_PATHS");
8509
8510 let cmd = "hostname; sw_vers 2>/dev/null | head -1; uname -s";
8512 let args = confined_dispatch_args(cmd, "/work/dir");
8513
8514 assert_eq!(args["cmd"], cmd);
8516 assert!(
8517 !args["cmd"]
8518 .as_str()
8519 .expect("cmd is a string")
8520 .contains("export "),
8521 "confined cmd must not carry an export prefix: {args}"
8522 );
8523 assert_eq!(args["cwd"], "/work/dir");
8524
8525 assert_eq!(args["env"]["VIRTUAL_ENV"], "/opt/fake-venv");
8527 let path = args["env"]["PATH"].as_str().expect("PATH in the env seam");
8528 assert!(
8529 path.starts_with("/opt/fake-venv/bin"),
8530 "venv bin must be prepended to PATH: {path}"
8531 );
8532 }
8533
8534 #[tokio::test]
8537 async fn confined_dispatch_env_seam_without_venv_783() {
8538 let _l = env_lock().await;
8539 let _venv = EnvVar::unset("NEWT_VENV");
8540 let _virtual = EnvVar::unset("VIRTUAL_ENV");
8541 let _paths = EnvVar::unset("NEWT_EXEC_PATHS");
8542 let _pass = EnvVar::unset("NEWT_SHELL_ENV_PASSTHROUGH"); let _home = EnvVar::set("HOME", "/home/testuser");
8544
8545 let args = confined_dispatch_args("ls -la", "/work/dir");
8546 assert_eq!(args["cmd"], "ls -la");
8547 let env = &args["env"];
8548 assert!(
8550 env.get("VIRTUAL_ENV").is_none(),
8551 "no venv ⇒ no VIRTUAL_ENV: {args}"
8552 );
8553 assert!(
8554 env.get("PATH").is_none(),
8555 "no venv/exec-paths ⇒ no PATH override: {args}"
8556 );
8557 assert_eq!(
8561 env["HOME"], "/home/testuser",
8562 "HOME must pass through: {args}"
8563 );
8564 assert!(
8565 env.get("SHELL").is_some(),
8566 "SHELL must identify the confined engine: {args}"
8567 );
8568 }
8569
8570 #[tokio::test]
8574 async fn yolo_keeps_the_fs_workspace_fence() {
8575 let _l = env_lock().await;
8576 let _on = EnvVar::set("NEWT_DISABLE_OCAP", "1");
8577 let ws = tempfile::TempDir::new().unwrap();
8578 let caveats = caveats_no_exec(ws.path());
8579 let escape = "/definitely-outside-the-fence/escape.txt";
8580 let out = run_tool(
8581 "write_file",
8582 serde_json::json!({"path": escape, "content": "nope"}),
8583 ws.path(),
8584 &caveats,
8585 )
8586 .await;
8587 assert_eq!(out, denied_fs_result("fs_write", escape));
8588 assert!(!std::path::Path::new(escape).exists());
8589
8590 let out = run_tool(
8591 "delete_file",
8592 serde_json::json!({"path": escape}),
8593 ws.path(),
8594 &caveats,
8595 )
8596 .await;
8597 assert_eq!(out, denied_fs_result("fs_write", escape));
8598
8599 let out = run_tool(
8600 "read_file",
8601 serde_json::json!({"path": "/etc/hostname"}),
8602 ws.path(),
8603 &caveats,
8604 )
8605 .await;
8606 assert_eq!(out, denied_fs_result("fs_read", "/etc/hostname"));
8607 }
8608
8609 #[cfg(unix)]
8614 #[tokio::test]
8615 async fn yolo_never_consults_the_permission_gate_for_exec() {
8616 struct PanicGate;
8617 impl super::PermissionGate for PanicGate {
8618 fn ask(&mut self, requests: &[super::PermissionRequest]) -> super::PermissionDecision {
8619 panic!("yolo exec must never prompt, but the gate was asked: {requests:?}");
8620 }
8621 fn ask_question(&mut self, question: &str) -> Option<String> {
8622 panic!("yolo exec must never prompt, but the gate was asked: {question:?}");
8623 }
8624 }
8625 let _l = env_lock().await;
8626 let _on = EnvVar::set("NEWT_DISABLE_OCAP", "1");
8627 let ws = tempfile::TempDir::new().unwrap();
8628 let caveats = caveats_no_exec(ws.path());
8629 let mut gate = PanicGate;
8630 let out = execute_tool(
8631 "run_command",
8632 &serde_json::json!({"command": "echo no-prompt"}),
8633 &ws.path().to_string_lossy(),
8634 false,
8635 20,
8636 &caveats,
8637 &mut NoMcp,
8638 None,
8639 None,
8640 None,
8641 None, Some(&mut gate),
8643 None,
8644 None, None, None, None, None, None, )
8651 .await;
8652 assert_eq!(out, "no-prompt\n");
8653 }
8654
8655 #[tokio::test]
8658 async fn yolo_keeps_the_tool_name_corrective_guard() {
8659 let _l = env_lock().await;
8660 let _on = EnvVar::set("NEWT_DISABLE_OCAP", "1");
8661 let ws = tempfile::TempDir::new().unwrap();
8662 let caveats = caveats_no_exec(ws.path());
8663 let out = run_tool(
8664 "run_command",
8665 serde_json::json!({"command": "read_file foo.txt"}),
8666 ws.path(),
8667 &caveats,
8668 )
8669 .await;
8670 assert!(out.contains("is a tool, not a shell command"), "got: {out}");
8671 }
8672
8673 struct RoutingStubGit;
8679 impl crate::agentic::GitTool for RoutingStubGit {
8680 fn dispatch(
8681 &self,
8682 op: &str,
8683 _args: &serde_json::Value,
8684 _caps: &crate::git_caveats::GitCaveats,
8685 ) -> Result<String, String> {
8686 match op {
8687 "status" => Ok("on branch main (routed via git built-in)".to_string()),
8688 other => Err(format!("unexpected routed git op '{other}'")),
8689 }
8690 }
8691 }
8692
8693 async fn run_routed_with_git(command: &str, ws: &std::path::Path, caveats: &Caveats) -> String {
8694 execute_tool(
8695 "run_command",
8696 &serde_json::json!({ "command": command }),
8697 &ws.to_string_lossy(),
8698 false,
8699 20,
8700 caveats,
8701 &mut NoMcp,
8702 None,
8703 None,
8704 None,
8705 None, None, None, Some(&RoutingStubGit as &dyn crate::agentic::GitTool),
8709 None, None, None, None, None, )
8715 .await
8716 }
8717
8718 #[test]
8723 fn routing_disabled_requires_exactly_1_and_is_independent_of_ocap() {
8724 let _l = ENV_LOCK.blocking_lock();
8725 let _no_ocap = EnvVar::unset("NEWT_DISABLE_OCAP");
8726 {
8727 let _unset = EnvVar::unset("NEWT_NO_ROUTE");
8728 assert!(!routing_disabled(), "absent ⇒ routing stays on");
8729 }
8730 for (value, expected) in [("1", true), ("0", false), ("", false), ("true", false)] {
8731 let _set = EnvVar::set("NEWT_NO_ROUTE", value);
8732 assert_eq!(routing_disabled(), expected, "NEWT_NO_ROUTE={value:?}");
8733 assert!(
8735 !ocap_disabled(),
8736 "NEWT_NO_ROUTE must not imply --disable-ocap"
8737 );
8738 }
8739 let _unset_route = EnvVar::unset("NEWT_NO_ROUTE");
8741 let _on_ocap = EnvVar::set("NEWT_DISABLE_OCAP", "1");
8742 assert!(ocap_disabled());
8743 assert!(
8744 !routing_disabled(),
8745 "--disable-ocap must not imply --no-route"
8746 );
8747 }
8748
8749 #[tokio::test]
8755 async fn routed_cat_goes_through_the_fs_floor_not_a_bypass() {
8756 let _l = env_lock().await;
8757 let _route_on = EnvVar::unset("NEWT_NO_ROUTE");
8758 let _ocap_off = EnvVar::unset("NEWT_DISABLE_OCAP");
8759 let ws = tempfile::TempDir::new().unwrap();
8760 let caveats = caveats_no_exec(ws.path()); let out = run_tool(
8762 "run_command",
8763 serde_json::json!({ "command": "cat /etc/shadow" }),
8764 ws.path(),
8765 &caveats,
8766 )
8767 .await;
8768 assert!(
8769 out.contains("capability denied: fs_read does not permit")
8770 && out.contains("/etc/shadow"),
8771 "routed cat must hit the fs floor, not run unconfined; got: {out}"
8772 );
8773 }
8774
8775 #[tokio::test]
8780 async fn routed_git_status_dispatches_through_the_git_builtin() {
8781 let _l = env_lock().await;
8782 let _route_on = EnvVar::unset("NEWT_NO_ROUTE");
8783 let _ocap_off = EnvVar::unset("NEWT_DISABLE_OCAP");
8784 let ws = tempfile::TempDir::new().unwrap();
8785 let caveats = caveats_no_exec(ws.path());
8786 let out = run_routed_with_git("git status", ws.path(), &caveats).await;
8787 assert!(
8788 out.contains("routed via git built-in"),
8789 "git status must route to the governed git built-in; got: {out}"
8790 );
8791 }
8792
8793 #[tokio::test]
8796 async fn routed_rm_dispatches_through_delete_file() {
8797 let _l = env_lock().await;
8798 let _route_on = EnvVar::unset("NEWT_NO_ROUTE");
8799 let _ocap_off = EnvVar::unset("NEWT_DISABLE_OCAP");
8800 let ws = tempfile::TempDir::new().unwrap();
8801 std::fs::write(ws.path().join("stale.txt"), "remove me\n").unwrap();
8802 let caveats = caveats_no_exec(ws.path());
8803 let out = run_tool(
8804 "run_command",
8805 serde_json::json!({ "command": "rm stale.txt" }),
8806 ws.path(),
8807 &caveats,
8808 )
8809 .await;
8810 assert!(out.starts_with("deleted stale.txt"), "got: {out}");
8811 assert!(
8812 !ws.path().join("stale.txt").exists(),
8813 "routed rm must remove the file through delete_file"
8814 );
8815 }
8816
8817 #[tokio::test]
8821 async fn state_modifying_git_add_is_not_routed() {
8822 let _l = env_lock().await;
8823 let _route_on = EnvVar::unset("NEWT_NO_ROUTE");
8824 let _ocap_off = EnvVar::unset("NEWT_DISABLE_OCAP");
8825 let ws = tempfile::TempDir::new().unwrap();
8826 let caveats = caveats_no_exec(ws.path());
8827 let out = run_routed_with_git("git add a.txt", ws.path(), &caveats).await;
8828 assert!(
8829 !out.contains("routed"),
8830 "git add must NOT route to the git built-in; got: {out}"
8831 );
8832 assert!(out.contains("is a tool, not a shell command"), "got: {out}");
8835 }
8836
8837 #[tokio::test]
8843 async fn no_route_bypasses_routing_but_keeps_l3() {
8844 let _l = env_lock().await;
8845 let _route_off = EnvVar::set("NEWT_NO_ROUTE", "1");
8846 let _ocap_off = EnvVar::unset("NEWT_DISABLE_OCAP");
8847 assert!(routing_disabled() && !ocap_disabled(), "L2 off, L3 on");
8848 let ws = tempfile::TempDir::new().unwrap();
8849 let caveats = caveats_no_exec(ws.path());
8850 let out = run_tool(
8851 "run_command",
8852 serde_json::json!({ "command": "cat /etc/shadow" }),
8853 ws.path(),
8854 &caveats,
8855 )
8856 .await;
8857 assert!(
8859 !out.contains("fs_read does not permit"),
8860 "--no-route must not route to read_file; got: {out}"
8861 );
8862 assert!(
8865 out.contains("capability denied"),
8866 "the L3 confined dispatch must still gate the command; got: {out}"
8867 );
8868 }
8869}