Skip to main content

newt_core/agentic/
tools.rs

1//! Built-in tool definitions and the tool executor for the agentic loop.
2//! Moved verbatim from `newt-tui` in Step 9.7 — the Caveats enforcement,
3//! shrink guard, build-check feedback, and agent-bridle routing are unchanged.
4
5use 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
19/// #719: default line window for `read_file`'s **model-facing** payload. The
20/// on-screen display is capped separately; this bounds what enters the model's
21/// context, so one read of a 15k-line file (e.g. `newt-tui/src/lib.rs`) can no
22/// longer saturate a small local model's window and abandon the task.
23const DEFAULT_READ_LIMIT: usize = 2_000;
24
25/// #726: default token budget for any tool's **model-facing** payload, mirroring
26/// Codex's `exec_command.max_output_tokens` (default 10k). One shared budget
27/// caps BOTH `read_file` (via [`paginate_read`]'s char backstop) and
28/// `run_command` (via [`cap_model_output`] around the shell envelope), so a
29/// verbose command can no longer flood the window — the same failure mode #719
30/// closed for `read_file`. Overridable by `[tools] max_output_tokens` in config;
31/// see [`set_max_output_tokens`].
32const DEFAULT_MAX_OUTPUT_TOKENS: usize = 10_000;
33const DEFAULT_OUTPUT_HEAD_TOKENS: usize = 1_500;
34
35/// Process-wide model-facing output budget, in tokens. Defaults to
36/// [`DEFAULT_MAX_OUTPUT_TOKENS`]; the resolved `[tools] max_output_tokens`
37/// config value is pushed here once at the config-resolution entry
38/// (`Config::resolve`) so the tool loop never re-reads config from disk. This is
39/// the v1 (three-Cs "working code first") seam: a const default with the config
40/// override wired at the entry, rather than threading a new `usize` through
41/// `ChatCtx` + `execute_tool` + every call site (≈60, mostly tests). Follow-up:
42/// thread it per-session like `tool_output_lines` once warranted.
43static MAX_OUTPUT_TOKENS: AtomicUsize = AtomicUsize::new(DEFAULT_MAX_OUTPUT_TOKENS);
44static OUTPUT_HEAD_TOKENS: AtomicUsize = AtomicUsize::new(DEFAULT_OUTPUT_HEAD_TOKENS);
45
46/// Set the process-wide model-facing output budget (tokens). Called once from
47/// `Config::resolve` with the resolved `[tools] max_output_tokens`. `0` means
48/// "no cap" — see [`cap_model_output`] / [`paginate_read`].
49pub fn set_max_output_tokens(max_tokens: usize) {
50    MAX_OUTPUT_TOKENS.store(max_tokens, Ordering::Relaxed);
51}
52
53/// Set the head allocation for oversized `run_command` output. The tail gets
54/// the remaining budget. `0` means pure-tail; values greater than the max output
55/// budget are clamped by [`cap_model_output`].
56pub fn set_output_head_tokens(head_tokens: usize) {
57    OUTPUT_HEAD_TOKENS.store(head_tokens, Ordering::Relaxed);
58}
59
60/// The active model-facing output budget (tokens). [`DEFAULT_MAX_OUTPUT_TOKENS`]
61/// until [`set_max_output_tokens`] overrides it.
62fn 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
70/// #726/#945: cap a tool's **model-facing** output to `max_tokens`' worth of
71/// chars, estimated with the default chars/token heuristic
72/// ([`crate::tokens::TokenEstimation`], 4 chars/token — the same constant the
73/// context estimator uses). Oversized output is rendered as head+tail rather
74/// than head-only so command summaries and failures at the end survive. A small
75/// output (or `max_tokens == 0`, meaning no cap) passes through verbatim. Pure
76/// (no fs / no global) — unit-tested directly.
77fn 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
127/// Window + cap a file's contents for `read_file`'s model-facing payload (#719,
128/// #726). Returns lines `[offset, offset+limit)` (1-based `offset`, default 1;
129/// `limit` default [`DEFAULT_READ_LIMIT`]), with the char backstop derived from
130/// the shared token budget (`max_output_tokens` × chars/token — #726, replacing
131/// #719's hardcoded 100k so both tools share one budget). A footer points at the
132/// next window so the model paginates instead of drowning. A whole-file read
133/// that fits both caps is returned verbatim (exact bytes). `max_output_tokens ==
134/// 0` disables the char backstop (only the line window applies). Pure (no fs) —
135/// unit-tested directly.
136fn 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); // 1-based
149    let limit = limit.filter(|&l| l > 0).unwrap_or(DEFAULT_READ_LIMIT);
150    // Common case: a whole-file read that fits both caps → return verbatim.
151    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(); // 1-based last line shown == end
160    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
371/// #728: always-advertised `request_user_input` tool definition — the GENERIC
372/// ask-the-human primitive. Pushed unconditionally in [`merged_tool_definitions`]
373/// like `resume_context` / `tool_search` / `get_context_remaining`: a model must
374/// always be able to ask the human a question, and it degrades honestly headless
375/// (the executor answers "no human available this session" when no interactive
376/// gate is present). Question-only for v1 — the multiple-choice `options` hint is
377/// deferred so no advertised arg is left unused.
378fn 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
403/// The `lifecycle` tool (#891) — the model-facing surface over the data-driven
404/// lifecycle system (#880). Instead of guessing a raw shell command, the model
405/// names a phase and newt runs THIS repo's resolved command for it
406/// ([`crate::tooling::resolved_phase_commands`]): the `.newt/config.toml`
407/// `[lifecycle]` override, else the matching tooling packs (Rust / Python /
408/// PyO3 / Go / drop-in / custom). Advertised ALWAYS — like `resume_context`,
409/// it degrades honestly (a phase with no configured command returns a clear
410/// "no command configured", not an error), so it needs no presence gate. The
411/// phase-name enum is built from [`crate::tooling::Phase::ALL`] so the schema
412/// can never drift from the vocabulary.
413pub 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/// The built-in tool definitions plus every connected MCP server's tools
456/// (namespaced `server__tool`). This is what the agent loop advertises to the
457/// model so it can call remote MCP tools alongside the built-ins.
458///
459/// `with_save_note` adds the `save_note` definition (Step 19.3) — true only
460/// when the caller supplied a `NoteSink`, so headless/eval sessions (which
461/// pass `note_sink: None`) never advertise a tool that can't be executed.
462/// `with_recall` gates the `recall` definition (Step 17.5) the same way on
463/// a supplied `RecallSource`. `with_memory_fetch` gates the `memory_fetch`
464/// definition (progressive-disclosure memory, Workstream A MVP, #319) the same
465/// way on a supplied `MemorySource` — `None` ⇒ the tool is never advertised,
466/// so eval / headless / ACP sessions are unaffected bit-for-bit.
467#[allow(clippy::too_many_arguments)] // presence-gated tool advertisers (one bool each)
468pub(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    // #894: everything after the base array is [`EXTENDED_TOOL_REGISTRY`],
485    // advertised in declaration order when its gate is satisfied. The always-on
486    // tools (resume_context #714 / tool_search #725 / get_context_remaining #727
487    // / request_user_input #728 / lifecycle #891) carry `Gate::Always` and ride
488    // every session, degrading honestly when their backing source is `None`; the
489    // rest are gated on the matching injected `with_*` capability (git PR4,
490    // compose_roster+crew #479, scratchpad #583, code_search #582, experiential
491    // #585, scheduled #586/#715/#716). Adding a tool is one `ToolSpec`, which
492    // updates this listing AND `ALL_TOOL_NAMES` together — no more drift.
493    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
513/// The always-on infra tools ([`Gate::Always`] in [`EXTENDED_TOOL_REGISTRY`]:
514/// resume_context / tool_search / get_context_remaining / request_user_input /
515/// lifecycle). The loop depends on these every round, so a persona allow-list
516/// can NEVER fence them off — they ride every session regardless of `tools:`.
517fn 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
523/// FR-1 part 2 (#997): is `name` callable under a persona whose `tools:`
524/// front-matter is `allow`? True when the persona names it, OR it is an
525/// always-on infra tool the loop can't run without. This is the single
526/// predicate behind BOTH the advertise-filter ([`filter_advertised_tools`])
527/// and the executor reject ([`execute_tool_with_offload`]) so the set the model
528/// SEES and the set it may RUN can never drift apart.
529///
530/// `pub` (not `pub(crate)`) since #1021 PR 5.2: a headless entry point
531/// (`newt-mcp-server`) filters its own, separately-built catalog by the same
532/// rule rather than reimplementing it.
533pub fn persona_tool_allowed(name: &str, allow: &[String]) -> bool {
534    allow.iter().any(|t| t == name) || is_always_on_tool(name)
535}
536
537/// The advertised name of one tool definition (`{"function":{"name":…}}`), or
538/// `None` for a shape without one (kept rather than dropped by the filter).
539fn 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
545/// FR-1 part 2 (#997): restrict an advertised catalog to a persona's allow-list.
546/// `allow = None` (no persona / no `tools:` list) returns `defs` untouched — the
547/// zero-cost path for every non-persona session. When `Some`, keep only the
548/// tools [`persona_tool_allowed`] admits (the persona's names ∪ the always-on
549/// infra). Pure over `serde_json::Value`; the caller wraps
550/// [`merged_tool_definitions`] with it at each catalog site.
551///
552/// `pub` (not `pub(crate)`) since #1021 PR 5.2: a headless entry point
553/// (`newt-mcp-server`) filters its own, separately-built catalog by the same
554/// rule rather than reimplementing it.
555pub 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
573/// The refusal returned to the model when it calls a tool the active persona
574/// does not grant (FR-1 part 2, #997). Names the tool and points at the escape
575/// hatch, so the model self-corrects to a granted tool instead of looping.
576fn 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
585/// Direct tool names the model must call as tool invocations, never as shell
586/// commands passed to `run_command`.
587const 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    // #496: `find …` typed at run_command redirects to the embedded `find`
596    // tool — which works even when the shell is unavailable in this build.
597    "find",
598    // PR4: `git …` typed at run_command redirects to the embedded `git` tool —
599    // but ONLY its built-in-served local ops; passthrough ops fall through (see
600    // [`GIT_PASSTHROUGH_SUBCOMMANDS`] / [`run_command_redirect`], #898/#1022).
601    "git",
602];
603
604/// #898/#1022: git subcommands that must pass through to the shell. The embedded `git` tool
605/// (newt-git) is LOCAL-ONLY — `clone`/`fetch`/`push` are deferred — so if
606/// run_command bounced *every* `git …` back to that pushless tool, a model
607/// could never push a branch (and then never see the "Create a pull request …
608/// by visiting: <URL>" line git prints, and never open a PR — issue #898). `rm`
609/// also passes through because `git rm` has index semantics that plain
610/// `delete_file` cannot reproduce (#1022). These ops are therefore allowed to
611/// fall through to the confined shell, where the `exec` / `net` / fs leashes
612/// still apply. Local read/edit ops (`status`/`log`/`diff`/`add`/`commit`/…)
613/// keep redirecting to the embedded tool when it can serve them.
614const GIT_PASSTHROUGH_SUBCOMMANDS: &[&str] = &["push", "fetch", "pull", "clone", "rm"];
615
616/// Decide whether a `run_command` invocation is really a misdirected call to a
617/// direct tool (`list_dir`/`read_file`/…/`git`), and if so which one — so the
618/// executor can bounce it with a correction and [`is_hallucination`] can count
619/// it. Returns `None` when the command should run in the shell as-is.
620///
621/// `git` is special (#898/#1022): only its built-in-served LOCAL ops redirect
622/// to the embedded git tool; its passthrough ops
623/// ([`GIT_PASSTHROUGH_SUBCOMMANDS`]) fall through so the model can actually
624/// push a branch, run `git rm`, and read any PR-creation URL git prints.
625fn 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/// #894: the built-in tool registry — ONE self-describing entry per non-base
639/// tool (name + JSON schema builder + presence gate), replacing the parallel
640/// hand-kept lists that used to drift (the `lifecycle` tool, #891, was
641/// advertised + dispatched yet missing from the old `ALL_TOOL_NAMES`, so every
642/// legitimate `lifecycle` call was miscounted as a hallucination). Adding a
643/// tool here now updates BOTH the advertised set ([`merged_tool_definitions`])
644/// AND the real-name set ([`ALL_TOOL_NAMES`]) atomically — you cannot half-wire
645/// one without the other. The base tools stay inlined in [`tool_definitions`]
646/// (their names mirrored in [`BASE_TOOL_NAMES`], guarded by a test); the
647/// executor dispatch match is intentionally left as a separate pass.
648///
649/// Presence condition for a registered tool. `Always` tools ride every session
650/// (they degrade honestly when their backing source is absent); the rest are
651/// advertised only when the matching `with_*` capability is injected.
652#[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
666/// One built-in (non-base) tool, declared in exactly one place.
667struct ToolSpec {
668    /// The tool name the model calls — must equal `(definition)()`'s function name.
669    name: &'static str,
670    /// Builds the tool's JSON schema (the same `*_tool_definition()` fns used
671    /// before; the registry just references them).
672    definition: fn() -> serde_json::Value,
673    /// When this tool is advertised + treated as a real (non-hallucinated) name.
674    gate: Gate,
675}
676
677/// The registered non-base tools, in advertised order. This IS the order
678/// [`merged_tool_definitions`] pushes them (after the base array), preserved
679/// byte-for-byte from the previous hand-written push ladder.
680const EXTENDED_TOOL_REGISTRY: &[ToolSpec] = &[
681    // Always-on (degrade gracefully when their source is None) —
682    // #714 / #725 / #727 / #728 / #891.
683    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    // #1004: present collected findings as a rendered Markdown document. Needs
709    // no injected capability (it writes only to the output sink every session
710    // has), so it rides Always and degrades to raw source when color is off.
711    ToolSpec {
712        name: "render_report",
713        definition: render_report_tool_definition,
714        gate: Gate::Always,
715    },
716    // Presence-gated on an injected capability (one `with_*` flag each).
717    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
789/// The base tools inlined in [`tool_definitions`], by name. The base array is
790/// the one place these tools are declared; this mirror exists only so
791/// [`ALL_TOOL_NAMES`] can be assembled without re-parsing JSON, and it is kept
792/// in lockstep by `base_tool_names_match_tool_definitions`. The EXTENDED tools'
793/// names are NOT duplicated here — they live on their [`ToolSpec`].
794const 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
807/// Every tool newt can dispatch by name — the base tools plus every registered
808/// one — DERIVED from [`BASE_TOOL_NAMES`] + [`EXTENDED_TOOL_REGISTRY`] so it can
809/// never drift from the advertised set. Single source of truth for
810/// [`is_hallucination`] (which names are real) and [`nearest_tool_name`]
811/// (suggestion candidates). MCP `server__tool` names contain `__` and are
812/// matched separately.
813static 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
821/// Whether `tool_name` is a built-in newt tool name. Dynamic MCP tool names are
822/// intentionally excluded; callers that need MCP should check their live MCP
823/// registry separately.
824pub(crate) fn known_builtin_tool_name(tool_name: &str) -> bool {
825    ALL_TOOL_NAMES.contains(&tool_name)
826}
827
828/// Whether a [`Gate`] is satisfied given this session's injected capabilities.
829/// Extracted so [`merged_tool_definitions`] reads as one loop over the registry.
830#[allow(clippy::too_many_arguments)] // mirrors merged_tool_definitions' with_* flags
831fn 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
857/// Returns `true` if a tool call looks like a hallucination:
858/// - `run_command` called with a tool name as the shell command, or
859/// - An unknown tool name (excluding MCP-namespaced `server__tool` names).
860pub(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        // A misdirected direct-tool call is a hallucination; a real shell
864        // command (including the git NETWORK ops #898 lets through) is not.
865        return run_command_redirect(cmd).is_some();
866    }
867    // MCP tools are namespaced with `__` — never treat them as hallucinations.
868    if tool_name.contains("__") {
869        return false;
870    }
871    // #894: derived from BASE_TOOL_NAMES + EXTENDED_TOOL_REGISTRY, so it can
872    // never drift from the advertised set.
873    !ALL_TOOL_NAMES.contains(&tool_name)
874}
875
876/// Outcome of resolving a foreign / hallucinated tool name against newt's real
877/// tools (Step 27.1). Weak local models routinely emit tool names learned from
878/// other agent harnesses (`str_replace_editor`, `execute`, `bash`); without a
879/// resolution layer each such call is a flat "unknown tool" that burns a whole
880/// tool-call round, so the model narrates instead of acting (the #215 advisory
881/// drift). Resolving them turns a wasted round into a self-correcting one.
882pub(crate) enum AliasOutcome {
883    /// A foreign name whose argument shape matches a real newt tool: rewrite the
884    /// call to this canonical name and dispatch it transparently.
885    Rewrite(&'static str),
886    /// A foreign name whose arguments do NOT match the real tool: return this
887    /// correction (naming the right tool + its signature) so the model retries.
888    Correct(String),
889}
890
891/// Map a non-newt tool name to a real tool, when we recognize it. Real tool
892/// names and MCP `server__tool` names return `None` and dispatch unchanged.
893pub(crate) fn resolve_tool_alias(name: &str) -> Option<AliasOutcome> {
894    match name {
895        // Shell aliases — same single `command` arg shape as run_command, so we
896        // rewrite and dispatch. (If the shell is unavailable this build, the
897        // run_command arm reports that — Step 27.3 presence-gates it.)
898        "execute" | "exec" | "bash" | "shell" | "sh" | "zsh" | "terminal" | "run_shell_command"
899        | "shell_command" | "system" => Some(AliasOutcome::Rewrite("run_command")),
900        // #891 lifecycle aliases — same `{phase, action}` arg shape as
901        // `lifecycle`, so we rewrite and dispatch. Common phrasings a model
902        // reaches for when it wants to run a named phase.
903        "run_phase" | "run_lifecycle" | "lifecycle_run" => Some(AliasOutcome::Rewrite("lifecycle")),
904        // Edit aliases — different arg shape; point at edit_file's signature.
905        "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 aliases — point at write_file.
913        "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        // Delete-file aliases — point at delete_file. `rm` typed as a shell
922        // command is redirected separately by `run_command_redirect`; `git rm`
923        // deliberately falls through to the shell/git path because it has index
924        // semantics beyond plain file deletion.
925        "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        // #721 mkdir coach — newt has no directory-creation tool, and the model
933        // does not need one: write_file creates parent directories automatically
934        // (create_dir_all), and an empty file for empty content. A model reaching
935        // for `mkdir` (the issue's live `mkdir -p …/src` dead-end) is coached to
936        // the tool that already covers it, turning an exec denial into a
937        // self-correcting tool call. `touch` is intentionally NOT here — it is
938        // already a create-file alias above (→ write_file), and a second arm
939        // would be a duplicate match.
940        "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        // Read / list aliases — point at read_file / list_dir.
949        "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        // #716 + #715 PR2 PLAN — start/revise a plan. The arg shape is free prose,
956        // not the ordered `{"plan":[{"step","status"}]}` array update_plan wants, so
957        // Correct (coach), never a silent Rewrite. When `scheduled` is off the
958        // dispatch arm for update_plan returns "scheduled planning is off" — the
959        // model still gets a coherent answer rather than a dead end. `update_plan`
960        // itself is the REAL tool now (falls through to `None`), so it is never an
961        // alias of itself; `set_plan`/`plan_advance` no longer exist.
962        "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        // #715 PR2 ADVANCE-ish verbs — there is no longer a separate "advance" tool;
972        // progress is recorded by re-sending the whole plan with the finished step
973        // marked completed. Coach the model back to update_plan.
974        "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        // #716 PLAN-READ — read the current plan. plan_get takes no args, so the
982        // foreign call's (empty) arg shape matches: safe to silently Rewrite.
983        // `what_was_i_doing` stays here (→ plan_get) — it asks specifically for
984        // the plan; the broader "where were we" reaches go to resume_context.
985        "get_plan" | "show_plan" | "read_plan" | "current_plan" | "what_was_i_doing" => {
986            Some(AliasOutcome::Rewrite("plan_get"))
987        }
988        // #714 RESUME — the instinctive "where did we leave off" reach. All take
989        // no args (resume_context is a self-read), so the (empty) arg shape
990        // matches: safe to silently Rewrite. Meets the dead-end reach the issue
991        // observed (the model retrying recall) by landing it on the affordance
992        // built for exactly this case.
993        "resume" | "where_were_we" | "where_did_we_leave_off" | "catch_me_up" | "recap" => {
994            Some(AliasOutcome::Rewrite("resume_context"))
995        }
996        // #716 CREW / DELEGATE — crew/team is the human-only `/team` toggle a
997        // model cannot self-enable, and the targets may be unadvertised, so this
998        // can only ever Correct (never silently Rewrite) and the message must NOT
999        // imply the model can invoke crew itself.
1000        "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        // #725 TOOL-DISCOVERY — the instinctive "which tool does X?" reach. All
1010        // mean exactly tool_search (a `query` arg, or none — execute_tool_search
1011        // lists everything on an empty query), so silently Rewrite. `tool_search`
1012        // itself is the REAL tool and falls through to `None` below — it is never
1013        // an alias of itself.
1014        "find_tool" | "search_tools" | "list_tools" | "which_tool" | "available_tools"
1015        | "what_tools" | "tools" => Some(AliasOutcome::Rewrite("tool_search")),
1016        // #716 WORKFLOW — no workflow/pipeline primitive exists; redirect to the
1017        // plan tools (and crew/team, which needs /team).
1018        "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        // #727 BUDGET — "how much context do I have left" reaches. get_context_remaining
1024        // takes no args (it is a self-read), so the foreign call's (empty) arg shape
1025        // matches: safe to silently Rewrite. The real name `get_context_remaining` is
1026        // NOT here — it falls through to `None` and dispatches unchanged (the loop
1027        // intercepts it), so it is never an alias of itself.
1028        "context_remaining" | "tokens_left" | "remaining_tokens" | "budget"
1029        | "how_much_context" | "context_budget" | "token_budget" => {
1030            Some(AliasOutcome::Rewrite("get_context_remaining"))
1031        }
1032        // #728 ASK-THE-HUMAN — the instinctive "ask the user a question" reach.
1033        // All mean exactly request_user_input (a `question` arg), so silently
1034        // Rewrite: the executor reads `question` and answers via the gate (or the
1035        // headless message). The real name `request_user_input` is NOT here — it
1036        // falls through to `None` and dispatches unchanged, so it is never an
1037        // alias of itself.
1038        "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
1044/// #727: true when `name` is `get_context_remaining` or one of its rewrite
1045/// aliases. The agentic loop computes the per-turn budget at the dispatch site
1046/// (where `num_ctx` and the conversation estimate are in scope) and renders it
1047/// there, bypassing [`execute_tool`] — so the loop must recognize both the
1048/// canonical name and the aliases that resolve to it.
1049pub(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
1057/// #717: classify a single tool/capability reach for the alias-seam telemetry.
1058///
1059/// Pure: given the name the model called, its args, the tool result string, and
1060/// whether the result read as success, decide whether this reach is phantom and
1061/// how it resolved. Returns `None` for an ordinary real call (nothing to mine).
1062/// See [`crate::PhantomReach`] / [`crate::PhantomResolution`].
1063///
1064/// `ok` is part of the signature for symmetry with the recording site (it keys
1065/// the sibling `ToolEvent`); v1's miss-patterns classify on name + result alone.
1066pub(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    // 1. A recognized foreign/alias name: rewrite to a real tool, or a
1074    //    correction naming the right one — the canonical alias-seam signal.
1075    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    // 2. An unknown name with no alias is a true phantom tool (hallucination).
1083    if is_hallucination(name, args) {
1084        return Some(crate::PhantomResolution::Unknown);
1085    }
1086    // 3. A real tool that returned empty-by-design — a high-signal "miss" that
1087    //    currently logs ok=true. These are the mineable real-tool reaches; the
1088    //    loop emits one per call, so a 3x identical-recall loop yields 3 records.
1089    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
1103/// #479 (G4): classify a `crew`/`compose_roster` reach made while the crew/team
1104/// surface is gated OFF (`advertise_team == false`, the default — the runner is
1105/// only built when the operator sets `NEWT_TEAM`).
1106///
1107/// Pure: given the name the model called and whether the team surface is
1108/// advertised this session, decide whether this is a gated-off delegation reach
1109/// worth mining. Returns `None` for everything else (non-crew names, or crew
1110/// names when the surface is ON — those dispatch normally).
1111///
1112/// This is a SEPARATE seam from [`classify_phantom_reach`] on purpose: `crew`
1113/// and `compose_roster` stay real names in [`ALL_TOOL_NAMES`] (so the ON path is
1114/// a normal dispatch and [`is_hallucination`] is unchanged), which means
1115/// `classify_phantom_reach` never flags them. The gated-off detection needs the
1116/// one fact that function does not have — `advertise_team` — which is known in
1117/// the agent loop, so the loop composes the two seams there.
1118pub(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
1130/// Classic Levenshtein edit distance (pure two-row DP). Inputs are tool names
1131/// (short), so the simple version is plenty — for fuzzy suggestions only.
1132fn 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
1148/// The closest real tool name to `name`, if one is near enough to be a likely
1149/// typo/variant (distance ≤ ⌈len/3⌉, min 1). Returns `None` when nothing is
1150/// close, so we never suggest a wildly-unrelated tool.
1151fn 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
1161/// Corrective message for a genuinely-unknown tool name: name the real base
1162/// tools and, when one is close, suggest it — so a weak model that missed the
1163/// catalog gets a path back instead of a dead end (Step 27.1). Kept
1164/// `unknown tool: {name}`-prefixed so existing `starts_with` checks hold.
1165fn 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
1180/// Build a shell prefix that exports venv/exec-path vars into the agent-bridle
1181/// confined shell.
1182///
1183/// Agent-bridle's confined shell does not inherit the host environment
1184/// (`do_not_inherit_env(true)`), so we inject `VIRTUAL_ENV` and prepend
1185/// venv/extra `bin/` dirs to `PATH` by prefixing every `run_command` cmd.
1186/// `NEWT_VENV` (set from `--venv` or auto-detected from `$VIRTUAL_ENV` by the
1187/// CLI) takes precedence; falls back to `$VIRTUAL_ENV` if the TUI was invoked
1188/// directly without going through the CLI's `dispatch`.
1189pub 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    // sh single-quoting: wrap in '', escape any ' as '\''
1200    let q = |s: &str| format!("'{}'", s.replace('\'', r"'\''"));
1201
1202    // Build a list of dirs to prepend to PATH (venv/bin first, then exec-paths).
1203    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
1231/// Build the venv/exec-path environment as a `{KEY:VALUE}` map for the confined
1232/// shell's structured `env` seam (agent-bridle, newt #783).
1233///
1234/// Same inputs as [`venv_cmd_prefix`] — `NEWT_VENV` (preferred) or
1235/// `$VIRTUAL_ENV`, plus `NEWT_EXEC_PATHS` — but delivered as host-supplied env
1236/// vars set directly on the spawned child instead of `export …;` text prepended
1237/// to the command. The `export` form is the #783 root cause: `export` is a
1238/// shell builtin, not a program, so the confined safe-subset engine refuses it
1239/// on a compound command (`a; b | c`). Passing the vars through the env seam
1240/// sidesteps that entirely and never touches the command text.
1241///
1242/// `PATH` is the venv `bin` (then any `NEWT_EXEC_PATHS` dirs) *prepended* to the
1243/// inherited host `PATH`: the env seam sets the value additively over the
1244/// child's ambient environment, so we read the host `PATH` here and build the
1245/// full string rather than relying on a `$PATH` expansion inside the value.
1246/// Returns an empty map when neither input is set (no env key is sent).
1247fn venv_env_map() -> std::collections::BTreeMap<String, String> {
1248    let mut map = std::collections::BTreeMap::new();
1249
1250    // Env passthrough: the confined shell has NO ambient shell variables, so
1251    // without this brush cannot expand `~` (it resolves `~` from its `HOME` shell
1252    // var, erroring "HOME not set") and the command silently used a literal
1253    // `~/…` path — leaving `<cwd>/~/…` debris on disk. Seed a minimal,
1254    // operator-configurable allow-list from the process env (default HOME+USER;
1255    // widened via `[shell] env_passthrough`, published as
1256    // NEWT_SHELL_ENV_PASSTHROUGH). Each var is set only when present, so nothing
1257    // is fabricated and the default stays narrow (the confined shell is a trust
1258    // boundary — a wide passthrough would leak secrets into a sandboxed command).
1259    for var in shell_env_passthrough() {
1260        if let Ok(val) = std::env::var(&var) {
1261            map.insert(var, val);
1262        }
1263    }
1264    // Identify the confined engine so `env` / scripts can tell they're in newt's
1265    // shell (e.g. `SHELL=safe-subset` / `brush` / `host`), not the login shell.
1266    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    // Dirs to prepend to PATH (venv/bin first, then any exec-paths), mirroring
1274    // venv_cmd_prefix's ordering.
1275    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
1300/// The confined-shell env passthrough list: `NEWT_SHELL_ENV_PASSTHROUGH`
1301/// (colon-separated, published from `[shell] env_passthrough`) or the minimal
1302/// default (`HOME`, `USER`). Empty entries are dropped.
1303fn 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
1314/// Build the dispatch args for agent-bridle's confined `shell` tool (#783): the
1315/// RAW user command (free-form `cmd` mode) plus the venv carried through the
1316/// structured `env` seam ([`venv_env_map`]). Deliberately NO `export …;` prefix
1317/// on `cmd` — that is what the confined safe-subset engine refuses on a
1318/// compound command (the #783 root cause); the env seam sets `VIRTUAL_ENV` /
1319/// `PATH` on the spawned child instead. The host-bypass (`--yolo`) path keeps
1320/// the prefix form because it runs on a real `/bin/sh` where `export` works.
1321fn 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
1329/// The shell engine selected for this process (ADR 0005 D2 seam). Resolved once
1330/// at startup by the CLI ([`crate::resolve_shell_engine`] over the `[shell]
1331/// engine` config, the `--shell-engine` flag, and the `--full-access`
1332/// auto-upgrade) and published via `NEWT_SHELL_ENGINE`, so the deep
1333/// `run_command` dispatch reads it without threading it through every signature
1334/// — the same env-published pattern as [`ocap_disabled`] /
1335/// [`full_access_requested`]. Absent or unparseable falls back to the
1336/// `safe-subset` default.
1337fn 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    // No engine was published (e.g. a non-CLI entry point that set
1345    // NEWT_FULL_ACCESS directly). Honor the same auto-upgrade the CLI applies so
1346    // `NEWT_FULL_ACCESS=1` alone still gets the full-grammar engine (`host` on
1347    // unix, `brush` on Windows).
1348    if full_access_requested() {
1349        crate::full_access_default_engine()
1350    } else {
1351        crate::ShellEngine::default()
1352    }
1353}
1354
1355/// agent-bridle's tool registry with the `"shell"` tool bound to the selected
1356/// engine (the ADR 0005 D2 seam: `safe-subset` / `host` / `brush` all honor the
1357/// same `Tool` contract under the `"shell"` name). `web_fetch` is added
1358/// unchanged. Mirrors `agent_bridle::registry()` but swaps the shell engine so
1359/// `[shell] engine = "host"` (or `--full-access`) routes `run_command` to the
1360/// full-grammar, kernel-jailed sandbox-host engine instead of the safe subset.
1361fn 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            // The carried brush engine (agent-bridle 0.7): in-process bash + the
1368            // L2 CommandInterceptor. The cross-platform engine — and on Windows
1369            // the ONLY full-grammar option, since `host` needs `/bin/sh`.
1370            #[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
1393// ---------------------------------------------------------------------------
1394// INTERIM (#297): the --disable-ocap / --yolo exec escape hatch
1395// ---------------------------------------------------------------------------
1396
1397/// INTERIM (#297): is the ocap exec bypass asserted for this invocation?
1398///
1399/// True only when `NEWT_DISABLE_OCAP=1` — set by the CLI's `--disable-ocap`
1400/// flag (alias `--yolo`) or exported directly for harness/pod use. The value
1401/// must be exactly `"1"`: a security bypass reads fail-closed, so anything
1402/// else (including `true`) leaves confinement on. Deliberately env-only —
1403/// there is NO config-file key, so the bypass can never silently persist; it
1404/// must be asserted per invocation.
1405///
1406/// Scope: `run_command` only. On stub-shell builds (the only crates.io-
1407/// publishable configuration) agent-bridle's `shell` tool fails closed on
1408/// every command, which makes agentic coding impossible without the brush
1409/// `CommandInterceptor` patch underneath. `web_fetch` is NOT bypassed: the
1410/// stub-shell branch stubs only the shell tool — `agent-bridle-tool-web`
1411/// ships the real leash-enforcing implementation (verified at agent-bridle
1412/// rev `2129c91`), so it does not fail closed. The fs tools keep the
1413/// newt-native workspace fence untouched: yolo is unconfined exec, fenced fs
1414/// — never a global authority-off switch.
1415///
1416/// Remove (or demote to a debug flag) when brush upstreams the
1417/// `CommandInterceptor` hook (reubeno/brush#1184) and agent-bridle's real
1418/// confined shell becomes the default everywhere — see agent-bridle#20 and
1419/// the `[patch.crates-io]` note in the workspace Cargo.toml.
1420pub fn ocap_disabled() -> bool {
1421    std::env::var("NEWT_DISABLE_OCAP").is_ok_and(|v| v == "1")
1422}
1423
1424/// Is the per-invocation `full_access` preset override asserted?
1425///
1426/// True only when `NEWT_FULL_ACCESS=1` — set by the CLI's `--full-access`
1427/// flag. The session policy is then built from the `full_access` preset
1428/// (`Caveats::top()`) regardless of the configured `[tui.permissions]`
1429/// preset, exactly as if the config said `preset = "full_access"` for this
1430/// one run. Like [`ocap_disabled`], the value must be exactly `"1"` — a
1431/// widening switch reads fail-closed — and it is deliberately env-only, so
1432/// the override can never silently persist.
1433///
1434/// This is a DISTINCT switch from [`ocap_disabled`] (`--yolo`): the two
1435/// compose but never alias. `--full-access` widens the session *authority*
1436/// (fs fence, net leash, exec allowlist → unrestricted, which also empties
1437/// the #774 exec floor); `--yolo` changes the exec *mechanism* (host shell
1438/// instead of the confined shell) and still honors whatever floor is in
1439/// force. `--yolo --full-access` together yield an unrestricted host shell.
1440pub fn full_access_requested() -> bool {
1441    std::env::var("NEWT_FULL_ACCESS").is_ok_and(|v| v == "1")
1442}
1443
1444/// facade P4 (#780): is the convenience **routing** turned OFF for this call?
1445///
1446/// True only when `NEWT_NO_ROUTE=1` — set by the CLI's `--no-route` flag. It
1447/// disables the L2 *convenience routing* ([`super::routing`]): a model's
1448/// `run_command("cat X")` runs the normal exec path as-is instead of being
1449/// rewritten to the governed `read_file` built-in.
1450///
1451/// **This is a DISTINCT switch from [`ocap_disabled`] (§7-F5).**
1452/// `--no-route` / `NEWT_NO_ROUTE` turns off L2 convenience only; it NEVER
1453/// disables the L3 boundary — the confined shell still gates exec and the fs
1454/// fence still governs reads. `--disable-ocap` / `--yolo` / `NEWT_DISABLE_OCAP`
1455/// (L3-OFF, a full host unconfine) is a completely separate mechanism: the two
1456/// names never alias, and turning routing off can never imply unconfined exec.
1457/// Reads fail-closed — only the exact value `1` turns routing off; deliberately
1458/// env-only, no config key, so it cannot silently persist.
1459pub fn routing_disabled() -> bool {
1460    std::env::var("NEWT_NO_ROUTE").is_ok_and(|v| v == "1")
1461}
1462
1463/// #307: does the named-permission-preset exec FLOOR permit running `cmd` on
1464/// the UNCONFINED host shell?
1465///
1466/// `None` ⇒ no preset is active; the floor imposes nothing, so the answer is
1467/// `true` (the `--disable-ocap` bypass behaves exactly as it did pre-#307).
1468///
1469/// `Some(scope)` ⇒ the bypass may proceed ONLY for a single, simple command
1470/// whose program (leading token) the scope authorizes. This is deliberately
1471/// conservative on TWO counts, because the host shell runs `cmd` verbatim with
1472/// no per-spawn interceptor:
1473///
1474/// 1. A **compound** command (containing a shell metacharacter that could chain
1475///    another program — `&&`, `||`, `;`, `|`, `` ` ``, `$(`, newline, `&`, `>`,
1476///    `<`) is NOT allowed to bypass. `echo ok && rm -rf /` would otherwise
1477///    smuggle `rm` past an `echo` grant. It falls through to the confined
1478///    shell, which gates every spawn.
1479/// 2. Only the leading token is matched, so a bare allow-listed program runs;
1480///    anything else is denied.
1481///
1482/// The denied command isn't refused outright — it falls to the confined-shell
1483/// path, which enforces the (already preset-clamped) `caveats`. So a restricted
1484/// triage/on-call mode keeps its ceiling even under `--yolo`.
1485fn 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; // no preset ⇒ bypass unchanged (bit-for-bit)
1489    };
1490    // Conservative: any shell control/redirection metacharacter that could
1491    // introduce a second program defeats leading-token matching, so refuse the
1492    // bypass and let the confined shell gate each spawn.
1493    const SHELL_META: &[char] = &['&', '|', ';', '`', '$', '\n', '>', '<', '(', ')'];
1494    if cmd.contains(SHELL_META) {
1495        return false;
1496    }
1497    match cmd.split_ascii_whitespace().next() {
1498        // An empty command runs nothing; let it through to the normal path.
1499        None => true,
1500        Some(prog) => scope.permits(&prog.to_string()),
1501    }
1502}
1503
1504/// INTERIM (#297): run `cmd` on the PLAIN host shell — no leash, no
1505/// interceptor, no sandbox — and wrap the outcome in an envelope structurally
1506/// identical to the confined shell's (`{ exit_code, stdout, stderr,
1507/// sandbox_kind }`, with `denied` / `denials` omitted exactly as the bridle
1508/// envelope omits them when nothing was denied). [`envelope_denied`] and
1509/// [`shell_envelope_output`] — and therefore the loop's truncation / denial /
1510/// exit-code handling — apply to it unchanged.
1511///
1512/// A spawn failure surfaces as `Err`, which the caller formats as the same
1513/// `error: …` string a bridle dispatch failure produces.
1514/// Run `cmd` through the SAME confined-shell path the `run_command` tool uses —
1515/// the venv env seam, the `--disable-ocap` host bypass under the #307 exec
1516/// floor, the agent-bridle confined shell, and the #263 permission-gate re-ask —
1517/// and render the envelope. Shared by the `run_command` and `lifecycle` (#891)
1518/// arms so both honor **identical** exec caveats; the caller prints the
1519/// tool-call line first.
1520#[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    // Venv injection (#783): the confined shell carries the venv via
1533    // agent-bridle's structured `env` seam (see `confined_dispatch_args` /
1534    // `venv_env_map`), NOT by prepending `export …;` to the command — an
1535    // `export` builtin is not a program, so the safe-subset engine refuses it on
1536    // a compound command (the #783 root cause). `cmd_with_venv` (the
1537    // `export …;`-prefixed form) is built ONLY for the host-bypass path below:
1538    // that runs on a real `/bin/sh` where `export` is a genuine builtin.
1539    let cmd_with_venv = match venv_cmd_prefix() {
1540        Some(prefix) => format!("{prefix}{cmd}"),
1541        None => cmd.to_string(),
1542    };
1543
1544    // INTERIM (#297): --disable-ocap / --yolo / NEWT_DISABLE_OCAP=1 — run the
1545    // command UNCONFINED on the host shell instead of the bridle's confined
1546    // shell. Nothing is denied here, so the #263 permission gate below is never
1547    // consulted. #307 FLOOR: a named-permission-preset clamp WINS over the
1548    // bypass — the unconfined host path is taken ONLY if the floor permits this
1549    // command's leading token; else it falls through to the confined shell,
1550    // which enforces the already-clamped `caveats`. `None` keeps the bypass
1551    // bit-for-bit.
1552    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    // #783: RAW cmd + venv via the env seam — never the `export …;` prefix,
1566    // which the confined safe-subset engine refuses.
1567    let dispatch_args = confined_dispatch_args(cmd, workspace);
1568    match bridle_registry()
1569        .dispatch("shell", dispatch_args.clone(), caveats)
1570        .await
1571    {
1572        // The confined shell ran. Its envelope carries
1573        // `{ exit_code, stdout, stderr, timed_out, ... }` plus — when the leash
1574        // refused a capability — the STRUCTURED denial fields
1575        // `{ denied: true, denials: [{ kind, target, reason }] }`. In free-form
1576        // mode an out-of-scope command is denied *inside* the shell by the brush
1577        // interceptor (the command genuinely does not run); we lift that to the
1578        // capability-denied UX by reading the structured `denied` field — NEVER
1579        // a stderr grep.
1580        Ok(envelope) if envelope_denied(&envelope) => {
1581            // #263: an interactive gate may turn this denial into a human grant.
1582            // ONE consult + ONE re-execution per call: a second denial (a
1583            // different target reached on the re-run) surfaces as the standard
1584            // envelope — the model can retry, which prompts afresh.
1585            if let Some(gate) = permission_gate {
1586                // #905: promptable exec denials OR net-host denials (agent-bridle
1587                // #196). On Allow, the re-mint widens the matching axis (net adds
1588                // the host to the allow-list), so the proxy admits it on re-run.
1589                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        // An argv-mode leash denial, or an error from inside the tool — surface
1622        // the reason; the dispatch error Display is safe to show.
1623        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        // Same `timed_out` flag the confined bridle envelope carries, so the
1634        // host-bypass path can never wedge the session on a hung child (#297).
1635        "timed_out": run.timed_out,
1636        // Honest provenance, same field the bridle envelope always carries:
1637        // nothing sandboxed this run.
1638        "sandbox_kind": "none",
1639    }))
1640}
1641
1642/// Result of a host-bypass shell run. Unlike a raw [`std::process::Output`] this
1643/// carries an explicit `timed_out` flag so the dispatch layer can emit the same
1644/// envelope shape the confined path does when a child is killed for running long.
1645struct HostShellRun {
1646    exit_code: i64,
1647    stdout: Vec<u8>,
1648    stderr: Vec<u8>,
1649    timed_out: bool,
1650}
1651
1652/// Wall-clock ceiling for a single host-bypass shell command. A child that
1653/// blocks past this (a REPL awaiting input, an accidental `cat` with no args,
1654/// an interactive prompt) is killed rather than wedging the whole turn — the
1655/// interrupt keyboard-watcher cannot help once a foreground child owns the tty.
1656///
1657/// Convention-driven: override with `NEWT_HOST_EXEC_TIMEOUT_SECS` (a positive
1658/// integer number of seconds). Absent/blank/invalid/zero falls back to the
1659/// 120s default, mirroring the confined shell's bound.
1660fn 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
1678/// macOS/BSD `cat -v` is not Unicode-aware: for a UTF-8 glyph such as `─`
1679/// (`e2 94 80`) it emits the lead byte raw (`e2`) and renders only the
1680/// continuation bytes as ASCII meta-control notation (`M-^TM-^@`). That byte
1681/// stream is invalid UTF-8, so a plain lossy decode becomes `�M-^TM-^@`.
1682///
1683/// Repair only that precise shape. This keeps ordinary valid UTF-8 untouched and
1684/// leaves unrelated binary output on the existing lossy fallback.
1685fn 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/// INTERIM (#297) host shell selection: `bash -c` with an `sh -c` fallback
1759/// when bash is absent — the same sh-compatible free-form mode the confined
1760/// shell ran, so [`venv_cmd_prefix`]'s `export …;` prefix works unchanged.
1761///
1762/// Hardened (#297) so a hung child can never wedge the turn:
1763/// - `kill_on_drop(true)` + `process_group(0)` (own process group) so the whole
1764///   child *tree* dies when we drop the handle, not just the immediate `bash`.
1765/// - stdin redirected from `/dev/null` so a child that reads stdin sees EOF
1766///   instead of blocking forever waiting on a tty the agent can't feed.
1767/// - a [`host_exec_timeout`] wall-clock ceiling: on expiry the child is killed
1768///   and we return `timed_out: true` with exit code 124, matching the confined
1769///   path's envelope shape.
1770#[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            // A child that reads stdin gets EOF, never a blocking wait on a tty
1780            // the agent cannot drive.
1781            .stdin(Stdio::null())
1782            .stdout(Stdio::piped())
1783            .stderr(Stdio::piped())
1784            // Own process group (setsid-equivalent, std-only since Rust 1.64):
1785            // detaches the child from our controlling tty so it can't steal the
1786            // interrupt byte, and gives the drop-kill a whole group to reap.
1787            .process_group(0)
1788            // Kill the child *tree* — not just `bash` — if we drop the handle
1789            // (timeout, cancel, panic).
1790            .kill_on_drop(true);
1791        c
1792    }
1793
1794    async fn run_one(child: tokio::process::Child) -> std::io::Result<HostShellRun> {
1795        // `wait_with_output` consumes the child; if the timeout fires first the
1796        // future is dropped and `kill_on_drop` reaps the whole group.
1797        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/// INTERIM (#297) host shell selection on Windows: `cmd /C`, the same shape
1829/// as [`build_check_shell`]. Bounded by [`host_exec_timeout`] with
1830/// `kill_on_drop` so a hung child cannot wedge the turn.
1831#[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
1866/// Lexically normalise a path *string* — collapse `.` and `..` components
1867/// without touching the filesystem — so containment is decided on the location
1868/// the caller actually named, not on a raw byte prefix. Does NOT resolve
1869/// symlinks (that needs `canonicalize`, which requires the path to exist and is
1870/// the still-open `fs-canonical-containment` deviation): a symlink *inside* the
1871/// workspace can still point out. What this DOES close are the string-only
1872/// escapes — `..` traversal and sibling-prefix collisions.
1873fn 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                // Pop a real segment; never climb above a root/prefix.
1881                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
1891/// Returns true if `full_path` is permitted by `scope`.
1892///
1893/// The `Caveats` lattice stores workspace-root strings (not individual file
1894/// paths) with exact-set semantics; this layer adds containment so that "the
1895/// workspace root is permitted" means "any path *under* it is permitted". Both
1896/// the candidate and each root are lexically normalised (collapsing `..`) and
1897/// then compared by whole path components via [`std::path::Path::starts_with`],
1898/// so `..` traversal (`/ws/../etc/passwd`) and sibling-prefix collisions
1899/// (`/ws-secret` vs root `/ws`) no longer escape the fence — unlike the raw
1900/// string prefix match this replaced. Symlink containment is still open
1901/// (`fs-canonical-containment`); creating one needs exec, which is gated separately.
1902pub(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
1914/// Full-access/custom unrestricted writes keep the final y/N guard in ordinary
1915/// interactive mode. Under --yolo the operator already chose an explicit
1916/// auto-run mode, so do not let EOF on stdin become a fake human denial.
1917fn 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
1936/// Run the configured build-check command in `workspace` and return a compact
1937/// result string appended to the tool output so the model sees it immediately.
1938pub(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
1987/// Whether a confined-shell envelope carries the STRUCTURED `denied: true`
1988/// flag — the leash's machine-readable signal that the brush interceptor
1989/// refused an exec / open inside the free-form command. Reads the structured
1990/// field agent-bridle emits; it does NOT parse stdout/stderr (the old stderr
1991/// string-match was fragile — a command that merely *printed* a denial-like
1992/// phrase could be misread, and any wording drift would silently break
1993/// detection).
1994fn 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
2001/// Build a human-readable denial message from the envelope's structured
2002/// `denials: [{ kind, target, reason }]` list, joining each entry's `reason`.
2003/// Falls back to a generic message when the list is missing or empty.
2004fn 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
2022/// The allowlist NAME for an exec target — the trailing path component (the
2023/// program's basename), so `/usr/bin/env` and `C:\tools\env.exe` resolve to the
2024/// command a grant would actually allowlist. Used when lifting a denied exec
2025/// into a #263 [`PermissionRequest`].
2026fn exec_allowlist_name(target: &str) -> &str {
2027    target
2028        .rsplit(['/', '\\'])
2029        .find(|part| !part.is_empty())
2030        .unwrap_or(target)
2031}
2032
2033/// #721: the model-actionable recovery appended to every capability denial the
2034/// MODEL sees. A denial used to be a DEAD-END: it told the *human* to edit
2035/// `[tui.permissions]`, which the model cannot do mid-turn, so the loop stalled
2036/// (the issue's `mkdir` reproduction). This sentence tells the *model* what IT
2037/// can do — ask the operator to grant the capability via the
2038/// `request_permissions` tool, or change approach. The gate is unchanged: the
2039/// call is STILL denied; only the coaching is added. In a headless flow with no
2040/// operator, `request_permissions` answers "no operator available", which is
2041/// itself a recoverable signal (switch strategy) rather than a config edit.
2042const 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
2047/// #479 (G4): the model-facing recovery coach when `crew`/`compose_roster` is
2048/// reached while the crew/team surface is OFF — the DEFAULT, since the runner is
2049/// only built when the operator sets `NEWT_TEAM`. Replaces the flat
2050/// `unknown tool: … (no crew surface …)` dead-end (which left the model nowhere
2051/// to go) with a model-actionable message in the #721 [`DENIAL_RECOVERY_HINT`]
2052/// style: it names the operator gesture that enables the surface (`NEWT_TEAM`)
2053/// AND a real solo alternative (the always-available file/exec tools), so the
2054/// reach is recoverable instead of a wall. The OCAP presence-gate is unchanged —
2055/// crew stays `NEWT_TEAM`-gated; only the coaching is added.
2056const 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
2062/// The model-facing result for a `crew`/`compose_roster` dispatch when no
2063/// `CrewRunner` was injected. One factored message + regression point carrying
2064/// [`CREW_OFF_RECOVERY_HINT`], so the recoverable wording can never drift.
2065fn crew_off_recovery_result(name: &str) -> String {
2066    format!("'{name}' is unavailable: {CREW_OFF_RECOVERY_HINT}")
2067}
2068
2069/// #721: the model-facing capability-denial message for an fs tool — the base
2070/// "{kind} does not permit '{path}'" line plus the recoverable, model-actionable
2071/// [`DENIAL_RECOVERY_HINT`]. One factored message + regression point shared by
2072/// every fs denial (read_file / write_file / edit_file / delete_file / list_dir /
2073/// find), so the recoverable wording can never drift between them.
2074fn denied_fs_result(kind: &str, path: &str) -> String {
2075    format!("capability denied: {kind} does not permit '{path}'. {DENIAL_RECOVERY_HINT}")
2076}
2077
2078/// The standard `run_command` capability-denial result, composed EXACTLY ONCE:
2079/// a single `capability denied: <bare reason>. <recovery hint>` for the model.
2080///
2081/// #775 (§2.5): the model-facing message is ONE clean level. Two earlier defects
2082/// are removed:
2083///
2084/// 1. The bare denial `reason` (a full sentence from the leash, e.g.
2085///    `exec of "export" is not within the granted authority`) is NO LONGER
2086///    stuffed into [`print_denied`]'s bare `'{target}'` slot. Doing so produced
2087///    the garbled `capability denied: exec does not permit '<whole reason
2088///    sentence> - add it via …>'` — a denial sentence nested inside another.
2089///    [`print_denied`] now receives only the BARE command target, matching its
2090///    `{axis} does not permit '{target}'` contract (the same shape the fs path
2091///    uses via [`denied_fs_result`]).
2092/// 2. The stale `extra_exec` config hint is gone from the model-facing message.
2093///    #721 superseded "edit your `[tui.permissions]` config" with the
2094///    model-actionable [`DENIAL_RECOVERY_HINT`] (`request_permissions`), so the
2095///    model now sees the bare reason once plus that hint — never a config edit
2096///    it cannot perform mid-turn.
2097///
2098/// The #263 prompt path still falls back here on deny (and on a second denial
2099/// after a re-execution).
2100fn denied_run_command_result(envelope: &serde_json::Value, color: bool) -> String {
2101    // Human transcript NOTICE: the bare denied command(s), never the reason
2102    // sentence (stuffing a sentence into print_denied's `'{target}'` slot was
2103    // the garble — see #775 above).
2104    print_denied(
2105        denial_axis_label(envelope),
2106        &exec_denial_target_label(envelope),
2107        color,
2108    );
2109    // Model-facing message: composed exactly once.
2110    format!(
2111        "capability denied: {}. {DENIAL_RECOVERY_HINT}",
2112        envelope_denial_reason(envelope)
2113    )
2114}
2115
2116/// The bare target for the human exec-denial NOTICE: the denied command name(s)
2117/// the leash refused, NEVER the reason sentence. Joins multiple targets with
2118/// `, `; falls back to a generic label so the notice always prints one clean
2119/// `{axis} does not permit '{target}'` line. (#775 — restores
2120/// [`print_denied`]'s bare-`'{target}'` contract.)
2121fn 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
2139/// The standard `run_command` success path: print + return stdout/stderr,
2140/// or `(exit N)` when the command produced no output. Factored verbatim so
2141/// the #263 re-execution path shares one formatter with the first dispatch.
2142fn 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    // DISPLAY path: on-screen tool output is capped by LINES (unchanged).
2159    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        // #726/#945: the MODEL-facing payload is capped by the shared TOKEN
2168        // budget using head+tail. When tool_offload is on, spill the FULL
2169        // redacted output before capping so the true tail and elided middle stay
2170        // recoverable via memory_fetch("spill:<id>") and grep.
2171        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        // #898: if this command's output carries a forge "open a pull/merge
2196        // request" URL (git prints it on push of a new branch), append an
2197        // explicit next-step hint so the model opens the PR instead of stalling.
2198        // Detected from the UNcapped output so a long push log can't truncate the
2199        // URL away, and appended AFTER the cap so the hint always survives.
2200        match pr_creation_url(&out) {
2201            Some(url) => format!("{capped}{}", pr_next_step_hint(url)),
2202            None => capped,
2203        }
2204    }
2205}
2206
2207/// #898: the forge "open a pull/merge request" URL that git prints on `push` of
2208/// a new branch — GitHub `…/pull/new/<branch>` (or a `…/compare/…` link) and
2209/// GitLab `…/merge_requests/new…`. Returned so [`shell_envelope_output`] can
2210/// append a next-step hint: models routinely push and then stall instead of
2211/// opening the PR (issue #898). Scans whitespace-split tokens because git emits
2212/// the URL on its own `remote:`-prefixed line.
2213fn 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
2222/// The next-step hint appended after a push whose output carries a PR-creation
2223/// URL (#898). Names the concrete `gh` command AND the tool boundary — the
2224/// embedded `git` tool cannot push or open PRs — so the model proceeds through
2225/// run_command + `gh` instead of looping back to the pushless git tool.
2226fn 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
2234/// Lift a confined-shell denial envelope into promptable #263 requests.
2235///
2236/// Returns `Some` only when EVERY structured denial entry is an `exec` kind
2237/// with a non-empty target — the case the human can meaningfully grant (the
2238/// allowlist name, same basename rule as the config hint). Any other kind
2239/// (e.g. an `open` refused inside the shell) keeps the standard denial:
2240/// guessing which fs axis an opaque `open` maps to would over-grant.
2241fn 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
2266/// #905: lift a confined-shell NET denial envelope into promptable #263 requests
2267/// — the `net`-axis sibling of [`exec_denial_requests`]. agent-bridle #196
2268/// surfaces a refused CONNECT host as `Denial { kind: "net", target: <host> }`
2269/// (with `denied: true`), so when the operator's `net` allow-list refuses a host
2270/// the shell reached (e.g. `git push` to `github.com`), this turns each into a
2271/// `PermissionRequest { kind: Net, target: host }` the gate can prompt per-host.
2272///
2273/// Returns `Some` only when EVERY denial is a `net` kind with a non-empty host
2274/// target — the case a grant is meaningful (add the host to the net allow-list).
2275/// A mixed or non-net batch returns `None` and keeps the standard denial.
2276fn 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
2301/// #905: the axis label for the human denial NOTICE — `net` when EVERY denial is
2302/// a net (host) refusal, else `exec` (exec / mixed / empty default). Keeps a net
2303/// denial from being mislabeled `exec does not permit '<host>'`.
2304fn 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
2320/// Consult the #263 gate for one denied fs path. Returns `true` only when
2321/// the human allowed it AND the re-minted caveats actually permit the path —
2322/// the widened authority is re-checked, never assumed.
2323fn 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
2342/// #1056: is `out` the embedded git tool's capability-denial for a WRITE op
2343/// (`add`/`commit`/`reset`/`branch`/…), as opposed to an engine error (e.g.
2344/// "nothing to commit") or a read? newt-git returns `capability denied: git <op>
2345/// not permitted` when the projected [`GitCaveats`](crate::git_caveats::GitCaveats)
2346/// deny a write; read ops (`status`/`log`/`diff`) are ungated so they never
2347/// produce this, and engine errors don't carry the `capability denied` marker.
2348fn is_git_write_denial(out: &str) -> bool {
2349    out.contains("capability denied: git ") && out.contains("not permitted")
2350}
2351
2352/// #1056: route a denied LOCAL git write through the gate — the git sibling of
2353/// [`fs_gate_allows`]. The git-write capability is **non-axis** (it widens no
2354/// `Caveats` axis), so the decision is binary: `Allow` ⇒ the git arm re-dispatches
2355/// under the local-write surface; `Deny` (or no gate, i.e. headless) ⇒ keep the
2356/// denial. The readonly-`/mode` FLOOR is enforced inside
2357/// [`PermissionGate::ask`], which refuses the grant when the active preset
2358/// projects no git-commit authority — so a git write can never pierce it here.
2359fn 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
2372/// #721: map the model-supplied `capability` string for `request_permissions`
2373/// onto a [`DenialKind`] axis. A small synonym set absorbs the names a weak
2374/// local model tends to emit; an unrecognized value returns `None` so the tool
2375/// coaches instead of guessing an axis (guessing would request the WRONG
2376/// authority). Pure — unit-tested directly.
2377fn 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
2387/// #721: the model-facing `request_permissions` tool — the capability-GRANT
2388/// path. It builds a [`PermissionRequest`] from `{capability, target, reason}`
2389/// and consults the SAME #263 [`PermissionGate`] a denial would: `Allow` reports
2390/// granted (and the gate has remembered any session grant, so the model's retry
2391/// of the original op rides the existing #263 re-exec machinery), `Deny` reports
2392/// declined, and **no gate** (headless / eval / ACP) reports that no operator is
2393/// available to grant — a recoverable signal (switch strategy), never a hang.
2394///
2395/// Reconciliation with #728: `request_permissions` (capability GRANT via
2396/// `gate.ask` / the #263 flow) and `request_user_input` (generic free-text Q&A
2397/// via `gate.ask_question`) are DISTINCT tools that share the ONE human-interface
2398/// gate ([`PermissionGate`]). They realize "both surface to the human" without
2399/// being merged: this one widens authority through the ocap gate, the other only
2400/// gathers text. `request_permissions` is deliberately NOT routed through
2401/// `request_user_input` — it mints caveats, which a free-text answer cannot.
2402fn 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        // The gate consults the operator and (for a session grant) remembers it,
2442        // exactly as a denial-driven prompt does. We do not re-execute anything
2443        // here — the model retries its original tool call, which rides the #263
2444        // re-exec path under the now-granted caveats.
2445        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        // Headless / eval / ACP: no interactive gate exists to grant authority.
2456        // This is recoverable (change strategy), not a config edit the model
2457        // can perform mid-turn.
2458        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
2469/// #728: returned by `request_user_input` when there is no human to ask — either
2470/// no interactive gate this session (headless / eval / ACP / piped) or the gate
2471/// has no operator available (`ask_question` returned `None`). A recoverable
2472/// signal the model can act on, NEVER a hang.
2473const HEADLESS_NO_HUMAN: &str = "no human available this session (running headless) \
2474    — proceed with your best judgment or state your assumption explicitly.";
2475
2476/// #728: the model-facing `request_user_input` tool — the GENERIC ask-the-human
2477/// path. It surfaces a free-text `question` to the operator through the SAME
2478/// human-interface gate a permission prompt uses ([`PermissionGate::ask_question`])
2479/// and returns the typed answer. With an operator present the answer is returned
2480/// verbatim; with NO gate (headless / eval / ACP / piped) — or when the gate has
2481/// no human to consult (`ask_question` returns `None`) — it returns the
2482/// [`HEADLESS_NO_HUMAN`] message and NEVER blocks.
2483///
2484/// Reconciliation with #721: this is the free-text Q&A path; `request_permissions`
2485/// is the capability-GRANT path (it mints caveats via the gate). Both surface to
2486/// the human through the one gate but are DISTINCT tools — one gathers text
2487/// (`ask_question`), the other widens authority (`ask`) — and are not merged.
2488fn 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    // `Some(answer)` from the gate → return it verbatim; a `None` gate (headless)
2506    // OR an `ask_question` that returns `None` (no human to consult) → the
2507    // recoverable headless message. Either way we never block without an answer.
2508    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
2516/// Best-effort host extraction for the #263 net pre-check. This only gates
2517/// whether to PROMPT — reachability enforcement stays with the bridle's
2518/// leash (host allowlist + SSRF screen). `None` (unparseable / non-http URL)
2519/// skips the pre-check entirely, leaving today's dispatch path untouched.
2520pub(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    // IPv6 literal `[::1]:8080` — the host is the bracketed part.
2527    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/// File-type restriction for the embedded `find` tool (#496).
2540#[derive(Clone, Copy, PartialEq, Eq, Debug)]
2541enum FindType {
2542    Files,
2543    Dirs,
2544    Any,
2545}
2546
2547/// Parsed, validated options for one `find` invocation.
2548struct FindOpts<'a> {
2549    /// Glob matched against each basename; `None` matches everything.
2550    name: Option<&'a str>,
2551    type_filter: FindType,
2552    /// Max depth below the search root (1 = immediate children); `None` =
2553    /// unlimited.
2554    max_depth: Option<usize>,
2555    /// Hard cap on returned matches.
2556    max_results: usize,
2557    /// Honour .gitignore + skip .git/target/node_modules/hidden dirs.
2558    respect_gitignore: bool,
2559    case_sensitive: bool,
2560}
2561
2562/// One-line summary of a `find` invocation for the tool trace (#529): the path
2563/// plus only the *non-default* filters, so two searches with different filters
2564/// don't both render as a bare `find: .`. Defaults (any type, unlimited depth,
2565/// the 1000-match cap, gitignore-respecting, case-sensitive) are omitted.
2566fn 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    // Mirrors the parse default in the `find` arm.
2580    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
2596/// Translate a shell-style basename glob (`*`, `?`) into an anchored regex.
2597/// Every other character is matched literally (regex metacharacters escaped),
2598/// so `pyo3_module.rs` matches only that exact basename, not `pyo3Xmodulexrs`.
2599fn 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            // Escape every regex metacharacter so the rest is literal.
2610            '.' | '+' | '(' | ')' | '|' | '[' | ']' | '{' | '}' | '^' | '$' | '\\' => {
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
2621/// Recursively walk `root` and collect matches as workspace-relative,
2622/// `/`-normalised, sorted paths. Pure-`ignore`-crate traversal (no shell, no
2623/// subprocess) — the whole point of #496. Never follows symlinked directories
2624/// (avoids cycles and workspace escapes). Returns `(matches, truncated)` where
2625/// `truncated` is true if `max_results` was reached and more existed.
2626fn 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        // Honour .gitignore even outside a git repo (the agent's cwd may not be
2645        // a checkout); without this `ignore` silently ignores gitignore files.
2646        .require_git(false)
2647        .follow_links(false);
2648    if let Some(d) = opts.max_depth {
2649        builder.max_depth(Some(d));
2650    }
2651    // The `ignore` walker prunes via .gitignore/hidden but has no built-in
2652    // skip for build/dep dirs. Prune them explicitly (and cheaply, before
2653    // descent) so a default `find` doesn't drown in target/ or node_modules/.
2654    // `.git` is already covered by `.hidden(true)`. Skipped only when respecting
2655    // ignores — `respect_gitignore=false` means "search everything".
2656    if opts.respect_gitignore {
2657        let mut ob = ignore::overrides::OverrideBuilder::new(root);
2658        // In override globs a leading `!` excludes; with no whitelist globs
2659        // present, everything else stays included.
2660        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            // Skip individual unreadable entries rather than failing the walk.
2673            Err(_) => continue,
2674        };
2675        // depth 0 is the search root itself — never a match.
2676        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/// Execute a single tool call and return the result string sent back to the model.
2707///
2708/// `run_command` is routed through agent-bridle's Caveats-confined, brush-backed
2709/// `shell` tool: the WHOLE command runs inside the leash (`echo ok && rm -rf /`
2710/// no longer slips `rm` past an `echo` grant — every external spawn passes the
2711/// interceptor's `before_exec` / `before_open` gate). The fs tools
2712/// (`read_file` / `write_file` / `list_dir`) keep enforcing the same `caveats`
2713/// via `permits_*` — rerouting them is out of scope.
2714///
2715/// `note_sink` backs the `save_note` tool (Step 19.3), `recall_source` the
2716/// `recall` tool (Step 17.5), and `memory_source` the `memory_fetch` tool
2717/// (progressive-disclosure memory, #319). `None` ⇒ the tool was never
2718/// advertised, so a call here is treated like any unknown tool.
2719///
2720/// `permission_gate` is the #263 prompted-grant seam: when present, a
2721/// capability denial consults the human (allow once / session allow / deny)
2722/// before failing; an allow re-executes the denied call under the gate's
2723/// freshly minted caveats. `None` (the default, and every headless caller)
2724/// keeps every denial exactly as it was — bit-for-bit. #721's
2725/// `request_permissions` tool also rides this gate: it lets the MODEL proactively
2726/// request a grant (vs. only reacting to a denial), and reports "no operator
2727/// available" when the gate is `None`.
2728///
2729/// INTERIM (#297): when [`ocap_disabled`] is asserted (`--disable-ocap` /
2730/// `--yolo` / `NEWT_DISABLE_OCAP=1`), `run_command` skips the confined shell
2731/// and runs on the plain host shell with the same venv/PATH prefix and an
2732/// envelope of the same shape — nothing is denied, so the #263 gate is never
2733/// consulted for exec. Every other tool (fs fence, `web_fetch` leash) is
2734/// unaffected. Removed when brush upstreams `CommandInterceptor`
2735/// (agent-bridle#20).
2736///
2737/// `exec_floor` (issue #307) is the **named-permission-preset clamp** acting as
2738/// a hard authority FLOOR over exec. `None` (every existing caller, and the
2739/// no-preset case) leaves the `--disable-ocap` bypass exactly as it was —
2740/// bit-for-bit. `Some(scope)` makes the bypass conditional: an out-of-floor
2741/// command does NOT take the unconfined host path, it falls through to the
2742/// confined shell, which enforces the already-clamped `caveats` and denies it.
2743/// This is what makes a deliberately-restricted on-call/triage mode win over a
2744/// `--yolo` flag — the preset clamp is consulted as a ceiling the bypass
2745/// cannot cross.
2746#[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        // The convenience wrapper carries no persona surface — callers that
2791        // enforce a persona allow-list use `execute_tool_with_offload` directly.
2792        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    // FR-1 part 2 (#997): the active persona's tool allow-list (its `tools:`
2821    // front-matter), or `None` when no persona is active. The name-scoped
2822    // enforcement half — advertisement is only cosmetic, so the boundary is
2823    // real ONLY because this executor refuses a disallowed name even if the
2824    // model calls it unprompted.
2825    persona_tools: Option<&[String]>,
2826) -> String {
2827    // FR-3 (#998): the absolute deny-list — a grant-independent veto checked
2828    // FIRST, above every other leash (persona, MCP, alias, routing). It refuses
2829    // catastrophic exec (ssh / rm / systemctl restart …) by STRUCTURAL target,
2830    // so no capability, mode, or persona grant can unlock it. Runs on the RAW
2831    // name + args (pre-rewrite) so a shell alias or a routed command can't slip
2832    // past — and only the exec TARGET is matched, so the same words quoted in a
2833    // coach's question or a runbook note are untouched.
2834    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    // FR-1 part 2 (#997): persona tool allow-list — refuse a BUILT-IN tool the
2841    // active persona does not grant, before any routing (alias, run_command
2842    // redirect). Checked against the CANONICAL name (aliases resolved first) so a
2843    // denied tool can't slip past under a foreign spelling; the always-on infra
2844    // tools ([`is_always_on_tool`]) always pass or the loop would wedge.
2845    //
2846    // Remote MCP tools are EXCLUDED here (FR-2, #1001): they carry no fs/exec/net
2847    // axis, so instead of a hard veto they fall through to the `mcp.handles`
2848    // branch, which PROMPTS the human for a name-based grant. A built-in stays
2849    // hard-vetoed — only remote tools get the softer prompt.
2850    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    // Remote MCP tools (namespaced `server__tool`) route to their server before
2864    // the built-in match. They map to NONE of the fs/exec/net caveat axes, so
2865    // FR-2 (#1001) gives them a NAME-based leash: the active persona's tool
2866    // allow-list. A tool the persona already grants dispatches directly; one it
2867    // does not is PROMPTED through the #263 [`PermissionGate`] (allow once /
2868    // session / deny) rather than hard-vetoed — so a human can grant a remote
2869    // READ tool on demand while a mutating one stays gated. With no persona
2870    // (`persona_tools == None`) remote tools dispatch unleashed, as before.
2871    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                // This branch always returns, so consuming the gate here never
2883                // races the later fs/exec dispatch (unreached once mcp handled).
2884                let granted = match permission_gate {
2885                    Some(gate) => {
2886                        matches!(gate.ask(&[request]), PermissionDecision::Allow(_))
2887                    }
2888                    // Headless / no operator to consult: fail-closed, like every
2889                    // other gate this session.
2890                    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    // Step 27.1: resolve foreign / hallucinated tool names (str_replace_editor,
2907    // execute, bash, …) BEFORE the dispatch match. Compatible-arg aliases
2908    // rewrite to the canonical name and dispatch transparently; the rest return
2909    // a correction that names the right tool. Real names (and MCP `server__tool`
2910    // names, handled above) fall through unchanged.
2911    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    // facade P4 (#780): hidden tool-call routing. After alias normalization, a
2918    // `run_command` (or a shell alias rewritten to one) whose command is a
2919    // read-only reach (`cat`/`ls`/`find` + read-only `git`) is SILENTLY
2920    // rewritten to the governed built-in, so the model's instinctive shell
2921    // calls go through the SAME fs / git caveat checks they would by calling
2922    // the built-in directly — routing is NOT a bypass (§4.4). The route/gate
2923    // split is pure DATA ([`super::routing::RouteTable`]). State-modifying git
2924    // and everything else stay on the exec path (`RouteDecision::Exec`).
2925    //
2926    // `--no-route` / `NEWT_NO_ROUTE` ([`routing_disabled`]) turns this L2
2927    // convenience OFF — the command runs the normal exec path as-is — while the
2928    // L3 boundary (the confined shell below, the fs fence) STAYS. It is a switch
2929    // DISTINCT from `--disable-ocap` (§7-F5): the routing escape never disables
2930    // confinement.
2931    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            // §4.4: log every silent rewrite (the original command + the
2936            // governed built-in it routed to). `None` ⇒ nothing was rewritten.
2937            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        // Model-curated memory (Step 19.3): routes add / replace / remove
2954        // through the caller's NoteSink — the same MemoryManager → NoteStore
2955        // path as `/remember`, so the 19.1 char-cap curator error and the
2956        // 19.2 write-time security scan apply identically.
2957        "save_note" => match note_sink {
2958            Some(sink) => execute_save_note(args, sink, color, tool_output_lines),
2959            // Without a sink the tool was never advertised — a call here is
2960            // a model hallucination; answer like any unknown tool.
2961            None => "unknown tool: save_note (no note store in this session)".to_string(),
2962        },
2963
2964        // Cross-session recall (Step 17.5): searches PAST conversations via
2965        // the caller's RecallSource — workspace-fenced by the store, current
2966        // conversation excluded by the source implementation.
2967        "recall" => match recall_source {
2968            Some(source) => execute_recall(args, source, color, tool_output_lines),
2969            // Without a source the tool was never advertised — same
2970            // unknown-tool answer as the sink-less save_note path.
2971            None => "unknown tool: recall (no conversation store in this session)".to_string(),
2972        },
2973
2974        // Progressive-disclosure memory (Workstream A MVP, #319): pulls the
2975        // verbatim body of one ADDRESSED item (`note:<id>` / `turn:<conv>#<seq>`)
2976        // via the caller's MemorySource — workspace-fenced by the underlying
2977        // NoteStore / ConversationStore. Same presence-gating as `recall`.
2978        "memory_fetch" => match memory_source {
2979            Some(source) => execute_memory_fetch(args, source, color, tool_output_lines),
2980            // Without a source the tool was never advertised — same
2981            // unknown-tool answer as the source-less recall path.
2982            None => "unknown tool: memory_fetch (no memory source in this session)".to_string(),
2983        },
2984
2985        // Step 26.4 (#583): scratchpad state tools — presence-gated on the
2986        // injected store (advertised only when the `scratchpad` feature is on).
2987        "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        // Step 26.5.5 (#582): semantic code search — presence-gated on the
3001        // injected searcher (advertised only when the `semantic` feature is on).
3002        "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        // Step 26.6a (#585): experiential record/recall — presence-gated on the
3012        // store (advertised only when the `experiential` feature is on).
3013        "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        // Step 26.6b (#586) / #715 PR2: scheduled update_plan — the single plan
3031        // WRITE tool, presence-gated on the ledger (advertised only when the
3032        // `scheduled` feature is on). Replaces plan_set + plan_advance.
3033        "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        // #716: read-only plan view (the alias target for "what was I doing?"
3038        // probes) — same presence gate as update_plan.
3039        "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        // #714: self-scoped resume recovery — reads THIS conversation's recent
3045        // turns (via the RecallSource's this_conversation_recent, the opposite
3046        // of recall's filter), the <plan>, and the <state>. Advertised ALWAYS,
3047        // so it reuses the already-present recall_source / step_ledger /
3048        // scratchpad_store params and degrades gracefully when they are None.
3049        "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        // #721: the capability-GRANT request — rides the SAME #263 gate a denial
3058        // would consult. Advertised always; `permission_gate` is `None` for
3059        // headless / eval / ACP, where it answers "no operator available" rather
3060        // than blocking. Consumes the gate (mutually exclusive with the
3061        // run_command / fs arms that also use it — only one arm runs per call).
3062        "request_permissions" => {
3063            execute_request_permissions(args, permission_gate, color, tool_output_lines)
3064        }
3065
3066        // #728: the GENERIC ask-the-human tool — surfaces a free-text question to
3067        // the operator via the SAME #263 human-interface gate (`ask_question`)
3068        // and returns the answer. Advertised always; `permission_gate` is `None`
3069        // for headless / eval / ACP, where it answers "no human available this
3070        // session" rather than blocking. Consumes the gate (mutually exclusive
3071        // with the run_command / fs / request_permissions arms that also use it —
3072        // only one arm runs per call).
3073        "request_user_input" => {
3074            execute_request_user_input(args, permission_gate, color, tool_output_lines)
3075        }
3076
3077        // #725: tool discovery — search THIS session's advertised catalog by
3078        // intent so a model that half-remembers a capability finds the real tool
3079        // name instead of fabricating one. Advertised always; the catalog is
3080        // rebuilt here from the live presence sources (the `with_*` flags derive
3081        // from which optional capabilities this call was handed), so the search
3082        // reflects exactly what was advertised — built-ins, presence-gated tools,
3083        // AND the connected MCP `server__tool` entries. The matcher
3084        // ([`super::tool_search::execute_tool_search`]) is pure; the print + the
3085        // catalog build live here.
3086        "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            // FR-1 part 2 (#997): search only what THIS persona may call, so
3090            // discovery never surfaces a tool the executor would then refuse.
3091            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        // Embedded git (PR4, #461): dispatch through the injected GitTool
3112        // (newt-git's LocalGitTool). `GitCaveats::from_session` projects the
3113        // session's authority onto the git surface (fail-closed: a read-only
3114        // session can read but not commit). Same presence-gating as `recall` —
3115        // without an injected impl the tool was never advertised.
3116        "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                    // Denials + engine errors surface verbatim so the model
3124                    // sees WHY (e.g. "denied: commit" on a read-only session).
3125                    Err(e) => format!("error: {e}"),
3126                };
3127                // #1056: a LOCAL git WRITE denied by the projected authority is
3128                // NOT a dead end (the trap that stranded the model between the git
3129                // tool and `run_command git`). Route it through the gate like
3130                // exec/fs: on an operator grant, re-dispatch under the local-write
3131                // surface (`GitCaveats::top()` — all LOCAL ops; network stays
3132                // closed / shell-net-gated). No gate (headless) or a decline keeps
3133                // the denial. The readonly-`/mode` floor is enforced in the gate.
3134                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        // Agent-callable orchestration (#479): compose_roster proposes a crew
3153        // roster from the live environment; crew dispatches a crew/team on a task
3154        // and returns the diff + verify status for the overseer to review. Both
3155        // route through the injected CrewRunner, which runs spawned crews under
3156        // `meet`-attenuated caveats. Same presence-gating as `git` (the `/team`
3157        // toggle) — without an injected impl the tools were never advertised.
3158        "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            // #479 (G4): replace the flat dead-end with a recoverable coach —
3169            // name the operator gesture (NEWT_TEAM) + a real solo alternative.
3170            None => crew_off_recovery_result(name),
3171        },
3172
3173        "run_command" => {
3174            let cmd = args["command"].as_str().unwrap_or("");
3175
3176            // Corrective guard: the model tried to call a tool as a shell binary.
3177            // Return a correction so the model can retry with the right tool call.
3178            // #898: git NETWORK ops (push/fetch/pull/clone) are NOT bounced — the
3179            // embedded git tool can't do them, so they fall through to the shell
3180            // (net-gated), letting the model push a branch and open a PR.
3181            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            // Route the WHOLE command through agent-bridle's confined shell
3192            // (free-form `cmd` mode) under the SAME Caveats the TUI resolved from
3193            // `[tui].permissions`. The confined-exec core is shared with the
3194            // `lifecycle` arm (#891) so both honor identical exec caveats.
3195            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        // #891: the model-facing lifecycle surface over the #880 system. Resolve
3210        // THIS repo's command for the named phase (`.newt/config.toml
3211        // [lifecycle]` → matching tooling packs) and run it through the SAME
3212        // confined exec path as run_command. `action=list` returns the resolved
3213        // command WITHOUT running it — a pure discovery read.
3214        // #1004: present collected findings as a rendered Markdown document in
3215        // the plain scroller. Always-on (no injected capability); self-prints
3216        // the rendered block and returns a short ack to the model.
3217        "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            // Resolve from the `[lifecycle]` override → matching tooling packs.
3233            // Several toolchains may each contribute a command; join with `&&`
3234            // so the confined shell runs them in sequence and short-circuits on
3235            // the first failure (the safe-subset engine supports `&&`).
3236            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                // #263: the gate may grant the read; deny (or no gate) keeps
3280                // the standard denial text bit-for-bit.
3281                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                    // #719: window + cap the MODEL-facing payload (the on-screen
3296                    // display is capped separately) so one read of a large file
3297                    // can't saturate the context window and abandon the task.
3298                    let offset = args["offset"].as_u64().map(|n| n as usize);
3299                    let limit = args["limit"].as_u64().map(|n| n as usize);
3300                    // #726: char backstop now derives from the shared token
3301                    // budget so read_file and run_command share one cap.
3302                    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                // #263: the gate may grant the write (the human's choice at
3317                // the prompt is the consent — the y/N confirm below stays
3318                // governed by the original scope shape, which a denial here
3319                // proves is `Only`, i.e. no second confirm).
3320                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            // Shrink guard: refuse if the proposed write removes > 30% of
3333            // lines AND > 30 lines absolute. This catches the failure mode
3334            // where a model replaces an entire large file with a small
3335            // fragment (observed in the wild: 4,247 → 107 lines).
3336            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            // Show first 20 lines as preview.
3359            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            // Auto-write when the caveat explicitly scopes fs_write (the
3368            // preset itself is the user's consent). Unrestricted writes still
3369            // confirm, but through the TUI gate so stdin is guarded out of
3370            // cbreak/nonblocking mode. --yolo is the explicit auto-accept mode.
3371            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                // #1022: deletion is a normal fs_write operation. A denial
3408                // consults the same prompted-grant path as write_file/edit_file,
3409                // so deletion is possible with operator approval instead of
3410                // being structurally unavailable.
3411                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                // #263: same prompted-grant path as write_file.
3466                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                // Show the file's actual head so the model can copy the exact
3488                // text and self-correct on the next call — instead of guessing
3489                // old_string blind and looping (the failure mode that left a
3490                // model unable to add a header comment). The content is already
3491                // in hand from the read above; no extra round needed.
3492                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                // #263: same prompted-grant path as read_file.
3548                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        // #496: embedded, shell-free file search. The reported breakage was an
3576        // agent that needed `find` but the build's shell tool was unavailable;
3577        // this arm walks the workspace with the `ignore` crate (no subprocess),
3578        // gated by the same fs_read caveat as list_dir/read_file.
3579        "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            // #529: echo the active filters, not a bare `find: .` — two very
3609            // different searches must not render identically in the trace.
3610            print_tool_call("find", &find_detail(path, &opts), color);
3611            if !full.exists() {
3612                return format!("error: no such path '{path}'");
3613            }
3614            // Defence-in-depth for a *recursive* read: refuse a root that
3615            // canonicalises outside the workspace (e.g. via `..`). `find` never
3616            // follows symlinks, so descent can't escape either.
3617            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            // Reads from the configured skill search path. This is a read of
3649            // trusted operator config (procedural knowledge), not an exec of
3650            // arbitrary code, so it is NOT leash-gated — any SCRIPTS the skill
3651            // bundles still run through `run_command`'s confined shell and are
3652            // governed by the session caveats. The same first-directory-wins
3653            // precedence as the index means we load the copy the model was
3654            // actually shown.
3655            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            // Route through agent-bridle's `web_fetch` tool under the SAME
3672            // Caveats. The `net` axis gates which hosts are reachable (host
3673            // allowlist + SSRF screen); an out-of-scope host is denied by the
3674            // leash, surfaced via the dispatch error. The tool returns extracted
3675            // markdown (`{ url, final_url, status, title, markdown }`) — the body
3676            // is untrusted page content, not a command result.
3677            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            // #263: with a gate present, pre-check the host against the `net`
3682            // axis so an out-of-allowlist host becomes a prompt instead of a
3683            // leash error. Allow ⇒ dispatch under the gate's minted caveats;
3684            // deny (or no gate, or an unparseable URL) ⇒ dispatch under the
3685            // ORIGINAL caveats — the leash produces today's denial verbatim.
3686            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                // A `net`-axis leash denial, or a fetch error (SSRF screen,
3728                // timeout, non-2xx) — surface the reason; Display is safe.
3729                Err(e) => format!("error: {e}"),
3730            }
3731        }
3732
3733        other => unknown_tool_message(other),
3734    }
3735}
3736
3737/// Classify an [`execute_tool`] result string as success or failure for the
3738/// turn's recorded tool events (Step 17.6, #246). Best-effort by necessity —
3739/// tool results are plain strings fed back to the model — so this mirrors
3740/// the failure prefixes this module (and `McpTools::call`) actually emit:
3741/// `error:`, `capability denied:`, and `unknown tool`. A successful
3742/// `run_command` whose *output* happens to start with one of these is
3743/// misclassified; the recorded event is an outcome claim, not a gate.
3744pub(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    // ---- #717: classify_phantom_reach (pure, no fs) ----
3757
3758    #[test]
3759    fn classify_phantom_rewrite_alias() {
3760        // A shell alias resolves to the canonical run_command rewrite.
3761        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        // An edit alias with the wrong arg shape returns Correct guidance.
3771        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        // A foreign name with no alias is a true phantom tool. (Note: #716 turned
3788        // the plan/crew/workflow notions into recognized aliases, so this uses a
3789        // name no family claims.)
3790        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        // #716 + #717: a foreign plan notion now resolves through the alias seam,
3802        // so the telemetry classifier records it as a Correct (coach) reach — the
3803        // new arms get phantom-reach telemetry for free.
3804        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        // state_get on an unset key is an empty-by-design real-tool miss.
3819        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        // recall with no hits is an empty-by-design real-tool miss.
3836        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        // #714 + #717: a "where were we" reach resolves through the alias seam to
3853        // a Rewrite, so the telemetry already captures it (no new wiring needed).
3854        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        // An ordinary successful real tool call is not phantom telemetry.
3864        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    // ---- #725: tool_search discovery (alias + name registry) ----
3874
3875    #[test]
3876    fn tool_search_is_a_real_tool_name() {
3877        // It must be in the canonical registry so a model calling it is never
3878        // treated as a hallucination.
3879        assert!(ALL_TOOL_NAMES.contains(&"tool_search"));
3880    }
3881
3882    #[test]
3883    fn discovery_verbs_alias_to_tool_search() {
3884        // The instinctive "which tool does X?" reaches silently Rewrite to the
3885        // real tool_search.
3886        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        // The real name must fall through unchanged (no recursive rewrite).
3908        assert!(resolve_tool_alias("tool_search").is_none());
3909    }
3910
3911    #[test]
3912    fn classify_phantom_discovery_reach_is_a_rewrite() {
3913        // #725 + #717: a discovery reach resolves through the alias seam to a
3914        // Rewrite, so the phantom telemetry captures it for free.
3915        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        // A real tool_search call is not phantom telemetry.
3925        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    // ---- #719: read_file payload window/cap/pagination (pure, no fs) ----
3943
3944    #[test]
3945    fn paginate_read_caps_a_large_file_to_the_default_window() {
3946        // A 15k-line file must NOT flood the model: default window is 2000 lines
3947        // with a footer to continue (regression for the 12.5k→168k saturation).
3948        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        // Whole-file read that fits both caps → exact bytes, no footer.
3982        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        // #726: the char backstop is now token-derived (budget × chars/token),
3991        // NOT a hardcoded 100k. One enormous line: the line window can't help;
3992        // the token-derived char backstop must. With a 1000-token budget the
3993        // backstop is ~4000 chars, so a 50k-char line is truncated near there.
3994        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        // A LARGER budget keeps more of the same line — the backstop tracks the
4010        // budget rather than a fixed constant.
4011        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        // #726: max_output_tokens == 0 means "no cap" — only the line window
4023        // applies, so a single huge line comes back verbatim.
4024        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    // ---- #726: shared token-based model-facing output cap ----
4036
4037    #[test]
4038    fn cap_model_output_passes_small_output_through_unchanged() {
4039        // Well under budget → exact bytes, no marker.
4040        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        // A multi-byte char straddling the cut must not be split — the body must
4061        // stay valid UTF-8 (no panic, no replacement char).
4062        let budget = 10; // ~40 chars
4063        let body = "é".repeat(1_000); // 2 bytes each
4064        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        // The budget→char conversion is the default 4 chars/token (the shared
4082        // estimator constant), so a 10k-token budget is a 40k-char backstop.
4083        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                // #721: advertised ALWAYS (core capability-grant request, no
4165                // presence gate) — part of the base tool_definitions() set.
4166                "request_permissions",
4167                // #714: advertised ALWAYS (no presence gate), so it joins the
4168                // base set even with every `with_*` flag off.
4169                "resume_context",
4170                // #725: advertised ALWAYS (a discovery tool must always be
4171                // present), so it too joins the base set with every flag off.
4172                "tool_search",
4173                // #727: advertised ALWAYS (read-only budget self-read, no
4174                // presence gate), pushed right after resume_context.
4175                "get_context_remaining",
4176                // #728: advertised ALWAYS (a model must always be able to ask the
4177                // human; degrades honestly headless), pushed last.
4178                "request_user_input",
4179                // #891: advertised ALWAYS (the model-facing lifecycle surface;
4180                // degrades honestly with "no command configured"), pushed after
4181                // request_user_input.
4182                "lifecycle",
4183                // #1004: advertised ALWAYS (present-findings surface; needs no
4184                // injected capability, degrades to raw source when color is
4185                // off), pushed after lifecycle.
4186                "render_report",
4187            ]
4188        );
4189    }
4190
4191    /// FR-1 part 2 (#997): a persona's `tools:` allow-list scopes the ADVERTISED
4192    /// catalog — only the named tools survive, PLUS the always-on infra tools the
4193    /// loop can't run without (which no persona may fence off). `None` leaves the
4194    /// catalog whole (the zero-cost path for every non-persona session).
4195    #[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        // No persona → catalog untouched.
4207        assert_eq!(
4208            name_set(&filter_advertised_tools(full.clone(), None)),
4209            name_set(&full),
4210            "None must be a no-op"
4211        );
4212        // A read-only coach (`tools = ["read_file"]`): read_file survives; the
4213        // mutating built-ins are dropped; every always-on infra tool still rides.
4214        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    /// FR-1 part 2 (#997): `persona_tool_allowed` is the single predicate behind
4244    /// BOTH the advertise-filter and the executor reject — a tool is callable iff
4245    /// the persona names it OR it is always-on infra — so the set the model sees
4246    /// and the set it may run can never drift apart.
4247    #[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    /// FR-1 part 2 (#997): the executor is the ENFORCEMENT half. Even a
4266    /// hallucinated call the advertise-filter can't intercept is refused BY NAME
4267    /// before any side effect — while a granted tool and the always-on infra
4268    /// pass. Regression for a coach persona whose `tools:` list must be a real
4269    /// boundary, not a cosmetic hint.
4270    #[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        // write_file is NOT granted → refused with the persona message, and the
4276        // file is never written (top caveats would otherwise permit it).
4277        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        // An always-on infra tool rides even though it is unlisted.
4289        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    /// FR-3 (#998): the absolute deny-list is wired into the executor and is
4304    /// GRANT-INDEPENDENT — even with top caveats and NO persona, a `run_command`
4305    /// whose exec target is forbidden (`ssh`) is refused before the shell runs,
4306    /// while an ordinary command is untouched. Guards against the deny module
4307    /// being present but never called.
4308    #[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(); // maximal grant — deny still bites
4312        let denied = call_offload(
4313            "run_command",
4314            &serde_json::json!({ "command": "ssh host 'uptime'" }),
4315            &ws,
4316            &caveats,
4317            None, // no persona — the floor is independent of any grant
4318        )
4319        .await;
4320        assert!(
4321            denied.contains("absolute deny-list"),
4322            "ssh must hit the deny-list, got: {denied}"
4323        );
4324        // A benign command sails past the deny gate (it reaches normal exec).
4325        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    /// Test-only thin wrapper over the 22-arg [`execute_tool_with_offload`] that
4340    /// fixes every optional seam to `None` and surfaces just the persona list.
4341    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,  // build_check_cmd
4357            None,  // note_sink
4358            None,  // recall_source
4359            None,  // memory_source
4360            None,  // permission_gate
4361            None,  // exec_floor
4362            None,  // git_tool
4363            None,  // crew_runner
4364            None,  // scratchpad_store
4365            None,  // code_search
4366            None,  // experience_store
4367            None,  // step_ledger
4368            false, // tool_offload
4369            None,  // spill_store
4370            persona_tools,
4371        )
4372        .await
4373    }
4374
4375    /// #894: each registry entry's schema-builder produces the SAME name the
4376    /// entry declares — catches a copy-paste where the `ToolSpec.name` and the
4377    /// `*_tool_definition()` disagree.
4378    #[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    /// #894: no built-in tool name is declared twice across the base array and
4392    /// the registry (a dup would double-advertise and confuse dispatch).
4393    #[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    /// #894 anti-drift (the payoff): with EVERY gate on, the advertised set from
4402    /// `merged_tool_definitions` equals `ALL_TOOL_NAMES` in BOTH directions. This
4403    /// is the test that would have caught the `lifecycle` drift — a tool
4404    /// advertised/dispatched but missing from the real-name set (or vice versa)
4405    /// fails here.
4406    #[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        // Every advertised tool is a real (non-hallucinated) name...
4418        for a in &advertised {
4419            assert!(
4420                names.contains(a),
4421                "advertised but not in ALL_TOOL_NAMES: {a}"
4422            );
4423        }
4424        // ...and every real name is actually advertised when its gate is on.
4425        for n in &names {
4426            assert!(
4427                advertised.contains(n),
4428                "in ALL_TOOL_NAMES but never advertised: {n}"
4429            );
4430        }
4431    }
4432
4433    /// #894: `BASE_TOOL_NAMES` mirrors the names inlined in `tool_definitions()`
4434    /// exactly and in order — the one hand-kept mirror, guarded here.
4435    #[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    /// #894 regression for the concrete drift that motivated the registry: the
4448    /// `lifecycle` tool (#891) is advertised + dispatched, so it MUST be a real
4449    /// name — otherwise every legitimate `lifecycle` call is miscounted as a
4450    /// hallucination (inflating the anti-loop counter). Before the registry it
4451    /// was missing from `ALL_TOOL_NAMES`; the derivation makes that impossible.
4452    #[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        // The schema's phase enum is built from `Phase::ALL`, so it can never
4467        // drift from the vocabulary the executor parses with `Phase::from_key`.
4468        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        // The canonical name is NOT an alias — it dispatches directly.
4495        assert!(resolve_tool_alias("lifecycle").is_none());
4496    }
4497
4498    #[tokio::test]
4499    async fn lifecycle_unknown_phase_lists_valid_phases() {
4500        // An unknown phase returns before any fs/subprocess touch, so this is a
4501        // fully-mocked unit test.
4502        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    /// `save_note` is sink-gated: absent from the base `tool_definitions`
4534    /// (headless/eval callers see no memory tool) and from the merged set
4535    /// without a sink; present in the merged set when a sink exists.
4536    #[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        // Headless/eval callers see no memory tool in the base set …
4546        let base = tool_definitions();
4547        assert!(!names(&base).contains(&"save_note"), "got: {base}");
4548        // … nor in the merged set without a sink …
4549        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        // … but a sink advertises it.
4554        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    /// `recall` is source-gated exactly like `save_note` is sink-gated
4561    /// (Step 17.5): absent from the base set and from the merged set
4562    /// without a source; present when one exists.
4563    #[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        // The two gates are independent: both on advertises both.
4583        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    /// `memory_fetch` is source-gated exactly like `recall` (#319): absent
4591    /// from the base set and from the merged set without a `MemorySource`;
4592    /// present when one exists. The flag is independent of the others.
4593    #[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        // Flag off (every existing caller, the inert default) → not advertised.
4605        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        // Flag on → advertised.
4610        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        // Independent of the save_note / recall gates: all three on lists all.
4615        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    /// `is_hallucination` correctly identifies tool-name-as-command and unknown
4624    /// tool names, and correctly skips MCP-namespaced tools.
4625    #[test]
4626    fn hallucination_detection_coverage() {
4627        // tool name passed to run_command → hallucination
4628        assert!(is_hallucination(
4629            "run_command",
4630            &serde_json::json!({"command": "list_dir ."})
4631        ));
4632        // normal shell command → not a hallucination
4633        assert!(!is_hallucination(
4634            "run_command",
4635            &serde_json::json!({"command": "cargo test"})
4636        ));
4637        // unknown tool → hallucination
4638        assert!(is_hallucination(
4639            "definitely_not_a_real_tool",
4640            &serde_json::json!({})
4641        ));
4642        // MCP-namespaced tool → not a hallucination
4643        assert!(!is_hallucination(
4644            "my_server__some_tool",
4645            &serde_json::json!({})
4646        ));
4647        // known direct tools → not hallucinations when called correctly
4648        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    /// #898/#1022: `run_command_redirect` bounces embedded-tool-served LOCAL git
4664    /// ops (and other direct tools), but lets git passthrough ops fall through
4665    /// to the shell — otherwise a model can never `git push` or `git rm`.
4666    #[test]
4667    fn run_command_redirect_lets_git_network_ops_through() {
4668        // Ops the embedded git tool cannot do faithfully → fall through (None).
4669        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        // Local ops the embedded git tool handles → still redirect.
4680        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        // Other direct tools still redirect; plain shell commands run as-is.
4693        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    /// #898 regression: a real `git push` at run_command must NOT be counted as
4701    /// a hallucination (it now runs), while a local `git status` still is.
4702    #[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    /// #898: the forge PR/MR-creation URL is extracted from git's push output
4723    /// (GitHub and GitLab), and ordinary URLs do not false-positive.
4724    #[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        // No PR URL present → None (ordinary fetch/clone output, plain links).
4739        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    /// #898: after a push whose output carries a PR-creation URL,
4747    /// `shell_envelope_output` appends the `gh pr create` next-step hint (and the
4748    /// URL survives), while ordinary command output is left untouched.
4749    #[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        // Ordinary output: no hint, payload unchanged.
4765        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        // A non-bool `denied` is treated as not-denied, never a panic.
4810        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        // Missing or empty denials → the generic message, never a panic.
4826        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        // Entries without a string `reason` are skipped.
4833        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    /// #775 (§2.5): the human exec-denial NOTICE shows the BARE command
4848    /// target(s), never the reason sentence — restoring [`print_denied`]'s
4849    /// `'{target}'` contract. Stuffing the full reason there produced the
4850    /// field-report garble `capability denied: exec does not permit '<whole
4851    /// reason sentence>'`.
4852    #[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        // It is the bare command — NEVER the reason sentence (which, in the
4865        // `'{target}'` slot, was the nested garble).
4866        assert!(!label.contains("is not within the granted authority"));
4867        // Multiple targets join cleanly; an envelope with no target falls back
4868        // to a generic label so the notice still prints one clean line.
4869        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        // Unparseable / non-http inputs skip the pre-check (None) rather
4892        // than guessing — enforcement stays with the leash either way.
4893        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        // The promptable case: every entry is an exec denial with a target;
4901        // the request target is the allowlist basename (the grantable name).
4902        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        // A non-exec entry anywhere keeps the standard denial: mapping an
4921        // opaque `open` onto an fs axis would over-grant.
4922        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        // Missing/empty pieces are never promptable.
4931        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    /// #905: a NET denial envelope (agent-bridle #196 shape) lifts to a per-host
4944    /// net PermissionRequest; the target is the CONNECT host verbatim (no
4945    /// basename mangling). Non-net / mixed / empty batches stay flat.
4946    #[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        // A non-net entry anywhere → not net-promptable (exec lifter handles exec).
4967        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        // Exec-only is not net-promptable; empty/missing targets never are.
4975        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    /// #905: the human denial NOTICE labels a pure-net refusal `net` (not `exec`),
4983    /// so it never reads "exec does not permit '<host>'". Exec / mixed stay `exec`.
4984    #[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        // `..` traversal must NOT escape: a path that lexically resolves outside
5007        // the workspace is denied even though it textually begins with it.
5008        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        // A sibling dir that merely shares the string prefix is not under /ws.
5017        assert!(
5018            !tui_permits_path(&only, "/ws-secret/file.rs"),
5019            "sibling-prefix collision escapes the workspace"
5020        );
5021        // A `..` that stays inside the workspace is still permitted.
5022        assert!(tui_permits_path(&only, "/ws/sub/../file.rs"));
5023    }
5024
5025    /// Ratchet for the OPEN `fs-canonical-containment` deviation (issue #522,
5026    /// `docs/security/ocap-deviations.md`). `tui_permits_path` is string-lexical:
5027    /// it collapses `..` but does NOT resolve symlinks, so a link *inside* the
5028    /// workspace pointing OUT is permitted even though the OS would read the
5029    /// outside target. This test builds the path the call sites do
5030    /// (`workspace.join(model_path)`) over a REAL symlink and PINS that residual.
5031    ///
5032    /// When canonicalize-then-contain lands (the deviation's closure criterion),
5033    /// the gate will deny the symlinked path and this assertion MUST flip to
5034    /// `!tui_permits_path(...)` — that break is the signal to close the deviation.
5035    /// Unix-only: Windows symlinks need privileges (mirrors
5036    /// `find_does_not_follow_symlinks_out_of_workspace`).
5037    #[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        // A symlink under the workspace whose target is OUTSIDE it.
5045        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        // What the read/write call sites feed the gate for model path "link/secret".
5050        let via_link = ws.path().join("link").join("secret");
5051        // RESIDUAL: permitted today — the gate can't see through the symlink.
5052        // Flip to `!` when the gate canonicalizes (closes fs-canonical-containment).
5053        assert!(
5054            tui_permits_path(&only, &via_link.to_string_lossy()),
5055            "string gate permits a symlinked escape — known residual (#522)"
5056        );
5057
5058        // Contrast: a plain `..` escape through the SAME root is already denied
5059        // (lexical containment, the part #502 did fix) — so this isn't a blanket
5060        // hole, only the symlink-resolution gap.
5061        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    // --- PR4: the `git` tool is presence-gated -----------------------------
5069
5070    #[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        // #479: the /team toggle advertises both crew tools, and only then.
5088        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        // Step 26.4 (#583): the scratchpad state tools, only with the gate on.
5100        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        // Step 26.5.5 (#582): the code_search tool, only with its gate on.
5115        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        // Step 26.6a (#585): the experiential record/recall tools, only with the gate.
5128        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        // Step 26.6b (#586) / #715 PR2: the scheduled update_plan + plan_get tools,
5140        // only with the gate (plan_set/plan_advance collapsed into update_plan).
5141        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        // Step 26.4: without a store the tool was never advertised → unknown.
5160        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        // With a store → routes to the executor and mutates it.
5184        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        // Step 26.5.5: no searcher → unknown tool (presence-gate parity).
5224        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        // with a searcher (empty index) → routes to the executor (labelled no-match).
5248        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        // Step 26.6a: no store → unknown tool for BOTH arms (presence-gate parity).
5287        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        // with a store → record routes to the executor and mutates it.
5296        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        // Step 26.6b / #716 / #715 PR2: no ledger → unknown tool for ALL plan arms
5332        // (presence-gate parity, including the read-only plan_get).
5333        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        // with a ledger → update_plan routes to the executor and mutates it.
5342        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        // #716: plan_get with a ledger renders the <plan> block, read-only.
5368        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        // #714: advertised ALWAYS, so dispatch never reports "unknown tool" —
5397        // with no recall_source (headless) it returns the clear no-history line.
5398        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, // build_check_cmd
5408            None, // note_sink
5409            None, // recall_source
5410            None, // memory_source
5411            None, // permission_gate
5412            None, // exec_floor
5413            None, // git_tool
5414            None, // crew_runner
5415            None, // scratchpad_store
5416            None, // code_search
5417            None, // experience_store
5418            None, // step_ledger
5419        )
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        // A nonexistent workspace dir → the command can't even spawn.
5440        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// ---------------------------------------------------------------------------
5446// execute_tool branch tests — edit_file / shrink guard / denial paths
5447// ---------------------------------------------------------------------------
5448
5449#[cfg(test)]
5450mod execute_tool_branch_tests {
5451    use super::super::NoMcp;
5452    use super::*;
5453    use crate::caveats::{Caveats, CountBound, Scope};
5454
5455    /// fs read everywhere, fs write scoped to the workspace (skips the y/N
5456    /// confirm — the scoped preset is the consent), nothing else.
5457    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    // --- PR4: the `git` tool arm in execute_tool ---------------------------
5469
5470    /// A stub GitTool: echoes the op, and refuses `commit` when the projected
5471    /// GitCaveats deny it — exercises the arm's caveat projection without a repo.
5472    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, // crew_runner
5513            None, // scratchpad_store
5514            None, // code_search
5515            None, // experience_store
5516            None, // step_ledger
5517        )
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        // A session with no fs_write → from_session denies commit_local.
5531        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        // The same session can still run a read op.
5542        let out = run_git("status", &read_only, Some(&StubGit)).await;
5543        assert!(out.contains("on branch main"), "got: {out}");
5544    }
5545
5546    /// Same as `run_git` but with a gate AND the git tool both injected — the
5547    /// #1056 path where a denied git write consults the operator.
5548    #[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), // permission_gate
5569            None,       // exec_floor
5570            Some(git),  // git_tool
5571            None,       // crew_runner
5572            None,       // scratchpad_store
5573            None,       // code_search
5574            None,       // experience_store
5575            None,       // step_ledger
5576        )
5577        .await
5578    }
5579
5580    /// #1056: a git WRITE the projected authority denies is no longer a dead end
5581    /// — with a gate that ALLOWS, the arm re-dispatches under the local-write
5582    /// surface and the commit lands (the deadlock fix). The gate is consulted for
5583    /// a `git_write` capability.
5584    #[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    /// Deny-by-default invariant: a gate that DECLINES keeps the git write denied.
5607    #[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    /// #1056: a git READ is never gated — the arm only routes WRITE denials, so a
5623    /// read op never even consults the gate.
5624    #[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    // #479: the agent-callable crew/compose_roster tools route through the
5658    // injected CrewRunner — same presence-gating + dispatch shape as `git`.
5659    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, // build_check_cmd
5691            None, // note_sink
5692            None, // recall_source
5693            None, // memory_source
5694            None, // permission_gate
5695            None, // exec_floor
5696            None, // git_tool
5697            crew,
5698            None, // scratchpad_store
5699            None, // code_search
5700            None, // experience_store
5701            None, // step_ledger
5702        )
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    /// #479 (G4): with no `CrewRunner` injected (the OFF default), the dispatch
5728    /// arm coaches recovery instead of the old flat `unknown tool` dead-end — it
5729    /// names the operator gesture (`NEWT_TEAM`) and a real solo alternative, and
5730    /// must NOT read as "unknown tool".
5731    #[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    /// #479 (G4): the factored coach helper names the gate + a real alternative
5742    /// and never reads as "unknown tool" — the regression point for the wording.
5743    #[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        // A real, always-available solo alternative is offered.
5749        assert!(out.contains("write_file"), "{out}");
5750        assert!(!out.contains("unknown tool"), "{out}");
5751    }
5752
5753    /// #479 (G4): the gated-off telemetry seam. A `crew`/`compose_roster` reach
5754    /// with the surface OFF records a `GatedOff` phantom; the same names with the
5755    /// surface ON record nothing (they dispatch normally), and a non-crew name is
5756    /// never gated-off.
5757    #[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        // A non-crew tool is never a gated-off reach, OFF or ON.
5774        assert_eq!(classify_gated_off_reach("read_file", false), None);
5775        assert_eq!(classify_gated_off_reach("read_file", true), None);
5776    }
5777
5778    /// #479 (G4) guard: the OFF-state changes do not touch `is_hallucination`
5779    /// (crew/compose_roster stay real names) or `classify_phantom_reach` for the
5780    /// crew names — both kept exactly so the ON path stays a normal dispatch.
5781    #[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    // --- #496: the embedded `find` tool -----------------------------------
5797
5798    /// Convenience for `find` calls through the real dispatch under a
5799    /// read-everything session.
5800    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    /// Regression for #496: an agent needed `find . -name pyo3_module.rs` but
5811    /// the build's shell tool was unavailable. The embedded tool must locate the
5812    /// file by basename, ignoring decoys, and return its workspace-relative path
5813    /// (no shell, no `| sort`). Fails before this tool existed (`unknown tool:
5814    /// find`).
5815    #[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"); // decoy: wrong extension
5821        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    /// The other call the blocked agent reached for:
5826    /// `find examples -maxdepth 2 -type f -name '*.py'`. Exercises glob + type
5827    /// filter + max_depth together, and confirms output is pre-sorted.
5828    #[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"); // depth 1 — match
5832        touch(ws.path(), "examples/sub/b.py"); // depth 2 — match
5833        touch(ws.path(), "examples/sub/deep/c.py"); // depth 3 — too deep
5834        touch(ws.path(), "examples/readme.md"); // wrong extension
5835        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        // Pre-sorted, exactly the two in-depth .py files, no dir, no .md, no
5844        // depth-3 file — and no shell `| sort` needed.
5845        assert_eq!(out, "examples/a.py\nexamples/sub/b.py", "got: {out}");
5846    }
5847
5848    /// Output is sorted ascending regardless of filesystem/creation order.
5849    #[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    /// `type` restricts to files or directories.
5865    #[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    /// .gitignore + the default build/dep skips are honoured by default and
5885    /// can be disabled with `respect_gitignore=false`.
5886    #[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    /// `max_results` caps output and the result notes the truncation.
5914    #[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    /// A missing root is a clear error, and an empty match set says so.
5931    #[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    /// fs_read denial: no scope + no prompt gate ⇒ capability denied (same UX
5942    /// as list_dir/read_file).
5943    #[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    /// A `..` root that escapes the workspace is refused even when the session
5963    /// grants fs_read everywhere (defence-in-depth for a recursive read).
5964    #[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        // fs_read: All, so the only thing that can stop the escape is the
5971        // canonical-root containment check.
5972        let out = run_find(serde_json::json!({ "path": ".." }), &ws).await;
5973        assert!(out.starts_with("capability denied"), "got: {out}");
5974    }
5975
5976    /// An empty `name` is treated as "match everything" (the `!g.is_empty()`
5977    /// guard routes `Some("")` to the no-filter path; without it the glob would
5978    /// compile to `^$` and match nothing).
5979    #[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    /// Hidden entries (dotfiles / dotdirs) are pruned by default and surface
5994    /// only when `respect_gitignore=false` — relevant because dotfiles can hold
5995    /// secrets (.env, .ssh). Pins the `.hidden(respect_gitignore)` branch.
5996    #[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    /// Security boundary: `find` never follows symlinked directories, so a link
6023    /// pointing outside the workspace cannot leak the target's contents (pins
6024    /// `.follow_links(false)`). Unix-only — Windows symlinks need privileges.
6025    #[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        // The symlink is present but is NOT descended into.
6035        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        // Sanity: a real in-workspace file is still found.
6041        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        // '*' is a wildcard; '.' is literal (not "any char").
6048        let re = glob_to_regex("*.py", true).unwrap();
6049        assert!(re.is_match("foo.py"));
6050        assert!(!re.is_match("foo.pyc")); // anchored at end
6051        assert!(!re.is_match("fooxpy")); // '.' is literal
6052                                         // Exact basename, '?' = single char, case-sensitivity honoured.
6053        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, // memory_source
6082            None,
6083            None,
6084            None, // git_tool
6085            None, // crew_runner
6086            None, // scratchpad_store
6087            None, // code_search
6088            None, // experience_store
6089            None, // step_ledger
6090        )
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        // The miss error now shows the file's actual contents so the model can
6144        // copy the exact text instead of blind-guessing old_string again.
6145        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        // The ambiguous edit must NOT have touched the file.
6161        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        // The guard refused — the original file must be intact.
6247        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        // Step 27.1: the bare "unknown tool: X" is now a corrective message that
6412        // still leads with the same prefix but also names the real catalog.
6413        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    // -- Step 27.1: tool-alias resolution + corrective feedback -------------
6421
6422    #[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        // #721: newt has no directory-creation tool — coach to write_file, which
6479        // does create_dir_all on the parent. Turns the issue's `mkdir -p …/src`
6480        // dead-end into a self-correcting tool call.
6481        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        // `touch` is intentionally NOT in the mkdir arm — it stays a create-file
6496        // alias (→ write_file), so there is no duplicate match arm / collision.
6497        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    // -- #716: plan / plan-read / crew / workflow alias families --------------
6524
6525    #[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        // #715 PR2: the advance-ish verbs coach update_plan + "completed" too.
6547        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        // #715 PR2: update_plan is the REAL tool now → not an alias (returns None),
6561        // exactly like the resume_context fix; the old set_plan name is gone too.
6562        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        // #714: the instinctive "where did we leave off" reaches redirect to the
6590        // self-recovery tool, not plan_get.
6591        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        // The REAL tool name is not an alias: it returns None so a direct
6607        // resume_context call dispatches as a real tool and is NOT logged as a
6608        // phantom Rewrite by #717 telemetry (real names must return None).
6609        assert!(
6610            resolve_tool_alias("resume_context").is_none(),
6611            "the real tool name must return None, not a self-Rewrite"
6612        );
6613        // No regression: `what_was_i_doing` still asks specifically for the plan.
6614        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            // Names the real targets...
6641            assert!(msg.contains("compose_roster"), "{n}: {msg}");
6642            assert!(msg.contains("crew"), "{n}: {msg}");
6643            // ...but makes clear the model cannot self-enable the /team surface.
6644            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    /// An incompatible-arg alias is corrected (not dead-ended) by execute_tool:
6693    /// a model that emits `str_replace_editor` is told to use edit_file. The
6694    /// correction returns before any fs/caveat work, so this is deterministic.
6695    #[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    // -- #263 prompted permission grants through execute_tool ---------------
6712
6713    /// Scripted gate: records every request it is asked about and answers
6714    /// allow (with caveats widened by exactly the requested grants) or deny.
6715    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        // #728: this gate exercises the GRANT path only; it has no human to
6748        // answer free-text questions, so it returns None (a trivial impl).
6749        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, // memory_source
6774            Some(gate),
6775            None,
6776            None, // git_tool
6777            None, // crew_runner
6778            None, // scratchpad_store
6779            None, // code_search
6780            None, // experience_store
6781            None, // step_ledger
6782        )
6783        .await
6784    }
6785
6786    /// FR-2 (#1001): a one-tool remote MCP server for testing the remote-tool
6787    /// leash — records whether `call` actually dispatched.
6788    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    /// FR-2 (#1001): a remote MCP tool OUTSIDE the persona's allow-list is
6854    /// PROMPTED (not hard-vetoed like a built-in). Deny → withheld and `call`
6855    /// never runs; Allow → dispatched; a tool the persona already grants
6856    /// dispatches with NO prompt; headless (no gate) fails closed.
6857    #[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()]; // no incident__create
6862
6863        // Gate DENIES → withheld; `call` never invoked; the human WAS prompted,
6864        // and prompted as a remote-tool grant (not an fs/exec/net axis).
6865        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        // Gate ALLOWS → dispatched.
6882        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        // A remote tool the persona GRANTS dispatches with NO prompt.
6897        let granted = vec!["incident__create".to_string()];
6898        let mut mcp = OneRemoteTool::new("incident__create");
6899        let mut gate = MockGate::new(false, &caveats); // would deny if asked
6900        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        // Headless (no gate) → fail-closed: withheld, `call` never runs.
6916        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    // -- #721 recoverable denials + request_permissions ---------------------
6934
6935    #[test]
6936    fn exec_denial_is_recoverable_not_a_dead_end() {
6937        // #721 + #775: the exec denial the MODEL sees is ONE clean level —
6938        // `capability denied: <bare reason>. <recovery hint>` — leading to the
6939        // model-actionable request_permissions path, NOT the stale `extra_exec`
6940        // config edit (which #721 superseded and the model cannot perform
6941        // mid-turn).
6942        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        // #775: the stale `extra_exec` config hint is GONE from the model-facing
6954        // message (it leaked in before; the human transcript carries the bare
6955        // command via print_denied instead).
6956        assert!(
6957            !out.contains("extra_exec"),
6958            "the model message must not carry the stale config hint: {out}"
6959        );
6960    }
6961
6962    /// #775 (§2.5) regression: the model-facing `run_command` denial is ONE
6963    /// clean level and never a denial sentence NESTED inside another. Before
6964    /// the fix, `denied_run_command_result` appended the `extra_exec` config
6965    /// hint to the reason (and `print_denied` stuffed that whole sentence into
6966    /// its bare `'{target}'` slot), yielding `capability denied: exec does not
6967    /// permit '<reason> - add it via …>'`. The model-facing return now carries
6968    /// exactly one `capability denied:`, the bare reason, and the recovery hint.
6969    #[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        // Exactly one denial prefix — never a `capability denied:` inside another.
6981        assert_eq!(
6982            out.matches("capability denied:").count(),
6983            1,
6984            "exactly one denial level: {out}"
6985        );
6986        // RED on today: the stale config hint was glued onto the model message.
6987        assert!(!out.contains("add it via"), "stale config hint: {out}");
6988        assert!(!out.contains("extra_exec"), "stale config hint: {out}");
6989        // No reason sentence nested inside a `does not permit '…'` slot.
6990        assert!(
6991            !out.contains("does not permit 'exec of"),
6992            "nested denial sentence: {out}"
6993        );
6994        // The bare reason and the #721 recovery hint are both present.
6995        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        // Mock gate ALLOWS → "granted" + the retry coaching; the gate was asked
7018        // with the parsed axis + target.
7019        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        // Mock gate DENIES → "denied" + don't-retry coaching.
7035        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        // NO gate (headless / eval) → "no operator available" — recoverable,
7046        // never a hang or a config-only dead end.
7047        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        // Unknown capability → coach listing the valid axes (no gate consulted).
7059        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        // Missing target → coach.
7068        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        // #721: a real, always-advertised tool — never an alias / hallucination.
7080        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    // -- #728 request_user_input (generic ask-the-human) --------------------
7092
7093    /// A gate that answers a free-text question with a scripted answer (or None
7094    /// for "no human"). Its grant path (`ask`) is irrelevant here — it denies.
7095    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        // A gate whose ask_question returns Some(answer) → the tool returns that
7120        // answer verbatim, and the gate was asked the exact question.
7121        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        // No gate (headless / eval / ACP) → the recoverable "no human available"
7138        // message — never a hang. (This test completing IS the no-hang proof: it
7139        // touches no real stdin.)
7140        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        // A gate present but with no human to consult (ask_question → None) →
7153        // the SAME headless message, not a hang or an empty answer.
7154        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        // Missing / blank question → coach; the gate is never consulted.
7167        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        // #728: a real, always-advertised tool — never an alias of itself or a
7184        // hallucination.
7185        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        // The always-advertised def rides in every session (empty MCP).
7195        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        // #728: the instinctive ask-the-human verbs resolve to the real tool.
7210        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        // End-to-end through the dispatcher: the question reaches the gate and
7231        // the answer flows back. Fully mocked (AskGate, no real stdin).
7232        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, // memory_source
7247            Some(&mut gate),
7248            None,
7249            None, // git_tool
7250            None, // crew_runner
7251            None, // scratchpad_store
7252            None, // code_search
7253            None, // experience_store
7254            None, // step_ledger
7255        )
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        // #727: real, always-advertised, no-arg budget read — never treated as
7264        // an alias of itself or a hallucination.
7265        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        // The always-advertised def rides in every session (empty MCP).
7275        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        // #727: the instinctive "how much context is left" reaches all resolve
7288        // to the canonical no-arg read (safe silent Rewrite — matching arg shape).
7289        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            // A Rewrite alias is mined by the #717 telemetry as a Rewrite.
7306            assert!(
7307                is_context_remaining_call(n),
7308                "{n} must be recognized as a budget call by the loop"
7309            );
7310        }
7311        // The canonical name is recognized by the loop but is NOT an alias.
7312        assert!(is_context_remaining_call("get_context_remaining"));
7313        assert!(resolve_tool_alias("get_context_remaining").is_none());
7314        // An unrelated name is neither.
7315        assert!(!is_context_remaining_call("read_file"));
7316    }
7317
7318    /// FLAG OFF (no gate): the denial is deterministic and still DENIES every
7319    /// fs op (the #263 default-deny posture is intact) — now in the #721
7320    /// recoverable form (`denied_fs_result`, carrying the request_permissions
7321    /// path), pinned via the shared helper so the wording can't drift.
7322    #[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        // #721: every fs denial now carries the model-actionable recovery path.
7377        assert!(out.contains("request_permissions"), "got: {out}");
7378    }
7379
7380    /// Gate allows an fs_read denial → the read proceeds and returns the
7381    /// real contents; the gate was consulted with the tool + axis + full
7382    /// path it would be granting.
7383    #[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    /// Gate denies → the result is the standard denial, bit-for-bit equal to
7409    /// the no-gate path (#263: deny = the current denial result).
7410    #[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    /// Gate allows fs_write denials → write_file, edit_file, and delete_file proceed.
7441    #[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    /// list_dir consults the gate on an fs_read denial like read_file does.
7497    #[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    /// A buggy/hostile gate answering Allow with caveats that STILL don't
7518    /// cover the path must not bypass enforcement: the widened authority is
7519    /// re-checked, never assumed (fs_gate_allows' re-check).
7520    #[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                // "Allow", but the caveats grant nothing at all.
7526                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, // memory_source
7558            Some(&mut gate),
7559            None,
7560            None, // git_tool
7561            None, // crew_runner
7562            None, // scratchpad_store
7563            None, // code_search
7564            None, // experience_store
7565            None, // step_ledger
7566        )
7567        .await;
7568        assert_eq!(out, denied_fs_result("fs_read", "secret.txt"));
7569    }
7570
7571    /// web_fetch with a gate: an out-of-allowlist host consults the gate
7572    /// with the parsed host; on deny the dispatch runs under the ORIGINAL
7573    /// caveats, so the leash produces today's denial (an `error:` result —
7574    /// nothing is fetched).
7575    #[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()); // net: Scope::none()
7579        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    /// Regression for the field report: github.com is outside the default net
7599    /// scope, so a TUI-provided gate must be consulted before the bridle leash
7600    /// returns the denial to the model.
7601    #[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()); // net: Scope::none()
7605        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    /// An unparseable URL skips the net pre-check entirely — the gate is
7622    /// never consulted and the dispatch (with the original caveats) answers.
7623    #[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    // -- save_note dispatch through execute_tool (Step 19.3) ----------------
7641
7642    #[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        // run_tool passes note_sink: None — the no-sink (headless) shape.
7647        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, // memory_source
7676            None,
7677            None,
7678            None, // git_tool
7679            None, // crew_runner
7680            None, // scratchpad_store
7681            None, // code_search
7682            None, // experience_store
7683            None, // step_ledger
7684        )
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    // -- recall dispatch through execute_tool (Step 17.5) -------------------
7694
7695    #[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        // run_tool passes recall_source: None — the no-store (headless) shape.
7700        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, // memory_source
7737            None,
7738            None,
7739            None, // git_tool
7740            None, // crew_runner
7741            None, // scratchpad_store
7742            None, // code_search
7743            None, // experience_store
7744            None, // step_ledger
7745        )
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    // -- memory_fetch dispatch through execute_tool (#319) ------------------
7756
7757    /// FLAG OFF (no source): a `memory_fetch` call is treated like any unknown
7758    /// tool — the inert-by-default shape (the tool was never advertised, so a
7759    /// call here is a hallucination). Mirrors `recall_without_source`.
7760    #[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        // run_tool passes memory_source: None — the no-source (headless) shape.
7765        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    /// FLAG ON (source present): a `memory_fetch` call routes through the
7777    /// injected `MemorySource` and returns its body. Mirrors
7778    /// `recall_with_source_routes_through_execute_tool`.
7779    #[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, // git_tool
7804            None, // crew_runner
7805            None, // scratchpad_store
7806            None, // code_search
7807            None, // experience_store
7808            None, // step_ledger
7809        )
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// ---------------------------------------------------------------------------
7820// INTERIM (#297) --disable-ocap / --yolo tests — the exec escape hatch.
7821// Removed with the bypass when brush upstreams CommandInterceptor
7822// (agent-bridle#20).
7823// ---------------------------------------------------------------------------
7824
7825#[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    /// Serializes every test that reads or writes `NEWT_DISABLE_OCAP` (and
7833    /// the venv vars the bypass forwards): the process environment is shared
7834    /// across the parallel test runner. Async-aware (tokio) so the guard may
7835    /// be held across the `execute_tool` awaits; no poisoning — the `EnvVar`
7836    /// guards below restore the environment even on panic.
7837    static ENV_LOCK: Mutex<()> = Mutex::const_new(());
7838
7839    async fn env_lock() -> MutexGuard<'static, ()> {
7840        ENV_LOCK.lock().await
7841    }
7842
7843    /// RAII env override: set/unset `key` for the test body, restore the
7844    /// previous value on drop — including on a failed assertion, so yolo can
7845    /// never leak into a neighboring test.
7846    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    /// Workspace-fenced fs, NO exec, NO net — the shape under which the
7875    /// confined shell denies (real build) or fails closed (stub build).
7876    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    /// #307: like [`run_tool`] but with an explicit exec FLOOR (the active
7897    /// named-permission-preset clamp). `Some(scope)` makes the `--disable-ocap`
7898    /// bypass conditional on the floor permitting the command; `None` is the
7899    /// pre-#307 behavior.
7900    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, // memory_source
7919            None,
7920            exec_floor,
7921            None, // git_tool
7922            None, // crew_runner
7923            None, // scratchpad_store
7924            None, // code_search
7925            None, // experience_store
7926            None, // step_ledger
7927        )
7928        .await
7929    }
7930
7931    /// The switch reads fail-closed: ONLY the exact value `1` (the value the
7932    /// CLI exports and the issue documents) asserts the bypass. This is also
7933    /// the env-var-equivalence half of the #297 test list — the flag and the
7934    /// env var are one mechanism (`--disable-ocap` just exports the var).
7935    #[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    /// Same fail-closed contract for the `--full-access` preset override:
7960    /// ONLY the exact value `1` asserts it (the flag and the env var are one
7961    /// mechanism — `--full-access` just exports the var).
7962    #[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    /// FLAG OFF ⇒ the command goes to the confined dispatch, which governs it.
7987    /// Built against the agent-bridle env-seam branch (#783), the bridle ships
7988    /// the REAL safe-subset shell (not the old fail-closed stub), so an
7989    /// ungranted `echo` under `exec = none` is DENIED by the L3 boundary. This
7990    /// is the "when the real shell returns" case the prior stub-build note
7991    /// anticipated; the "unavailable in this build" stub error is retired.
7992    #[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    /// FLAG ON: a command the confined shell fails closed on now runs on the
8012    /// host shell and returns its real output through the SAME envelope
8013    /// formatter (`shell_envelope_output`).
8014    #[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        // No output ⇒ the same `(exit N)` shape the bridle path produces.
8031        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    /// #726/#945: a verbose `run_command` MUST NOT flood the model's context
8042    /// window, but MUST still surface both ends of the output — a command's
8043    /// summary/failure/exit status lives at the TAIL, and #726's original
8044    /// head-only cap silently dropped exactly that (the gap #945 closed). Runs
8045    /// through the real host shell (yolo path) so it exercises the actual
8046    /// `shell_envelope_output` → cap composition. The global budget is the
8047    /// default 10k in the test binary (nothing raises it above default), so
8048    /// the assertions are upper-bounded and robust regardless of a smaller
8049    /// racing value. This test goes through the legacy `execute_tool` path
8050    /// (`run_tool`/`run_tool_with_floor` below), which has no spill store —
8051    /// the no-spill-id elision marker branch, not the `spill:<id>` one.
8052    #[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        // ~350k chars of output — well over the default ~40k-char budget.
8060        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        // #945: the HEAD survives — the earliest lines are still visible.
8078        assert!(
8079            out.starts_with("1\n2\n3\n"),
8080            "head preserved: {:?}",
8081            &out[..out.len().min(160)]
8082        );
8083        // #945 (the regression this test now guards): the TAIL survives too —
8084        // under the old head-only cap this was the first assertion to break
8085        // (it asserted the OPPOSITE: `!out.contains("60000")`).
8086        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    // --- #307 floor property: preset clamp WINS over --disable-ocap -------
8094
8095    /// Unit-cover every branch of the bypass-floor predicate.
8096    #[test]
8097    fn exec_floor_permits_covers_each_branch() {
8098        use crate::caveats::Scope;
8099        // No floor ⇒ always permit (bit-for-bit pre-#307).
8100        assert!(exec_floor_permits(None, "rm -rf /"));
8101        // Empty command ⇒ let it through to the normal path.
8102        let only_echo = Scope::only(["echo".to_string()]);
8103        assert!(exec_floor_permits(Some(&only_echo), ""));
8104        // In-floor simple command ⇒ permitted.
8105        assert!(exec_floor_permits(Some(&only_echo), "echo hi"));
8106        // Out-of-floor program ⇒ refused.
8107        assert!(!exec_floor_permits(Some(&only_echo), "rm hi"));
8108        // Compound command ⇒ refused even with an allow-listed leading token.
8109        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        // `Scope::All` floor permits any simple command.
8113        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    /// ADVERSARIAL PROBE (review #312): exhaustively attack `exec_floor_permits`
8119    /// with EVERY shell injection / compound form so the floor is proven against
8120    /// more than just `&&`. An `echo`-only floor must refuse to bypass for any
8121    /// form that could chain or substitute a second program.
8122    #[test]
8123    fn exec_floor_refuses_every_metacharacter_form() {
8124        use crate::caveats::Scope;
8125        let echo = Scope::only(["echo".to_string()]);
8126        // Each of these begins with the allow-listed `echo` but smuggles or
8127        // could smuggle a second program. None may bypass.
8128        let attacks = [
8129            "echo ok && rm -rf /tmp/x", // && and
8130            "echo ok || rm -rf /tmp/x", // || or
8131            "echo ok ; rm -rf /tmp/x",  // ; sequence
8132            "echo ok | sh",             // | pipe
8133            "echo ok|sh",               // | no spaces
8134            "echo $(rm x)",             // $() command substitution
8135            "echo ${IFS}rm",            // ${} parameter expansion
8136            "echo `rm x`",              // backtick substitution
8137            "echo ok & rm x",           // & background
8138            "echo ok > /etc/passwd",    // > redirect out
8139            "echo ok >> /etc/passwd",   // >> append
8140            "echo < /etc/shadow",       // < redirect in
8141            "echo ok 2> err",           // 2> fd redirect (contains >)
8142            "(rm x)",                   // ( subshell
8143            "echo ok\nrm -rf /tmp/x",   // newline-separated
8144            "echo ok\nrm x\n",          // trailing newline
8145        ];
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        // Forms with NO shell metacharacter that should still be refused because
8153        // the LEADING TOKEN is not the allow-listed program:
8154        let leading_token_attacks = [
8155            "rm -rf /tmp/x", // plain out-of-floor program
8156            "FOO=bar rm x",  // env-prefix: leading token `FOO=bar` ∉ floor
8157            "/bin/echo ok",  // path form: `/bin/echo` ≠ `echo` (exact match)
8158            "  rm x",        // leading whitespace, still `rm`
8159            "env rm x",      // `env` wrapper, leading token `env` ∉ floor
8160            "bash -c rm",    // `bash` ∉ floor
8161        ];
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        // Sanity: a bare in-floor command with only a benign arg DOES bypass —
8169        // the floor is a ceiling, not a blanket off-switch. (A dangerous arg to
8170        // a permitted program is the user's accepted risk: they allow-listed it.)
8171        assert!(exec_floor_permits(Some(&echo), "echo hello world"));
8172        assert!(exec_floor_permits(Some(&echo), "echo -n trailing"));
8173    }
8174
8175    /// FLOOR TEST (a) — the security contract: with `--disable-ocap` asserted,
8176    /// an exec FLOOR that denies the command must STOP the unconfined bypass.
8177    /// `echo` is outside a readonly floor (`exec = none`), so even with yolo on
8178    /// it does NOT run on the host shell — it falls through to the confined
8179    /// dispatch, which (env-seam real shell) DENIES it. A deliberately
8180    /// restricted triage mode is NOT un-clamped by `--yolo`.
8181    #[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        // A readonly-triage preset clamp: exec denies everything.
8188        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        // The bypass did NOT fire: the command never reached the host shell, so
8202        // it fell to the confined dispatch and was denied, not `should-not-run\n`.
8203        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    /// FLOOR TEST (a, positive) — a command INSIDE the floor still takes the
8211    /// fast unconfined path under `--disable-ocap`. The floor is a ceiling, not
8212    /// a blanket off-switch: an explicitly allow-listed command runs.
8213    #[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        // A triage preset that allow-lists `echo`.
8221        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    /// FLOOR conservatism — a COMPOUND command never bypasses under an active
8239    /// floor, even if its leading token is allow-listed: `echo ok && rm -rf /`
8240    /// must not smuggle `rm` past an `echo` grant. It falls to the confined
8241    /// shell (env-seam real shell ⇒ denied), which gates each spawn.
8242    #[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        // `echo` is allow-listed, but the `&&` chains an unlisted `rm`.
8249        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        // Compound ⇒ never bypasses; it falls to the confined shell, which
8265        // (env-seam real shell) denies the ungranted command under `exec = none`.
8266        assert!(
8267            out.contains("capability denied"),
8268            "fell to confined dispatch and was denied, got: {out}"
8269        );
8270    }
8271
8272    /// FLOOR TEST (c) — `None` floor is bit-for-bit the pre-#307 bypass: a
8273    /// denied-by-caveats command still runs unconfined under `--disable-ocap`,
8274    /// proving the floor is opt-in and the no-preset case is unchanged.
8275    #[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    /// Envelope parity (#297): the host-shell envelope is structurally
8294    /// identical to the bridle one — `exit_code` / `stdout` / `stderr` /
8295    /// `sandbox_kind`, `denied`/`denials` omitted (⇒ not denied) — so the
8296    /// existing envelope readers apply unchanged.
8297    #[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        // Omitted exactly as the bridle envelope omits them on the
8312        // nothing-was-denied path — `envelope_denied` reads it natively.
8313        assert!(envelope.get("denied").is_none(), "got: {envelope}");
8314        assert!(envelope.get("denials").is_none(), "got: {envelope}");
8315        assert!(!envelope_denied(&envelope));
8316        // And the shared formatter renders it like any confined result.
8317        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        // This is what macOS/BSD `cat -v` emits for "─ —\n" in a UTF-8
8332        // locale: the leading e2 byte is raw, while continuation bytes are
8333        // rendered as M-^T/M-^@ etc. A lossy decode would display
8334        // "�M-^TM-^@ �M-^@M-^T".
8335        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        // "é" is c3 a9; BSD `cat -v` leaves c3 raw and renders a9 as M-).
8342        let cat_v = b"caf\xc3M-)\n";
8343        assert_eq!(decode_shell_stream(cat_v), "café\n");
8344    }
8345
8346    /// The venv/PATH prefix logic rides the HOST-BYPASS path unchanged: the
8347    /// `export VIRTUAL_ENV=…; export PATH=…;` prefix is prepended to the
8348    /// `--yolo` command, which runs on a real `/bin/sh` where `export` works.
8349    /// (The confined path no longer gets the prefix — it uses the env seam;
8350    /// see `confined_dispatch_uses_env_seam_not_export_prefix_783`.)
8351    #[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    /// In --yolo mode an unrestricted fs mutation prompt must not read EOF as
8372    /// a human decline. The flag is already an explicit interactive override,
8373    /// so final write/delete confirms auto-accept instead of auto-skipping.
8374    #[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    /// Non-yolo unrestricted fs mutations still ask, but through the
8409    /// PermissionGate question seam. In the TUI that seam owns
8410    /// PromptStdinGuard, so cbreak/VMIN=0 stdin cannot auto-answer "not y".
8411    #[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, // memory_source
8448            Some(&mut gate),
8449            None,
8450            None, // git_tool
8451            None, // crew_runner
8452            None, // scratchpad_store
8453            None, // code_search
8454            None, // experience_store
8455            None, // step_ledger
8456        )
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, // memory_source
8476            Some(&mut gate),
8477            None,
8478            None, // git_tool
8479            None, // crew_runner
8480            None, // scratchpad_store
8481            None, // code_search
8482            None, // experience_store
8483            None, // step_ledger
8484        )
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    /// #783 regression (Bug A): the confined-shell dispatch carries the RAW
8498    /// user command and the venv via agent-bridle's structured `env` seam — NOT
8499    /// an `export …;` prefix on `cmd`. The old code built
8500    /// `{ "cmd": cmd_with_venv, "cwd": … }` (the prefixed form), and the
8501    /// confined safe-subset engine refuses an `export` builtin on a compound
8502    /// command, which is the bug. Pure: builds the dispatch args only, no spawn.
8503    #[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        // The literal failing case from #783.
8511        let cmd = "hostname; sw_vers 2>/dev/null | head -1; uname -s";
8512        let args = confined_dispatch_args(cmd, "/work/dir");
8513
8514        // The command is passed RAW — no `export …;` prefix smuggled in.
8515        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        // The venv rides the env seam: VIRTUAL_ENV + venv bin prepended to PATH.
8526        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    /// #783: with neither venv input set, the env seam is empty (no spurious
8535    /// VIRTUAL_ENV / PATH keys) — the no-venv invocation is unaffected.
8536    #[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"); // ⇒ default HOME+USER
8543        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        // #783: without a venv, no VIRTUAL_ENV / PATH override is injected...
8549        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        // ...but HOME now passes through so brush can expand `~` (the confined
8558        // shell had NO env before, so `~` stayed literal and left `~/…` debris),
8559        // and SHELL identifies the confined engine.
8560        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    /// fs fence under yolo (#297): the newt-native workspace fence is NOT
8571    /// bypassed — a write/read outside the granted scope keeps the standard
8572    /// denial bit-for-bit. Yolo is unconfined exec, never authority-off.
8573    #[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    /// Precedence (#297): with both `--disable-ocap` and a #263 gate present,
8610    /// exec never prompts — nothing is denied, so the gate is structurally
8611    /// unreachable for run_command. (fs prompting stays live; the fs-fence
8612    /// test above and the #263 suite cover that axis.)
8613    #[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, // memory_source
8642            Some(&mut gate),
8643            None,
8644            None, // git_tool
8645            None, // crew_runner
8646            None, // scratchpad_store
8647            None, // code_search
8648            None, // experience_store
8649            None, // step_ledger
8650        )
8651        .await;
8652        assert_eq!(out, "no-prompt\n");
8653    }
8654
8655    /// The corrective tool-name guard still answers BEFORE the bypass: yolo
8656    /// changes where commands run, not what counts as a command.
8657    #[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    // --- facade P4 (#780): hidden tool-call routing dispatch ---------------
8674
8675    /// A git stub that proves *which path served the call*: a routed
8676    /// `git status` lands here as op `status`; a routed write op would surface
8677    /// the unexpected-op error (so a test can assert it was NOT routed).
8678    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, // memory_source
8706            None, // permission_gate
8707            None, // exec_floor
8708            Some(&RoutingStubGit as &dyn crate::agentic::GitTool),
8709            None, // crew_runner
8710            None, // scratchpad_store
8711            None, // code_search
8712            None, // experience_store
8713            None, // step_ledger
8714        )
8715        .await
8716    }
8717
8718    /// The routing switch reads fail-closed (only the exact `1`), and it is a
8719    /// DISTINCT mechanism from `ocap_disabled` (§7-F5): asserting `NEWT_NO_ROUTE`
8720    /// never moves `ocap_disabled`, and asserting `NEWT_DISABLE_OCAP` never moves
8721    /// `routing_disabled`. The two switches can never alias.
8722    #[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            // F5: turning routing off NEVER turns on the L3-off unconfine.
8734            assert!(
8735                !ocap_disabled(),
8736                "NEWT_NO_ROUTE must not imply --disable-ocap"
8737            );
8738        }
8739        // And the inverse: --disable-ocap must not imply --no-route.
8740        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    /// TDD: a routed read goes through the SAME fs floor — routing is NOT a
8750    /// bypass. An out-of-scope `cat /etc/shadow` routes to `read_file` and is
8751    /// denied by `fs_read` exactly as a direct `read_file` would be (the denial
8752    /// short-circuits before any real fs access). The `fs_read` denial wording
8753    /// also proves it reached the `read_file` arm (vs. the exec/shell path).
8754    #[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()); // fs_read scoped to ws only
8761        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    /// TDD: read-only `git status` is silently routed to the governed `git`
8776    /// built-in (the stub proves the built-in served it). Revert the routing
8777    /// promotion and this is red — the command would instead hit the run_command
8778    /// corrective guard.
8779    #[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    /// #1022: `run_command("rm file")` routes to the governed delete_file arm,
8794    /// so deletion works under fs_write without requiring raw shell `rm`.
8795    #[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    /// TDD: state-modifying `git add` is GATED as exec — NOT silently routed
8818    /// (owner decision 2). It never reaches the git built-in (no unexpected-op
8819    /// error from the stub); it falls through to the normal run_command path.
8820    #[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        // It falls through to the run_command path (git ∈ DIRECT_TOOL_NAMES ⇒
8833        // the existing corrective guard), never silently routed.
8834        assert!(out.contains("is a tool, not a shell command"), "got: {out}");
8835    }
8836
8837    /// F5 (§7-F5): `--no-route` bypasses routing but NEVER disables L3. With
8838    /// `NEWT_NO_ROUTE=1`, the same out-of-bounds `cat` is no longer routed to
8839    /// `read_file` (no `fs_read` denial), yet it does NOT run unconfined — it
8840    /// falls to the confined shell (env-seam real shell ⇒ denied), and
8841    /// `ocap_disabled()` stays false. The boundary holds.
8842    #[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        // Routing was OFF ⇒ NOT rewritten to read_file (no fs_read denial)…
8858        assert!(
8859            !out.contains("fs_read does not permit"),
8860            "--no-route must not route to read_file; got: {out}"
8861        );
8862        // …and the command did NOT run unconfined: it took the confined shell
8863        // (env-seam real shell ⇒ denied — the L3 boundary held).
8864        assert!(
8865            out.contains("capability denied"),
8866            "the L3 confined dispatch must still gate the command; got: {out}"
8867        );
8868    }
8869}