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::spill::{self, SpillStore};
14use crate::caveats::CaveatsExt as _;
15use std::sync::atomic::{AtomicUsize, Ordering};
16use std::sync::LazyLock;
17
18/// #719: default line window for `read_file`'s **model-facing** payload. The
19/// on-screen display is capped separately; this bounds what enters the model's
20/// context, so one read of a 15k-line file (e.g. `newt-tui/src/lib.rs`) can no
21/// longer saturate a small local model's window and abandon the task.
22const DEFAULT_READ_LIMIT: usize = 2_000;
23
24/// #726: default token budget for any tool's **model-facing** payload, mirroring
25/// Codex's `exec_command.max_output_tokens` (default 10k). One shared budget
26/// caps BOTH `read_file` (via [`paginate_read`]'s char backstop) and
27/// `run_command` (via [`cap_model_output`] around the shell envelope), so a
28/// verbose command can no longer flood the window — the same failure mode #719
29/// closed for `read_file`. Overridable by `[tools] max_output_tokens` in config;
30/// see [`set_max_output_tokens`].
31const DEFAULT_MAX_OUTPUT_TOKENS: usize = 10_000;
32const DEFAULT_OUTPUT_HEAD_TOKENS: usize = 1_500;
33
34/// Process-wide model-facing output budget, in tokens. Defaults to
35/// [`DEFAULT_MAX_OUTPUT_TOKENS`]; the resolved `[tools] max_output_tokens`
36/// config value is pushed here once at the config-resolution entry
37/// (`Config::resolve`) so the tool loop never re-reads config from disk. This is
38/// the v1 (three-Cs "working code first") seam: a const default with the config
39/// override wired at the entry, rather than threading a new `usize` through
40/// `ChatCtx` + `execute_tool` + every call site (≈60, mostly tests). Follow-up:
41/// thread it per-session like `tool_output_lines` once warranted.
42static MAX_OUTPUT_TOKENS: AtomicUsize = AtomicUsize::new(DEFAULT_MAX_OUTPUT_TOKENS);
43static OUTPUT_HEAD_TOKENS: AtomicUsize = AtomicUsize::new(DEFAULT_OUTPUT_HEAD_TOKENS);
44
45/// Set the process-wide model-facing output budget (tokens). Called once from
46/// `Config::resolve` with the resolved `[tools] max_output_tokens`. `0` means
47/// "no cap" — see [`cap_model_output`] / [`paginate_read`].
48pub fn set_max_output_tokens(max_tokens: usize) {
49    MAX_OUTPUT_TOKENS.store(max_tokens, Ordering::Relaxed);
50}
51
52/// Set the head allocation for oversized `run_command` output. The tail gets
53/// the remaining budget. `0` means pure-tail; values greater than the max output
54/// budget are clamped by [`cap_model_output`].
55pub fn set_output_head_tokens(head_tokens: usize) {
56    OUTPUT_HEAD_TOKENS.store(head_tokens, Ordering::Relaxed);
57}
58
59/// The active model-facing output budget (tokens). [`DEFAULT_MAX_OUTPUT_TOKENS`]
60/// until [`set_max_output_tokens`] overrides it.
61fn max_output_tokens() -> usize {
62    MAX_OUTPUT_TOKENS.load(Ordering::Relaxed)
63}
64
65fn output_head_tokens() -> usize {
66    OUTPUT_HEAD_TOKENS.load(Ordering::Relaxed)
67}
68
69/// #726/#945: cap a tool's **model-facing** output to `max_tokens`' worth of
70/// chars, estimated with the default chars/token heuristic
71/// ([`crate::tokens::TokenEstimation`], 4 chars/token — the same constant the
72/// context estimator uses). Oversized output is rendered as head+tail rather
73/// than head-only so command summaries and failures at the end survive. A small
74/// output (or `max_tokens == 0`, meaning no cap) passes through verbatim. Pure
75/// (no fs / no global) — unit-tested directly.
76fn cap_model_output(text: &str, max_tokens: usize) -> String {
77    cap_model_output_with_handle(text, max_tokens, output_head_tokens(), None)
78}
79
80fn cap_model_output_with_handle(
81    text: &str,
82    max_tokens: usize,
83    head_tokens: usize,
84    spill_id: Option<&str>,
85) -> String {
86    if max_tokens == 0 {
87        return text.to_string();
88    }
89    let est = crate::tokens::TokenEstimation::default();
90    if est.tokens_for_chars(text.len()) <= max_tokens {
91        return text.to_string();
92    }
93    let max_chars = est.chars_for_tokens(max_tokens);
94    let head_tokens = head_tokens.min(max_tokens);
95    let head_chars = est.chars_for_tokens(head_tokens).min(max_chars);
96    let tail_chars = max_chars.saturating_sub(head_chars);
97    let total_chars = text.chars().count();
98    let shown_chars = head_chars.saturating_add(tail_chars).min(total_chars);
99    let elided = total_chars.saturating_sub(shown_chars);
100    let head = take_chars(text, head_chars);
101    let tail = take_tail_chars(text, tail_chars);
102    let marker = match spill_id {
103        Some(id) => format!(
104            "[… {elided} chars elided (head+tail shown). Full output: \
105             memory_fetch(\"spill:{id}\"); search it with \
106             memory_fetch(\"spill:{id}\", grep=\"<pattern>\") …]"
107        ),
108        None => format!(
109            "[… {elided} chars elided (head+tail shown; ~{max_tokens} token budget). \
110             Narrow the command or use a more specific grep/filter if needed …]"
111        ),
112    };
113    format!("{head}\n\n{marker}\n\n{tail}")
114}
115
116fn take_chars(text: &str, max_chars: usize) -> String {
117    text.chars().take(max_chars).collect()
118}
119
120fn take_tail_chars(text: &str, max_chars: usize) -> String {
121    let mut chars: Vec<char> = text.chars().rev().take(max_chars).collect();
122    chars.reverse();
123    chars.into_iter().collect()
124}
125
126/// Window + cap a file's contents for `read_file`'s model-facing payload (#719,
127/// #726). Returns lines `[offset, offset+limit)` (1-based `offset`, default 1;
128/// `limit` default [`DEFAULT_READ_LIMIT`]), with the char backstop derived from
129/// the shared token budget (`max_output_tokens` × chars/token — #726, replacing
130/// #719's hardcoded 100k so both tools share one budget). A footer points at the
131/// next window so the model paginates instead of drowning. A whole-file read
132/// that fits both caps is returned verbatim (exact bytes). `max_output_tokens ==
133/// 0` disables the char backstop (only the line window applies). Pure (no fs) —
134/// unit-tested directly.
135fn paginate_read(
136    contents: &str,
137    offset: Option<usize>,
138    limit: Option<usize>,
139    max_output_tokens: usize,
140) -> String {
141    let max_chars = if max_output_tokens == 0 {
142        usize::MAX
143    } else {
144        crate::tokens::TokenEstimation::default().chars_for_tokens(max_output_tokens)
145    };
146    let total = contents.lines().count();
147    let start = offset.filter(|&o| o > 0).unwrap_or(1); // 1-based
148    let limit = limit.filter(|&l| l > 0).unwrap_or(DEFAULT_READ_LIMIT);
149    // Common case: a whole-file read that fits both caps → return verbatim.
150    if start == 1 && limit >= total && contents.len() <= max_chars {
151        return contents.to_string();
152    }
153    let start0 = start - 1;
154    if start0 >= total {
155        return format!("(offset {start} is past end of file — {total} lines total)");
156    }
157    let window: Vec<&str> = contents.lines().skip(start0).take(limit).collect();
158    let end = start0 + window.len(); // 1-based last line shown == end
159    let mut body = window.join("\n");
160    let char_capped = body.len() > max_chars;
161    if char_capped {
162        let mut cut = max_chars;
163        while cut > 0 && !body.is_char_boundary(cut) {
164            cut -= 1;
165        }
166        body.truncate(cut);
167    }
168    let footer = if char_capped {
169        Some(format!(
170            "payload truncated to {max_chars} chars (~{max_output_tokens} tokens) from line \
171             {start}; call read_file with a higher offset (and/or smaller limit) to continue"
172        ))
173    } else if end < total {
174        Some(format!(
175            "showing lines {start}-{end} of {total}; \
176             call read_file with offset={} to continue",
177            end + 1
178        ))
179    } else {
180        None
181    };
182    match footer {
183        Some(f) => format!("{body}\n\n[{f}]"),
184        None => body,
185    }
186}
187
188pub fn tool_definitions() -> serde_json::Value {
189    serde_json::json!([
190        {
191            "type": "function",
192            "function": {
193                "name": "run_command",
194                "description": "Run a shell command in the workspace directory and return its output",
195                "parameters": {
196                    "type": "object",
197                    "properties": {
198                        "command": { "type": "string", "description": "The shell command to run" }
199                    },
200                    "required": ["command"]
201                }
202            }
203        },
204        {
205            "type": "function",
206            "function": {
207                "name": "read_file",
208                "description": "Read a file in the workspace. Returns up to `limit` lines \
209                                (default 2000) starting at 1-based `offset` (default 1). Large \
210                                files come back with a footer pointing at the next window — read \
211                                them in pages with offset/limit rather than all at once, or the \
212                                full file can saturate the context window.",
213                "parameters": {
214                    "type": "object",
215                    "properties": {
216                        "path": { "type": "string", "description": "File path relative to workspace root" },
217                        "offset": { "type": "integer", "description": "1-based line number to start at (default 1)" },
218                        "limit": { "type": "integer", "description": "Maximum number of lines to return (default 2000)" }
219                    },
220                    "required": ["path"]
221                }
222            }
223        },
224        {
225            "type": "function",
226            "function": {
227                "name": "write_file",
228                "description": "Write or overwrite a file in the workspace. \
229                                WARNING: use edit_file instead when modifying an existing file — \
230                                write_file replaces the entire contents and will fail if the new \
231                                content is significantly shorter than the original (shrink guard). \
232                                Only use write_file for new files or full rewrites you have \
233                                explicitly generated in their entirety.",
234                "parameters": {
235                    "type": "object",
236                    "properties": {
237                        "path": { "type": "string", "description": "File path relative to workspace root" },
238                        "content": { "type": "string", "description": "The complete new file contents" }
239                    },
240                    "required": ["path", "content"]
241                }
242            }
243        },
244        {
245            "type": "function",
246            "function": {
247                "name": "edit_file",
248                "description": "Make a targeted edit to an existing file by replacing one exact \
249                                string with another. Safer than write_file for modifying existing \
250                                files — you only generate the change, not the whole file. \
251                                Fails with a clear error if old_string is not found or matches \
252                                multiple times (add more surrounding context to make it unique).",
253                "parameters": {
254                    "type": "object",
255                    "properties": {
256                        "path": { "type": "string", "description": "File path relative to workspace root" },
257                        "old_string": { "type": "string", "description": "Exact string to find and replace (must match exactly once)" },
258                        "new_string": { "type": "string", "description": "Replacement string" }
259                    },
260                    "required": ["path", "old_string", "new_string"]
261                }
262            }
263        },
264        {
265            "type": "function",
266            "function": {
267                "name": "list_dir",
268                "description": "List files in a directory",
269                "parameters": {
270                    "type": "object",
271                    "properties": {
272                        "path": { "type": "string", "description": "Directory path relative to workspace root (use '.' for root)" }
273                    },
274                    "required": ["path"]
275                }
276            }
277        },
278        {
279            "type": "function",
280            "function": {
281                "name": "find",
282                "description": "Find files and directories by name under the workspace, recursively, WITHOUT a shell (use this instead of the `find` shell command). Returns matching paths relative to the workspace root, one per line, already sorted — no need to pipe to `sort`. Respects .gitignore and skips noise (.git, target, node_modules) by default.",
283                "parameters": {
284                    "type": "object",
285                    "properties": {
286                        "path": { "type": "string", "description": "Directory to search under, relative to workspace root. Default '.' (the whole workspace)." },
287                        "name": { "type": "string", "description": "Glob matched against each entry's basename, e.g. '*.py' or 'pyo3_module.rs'. '*' matches any run, '?' any single char. Omit to match everything." },
288                        "type": { "type": "string", "enum": ["f", "d", "any"], "description": "Restrict to files ('f'), directories ('d'), or both ('any', the default)." },
289                        "max_depth": { "type": "integer", "description": "Maximum directory depth below `path` (1 = immediate children only). Omit for unlimited." },
290                        "max_results": { "type": "integer", "description": "Cap on the number of matches returned. Default 1000; output notes when truncated." },
291                        "respect_gitignore": { "type": "boolean", "description": "When true (default) skip .gitignored paths plus .git/target/node_modules/hidden dirs. Set false to search everything." },
292                        "case_sensitive": { "type": "boolean", "description": "Case-sensitive basename match. Default true." }
293                    },
294                    "required": []
295                }
296            }
297        },
298        {
299            "type": "function",
300            "function": {
301                "name": "use_skill",
302                "description": "Load a skill's full procedural instructions on demand. The system prompt lists the available skills (name + description); call this with a skill's name to get its complete SKILL.md body plus the paths of any bundled files (scripts/templates) you can read or run.",
303                "parameters": {
304                    "type": "object",
305                    "properties": {
306                        "name": { "type": "string", "description": "The skill name as shown in the 'Available skills' index" }
307                    },
308                    "required": ["name"]
309                }
310            }
311        },
312        {
313            "type": "function",
314            "function": {
315                "name": "web_fetch",
316                "description": "Fetch an http(s) URL and return its main content as clean markdown. Use this to read documentation, issues, or pages the task references. Reachable hosts are gated by the session's network capability; the returned text is untrusted page content.",
317                "parameters": {
318                    "type": "object",
319                    "properties": {
320                        "url": { "type": "string", "description": "The http(s) URL to fetch" },
321                        "max_bytes": { "type": "integer", "description": "Optional cap on bytes downloaded (default 5 MiB, max 25 MiB)" }
322                    },
323                    "required": ["url"]
324                }
325            }
326        },
327        {
328            "type": "function",
329            "function": {
330                "name": "request_permissions",
331                "description": "Ask the operator to GRANT a capability you were denied — the \
332                                capability-grant path (#721). Call this AFTER a `capability denied` \
333                                result to request authority you don't currently have. If an operator \
334                                is present they may allow it; then retry the original operation. In a \
335                                headless session (no operator) you'll be told the capability must be \
336                                configured by the owner — change approach. This requests AUTHORITY \
337                                (it mints a capability grant); it is NOT a way to ask the user a \
338                                free-text question.",
339                "parameters": {
340                    "type": "object",
341                    "properties": {
342                        "capability": { "type": "string", "enum": ["exec", "fs_read", "fs_write", "net"], "description": "Which capability axis to request" },
343                        "target": { "type": "string", "description": "What to grant: a command name (exec), a path (fs_read/fs_write), or a host (net)" },
344                        "reason": { "type": "string", "description": "Why you need it — shown to the operator deciding" }
345                    },
346                    "required": ["capability", "target", "reason"]
347                }
348            }
349        }
350    ])
351}
352
353/// #728: always-advertised `request_user_input` tool definition — the GENERIC
354/// ask-the-human primitive. Pushed unconditionally in [`merged_tool_definitions`]
355/// like `resume_context` / `tool_search` / `get_context_remaining`: a model must
356/// always be able to ask the human a question, and it degrades honestly headless
357/// (the executor answers "no human available this session" when no interactive
358/// gate is present). Question-only for v1 — the multiple-choice `options` hint is
359/// deferred so no advertised arg is left unused.
360fn request_user_input_tool_definition() -> serde_json::Value {
361    serde_json::json!({
362        "type": "function",
363        "function": {
364            "name": "request_user_input",
365            "description": "Ask the human operator a free-text question and get \
366                            their answer. Use this to resolve genuine ambiguity \
367                            instead of guessing or narrating (e.g. 'which database \
368                            should I target?', 'is this the file you meant?'). This \
369                            asks for INFORMATION, not authority — to request a \
370                            capability you were denied, use request_permissions \
371                            instead. In a headless session (no operator) you'll be \
372                            told no human is available — then proceed with your best \
373                            judgment and state your assumption explicitly.",
374            "parameters": {
375                "type": "object",
376                "properties": {
377                    "question": { "type": "string", "description": "The free-text question to ask the human" }
378                },
379                "required": ["question"]
380            }
381        }
382    })
383}
384
385/// The `lifecycle` tool (#891) — the model-facing surface over the data-driven
386/// lifecycle system (#880). Instead of guessing a raw shell command, the model
387/// names a phase and newt runs THIS repo's resolved command for it
388/// ([`crate::tooling::resolved_phase_commands`]): the `.newt/config.toml`
389/// `[lifecycle]` override, else the matching tooling packs (Rust / Python /
390/// PyO3 / Go / drop-in / custom). Advertised ALWAYS — like `resume_context`,
391/// it degrades honestly (a phase with no configured command returns a clear
392/// "no command configured", not an error), so it needs no presence gate. The
393/// phase-name enum is built from [`crate::tooling::Phase::ALL`] so the schema
394/// can never drift from the vocabulary.
395pub fn lifecycle_tool_definition() -> serde_json::Value {
396    let phases: Vec<&str> = crate::tooling::Phase::ALL
397        .iter()
398        .map(|p| p.as_str())
399        .collect();
400    serde_json::json!({
401        "type": "function",
402        "function": {
403            "name": "lifecycle",
404            "description": "Run a named project lifecycle phase using THIS repo's \
405                configured command instead of guessing a raw shell command. \
406                Phases: setup (resolve deps / prepare a checkout), format \
407                (auto-format the tree), lint (static analysis), test (run the \
408                tests), check (the full gate a change must pass), clean (remove \
409                build artifacts). The command is resolved from \
410                .newt/config.toml [lifecycle] and the repo's tooling packs \
411                (Rust / Python / PyO3 / Go / custom), so `check` runs the RIGHT \
412                gate for this project. Prefer this over run_command for build / \
413                test / format / lint / check work so the project's own \
414                conventions are honored uniformly across build systems. Use \
415                action=list to see the resolved command without running it.",
416            "parameters": {
417                "type": "object",
418                "properties": {
419                    "phase": {
420                        "type": "string",
421                        "enum": phases,
422                        "description": "The lifecycle phase to run."
423                    },
424                    "action": {
425                        "type": "string",
426                        "enum": ["run", "list"],
427                        "description": "run (default) executes the phase's resolved command; \
428                                        list returns the command without running it."
429                    }
430                },
431                "required": ["phase"]
432            }
433        }
434    })
435}
436
437/// The built-in tool definitions plus every connected MCP server's tools
438/// (namespaced `server__tool`). This is what the agent loop advertises to the
439/// model so it can call remote MCP tools alongside the built-ins.
440///
441/// `with_save_note` adds the `save_note` definition (Step 19.3) — true only
442/// when the caller supplied a `NoteSink`, so headless/eval sessions (which
443/// pass `note_sink: None`) never advertise a tool that can't be executed.
444/// `with_recall` gates the `recall` definition (Step 17.5) the same way on
445/// a supplied `RecallSource`. `with_memory_fetch` gates the `memory_fetch`
446/// definition (progressive-disclosure memory, Workstream A MVP, #319) the same
447/// way on a supplied `MemorySource` — `None` ⇒ the tool is never advertised,
448/// so eval / headless / ACP sessions are unaffected bit-for-bit.
449#[allow(clippy::too_many_arguments)] // presence-gated tool advertisers (one bool each)
450pub(crate) fn merged_tool_definitions(
451    mcp: &dyn McpTools,
452    with_save_note: bool,
453    with_recall: bool,
454    with_memory_fetch: bool,
455    with_git: bool,
456    with_team: bool,
457    with_scratchpad: bool,
458    with_code_search: bool,
459    with_experiential: bool,
460    with_scheduled: bool,
461) -> serde_json::Value {
462    let mut defs = match tool_definitions() {
463        serde_json::Value::Array(a) => a,
464        other => vec![other],
465    };
466    // #894: everything after the base array is [`EXTENDED_TOOL_REGISTRY`],
467    // advertised in declaration order when its gate is satisfied. The always-on
468    // tools (resume_context #714 / tool_search #725 / get_context_remaining #727
469    // / request_user_input #728 / lifecycle #891) carry `Gate::Always` and ride
470    // every session, degrading honestly when their backing source is `None`; the
471    // rest are gated on the matching injected `with_*` capability (git PR4,
472    // compose_roster+crew #479, scratchpad #583, code_search #582, experiential
473    // #585, scheduled #586/#715/#716). Adding a tool is one `ToolSpec`, which
474    // updates this listing AND `ALL_TOOL_NAMES` together — no more drift.
475    for spec in EXTENDED_TOOL_REGISTRY {
476        if gate_satisfied(
477            spec.gate,
478            with_save_note,
479            with_recall,
480            with_memory_fetch,
481            with_git,
482            with_team,
483            with_scratchpad,
484            with_code_search,
485            with_experiential,
486            with_scheduled,
487        ) {
488            defs.push((spec.definition)());
489        }
490    }
491    defs.extend(mcp.tool_defs());
492    serde_json::Value::Array(defs)
493}
494
495/// Direct tool names the model must call as tool invocations, never as shell
496/// commands passed to `run_command`.
497const DIRECT_TOOL_NAMES: &[&str] = &[
498    "list_dir",
499    "read_file",
500    "write_file",
501    "edit_file",
502    "use_skill",
503    "web_fetch",
504    // #496: `find …` typed at run_command redirects to the embedded `find`
505    // tool — which works even when the shell is unavailable in this build.
506    "find",
507    // PR4: `git …` typed at run_command redirects to the embedded `git` tool —
508    // but ONLY its LOCAL ops; the network ops fall through (see
509    // [`GIT_NETWORK_SUBCOMMANDS`] / [`run_command_redirect`], #898).
510    "git",
511];
512
513/// #898: git subcommands that reach the network. The embedded `git` tool
514/// (newt-git) is LOCAL-ONLY — `clone`/`fetch`/`push` are deferred — so if
515/// run_command bounced *every* `git …` back to that pushless tool, a model
516/// could never push a branch (and then never see the "Create a pull request …
517/// by visiting: <URL>" line git prints, and never open a PR — issue #898).
518/// These ops are therefore allowed to fall through to the confined shell, where
519/// the `net` caveat still gates whether the remote is reachable. Local ops
520/// (`status`/`log`/`diff`/`add`/`commit`/…) keep redirecting to the embedded
521/// tool, which does them better and works even when the shell is unavailable.
522const GIT_NETWORK_SUBCOMMANDS: &[&str] = &["push", "fetch", "pull", "clone"];
523
524/// Decide whether a `run_command` invocation is really a misdirected call to a
525/// direct tool (`list_dir`/`read_file`/…/`git`), and if so which one — so the
526/// executor can bounce it with a correction and [`is_hallucination`] can count
527/// it. Returns `None` when the command should run in the shell as-is.
528///
529/// `git` is special (#898): only its LOCAL ops redirect to the embedded git
530/// tool; its network ops ([`GIT_NETWORK_SUBCOMMANDS`]) fall through so the model
531/// can actually push a branch and read the PR-creation URL git prints.
532fn run_command_redirect(command: &str) -> Option<&'static str> {
533    let mut tokens = command.split_ascii_whitespace();
534    let first = tokens.next().unwrap_or("");
535    if first == "git" {
536        let sub = tokens.next().unwrap_or("");
537        if GIT_NETWORK_SUBCOMMANDS.contains(&sub) {
538            return None;
539        }
540        return Some("git");
541    }
542    DIRECT_TOOL_NAMES.iter().copied().find(|&t| t == first)
543}
544
545/// #894: the built-in tool registry — ONE self-describing entry per non-base
546/// tool (name + JSON schema builder + presence gate), replacing the parallel
547/// hand-kept lists that used to drift (the `lifecycle` tool, #891, was
548/// advertised + dispatched yet missing from the old `ALL_TOOL_NAMES`, so every
549/// legitimate `lifecycle` call was miscounted as a hallucination). Adding a
550/// tool here now updates BOTH the advertised set ([`merged_tool_definitions`])
551/// AND the real-name set ([`ALL_TOOL_NAMES`]) atomically — you cannot half-wire
552/// one without the other. The base tools stay inlined in [`tool_definitions`]
553/// (their names mirrored in [`BASE_TOOL_NAMES`], guarded by a test); the
554/// executor dispatch match is intentionally left as a separate pass.
555///
556/// Presence condition for a registered tool. `Always` tools ride every session
557/// (they degrade honestly when their backing source is absent); the rest are
558/// advertised only when the matching `with_*` capability is injected.
559#[derive(Clone, Copy, PartialEq, Eq)]
560enum Gate {
561    Always,
562    SaveNote,
563    Recall,
564    MemoryFetch,
565    Git,
566    Team,
567    Scratchpad,
568    CodeSearch,
569    Experiential,
570    Scheduled,
571}
572
573/// One built-in (non-base) tool, declared in exactly one place.
574struct ToolSpec {
575    /// The tool name the model calls — must equal `(definition)()`'s function name.
576    name: &'static str,
577    /// Builds the tool's JSON schema (the same `*_tool_definition()` fns used
578    /// before; the registry just references them).
579    definition: fn() -> serde_json::Value,
580    /// When this tool is advertised + treated as a real (non-hallucinated) name.
581    gate: Gate,
582}
583
584/// The registered non-base tools, in advertised order. This IS the order
585/// [`merged_tool_definitions`] pushes them (after the base array), preserved
586/// byte-for-byte from the previous hand-written push ladder.
587const EXTENDED_TOOL_REGISTRY: &[ToolSpec] = &[
588    // Always-on (degrade gracefully when their source is None) —
589    // #714 / #725 / #727 / #728 / #891.
590    ToolSpec {
591        name: "resume_context",
592        definition: super::resume::resume_context_tool_definition,
593        gate: Gate::Always,
594    },
595    ToolSpec {
596        name: "tool_search",
597        definition: super::tool_search::tool_search_tool_definition,
598        gate: Gate::Always,
599    },
600    ToolSpec {
601        name: "get_context_remaining",
602        definition: super::budget::get_context_remaining_tool_definition,
603        gate: Gate::Always,
604    },
605    ToolSpec {
606        name: "request_user_input",
607        definition: request_user_input_tool_definition,
608        gate: Gate::Always,
609    },
610    ToolSpec {
611        name: "lifecycle",
612        definition: lifecycle_tool_definition,
613        gate: Gate::Always,
614    },
615    // Presence-gated on an injected capability (one `with_*` flag each).
616    ToolSpec {
617        name: "save_note",
618        definition: save_note_tool_definition,
619        gate: Gate::SaveNote,
620    },
621    ToolSpec {
622        name: "recall",
623        definition: recall_tool_definition,
624        gate: Gate::Recall,
625    },
626    ToolSpec {
627        name: "memory_fetch",
628        definition: memory_fetch_tool_definition,
629        gate: Gate::MemoryFetch,
630    },
631    ToolSpec {
632        name: "git",
633        definition: super::git_tool::git_tool_definition,
634        gate: Gate::Git,
635    },
636    ToolSpec {
637        name: "compose_roster",
638        definition: super::crew_tool::compose_roster_tool_definition,
639        gate: Gate::Team,
640    },
641    ToolSpec {
642        name: "crew",
643        definition: super::crew_tool::crew_tool_definition,
644        gate: Gate::Team,
645    },
646    ToolSpec {
647        name: "state_set",
648        definition: super::scratchpad::state_set_tool_definition,
649        gate: Gate::Scratchpad,
650    },
651    ToolSpec {
652        name: "state_get",
653        definition: super::scratchpad::state_get_tool_definition,
654        gate: Gate::Scratchpad,
655    },
656    ToolSpec {
657        name: "state_clear",
658        definition: super::scratchpad::state_clear_tool_definition,
659        gate: Gate::Scratchpad,
660    },
661    ToolSpec {
662        name: "code_search",
663        definition: super::semantic::code_search_tool_definition,
664        gate: Gate::CodeSearch,
665    },
666    ToolSpec {
667        name: "experience_record",
668        definition: super::experiential::experience_record_tool_definition,
669        gate: Gate::Experiential,
670    },
671    ToolSpec {
672        name: "experience_recall",
673        definition: super::experiential::experience_recall_tool_definition,
674        gate: Gate::Experiential,
675    },
676    ToolSpec {
677        name: "update_plan",
678        definition: super::scheduled::update_plan_tool_definition,
679        gate: Gate::Scheduled,
680    },
681    ToolSpec {
682        name: "plan_get",
683        definition: super::scheduled::plan_get_tool_definition,
684        gate: Gate::Scheduled,
685    },
686];
687
688/// The base tools inlined in [`tool_definitions`], by name. The base array is
689/// the one place these tools are declared; this mirror exists only so
690/// [`ALL_TOOL_NAMES`] can be assembled without re-parsing JSON, and it is kept
691/// in lockstep by `base_tool_names_match_tool_definitions`. The EXTENDED tools'
692/// names are NOT duplicated here — they live on their [`ToolSpec`].
693const BASE_TOOL_NAMES: &[&str] = &[
694    "run_command",
695    "read_file",
696    "write_file",
697    "edit_file",
698    "list_dir",
699    "find",
700    "use_skill",
701    "web_fetch",
702    "request_permissions",
703];
704
705/// Every tool newt can dispatch by name — the base tools plus every registered
706/// one — DERIVED from [`BASE_TOOL_NAMES`] + [`EXTENDED_TOOL_REGISTRY`] so it can
707/// never drift from the advertised set. Single source of truth for
708/// [`is_hallucination`] (which names are real) and [`nearest_tool_name`]
709/// (suggestion candidates). MCP `server__tool` names contain `__` and are
710/// matched separately.
711static ALL_TOOL_NAMES: LazyLock<Vec<&'static str>> = LazyLock::new(|| {
712    BASE_TOOL_NAMES
713        .iter()
714        .copied()
715        .chain(EXTENDED_TOOL_REGISTRY.iter().map(|s| s.name))
716        .collect()
717});
718
719/// Whether `tool_name` is a built-in newt tool name. Dynamic MCP tool names are
720/// intentionally excluded; callers that need MCP should check their live MCP
721/// registry separately.
722pub(crate) fn known_builtin_tool_name(tool_name: &str) -> bool {
723    ALL_TOOL_NAMES.contains(&tool_name)
724}
725
726/// Whether a [`Gate`] is satisfied given this session's injected capabilities.
727/// Extracted so [`merged_tool_definitions`] reads as one loop over the registry.
728#[allow(clippy::too_many_arguments)] // mirrors merged_tool_definitions' with_* flags
729fn gate_satisfied(
730    gate: Gate,
731    with_save_note: bool,
732    with_recall: bool,
733    with_memory_fetch: bool,
734    with_git: bool,
735    with_team: bool,
736    with_scratchpad: bool,
737    with_code_search: bool,
738    with_experiential: bool,
739    with_scheduled: bool,
740) -> bool {
741    match gate {
742        Gate::Always => true,
743        Gate::SaveNote => with_save_note,
744        Gate::Recall => with_recall,
745        Gate::MemoryFetch => with_memory_fetch,
746        Gate::Git => with_git,
747        Gate::Team => with_team,
748        Gate::Scratchpad => with_scratchpad,
749        Gate::CodeSearch => with_code_search,
750        Gate::Experiential => with_experiential,
751        Gate::Scheduled => with_scheduled,
752    }
753}
754
755/// Returns `true` if a tool call looks like a hallucination:
756/// - `run_command` called with a tool name as the shell command, or
757/// - An unknown tool name (excluding MCP-namespaced `server__tool` names).
758pub(crate) fn is_hallucination(tool_name: &str, args: &serde_json::Value) -> bool {
759    if tool_name == "run_command" {
760        let cmd = args["command"].as_str().unwrap_or("");
761        // A misdirected direct-tool call is a hallucination; a real shell
762        // command (including the git NETWORK ops #898 lets through) is not.
763        return run_command_redirect(cmd).is_some();
764    }
765    // MCP tools are namespaced with `__` — never treat them as hallucinations.
766    if tool_name.contains("__") {
767        return false;
768    }
769    // #894: derived from BASE_TOOL_NAMES + EXTENDED_TOOL_REGISTRY, so it can
770    // never drift from the advertised set.
771    !ALL_TOOL_NAMES.contains(&tool_name)
772}
773
774/// Outcome of resolving a foreign / hallucinated tool name against newt's real
775/// tools (Step 27.1). Weak local models routinely emit tool names learned from
776/// other agent harnesses (`str_replace_editor`, `execute`, `bash`); without a
777/// resolution layer each such call is a flat "unknown tool" that burns a whole
778/// tool-call round, so the model narrates instead of acting (the #215 advisory
779/// drift). Resolving them turns a wasted round into a self-correcting one.
780pub(crate) enum AliasOutcome {
781    /// A foreign name whose argument shape matches a real newt tool: rewrite the
782    /// call to this canonical name and dispatch it transparently.
783    Rewrite(&'static str),
784    /// A foreign name whose arguments do NOT match the real tool: return this
785    /// correction (naming the right tool + its signature) so the model retries.
786    Correct(String),
787}
788
789/// Map a non-newt tool name to a real tool, when we recognize it. Real tool
790/// names and MCP `server__tool` names return `None` and dispatch unchanged.
791pub(crate) fn resolve_tool_alias(name: &str) -> Option<AliasOutcome> {
792    match name {
793        // Shell aliases — same single `command` arg shape as run_command, so we
794        // rewrite and dispatch. (If the shell is unavailable this build, the
795        // run_command arm reports that — Step 27.3 presence-gates it.)
796        "execute" | "exec" | "bash" | "shell" | "sh" | "zsh" | "terminal" | "run_shell_command"
797        | "shell_command" | "system" => Some(AliasOutcome::Rewrite("run_command")),
798        // #891 lifecycle aliases — same `{phase, action}` arg shape as
799        // `lifecycle`, so we rewrite and dispatch. Common phrasings a model
800        // reaches for when it wants to run a named phase.
801        "run_phase" | "run_lifecycle" | "lifecycle_run" => Some(AliasOutcome::Rewrite("lifecycle")),
802        // Edit aliases — different arg shape; point at edit_file's signature.
803        "str_replace_editor" | "str_replace" | "str-replace-editor" | "apply_patch" | "edit"
804        | "editor" | "replace_in_file" | "search_replace" => Some(AliasOutcome::Correct(format!(
805            "'{name}' is not a newt tool. To change an existing file, call \
806                 edit_file with {{\"path\", \"old_string\", \"new_string\"}} \
807                 (replaces one exact occurrence). For a new file or a full \
808                 rewrite, call write_file with {{\"path\", \"content\"}}."
809        ))),
810        // Create-file aliases — point at write_file.
811        "create_file" | "new_file" | "createfile" | "add_file" | "touch" => {
812            Some(AliasOutcome::Correct(format!(
813                "'{name}' is not a newt tool. To create or overwrite a file, call \
814                 write_file with {{\"path\", \"content\"}}. To change part of an \
815                 existing file, call edit_file with \
816                 {{\"path\", \"old_string\", \"new_string\"}}."
817            )))
818        }
819        // #721 mkdir coach — newt has no directory-creation tool, and the model
820        // does not need one: write_file creates parent directories automatically
821        // (create_dir_all), and an empty file for empty content. A model reaching
822        // for `mkdir` (the issue's live `mkdir -p …/src` dead-end) is coached to
823        // the tool that already covers it, turning an exec denial into a
824        // self-correcting tool call. `touch` is intentionally NOT here — it is
825        // already a create-file alias above (→ write_file), and a second arm
826        // would be a duplicate match.
827        "mkdir" | "make_dir" | "makedirs" | "mkdirs" | "create_dir" | "create_directory" => {
828            Some(AliasOutcome::Correct(
829                "newt has no mkdir/touch tool — call write_file; it creates parent \
830                 directories automatically (create_dir_all). For an empty file, call \
831                 write_file with empty content."
832                    .to_string(),
833            ))
834        }
835        // Read / list aliases — point at read_file / list_dir.
836        "cat" | "open_file" | "view_file" | "view" | "open" => {
837            Some(AliasOutcome::Correct(format!(
838                "'{name}' is not a newt tool. To read a file, call read_file with \
839                 {{\"path\"}}. To list a directory, call list_dir with {{\"path\"}}."
840            )))
841        }
842        // #716 + #715 PR2 PLAN — start/revise a plan. The arg shape is free prose,
843        // not the ordered `{"plan":[{"step","status"}]}` array update_plan wants, so
844        // Correct (coach), never a silent Rewrite. When `scheduled` is off the
845        // dispatch arm for update_plan returns "scheduled planning is off" — the
846        // model still gets a coherent answer rather than a dead end. `update_plan`
847        // itself is the REAL tool now (falls through to `None`), so it is never an
848        // alias of itself; `set_plan`/`plan_advance` no longer exist.
849        "enter_plan" | "enter_plan_mode" | "plan_mode" | "start_plan" | "begin_plan"
850        | "make_plan" | "create_plan" | "plan" | "planning" | "todo" | "todos" | "todo_write" => {
851            Some(AliasOutcome::Correct(format!(
852                "'{name}' is not a newt tool. To start or revise your plan, call update_plan with \
853                 {{\"plan\":[{{\"step\",\"status\"}}]}} — send the full ordered list each time, \
854                 each step's status one of pending/in_progress/completed (exactly one \
855                 in_progress)."
856            )))
857        }
858        // #715 PR2 ADVANCE-ish verbs — there is no longer a separate "advance" tool;
859        // progress is recorded by re-sending the whole plan with the finished step
860        // marked completed. Coach the model back to update_plan.
861        "next_step" | "complete_step" | "finish_step" | "mark_done" | "step_done" => {
862            Some(AliasOutcome::Correct(format!(
863                "'{name}' is not a newt tool. To advance your plan, call update_plan with the \
864                 full plan and mark the finished step \"completed\" (and the next one \
865                 \"in_progress\")."
866            )))
867        }
868        // #716 PLAN-READ — read the current plan. plan_get takes no args, so the
869        // foreign call's (empty) arg shape matches: safe to silently Rewrite.
870        // `what_was_i_doing` stays here (→ plan_get) — it asks specifically for
871        // the plan; the broader "where were we" reaches go to resume_context.
872        "get_plan" | "show_plan" | "read_plan" | "current_plan" | "what_was_i_doing" => {
873            Some(AliasOutcome::Rewrite("plan_get"))
874        }
875        // #714 RESUME — the instinctive "where did we leave off" reach. All take
876        // no args (resume_context is a self-read), so the (empty) arg shape
877        // matches: safe to silently Rewrite. Meets the dead-end reach the issue
878        // observed (the model retrying recall) by landing it on the affordance
879        // built for exactly this case.
880        "resume" | "where_were_we" | "where_did_we_leave_off" | "catch_me_up" | "recap" => {
881            Some(AliasOutcome::Rewrite("resume_context"))
882        }
883        // #716 CREW / DELEGATE — crew/team is the human-only `/team` toggle a
884        // model cannot self-enable, and the targets may be unadvertised, so this
885        // can only ever Correct (never silently Rewrite) and the message must NOT
886        // imply the model can invoke crew itself.
887        "delegate" | "spawn_agent" | "subagent" | "sub_agent" | "crew_dispatch" | "run_crew"
888        | "dispatch_crew" | "fork_agent" | "assign" | "team" => {
889            Some(AliasOutcome::Correct(format!(
890                "'{name}' is not a newt tool. Crew/team delegation is only available once the \
891                 human enables /team this session — you cannot turn it on yourself. When /team \
892                 is on, compose_roster ({{\"mode\"}}) proposes a roster and crew ({{\"task\"}}) \
893                 dispatches it."
894            )))
895        }
896        // #725 TOOL-DISCOVERY — the instinctive "which tool does X?" reach. All
897        // mean exactly tool_search (a `query` arg, or none — execute_tool_search
898        // lists everything on an empty query), so silently Rewrite. `tool_search`
899        // itself is the REAL tool and falls through to `None` below — it is never
900        // an alias of itself.
901        "find_tool" | "search_tools" | "list_tools" | "which_tool" | "available_tools"
902        | "what_tools" | "tools" => Some(AliasOutcome::Rewrite("tool_search")),
903        // #716 WORKFLOW — no workflow/pipeline primitive exists; redirect to the
904        // plan tools (and crew/team, which needs /team).
905        "workflow" | "run_workflow" | "start_workflow" | "pipeline" => Some(AliasOutcome::Correct(
906            "newt has no workflow tool; sequence the work with update_plan (the full ordered \
907                 plan with statuses), or delegate subtasks via crew/team (needs /team)."
908                .to_string(),
909        )),
910        // #727 BUDGET — "how much context do I have left" reaches. get_context_remaining
911        // takes no args (it is a self-read), so the foreign call's (empty) arg shape
912        // matches: safe to silently Rewrite. The real name `get_context_remaining` is
913        // NOT here — it falls through to `None` and dispatches unchanged (the loop
914        // intercepts it), so it is never an alias of itself.
915        "context_remaining" | "tokens_left" | "remaining_tokens" | "budget"
916        | "how_much_context" | "context_budget" | "token_budget" => {
917            Some(AliasOutcome::Rewrite("get_context_remaining"))
918        }
919        // #728 ASK-THE-HUMAN — the instinctive "ask the user a question" reach.
920        // All mean exactly request_user_input (a `question` arg), so silently
921        // Rewrite: the executor reads `question` and answers via the gate (or the
922        // headless message). The real name `request_user_input` is NOT here — it
923        // falls through to `None` and dispatches unchanged, so it is never an
924        // alias of itself.
925        "ask_user" | "ask_human" | "prompt_user" | "get_user_input" | "ask_question"
926        | "clarify" | "ask" => Some(AliasOutcome::Rewrite("request_user_input")),
927        _ => None,
928    }
929}
930
931/// #727: true when `name` is `get_context_remaining` or one of its rewrite
932/// aliases. The agentic loop computes the per-turn budget at the dispatch site
933/// (where `num_ctx` and the conversation estimate are in scope) and renders it
934/// there, bypassing [`execute_tool`] — so the loop must recognize both the
935/// canonical name and the aliases that resolve to it.
936pub(crate) fn is_context_remaining_call(name: &str) -> bool {
937    name == "get_context_remaining"
938        || matches!(
939            resolve_tool_alias(name),
940            Some(AliasOutcome::Rewrite("get_context_remaining"))
941        )
942}
943
944/// #717: classify a single tool/capability reach for the alias-seam telemetry.
945///
946/// Pure: given the name the model called, its args, the tool result string, and
947/// whether the result read as success, decide whether this reach is phantom and
948/// how it resolved. Returns `None` for an ordinary real call (nothing to mine).
949/// See [`crate::PhantomReach`] / [`crate::PhantomResolution`].
950///
951/// `ok` is part of the signature for symmetry with the recording site (it keys
952/// the sibling `ToolEvent`); v1's miss-patterns classify on name + result alone.
953pub(crate) fn classify_phantom_reach(
954    name: &str,
955    args: &serde_json::Value,
956    result: &str,
957    ok: bool,
958) -> Option<crate::PhantomResolution> {
959    let _ = ok;
960    // 1. A recognized foreign/alias name: rewrite to a real tool, or a
961    //    correction naming the right one — the canonical alias-seam signal.
962    match resolve_tool_alias(name) {
963        Some(AliasOutcome::Rewrite(canonical)) => {
964            return Some(crate::PhantomResolution::Rewrite(canonical.to_string()))
965        }
966        Some(AliasOutcome::Correct(msg)) => return Some(crate::PhantomResolution::Correct(msg)),
967        None => {}
968    }
969    // 2. An unknown name with no alias is a true phantom tool (hallucination).
970    if is_hallucination(name, args) {
971        return Some(crate::PhantomResolution::Unknown);
972    }
973    // 3. A real tool that returned empty-by-design — a high-signal "miss" that
974    //    currently logs ok=true. These are the mineable real-tool reaches; the
975    //    loop emits one per call, so a 3x identical-recall loop yields 3 records.
976    let r = result.trim_start();
977    if name == "state_get" && r.starts_with("no such key") {
978        return Some(crate::PhantomResolution::RealToolMiss(
979            "state_get on an unset key".into(),
980        ));
981    }
982    if name == "recall" && r.starts_with("no matches in past conversations") {
983        return Some(crate::PhantomResolution::RealToolMiss(
984            "recall returned no matches".into(),
985        ));
986    }
987    None
988}
989
990/// #479 (G4): classify a `crew`/`compose_roster` reach made while the crew/team
991/// surface is gated OFF (`advertise_team == false`, the default — the runner is
992/// only built when the operator sets `NEWT_TEAM`).
993///
994/// Pure: given the name the model called and whether the team surface is
995/// advertised this session, decide whether this is a gated-off delegation reach
996/// worth mining. Returns `None` for everything else (non-crew names, or crew
997/// names when the surface is ON — those dispatch normally).
998///
999/// This is a SEPARATE seam from [`classify_phantom_reach`] on purpose: `crew`
1000/// and `compose_roster` stay real names in [`ALL_TOOL_NAMES`] (so the ON path is
1001/// a normal dispatch and [`is_hallucination`] is unchanged), which means
1002/// `classify_phantom_reach` never flags them. The gated-off detection needs the
1003/// one fact that function does not have — `advertise_team` — which is known in
1004/// the agent loop, so the loop composes the two seams there.
1005pub(crate) fn classify_gated_off_reach(
1006    name: &str,
1007    advertise_team: bool,
1008) -> Option<crate::PhantomResolution> {
1009    if !advertise_team && (name == "crew" || name == "compose_roster") {
1010        return Some(crate::PhantomResolution::GatedOff(
1011            "crew/team surface off (NEWT_TEAM)".into(),
1012        ));
1013    }
1014    None
1015}
1016
1017/// Classic Levenshtein edit distance (pure two-row DP). Inputs are tool names
1018/// (short), so the simple version is plenty — for fuzzy suggestions only.
1019fn levenshtein(a: &str, b: &str) -> usize {
1020    let a: Vec<char> = a.chars().collect();
1021    let b: Vec<char> = b.chars().collect();
1022    let mut prev: Vec<usize> = (0..=b.len()).collect();
1023    let mut cur = vec![0usize; b.len() + 1];
1024    for (i, ca) in a.iter().enumerate() {
1025        cur[0] = i + 1;
1026        for (j, cb) in b.iter().enumerate() {
1027            let cost = usize::from(ca != cb);
1028            cur[j + 1] = (prev[j + 1] + 1).min(cur[j] + 1).min(prev[j] + cost);
1029        }
1030        std::mem::swap(&mut prev, &mut cur);
1031    }
1032    prev[b.len()]
1033}
1034
1035/// The closest real tool name to `name`, if one is near enough to be a likely
1036/// typo/variant (distance ≤ ⌈len/3⌉, min 1). Returns `None` when nothing is
1037/// close, so we never suggest a wildly-unrelated tool.
1038fn nearest_tool_name(name: &str) -> Option<&'static str> {
1039    let threshold = (name.chars().count() / 3).max(1);
1040    ALL_TOOL_NAMES
1041        .iter()
1042        .map(|&t| (levenshtein(name, t), t))
1043        .filter(|(d, _)| *d <= threshold)
1044        .min_by_key(|(d, _)| *d)
1045        .map(|(_, t)| t)
1046}
1047
1048/// Corrective message for a genuinely-unknown tool name: name the real base
1049/// tools and, when one is close, suggest it — so a weak model that missed the
1050/// catalog gets a path back instead of a dead end (Step 27.1). Kept
1051/// `unknown tool: {name}`-prefixed so existing `starts_with` checks hold.
1052fn unknown_tool_message(name: &str) -> String {
1053    const BASE: &str =
1054        "run_command, read_file, write_file, edit_file, list_dir, find, use_skill, web_fetch";
1055    match nearest_tool_name(name) {
1056        Some(sugg) => format!(
1057            "unknown tool: {name}. Did you mean '{sugg}'? Available tools include: \
1058             {BASE} (plus git and any memory/plan tools enabled this session)."
1059        ),
1060        None => format!(
1061            "unknown tool: {name}. Available tools include: {BASE} (plus git and \
1062             any memory/plan tools enabled this session)."
1063        ),
1064    }
1065}
1066
1067/// Build a shell prefix that exports venv/exec-path vars into the agent-bridle
1068/// confined shell.
1069///
1070/// Agent-bridle's confined shell does not inherit the host environment
1071/// (`do_not_inherit_env(true)`), so we inject `VIRTUAL_ENV` and prepend
1072/// venv/extra `bin/` dirs to `PATH` by prefixing every `run_command` cmd.
1073/// `NEWT_VENV` (set from `--venv` or auto-detected from `$VIRTUAL_ENV` by the
1074/// CLI) takes precedence; falls back to `$VIRTUAL_ENV` if the TUI was invoked
1075/// directly without going through the CLI's `dispatch`.
1076pub fn venv_cmd_prefix() -> Option<String> {
1077    let venv = std::env::var("NEWT_VENV")
1078        .or_else(|_| std::env::var("VIRTUAL_ENV"))
1079        .ok();
1080    let exec_paths = std::env::var("NEWT_EXEC_PATHS").ok();
1081
1082    if venv.is_none() && exec_paths.is_none() {
1083        return None;
1084    }
1085
1086    // sh single-quoting: wrap in '', escape any ' as '\''
1087    let q = |s: &str| format!("'{}'", s.replace('\'', r"'\''"));
1088
1089    // Build a list of dirs to prepend to PATH (venv/bin first, then exec-paths).
1090    let mut path_dirs: Vec<String> = Vec::new();
1091    let mut prefix = String::new();
1092
1093    if let Some(ref venv) = venv {
1094        let venv_bin = format!("{venv}/bin");
1095        prefix.push_str(&format!("export VIRTUAL_ENV={}; ", q(venv)));
1096        path_dirs.push(venv_bin);
1097    }
1098    if let Some(ref paths) = exec_paths {
1099        for dir in paths.split(':') {
1100            if !dir.is_empty() {
1101                path_dirs.push(dir.to_string());
1102            }
1103        }
1104    }
1105
1106    if !path_dirs.is_empty() {
1107        let quoted: Vec<String> = path_dirs.iter().map(|d| q(d)).collect();
1108        prefix.push_str(&format!("export PATH={}:\"$PATH\"; ", quoted.join(":")));
1109    }
1110
1111    if prefix.is_empty() {
1112        None
1113    } else {
1114        Some(prefix)
1115    }
1116}
1117
1118/// Build the venv/exec-path environment as a `{KEY:VALUE}` map for the confined
1119/// shell's structured `env` seam (agent-bridle, newt #783).
1120///
1121/// Same inputs as [`venv_cmd_prefix`] — `NEWT_VENV` (preferred) or
1122/// `$VIRTUAL_ENV`, plus `NEWT_EXEC_PATHS` — but delivered as host-supplied env
1123/// vars set directly on the spawned child instead of `export …;` text prepended
1124/// to the command. The `export` form is the #783 root cause: `export` is a
1125/// shell builtin, not a program, so the confined safe-subset engine refuses it
1126/// on a compound command (`a; b | c`). Passing the vars through the env seam
1127/// sidesteps that entirely and never touches the command text.
1128///
1129/// `PATH` is the venv `bin` (then any `NEWT_EXEC_PATHS` dirs) *prepended* to the
1130/// inherited host `PATH`: the env seam sets the value additively over the
1131/// child's ambient environment, so we read the host `PATH` here and build the
1132/// full string rather than relying on a `$PATH` expansion inside the value.
1133/// Returns an empty map when neither input is set (no env key is sent).
1134fn venv_env_map() -> std::collections::BTreeMap<String, String> {
1135    let mut map = std::collections::BTreeMap::new();
1136    let venv = std::env::var("NEWT_VENV")
1137        .or_else(|_| std::env::var("VIRTUAL_ENV"))
1138        .ok();
1139    let exec_paths = std::env::var("NEWT_EXEC_PATHS").ok();
1140
1141    if venv.is_none() && exec_paths.is_none() {
1142        return map;
1143    }
1144
1145    // Dirs to prepend to PATH (venv/bin first, then any exec-paths), mirroring
1146    // venv_cmd_prefix's ordering.
1147    let mut path_dirs: Vec<String> = Vec::new();
1148    if let Some(ref venv) = venv {
1149        map.insert("VIRTUAL_ENV".to_string(), venv.clone());
1150        path_dirs.push(format!("{venv}/bin"));
1151    }
1152    if let Some(ref paths) = exec_paths {
1153        for dir in paths.split(':') {
1154            if !dir.is_empty() {
1155                path_dirs.push(dir.to_string());
1156            }
1157        }
1158    }
1159
1160    if !path_dirs.is_empty() {
1161        let prepend = path_dirs.join(":");
1162        let path = match std::env::var("PATH") {
1163            Ok(inherited) if !inherited.is_empty() => format!("{prepend}:{inherited}"),
1164            _ => prepend,
1165        };
1166        map.insert("PATH".to_string(), path);
1167    }
1168
1169    map
1170}
1171
1172/// Build the dispatch args for agent-bridle's confined `shell` tool (#783): the
1173/// RAW user command (free-form `cmd` mode) plus the venv carried through the
1174/// structured `env` seam ([`venv_env_map`]). Deliberately NO `export …;` prefix
1175/// on `cmd` — that is what the confined safe-subset engine refuses on a
1176/// compound command (the #783 root cause); the env seam sets `VIRTUAL_ENV` /
1177/// `PATH` on the spawned child instead. The host-bypass (`--yolo`) path keeps
1178/// the prefix form because it runs on a real `/bin/sh` where `export` works.
1179fn confined_dispatch_args(cmd: &str, workspace: &str) -> serde_json::Value {
1180    serde_json::json!({
1181        "cmd": cmd,
1182        "cwd": workspace,
1183        "env": venv_env_map(),
1184    })
1185}
1186
1187/// The shell engine selected for this process (ADR 0005 D2 seam). Resolved once
1188/// at startup by the CLI ([`crate::resolve_shell_engine`] over the `[shell]
1189/// engine` config, the `--shell-engine` flag, and the `--full-access`
1190/// auto-upgrade) and published via `NEWT_SHELL_ENGINE`, so the deep
1191/// `run_command` dispatch reads it without threading it through every signature
1192/// — the same env-published pattern as [`ocap_disabled`] /
1193/// [`full_access_requested`]. Absent or unparseable falls back to the
1194/// `safe-subset` default.
1195fn shell_engine() -> crate::ShellEngine {
1196    if let Some(engine) = std::env::var("NEWT_SHELL_ENGINE")
1197        .ok()
1198        .and_then(|s| s.parse::<crate::ShellEngine>().ok())
1199    {
1200        return engine;
1201    }
1202    // No engine was published (e.g. a non-CLI entry point that set
1203    // NEWT_FULL_ACCESS directly). Honor the same auto-upgrade the CLI applies so
1204    // `NEWT_FULL_ACCESS=1` alone still gets the full-grammar engine (`host` on
1205    // unix, `brush` on Windows).
1206    if full_access_requested() {
1207        crate::full_access_default_engine()
1208    } else {
1209        crate::ShellEngine::default()
1210    }
1211}
1212
1213/// agent-bridle's tool registry with the `"shell"` tool bound to the selected
1214/// engine (the ADR 0005 D2 seam: `safe-subset` / `host` / `brush` all honor the
1215/// same `Tool` contract under the `"shell"` name). `web_fetch` is added
1216/// unchanged. Mirrors `agent_bridle::registry()` but swaps the shell engine so
1217/// `[shell] engine = "host"` (or `--full-access`) routes `run_command` to the
1218/// full-grammar, kernel-jailed sandbox-host engine instead of the safe subset.
1219fn bridle_registry() -> agent_bridle::Registry {
1220    use std::sync::Arc;
1221    let shell: Arc<dyn agent_bridle::Tool> = match shell_engine() {
1222        crate::ShellEngine::SafeSubset => Arc::new(agent_bridle::ShellTool::new()),
1223        crate::ShellEngine::Host => Arc::new(agent_bridle::HostShellTool::new()),
1224        crate::ShellEngine::Brush => {
1225            // The carried brush engine (agent-bridle 0.7): in-process bash + the
1226            // L2 CommandInterceptor. The cross-platform engine — and on Windows
1227            // the ONLY full-grammar option, since `host` needs `/bin/sh`.
1228            #[cfg(windows)]
1229            {
1230                use std::sync::Once;
1231                static WARN: Once = Once::new();
1232                WARN.call_once(|| {
1233                    tracing::warn!(
1234                        "using the 'brush' shell engine on Windows: run_command runs a \
1235                         bash-in-Rust shell for internal-tooling compatibility. Native \
1236                         PowerShell/cmd code paths are a FUTURE release — not written yet \
1237                         (we are opinionated Linux developers who occasionally use a \
1238                         MacBook). Bash-isms work; Windows-native shell semantics do not."
1239                    );
1240                });
1241            }
1242            Arc::new(agent_bridle::BrushShellTool::new())
1243        }
1244    };
1245    agent_bridle::Registry::builder()
1246        .tool(shell)
1247        .tool(Arc::new(agent_bridle::WebFetchTool::new()))
1248        .build()
1249}
1250
1251// ---------------------------------------------------------------------------
1252// INTERIM (#297): the --disable-ocap / --yolo exec escape hatch
1253// ---------------------------------------------------------------------------
1254
1255/// INTERIM (#297): is the ocap exec bypass asserted for this invocation?
1256///
1257/// True only when `NEWT_DISABLE_OCAP=1` — set by the CLI's `--disable-ocap`
1258/// flag (alias `--yolo`) or exported directly for harness/pod use. The value
1259/// must be exactly `"1"`: a security bypass reads fail-closed, so anything
1260/// else (including `true`) leaves confinement on. Deliberately env-only —
1261/// there is NO config-file key, so the bypass can never silently persist; it
1262/// must be asserted per invocation.
1263///
1264/// Scope: `run_command` only. On stub-shell builds (the only crates.io-
1265/// publishable configuration) agent-bridle's `shell` tool fails closed on
1266/// every command, which makes agentic coding impossible without the brush
1267/// `CommandInterceptor` patch underneath. `web_fetch` is NOT bypassed: the
1268/// stub-shell branch stubs only the shell tool — `agent-bridle-tool-web`
1269/// ships the real leash-enforcing implementation (verified at agent-bridle
1270/// rev `2129c91`), so it does not fail closed. The fs tools keep the
1271/// newt-native workspace fence untouched: yolo is unconfined exec, fenced fs
1272/// — never a global authority-off switch.
1273///
1274/// Remove (or demote to a debug flag) when brush upstreams the
1275/// `CommandInterceptor` hook (reubeno/brush#1184) and agent-bridle's real
1276/// confined shell becomes the default everywhere — see agent-bridle#20 and
1277/// the `[patch.crates-io]` note in the workspace Cargo.toml.
1278pub fn ocap_disabled() -> bool {
1279    std::env::var("NEWT_DISABLE_OCAP").is_ok_and(|v| v == "1")
1280}
1281
1282/// Is the per-invocation `full_access` preset override asserted?
1283///
1284/// True only when `NEWT_FULL_ACCESS=1` — set by the CLI's `--full-access`
1285/// flag. The session policy is then built from the `full_access` preset
1286/// (`Caveats::top()`) regardless of the configured `[tui.permissions]`
1287/// preset, exactly as if the config said `preset = "full_access"` for this
1288/// one run. Like [`ocap_disabled`], the value must be exactly `"1"` — a
1289/// widening switch reads fail-closed — and it is deliberately env-only, so
1290/// the override can never silently persist.
1291///
1292/// This is a DISTINCT switch from [`ocap_disabled`] (`--yolo`): the two
1293/// compose but never alias. `--full-access` widens the session *authority*
1294/// (fs fence, net leash, exec allowlist → unrestricted, which also empties
1295/// the #774 exec floor); `--yolo` changes the exec *mechanism* (host shell
1296/// instead of the confined shell) and still honors whatever floor is in
1297/// force. `--yolo --full-access` together yield an unrestricted host shell.
1298pub fn full_access_requested() -> bool {
1299    std::env::var("NEWT_FULL_ACCESS").is_ok_and(|v| v == "1")
1300}
1301
1302/// facade P4 (#780): is the convenience **routing** turned OFF for this call?
1303///
1304/// True only when `NEWT_NO_ROUTE=1` — set by the CLI's `--no-route` flag. It
1305/// disables the L2 *convenience routing* ([`super::routing`]): a model's
1306/// `run_command("cat X")` runs the normal exec path as-is instead of being
1307/// rewritten to the governed `read_file` built-in.
1308///
1309/// **This is a DISTINCT switch from [`ocap_disabled`] (§7-F5).**
1310/// `--no-route` / `NEWT_NO_ROUTE` turns off L2 convenience only; it NEVER
1311/// disables the L3 boundary — the confined shell still gates exec and the fs
1312/// fence still governs reads. `--disable-ocap` / `--yolo` / `NEWT_DISABLE_OCAP`
1313/// (L3-OFF, a full host unconfine) is a completely separate mechanism: the two
1314/// names never alias, and turning routing off can never imply unconfined exec.
1315/// Reads fail-closed — only the exact value `1` turns routing off; deliberately
1316/// env-only, no config key, so it cannot silently persist.
1317pub fn routing_disabled() -> bool {
1318    std::env::var("NEWT_NO_ROUTE").is_ok_and(|v| v == "1")
1319}
1320
1321/// #307: does the named-permission-preset exec FLOOR permit running `cmd` on
1322/// the UNCONFINED host shell?
1323///
1324/// `None` ⇒ no preset is active; the floor imposes nothing, so the answer is
1325/// `true` (the `--disable-ocap` bypass behaves exactly as it did pre-#307).
1326///
1327/// `Some(scope)` ⇒ the bypass may proceed ONLY for a single, simple command
1328/// whose program (leading token) the scope authorizes. This is deliberately
1329/// conservative on TWO counts, because the host shell runs `cmd` verbatim with
1330/// no per-spawn interceptor:
1331///
1332/// 1. A **compound** command (containing a shell metacharacter that could chain
1333///    another program — `&&`, `||`, `;`, `|`, `` ` ``, `$(`, newline, `&`, `>`,
1334///    `<`) is NOT allowed to bypass. `echo ok && rm -rf /` would otherwise
1335///    smuggle `rm` past an `echo` grant. It falls through to the confined
1336///    shell, which gates every spawn.
1337/// 2. Only the leading token is matched, so a bare allow-listed program runs;
1338///    anything else is denied.
1339///
1340/// The denied command isn't refused outright — it falls to the confined-shell
1341/// path, which enforces the (already preset-clamped) `caveats`. So a restricted
1342/// triage/on-call mode keeps its ceiling even under `--yolo`.
1343fn exec_floor_permits(floor: Option<&crate::caveats::Scope<String>>, cmd: &str) -> bool {
1344    use crate::caveats::ScopeExt as _;
1345    let Some(scope) = floor else {
1346        return true; // no preset ⇒ bypass unchanged (bit-for-bit)
1347    };
1348    // Conservative: any shell control/redirection metacharacter that could
1349    // introduce a second program defeats leading-token matching, so refuse the
1350    // bypass and let the confined shell gate each spawn.
1351    const SHELL_META: &[char] = &['&', '|', ';', '`', '$', '\n', '>', '<', '(', ')'];
1352    if cmd.contains(SHELL_META) {
1353        return false;
1354    }
1355    match cmd.split_ascii_whitespace().next() {
1356        // An empty command runs nothing; let it through to the normal path.
1357        None => true,
1358        Some(prog) => scope.permits(&prog.to_string()),
1359    }
1360}
1361
1362/// INTERIM (#297): run `cmd` on the PLAIN host shell — no leash, no
1363/// interceptor, no sandbox — and wrap the outcome in an envelope structurally
1364/// identical to the confined shell's (`{ exit_code, stdout, stderr,
1365/// sandbox_kind }`, with `denied` / `denials` omitted exactly as the bridle
1366/// envelope omits them when nothing was denied). [`envelope_denied`] and
1367/// [`shell_envelope_output`] — and therefore the loop's truncation / denial /
1368/// exit-code handling — apply to it unchanged.
1369///
1370/// A spawn failure surfaces as `Err`, which the caller formats as the same
1371/// `error: …` string a bridle dispatch failure produces.
1372/// Run `cmd` through the SAME confined-shell path the `run_command` tool uses —
1373/// the venv env seam, the `--disable-ocap` host bypass under the #307 exec
1374/// floor, the agent-bridle confined shell, and the #263 permission-gate re-ask —
1375/// and render the envelope. Shared by the `run_command` and `lifecycle` (#891)
1376/// arms so both honor **identical** exec caveats; the caller prints the
1377/// tool-call line first.
1378#[allow(clippy::too_many_arguments)]
1379async fn exec_confined_command(
1380    cmd: &str,
1381    workspace: &str,
1382    color: bool,
1383    tool_output_lines: usize,
1384    caveats: &crate::caveats::Caveats,
1385    exec_floor: Option<&crate::caveats::Scope<String>>,
1386    permission_gate: Option<&mut dyn PermissionGate>,
1387    tool_offload: bool,
1388    spill_store: Option<&dyn SpillStore>,
1389) -> String {
1390    // Venv injection (#783): the confined shell carries the venv via
1391    // agent-bridle's structured `env` seam (see `confined_dispatch_args` /
1392    // `venv_env_map`), NOT by prepending `export …;` to the command — an
1393    // `export` builtin is not a program, so the safe-subset engine refuses it on
1394    // a compound command (the #783 root cause). `cmd_with_venv` (the
1395    // `export …;`-prefixed form) is built ONLY for the host-bypass path below:
1396    // that runs on a real `/bin/sh` where `export` is a genuine builtin.
1397    let cmd_with_venv = match venv_cmd_prefix() {
1398        Some(prefix) => format!("{prefix}{cmd}"),
1399        None => cmd.to_string(),
1400    };
1401
1402    // INTERIM (#297): --disable-ocap / --yolo / NEWT_DISABLE_OCAP=1 — run the
1403    // command UNCONFINED on the host shell instead of the bridle's confined
1404    // shell. Nothing is denied here, so the #263 permission gate below is never
1405    // consulted. #307 FLOOR: a named-permission-preset clamp WINS over the
1406    // bypass — the unconfined host path is taken ONLY if the floor permits this
1407    // command's leading token; else it falls through to the confined shell,
1408    // which enforces the already-clamped `caveats`. `None` keeps the bypass
1409    // bit-for-bit.
1410    if ocap_disabled() && exec_floor_permits(exec_floor, cmd) {
1411        return match host_shell_dispatch(&cmd_with_venv, workspace).await {
1412            Ok(envelope) => shell_envelope_output(
1413                &envelope,
1414                tool_output_lines,
1415                color,
1416                tool_offload,
1417                spill_store,
1418            ),
1419            Err(e) => format!("error: {e}"),
1420        };
1421    }
1422
1423    // #783: RAW cmd + venv via the env seam — never the `export …;` prefix,
1424    // which the confined safe-subset engine refuses.
1425    let dispatch_args = confined_dispatch_args(cmd, workspace);
1426    match bridle_registry()
1427        .dispatch("shell", dispatch_args.clone(), caveats)
1428        .await
1429    {
1430        // The confined shell ran. Its envelope carries
1431        // `{ exit_code, stdout, stderr, timed_out, ... }` plus — when the leash
1432        // refused a capability — the STRUCTURED denial fields
1433        // `{ denied: true, denials: [{ kind, target, reason }] }`. In free-form
1434        // mode an out-of-scope command is denied *inside* the shell by the brush
1435        // interceptor (the command genuinely does not run); we lift that to the
1436        // capability-denied UX by reading the structured `denied` field — NEVER
1437        // a stderr grep.
1438        Ok(envelope) if envelope_denied(&envelope) => {
1439            // #263: an interactive gate may turn this denial into a human grant.
1440            // ONE consult + ONE re-execution per call: a second denial (a
1441            // different target reached on the re-run) surfaces as the standard
1442            // envelope — the model can retry, which prompts afresh.
1443            if let Some(gate) = permission_gate {
1444                // #905: promptable exec denials OR net-host denials (agent-bridle
1445                // #196). On Allow, the re-mint widens the matching axis (net adds
1446                // the host to the allow-list), so the proxy admits it on re-run.
1447                if let Some(requests) =
1448                    exec_denial_requests(&envelope).or_else(|| net_denial_requests(&envelope))
1449                {
1450                    if let PermissionDecision::Allow(widened) = gate.ask(&requests) {
1451                        return match bridle_registry()
1452                            .dispatch("shell", dispatch_args, &widened)
1453                            .await
1454                        {
1455                            Ok(env2) if envelope_denied(&env2) => {
1456                                denied_run_command_result(&env2, color)
1457                            }
1458                            Ok(env2) => shell_envelope_output(
1459                                &env2,
1460                                tool_output_lines,
1461                                color,
1462                                tool_offload,
1463                                spill_store,
1464                            ),
1465                            Err(e) => format!("error: {e}"),
1466                        };
1467                    }
1468                }
1469            }
1470            denied_run_command_result(&envelope, color)
1471        }
1472        Ok(envelope) => shell_envelope_output(
1473            &envelope,
1474            tool_output_lines,
1475            color,
1476            tool_offload,
1477            spill_store,
1478        ),
1479        // An argv-mode leash denial, or an error from inside the tool — surface
1480        // the reason; the dispatch error Display is safe to show.
1481        Err(e) => format!("error: {e}"),
1482    }
1483}
1484
1485async fn host_shell_dispatch(cmd: &str, cwd: &str) -> std::io::Result<serde_json::Value> {
1486    let output = host_shell_output(cmd, cwd).await?;
1487    Ok(serde_json::json!({
1488        "exit_code": output.status.code().unwrap_or(-1),
1489        "stdout": decode_shell_stream(&output.stdout),
1490        "stderr": decode_shell_stream(&output.stderr),
1491        // Honest provenance, same field the bridle envelope always carries:
1492        // nothing sandboxed this run.
1493        "sandbox_kind": "none",
1494    }))
1495}
1496
1497fn decode_shell_stream(bytes: &[u8]) -> String {
1498    match std::str::from_utf8(bytes) {
1499        Ok(text) => text.to_string(),
1500        Err(_) => repair_bsd_cat_v_utf8(bytes)
1501            .unwrap_or_else(|| String::from_utf8_lossy(bytes).into_owned()),
1502    }
1503}
1504
1505/// macOS/BSD `cat -v` is not Unicode-aware: for a UTF-8 glyph such as `─`
1506/// (`e2 94 80`) it emits the lead byte raw (`e2`) and renders only the
1507/// continuation bytes as ASCII meta-control notation (`M-^TM-^@`). That byte
1508/// stream is invalid UTF-8, so a plain lossy decode becomes `�M-^TM-^@`.
1509///
1510/// Repair only that precise shape. This keeps ordinary valid UTF-8 untouched and
1511/// leaves unrelated binary output on the existing lossy fallback.
1512fn repair_bsd_cat_v_utf8(bytes: &[u8]) -> Option<String> {
1513    let mut repaired = Vec::with_capacity(bytes.len());
1514    let mut changed = false;
1515    let mut i = 0;
1516    while i < bytes.len() {
1517        let lead = bytes[i];
1518        let Some(cont_count) = utf8_continuation_count(lead) else {
1519            repaired.push(lead);
1520            i += 1;
1521            continue;
1522        };
1523
1524        let mut seq = Vec::with_capacity(cont_count + 1);
1525        seq.push(lead);
1526        let mut j = i + 1;
1527        let mut ok = true;
1528        for _ in 0..cont_count {
1529            match parse_cat_v_meta_byte(bytes, j) {
1530                Some((cont, next)) if (0x80..=0xbf).contains(&cont) => {
1531                    seq.push(cont);
1532                    j = next;
1533                }
1534                _ => {
1535                    ok = false;
1536                    break;
1537                }
1538            }
1539        }
1540
1541        if ok && std::str::from_utf8(&seq).is_ok() {
1542            repaired.extend_from_slice(&seq);
1543            changed = true;
1544            i = j;
1545        } else {
1546            repaired.push(lead);
1547            i += 1;
1548        }
1549    }
1550
1551    changed.then(|| String::from_utf8(repaired).ok()).flatten()
1552}
1553
1554fn utf8_continuation_count(lead: u8) -> Option<usize> {
1555    match lead {
1556        0xc2..=0xdf => Some(1),
1557        0xe0..=0xef => Some(2),
1558        0xf0..=0xf4 => Some(3),
1559        _ => None,
1560    }
1561}
1562
1563fn parse_cat_v_meta_byte(bytes: &[u8], start: usize) -> Option<(u8, usize)> {
1564    if start + 2 > bytes.len() || &bytes[start..start + 2] != b"M-" {
1565        return None;
1566    }
1567    let pos = start + 2;
1568    match bytes.get(pos).copied()? {
1569        b'^' => {
1570            let c = bytes.get(pos + 1).copied()?;
1571            let low = if c == b'?' {
1572                0x7f
1573            } else if (b'@'..=b'_').contains(&c) {
1574                c - b'@'
1575            } else {
1576                return None;
1577            };
1578            Some((low | 0x80, pos + 2))
1579        }
1580        c if (0x20..=0x7e).contains(&c) => Some((c | 0x80, pos + 1)),
1581        _ => None,
1582    }
1583}
1584
1585/// INTERIM (#297) host shell selection: `bash -c` with an `sh -c` fallback
1586/// when bash is absent — the same sh-compatible free-form mode the confined
1587/// shell ran, so [`venv_cmd_prefix`]'s `export …;` prefix works unchanged.
1588#[cfg(not(windows))]
1589async fn host_shell_output(cmd: &str, cwd: &str) -> std::io::Result<std::process::Output> {
1590    fn shell(program: &str, cmd: &str, cwd: &str) -> tokio::process::Command {
1591        let mut c = tokio::process::Command::new(program);
1592        c.arg("-c").arg(cmd).current_dir(cwd);
1593        c
1594    }
1595    match shell("bash", cmd, cwd).output().await {
1596        Err(e) if e.kind() == std::io::ErrorKind::NotFound => shell("sh", cmd, cwd).output().await,
1597        other => other,
1598    }
1599}
1600
1601/// INTERIM (#297) host shell selection on Windows: `cmd /C`, the same shape
1602/// as [`build_check_shell`].
1603#[cfg(windows)]
1604async fn host_shell_output(cmd: &str, cwd: &str) -> std::io::Result<std::process::Output> {
1605    tokio::process::Command::new("cmd")
1606        .args(["/C", cmd])
1607        .current_dir(cwd)
1608        .output()
1609        .await
1610}
1611
1612/// Lexically normalise a path *string* — collapse `.` and `..` components
1613/// without touching the filesystem — so containment is decided on the location
1614/// the caller actually named, not on a raw byte prefix. Does NOT resolve
1615/// symlinks (that needs `canonicalize`, which requires the path to exist and is
1616/// the still-open `fs-canonical-containment` deviation): a symlink *inside* the
1617/// workspace can still point out. What this DOES close are the string-only
1618/// escapes — `..` traversal and sibling-prefix collisions.
1619fn lexically_normalize(path: &str) -> std::path::PathBuf {
1620    use std::path::{Component, PathBuf};
1621    let mut out = PathBuf::new();
1622    for comp in std::path::Path::new(path).components() {
1623        match comp {
1624            Component::CurDir => {}
1625            Component::ParentDir => {
1626                // Pop a real segment; never climb above a root/prefix.
1627                if !out.pop() {
1628                    out.push(comp.as_os_str());
1629                }
1630            }
1631            other => out.push(other.as_os_str()),
1632        }
1633    }
1634    out
1635}
1636
1637/// Returns true if `full_path` is permitted by `scope`.
1638///
1639/// The `Caveats` lattice stores workspace-root strings (not individual file
1640/// paths) with exact-set semantics; this layer adds containment so that "the
1641/// workspace root is permitted" means "any path *under* it is permitted". Both
1642/// the candidate and each root are lexically normalised (collapsing `..`) and
1643/// then compared by whole path components via [`std::path::Path::starts_with`],
1644/// so `..` traversal (`/ws/../etc/passwd`) and sibling-prefix collisions
1645/// (`/ws-secret` vs root `/ws`) no longer escape the fence — unlike the raw
1646/// string prefix match this replaced. Symlink containment is still open
1647/// (`fs-canonical-containment`); creating one needs exec, which is gated separately.
1648pub(crate) fn tui_permits_path(scope: &crate::caveats::Scope<String>, full_path: &str) -> bool {
1649    match scope {
1650        crate::caveats::Scope::All => true,
1651        crate::caveats::Scope::Only(set) if set.is_empty() => false,
1652        crate::caveats::Scope::Only(set) => {
1653            let candidate = lexically_normalize(full_path);
1654            set.iter()
1655                .any(|root| candidate.starts_with(lexically_normalize(root)))
1656        }
1657    }
1658}
1659
1660/// Run the configured build-check command in `workspace` and return a compact
1661/// result string appended to the tool output so the model sees it immediately.
1662pub(crate) fn run_build_check(cmd: &str, workspace: &str) -> String {
1663    let result = build_check_shell(cmd).current_dir(workspace).output();
1664    match result {
1665        Ok(out) if out.status.success() => "  ✓ build check passed".to_string(),
1666        Ok(out) => {
1667            let stderr = String::from_utf8_lossy(&out.stderr);
1668            let stdout = String::from_utf8_lossy(&out.stdout);
1669            let combined = format!("{stdout}{stderr}");
1670            let excerpt: String = combined.lines().take(8).collect::<Vec<_>>().join("\n");
1671            format!("  ✗ build check failed:\n{excerpt}")
1672        }
1673        Err(e) => format!("  ⚠ build check could not run: {e}"),
1674    }
1675}
1676
1677#[cfg(windows)]
1678fn build_check_shell(cmd: &str) -> std::process::Command {
1679    let mut shell = std::process::Command::new("cmd");
1680    shell.args(["/C", cmd]);
1681    shell
1682}
1683
1684#[cfg(not(windows))]
1685fn build_check_shell(cmd: &str) -> std::process::Command {
1686    let mut shell = std::process::Command::new("sh");
1687    shell.args(["-c", cmd]);
1688    shell
1689}
1690
1691#[cfg(all(test, windows))]
1692fn passing_build_check_cmd() -> &'static str {
1693    "exit /B 0"
1694}
1695
1696#[cfg(all(test, not(windows)))]
1697fn passing_build_check_cmd() -> &'static str {
1698    "true"
1699}
1700
1701#[cfg(all(test, windows))]
1702fn failing_build_check_cmd(message: &str) -> String {
1703    format!("echo {message} 1>&2 & exit /B 1")
1704}
1705
1706#[cfg(all(test, not(windows)))]
1707fn failing_build_check_cmd(message: &str) -> String {
1708    format!("echo {message} >&2; exit 1")
1709}
1710
1711/// Whether a confined-shell envelope carries the STRUCTURED `denied: true`
1712/// flag — the leash's machine-readable signal that the brush interceptor
1713/// refused an exec / open inside the free-form command. Reads the structured
1714/// field agent-bridle emits; it does NOT parse stdout/stderr (the old stderr
1715/// string-match was fragile — a command that merely *printed* a denial-like
1716/// phrase could be misread, and any wording drift would silently break
1717/// detection).
1718fn envelope_denied(envelope: &serde_json::Value) -> bool {
1719    envelope
1720        .get("denied")
1721        .and_then(serde_json::Value::as_bool)
1722        .unwrap_or(false)
1723}
1724
1725/// Build a human-readable denial message from the envelope's structured
1726/// `denials: [{ kind, target, reason }]` list, joining each entry's `reason`.
1727/// Falls back to a generic message when the list is missing or empty.
1728fn envelope_denial_reason(envelope: &serde_json::Value) -> String {
1729    let reasons: Vec<String> = envelope
1730        .get("denials")
1731        .and_then(serde_json::Value::as_array)
1732        .map(|arr| {
1733            arr.iter()
1734                .filter_map(|d| d.get("reason").and_then(serde_json::Value::as_str))
1735                .map(str::to_string)
1736                .collect()
1737        })
1738        .unwrap_or_default();
1739    if reasons.is_empty() {
1740        "denied: the capability leash refused an operation".to_string()
1741    } else {
1742        reasons.join("; ")
1743    }
1744}
1745
1746/// The allowlist NAME for an exec target — the trailing path component (the
1747/// program's basename), so `/usr/bin/env` and `C:\tools\env.exe` resolve to the
1748/// command a grant would actually allowlist. Used when lifting a denied exec
1749/// into a #263 [`PermissionRequest`].
1750fn exec_allowlist_name(target: &str) -> &str {
1751    target
1752        .rsplit(['/', '\\'])
1753        .find(|part| !part.is_empty())
1754        .unwrap_or(target)
1755}
1756
1757/// #721: the model-actionable recovery appended to every capability denial the
1758/// MODEL sees. A denial used to be a DEAD-END: it told the *human* to edit
1759/// `[tui.permissions]`, which the model cannot do mid-turn, so the loop stalled
1760/// (the issue's `mkdir` reproduction). This sentence tells the *model* what IT
1761/// can do — ask the operator to grant the capability via the
1762/// `request_permissions` tool, or change approach. The gate is unchanged: the
1763/// call is STILL denied; only the coaching is added. In a headless flow with no
1764/// operator, `request_permissions` answers "no operator available", which is
1765/// itself a recoverable signal (switch strategy) rather than a config edit.
1766const DENIAL_RECOVERY_HINT: &str =
1767    "This is outside your granted authority — call request_permissions with the \
1768     capability, a target, and a reason to ask the operator to grant it, or take \
1769     a different approach that stays within your current authority.";
1770
1771/// #479 (G4): the model-facing recovery coach when `crew`/`compose_roster` is
1772/// reached while the crew/team surface is OFF — the DEFAULT, since the runner is
1773/// only built when the operator sets `NEWT_TEAM`. Replaces the flat
1774/// `unknown tool: … (no crew surface …)` dead-end (which left the model nowhere
1775/// to go) with a model-actionable message in the #721 [`DENIAL_RECOVERY_HINT`]
1776/// style: it names the operator gesture that enables the surface (`NEWT_TEAM`)
1777/// AND a real solo alternative (the always-available file/exec tools), so the
1778/// reach is recoverable instead of a wall. The OCAP presence-gate is unchanged —
1779/// crew stays `NEWT_TEAM`-gated; only the coaching is added.
1780const CREW_OFF_RECOVERY_HINT: &str =
1781    "the crew/team surface is not enabled this session (the operator launches it \
1782     with NEWT_TEAM). Accomplish this yourself with the available tools \
1783     (read_file/write_file/edit_file/run_command/...), or ask the operator to \
1784     enable a crew.";
1785
1786/// The model-facing result for a `crew`/`compose_roster` dispatch when no
1787/// `CrewRunner` was injected. One factored message + regression point carrying
1788/// [`CREW_OFF_RECOVERY_HINT`], so the recoverable wording can never drift.
1789fn crew_off_recovery_result(name: &str) -> String {
1790    format!("'{name}' is unavailable: {CREW_OFF_RECOVERY_HINT}")
1791}
1792
1793/// #721: the model-facing capability-denial message for an fs tool — the base
1794/// "{kind} does not permit '{path}'" line plus the recoverable, model-actionable
1795/// [`DENIAL_RECOVERY_HINT`]. One factored message + regression point shared by
1796/// every fs denial (read_file / write_file / edit_file / list_dir / find), so
1797/// the recoverable wording can never drift between them.
1798fn denied_fs_result(kind: &str, path: &str) -> String {
1799    format!("capability denied: {kind} does not permit '{path}'. {DENIAL_RECOVERY_HINT}")
1800}
1801
1802/// The standard `run_command` capability-denial result, composed EXACTLY ONCE:
1803/// a single `capability denied: <bare reason>. <recovery hint>` for the model.
1804///
1805/// #775 (§2.5): the model-facing message is ONE clean level. Two earlier defects
1806/// are removed:
1807///
1808/// 1. The bare denial `reason` (a full sentence from the leash, e.g.
1809///    `exec of "export" is not within the granted authority`) is NO LONGER
1810///    stuffed into [`print_denied`]'s bare `'{target}'` slot. Doing so produced
1811///    the garbled `capability denied: exec does not permit '<whole reason
1812///    sentence> - add it via …>'` — a denial sentence nested inside another.
1813///    [`print_denied`] now receives only the BARE command target, matching its
1814///    `{axis} does not permit '{target}'` contract (the same shape the fs path
1815///    uses via [`denied_fs_result`]).
1816/// 2. The stale `extra_exec` config hint is gone from the model-facing message.
1817///    #721 superseded "edit your `[tui.permissions]` config" with the
1818///    model-actionable [`DENIAL_RECOVERY_HINT`] (`request_permissions`), so the
1819///    model now sees the bare reason once plus that hint — never a config edit
1820///    it cannot perform mid-turn.
1821///
1822/// The #263 prompt path still falls back here on deny (and on a second denial
1823/// after a re-execution).
1824fn denied_run_command_result(envelope: &serde_json::Value, color: bool) -> String {
1825    // Human transcript NOTICE: the bare denied command(s), never the reason
1826    // sentence (stuffing a sentence into print_denied's `'{target}'` slot was
1827    // the garble — see #775 above).
1828    print_denied(
1829        denial_axis_label(envelope),
1830        &exec_denial_target_label(envelope),
1831        color,
1832    );
1833    // Model-facing message: composed exactly once.
1834    format!(
1835        "capability denied: {}. {DENIAL_RECOVERY_HINT}",
1836        envelope_denial_reason(envelope)
1837    )
1838}
1839
1840/// The bare target for the human exec-denial NOTICE: the denied command name(s)
1841/// the leash refused, NEVER the reason sentence. Joins multiple targets with
1842/// `, `; falls back to a generic label so the notice always prints one clean
1843/// `{axis} does not permit '{target}'` line. (#775 — restores
1844/// [`print_denied`]'s bare-`'{target}'` contract.)
1845fn exec_denial_target_label(envelope: &serde_json::Value) -> String {
1846    let targets: Vec<&str> = envelope
1847        .get("denials")
1848        .and_then(serde_json::Value::as_array)
1849        .map(|arr| {
1850            arr.iter()
1851                .filter_map(|d| d.get("target").and_then(serde_json::Value::as_str))
1852                .filter(|t| !t.is_empty())
1853                .collect()
1854        })
1855        .unwrap_or_default();
1856    if targets.is_empty() {
1857        "a command".to_string()
1858    } else {
1859        targets.join(", ")
1860    }
1861}
1862
1863/// The standard `run_command` success path: print + return stdout/stderr,
1864/// or `(exit N)` when the command produced no output. Factored verbatim so
1865/// the #263 re-execution path shares one formatter with the first dispatch.
1866fn shell_envelope_output(
1867    envelope: &serde_json::Value,
1868    tool_output_lines: usize,
1869    color: bool,
1870    tool_offload: bool,
1871    spill_store: Option<&dyn SpillStore>,
1872) -> String {
1873    let stdout = envelope
1874        .get("stdout")
1875        .and_then(serde_json::Value::as_str)
1876        .unwrap_or("");
1877    let stderr = envelope
1878        .get("stderr")
1879        .and_then(serde_json::Value::as_str)
1880        .unwrap_or("");
1881    let out = format!("{stdout}{stderr}");
1882    // DISPLAY path: on-screen tool output is capped by LINES (unchanged).
1883    print_tool_output(&out, tool_output_lines, color);
1884    if out.trim().is_empty() {
1885        let code = envelope
1886            .get("exit_code")
1887            .and_then(serde_json::Value::as_i64)
1888            .unwrap_or(-1);
1889        format!("(exit {code})")
1890    } else {
1891        // #726/#945: the MODEL-facing payload is capped by the shared TOKEN
1892        // budget using head+tail. When tool_offload is on, spill the FULL
1893        // redacted output before capping so the true tail and elided middle stay
1894        // recoverable via memory_fetch("spill:<id>") and grep.
1895        let max_tokens = max_output_tokens();
1896        let est = crate::tokens::TokenEstimation::default();
1897        let over_model_budget = max_tokens != 0 && est.tokens_for_chars(out.len()) > max_tokens;
1898        let over_spill_budget = out.chars().count() > spill::TOOL_RESULT_SPILL_CAP;
1899        let should_spill =
1900            max_tokens != 0 && tool_offload && (over_model_budget || over_spill_budget);
1901        let capped = if should_spill {
1902            match spill_store {
1903                Some(store) => {
1904                    let (id, redacted) = spill::store_redacted_full(&out, store);
1905                    let teaser_tokens =
1906                        est.tokens_for_chars(spill::TOOL_RESULT_SPILL_CAP.saturating_sub(512));
1907                    cap_model_output_with_handle(
1908                        &redacted,
1909                        max_tokens.min(teaser_tokens),
1910                        output_head_tokens(),
1911                        Some(&id),
1912                    )
1913                }
1914                None => cap_model_output(&out, max_tokens),
1915            }
1916        } else {
1917            cap_model_output(&out, max_tokens)
1918        };
1919        // #898: if this command's output carries a forge "open a pull/merge
1920        // request" URL (git prints it on push of a new branch), append an
1921        // explicit next-step hint so the model opens the PR instead of stalling.
1922        // Detected from the UNcapped output so a long push log can't truncate the
1923        // URL away, and appended AFTER the cap so the hint always survives.
1924        match pr_creation_url(&out) {
1925            Some(url) => format!("{capped}{}", pr_next_step_hint(url)),
1926            None => capped,
1927        }
1928    }
1929}
1930
1931/// #898: the forge "open a pull/merge request" URL that git prints on `push` of
1932/// a new branch — GitHub `…/pull/new/<branch>` (or a `…/compare/…` link) and
1933/// GitLab `…/merge_requests/new…`. Returned so [`shell_envelope_output`] can
1934/// append a next-step hint: models routinely push and then stall instead of
1935/// opening the PR (issue #898). Scans whitespace-split tokens because git emits
1936/// the URL on its own `remote:`-prefixed line.
1937fn pr_creation_url(output: &str) -> Option<&str> {
1938    output.split_whitespace().find(|tok| {
1939        tok.starts_with("https://")
1940            && (tok.contains("/pull/new/")
1941                || tok.contains("/merge_requests/new")
1942                || tok.contains("/compare/"))
1943    })
1944}
1945
1946/// The next-step hint appended after a push whose output carries a PR-creation
1947/// URL (#898). Names the concrete `gh` command AND the tool boundary — the
1948/// embedded `git` tool cannot push or open PRs — so the model proceeds through
1949/// run_command + `gh` instead of looping back to the pushless git tool.
1950fn pr_next_step_hint(url: &str) -> String {
1951    format!(
1952        "\n\n[newt] A branch was pushed. To open a pull request now, call \
1953         run_command with `gh pr create --fill` (the `gh` CLI is available; the \
1954         `git` tool cannot push or open PRs). Or open this URL: {url}"
1955    )
1956}
1957
1958/// Lift a confined-shell denial envelope into promptable #263 requests.
1959///
1960/// Returns `Some` only when EVERY structured denial entry is an `exec` kind
1961/// with a non-empty target — the case the human can meaningfully grant (the
1962/// allowlist name, same basename rule as the config hint). Any other kind
1963/// (e.g. an `open` refused inside the shell) keeps the standard denial:
1964/// guessing which fs axis an opaque `open` maps to would over-grant.
1965fn exec_denial_requests(envelope: &serde_json::Value) -> Option<Vec<PermissionRequest>> {
1966    let denials = envelope.get("denials")?.as_array()?;
1967    if denials.is_empty() {
1968        return None;
1969    }
1970    let mut requests = Vec::with_capacity(denials.len());
1971    for d in denials {
1972        if d.get("kind")?.as_str()? != "exec" {
1973            return None;
1974        }
1975        let target = d.get("target")?.as_str().filter(|t| !t.is_empty())?;
1976        requests.push(PermissionRequest {
1977            tool: "run_command".to_string(),
1978            kind: DenialKind::Exec,
1979            target: exec_allowlist_name(target).to_string(),
1980            reason: d
1981                .get("reason")
1982                .and_then(serde_json::Value::as_str)
1983                .unwrap_or_default()
1984                .to_string(),
1985        });
1986    }
1987    Some(requests)
1988}
1989
1990/// #905: lift a confined-shell NET denial envelope into promptable #263 requests
1991/// — the `net`-axis sibling of [`exec_denial_requests`]. agent-bridle #196
1992/// surfaces a refused CONNECT host as `Denial { kind: "net", target: <host> }`
1993/// (with `denied: true`), so when the operator's `net` allow-list refuses a host
1994/// the shell reached (e.g. `git push` to `github.com`), this turns each into a
1995/// `PermissionRequest { kind: Net, target: host }` the gate can prompt per-host.
1996///
1997/// Returns `Some` only when EVERY denial is a `net` kind with a non-empty host
1998/// target — the case a grant is meaningful (add the host to the net allow-list).
1999/// A mixed or non-net batch returns `None` and keeps the standard denial.
2000fn net_denial_requests(envelope: &serde_json::Value) -> Option<Vec<PermissionRequest>> {
2001    let denials = envelope.get("denials")?.as_array()?;
2002    if denials.is_empty() {
2003        return None;
2004    }
2005    let mut requests = Vec::with_capacity(denials.len());
2006    for d in denials {
2007        if d.get("kind")?.as_str()? != "net" {
2008            return None;
2009        }
2010        let host = d.get("target")?.as_str().filter(|t| !t.is_empty())?;
2011        requests.push(PermissionRequest {
2012            tool: "run_command".to_string(),
2013            kind: DenialKind::Net,
2014            target: host.to_string(),
2015            reason: d
2016                .get("reason")
2017                .and_then(serde_json::Value::as_str)
2018                .unwrap_or_default()
2019                .to_string(),
2020        });
2021    }
2022    Some(requests)
2023}
2024
2025/// #905: the axis label for the human denial NOTICE — `net` when EVERY denial is
2026/// a net (host) refusal, else `exec` (exec / mixed / empty default). Keeps a net
2027/// denial from being mislabeled `exec does not permit '<host>'`.
2028fn denial_axis_label(envelope: &serde_json::Value) -> &'static str {
2029    let all_net = envelope
2030        .get("denials")
2031        .and_then(serde_json::Value::as_array)
2032        .filter(|arr| !arr.is_empty())
2033        .is_some_and(|arr| {
2034            arr.iter()
2035                .all(|d| d.get("kind").and_then(serde_json::Value::as_str) == Some("net"))
2036        });
2037    if all_net {
2038        "net"
2039    } else {
2040        "exec"
2041    }
2042}
2043
2044/// Consult the #263 gate for one denied fs path. Returns `true` only when
2045/// the human allowed it AND the re-minted caveats actually permit the path —
2046/// the widened authority is re-checked, never assumed.
2047fn fs_gate_allows(
2048    gate: &mut dyn PermissionGate,
2049    tool: &str,
2050    kind: DenialKind,
2051    full_path: &str,
2052    axis: impl Fn(&crate::caveats::Caveats) -> &crate::caveats::Scope<String>,
2053) -> bool {
2054    let request = PermissionRequest {
2055        tool: tool.to_string(),
2056        kind,
2057        target: full_path.to_string(),
2058        reason: format!("{} does not permit '{full_path}'", kind.as_str()),
2059    };
2060    match gate.ask(std::slice::from_ref(&request)) {
2061        PermissionDecision::Allow(widened) => tui_permits_path(axis(&widened), full_path),
2062        PermissionDecision::Deny => false,
2063    }
2064}
2065
2066/// #721: map the model-supplied `capability` string for `request_permissions`
2067/// onto a [`DenialKind`] axis. A small synonym set absorbs the names a weak
2068/// local model tends to emit; an unrecognized value returns `None` so the tool
2069/// coaches instead of guessing an axis (guessing would request the WRONG
2070/// authority). Pure — unit-tested directly.
2071fn parse_capability(s: &str) -> Option<DenialKind> {
2072    match s.trim().to_ascii_lowercase().as_str() {
2073        "exec" | "run" | "run_command" | "command" | "shell" => Some(DenialKind::Exec),
2074        "fs_read" | "fs-read" | "read" | "read_file" => Some(DenialKind::FsRead),
2075        "fs_write" | "fs-write" | "write" | "write_file" => Some(DenialKind::FsWrite),
2076        "net" | "network" | "web" | "web_fetch" => Some(DenialKind::Net),
2077        _ => None,
2078    }
2079}
2080
2081/// #721: the model-facing `request_permissions` tool — the capability-GRANT
2082/// path. It builds a [`PermissionRequest`] from `{capability, target, reason}`
2083/// and consults the SAME #263 [`PermissionGate`] a denial would: `Allow` reports
2084/// granted (and the gate has remembered any session grant, so the model's retry
2085/// of the original op rides the existing #263 re-exec machinery), `Deny` reports
2086/// declined, and **no gate** (headless / eval / ACP) reports that no operator is
2087/// available to grant — a recoverable signal (switch strategy), never a hang.
2088///
2089/// Reconciliation with #728: `request_permissions` (capability GRANT via
2090/// `gate.ask` / the #263 flow) and `request_user_input` (generic free-text Q&A
2091/// via `gate.ask_question`) are DISTINCT tools that share the ONE human-interface
2092/// gate ([`PermissionGate`]). They realize "both surface to the human" without
2093/// being merged: this one widens authority through the ocap gate, the other only
2094/// gathers text. `request_permissions` is deliberately NOT routed through
2095/// `request_user_input` — it mints caveats, which a free-text answer cannot.
2096fn execute_request_permissions(
2097    args: &serde_json::Value,
2098    gate: Option<&mut dyn PermissionGate>,
2099    color: bool,
2100    tool_output_lines: usize,
2101) -> String {
2102    let capability = args["capability"].as_str().unwrap_or("").trim();
2103    let target = args["target"].as_str().unwrap_or("").trim();
2104    let reason = args["reason"].as_str().unwrap_or("").trim();
2105    print_tool_call("request_permissions", capability, color);
2106
2107    let Some(kind) = parse_capability(capability) else {
2108        let out = format!(
2109            "request_permissions: unknown capability '{capability}'. Use one of: \
2110             exec, fs_read, fs_write, net."
2111        );
2112        print_tool_output(&out, tool_output_lines, color);
2113        return out;
2114    };
2115    if target.is_empty() {
2116        let out = "request_permissions: 'target' is required — the command name (exec), \
2117                   the path (fs_read/fs_write), or the host (net)."
2118            .to_string();
2119        print_tool_output(&out, tool_output_lines, color);
2120        return out;
2121    }
2122
2123    let request = PermissionRequest {
2124        tool: "request_permissions".to_string(),
2125        kind,
2126        target: target.to_string(),
2127        reason: if reason.is_empty() {
2128            format!("model requested {capability} for '{target}'")
2129        } else {
2130            reason.to_string()
2131        },
2132    };
2133
2134    let out = match gate {
2135        // The gate consults the operator and (for a session grant) remembers it,
2136        // exactly as a denial-driven prompt does. We do not re-execute anything
2137        // here — the model retries its original tool call, which rides the #263
2138        // re-exec path under the now-granted caveats.
2139        Some(g) => match g.ask(std::slice::from_ref(&request)) {
2140            PermissionDecision::Allow(_widened) => format!(
2141                "granted: the operator allowed {capability} for '{target}'. \
2142                 Retry the original operation now."
2143            ),
2144            PermissionDecision::Deny => format!(
2145                "denied: the operator declined {capability} for '{target}'. \
2146                 Do not retry it — take a different approach."
2147            ),
2148        },
2149        // Headless / eval / ACP: no interactive gate exists to grant authority.
2150        // This is recoverable (change strategy), not a config edit the model
2151        // can perform mid-turn.
2152        None => format!(
2153            "no operator available to grant {capability} for '{target}' — this session \
2154             has no interactive permission gate (headless / eval / piped). The capability \
2155             must be configured by the owner (e.g. [tui.permissions] in newt config); \
2156             take a different approach for now."
2157        ),
2158    };
2159    print_tool_output(&out, tool_output_lines, color);
2160    out
2161}
2162
2163/// #728: returned by `request_user_input` when there is no human to ask — either
2164/// no interactive gate this session (headless / eval / ACP / piped) or the gate
2165/// has no operator available (`ask_question` returned `None`). A recoverable
2166/// signal the model can act on, NEVER a hang.
2167const HEADLESS_NO_HUMAN: &str = "no human available this session (running headless) \
2168    — proceed with your best judgment or state your assumption explicitly.";
2169
2170/// #728: the model-facing `request_user_input` tool — the GENERIC ask-the-human
2171/// path. It surfaces a free-text `question` to the operator through the SAME
2172/// human-interface gate a permission prompt uses ([`PermissionGate::ask_question`])
2173/// and returns the typed answer. With an operator present the answer is returned
2174/// verbatim; with NO gate (headless / eval / ACP / piped) — or when the gate has
2175/// no human to consult (`ask_question` returns `None`) — it returns the
2176/// [`HEADLESS_NO_HUMAN`] message and NEVER blocks.
2177///
2178/// Reconciliation with #721: this is the free-text Q&A path; `request_permissions`
2179/// is the capability-GRANT path (it mints caveats via the gate). Both surface to
2180/// the human through the one gate but are DISTINCT tools — one gathers text
2181/// (`ask_question`), the other widens authority (`ask`) — and are not merged.
2182fn execute_request_user_input(
2183    args: &serde_json::Value,
2184    gate: Option<&mut dyn PermissionGate>,
2185    color: bool,
2186    tool_output_lines: usize,
2187) -> String {
2188    let question = args["question"].as_str().unwrap_or("").trim();
2189    print_tool_call("request_user_input", question, color);
2190
2191    if question.is_empty() {
2192        let out = "request_user_input: 'question' is required — the free-text \
2193                   question to ask the human."
2194            .to_string();
2195        print_tool_output(&out, tool_output_lines, color);
2196        return out;
2197    }
2198
2199    // `Some(answer)` from the gate → return it verbatim; a `None` gate (headless)
2200    // OR an `ask_question` that returns `None` (no human to consult) → the
2201    // recoverable headless message. Either way we never block without an answer.
2202    let out = match gate.and_then(|g| g.ask_question(question)) {
2203        Some(answer) => answer,
2204        None => HEADLESS_NO_HUMAN.to_string(),
2205    };
2206    print_tool_output(&out, tool_output_lines, color);
2207    out
2208}
2209
2210/// Best-effort host extraction for the #263 net pre-check. This only gates
2211/// whether to PROMPT — reachability enforcement stays with the bridle's
2212/// leash (host allowlist + SSRF screen). `None` (unparseable / non-http URL)
2213/// skips the pre-check entirely, leaving today's dispatch path untouched.
2214pub(crate) fn host_of_url(url: &str) -> Option<String> {
2215    let rest = url
2216        .strip_prefix("https://")
2217        .or_else(|| url.strip_prefix("http://"))?;
2218    let authority = rest.split(['/', '?', '#']).next()?;
2219    let host_port = authority.rsplit('@').next()?;
2220    // IPv6 literal `[::1]:8080` — the host is the bracketed part.
2221    let host = if let Some(stripped) = host_port.strip_prefix('[') {
2222        stripped.split(']').next()?
2223    } else {
2224        host_port.split(':').next()?
2225    };
2226    if host.is_empty() {
2227        None
2228    } else {
2229        Some(host.to_ascii_lowercase())
2230    }
2231}
2232
2233/// File-type restriction for the embedded `find` tool (#496).
2234#[derive(Clone, Copy, PartialEq, Eq, Debug)]
2235enum FindType {
2236    Files,
2237    Dirs,
2238    Any,
2239}
2240
2241/// Parsed, validated options for one `find` invocation.
2242struct FindOpts<'a> {
2243    /// Glob matched against each basename; `None` matches everything.
2244    name: Option<&'a str>,
2245    type_filter: FindType,
2246    /// Max depth below the search root (1 = immediate children); `None` =
2247    /// unlimited.
2248    max_depth: Option<usize>,
2249    /// Hard cap on returned matches.
2250    max_results: usize,
2251    /// Honour .gitignore + skip .git/target/node_modules/hidden dirs.
2252    respect_gitignore: bool,
2253    case_sensitive: bool,
2254}
2255
2256/// One-line summary of a `find` invocation for the tool trace (#529): the path
2257/// plus only the *non-default* filters, so two searches with different filters
2258/// don't both render as a bare `find: .`. Defaults (any type, unlimited depth,
2259/// the 1000-match cap, gitignore-respecting, case-sensitive) are omitted.
2260fn find_detail(path: &str, opts: &FindOpts) -> String {
2261    let mut parts: Vec<String> = Vec::new();
2262    if let Some(name) = opts.name {
2263        parts.push(format!("name={name}"));
2264    }
2265    match opts.type_filter {
2266        FindType::Files => parts.push("type=f".to_string()),
2267        FindType::Dirs => parts.push("type=d".to_string()),
2268        FindType::Any => {}
2269    }
2270    if let Some(d) = opts.max_depth {
2271        parts.push(format!("depth={d}"));
2272    }
2273    // Mirrors the parse default in the `find` arm.
2274    if opts.max_results != 1000 {
2275        parts.push(format!("max={}", opts.max_results));
2276    }
2277    if !opts.respect_gitignore {
2278        parts.push("no-gitignore".to_string());
2279    }
2280    if !opts.case_sensitive {
2281        parts.push("icase".to_string());
2282    }
2283    if parts.is_empty() {
2284        path.to_string()
2285    } else {
2286        format!("{path} ({})", parts.join(", "))
2287    }
2288}
2289
2290/// Translate a shell-style basename glob (`*`, `?`) into an anchored regex.
2291/// Every other character is matched literally (regex metacharacters escaped),
2292/// so `pyo3_module.rs` matches only that exact basename, not `pyo3Xmodulexrs`.
2293fn glob_to_regex(glob: &str, case_sensitive: bool) -> Result<regex::Regex, String> {
2294    let mut re = String::with_capacity(glob.len() + 8);
2295    if !case_sensitive {
2296        re.push_str("(?i)");
2297    }
2298    re.push('^');
2299    for ch in glob.chars() {
2300        match ch {
2301            '*' => re.push_str(".*"),
2302            '?' => re.push('.'),
2303            // Escape every regex metacharacter so the rest is literal.
2304            '.' | '+' | '(' | ')' | '|' | '[' | ']' | '{' | '}' | '^' | '$' | '\\' => {
2305                re.push('\\');
2306                re.push(ch);
2307            }
2308            other => re.push(other),
2309        }
2310    }
2311    re.push('$');
2312    regex::Regex::new(&re).map_err(|e| format!("invalid name pattern: {e}"))
2313}
2314
2315/// Recursively walk `root` and collect matches as workspace-relative,
2316/// `/`-normalised, sorted paths. Pure-`ignore`-crate traversal (no shell, no
2317/// subprocess) — the whole point of #496. Never follows symlinked directories
2318/// (avoids cycles and workspace escapes). Returns `(matches, truncated)` where
2319/// `truncated` is true if `max_results` was reached and more existed.
2320fn find_walk(
2321    root: &std::path::Path,
2322    workspace_root: &std::path::Path,
2323    opts: &FindOpts<'_>,
2324) -> Result<(Vec<String>, bool), String> {
2325    let pattern = match opts.name {
2326        Some(g) if !g.is_empty() => Some(glob_to_regex(g, opts.case_sensitive)?),
2327        _ => None,
2328    };
2329
2330    let mut builder = ignore::WalkBuilder::new(root);
2331    builder
2332        .hidden(opts.respect_gitignore)
2333        .ignore(opts.respect_gitignore)
2334        .git_ignore(opts.respect_gitignore)
2335        .git_global(opts.respect_gitignore)
2336        .git_exclude(opts.respect_gitignore)
2337        .parents(opts.respect_gitignore)
2338        // Honour .gitignore even outside a git repo (the agent's cwd may not be
2339        // a checkout); without this `ignore` silently ignores gitignore files.
2340        .require_git(false)
2341        .follow_links(false);
2342    if let Some(d) = opts.max_depth {
2343        builder.max_depth(Some(d));
2344    }
2345    // The `ignore` walker prunes via .gitignore/hidden but has no built-in
2346    // skip for build/dep dirs. Prune them explicitly (and cheaply, before
2347    // descent) so a default `find` doesn't drown in target/ or node_modules/.
2348    // `.git` is already covered by `.hidden(true)`. Skipped only when respecting
2349    // ignores — `respect_gitignore=false` means "search everything".
2350    if opts.respect_gitignore {
2351        let mut ob = ignore::overrides::OverrideBuilder::new(root);
2352        // In override globs a leading `!` excludes; with no whitelist globs
2353        // present, everything else stays included.
2354        if ob.add("!target/").is_ok() && ob.add("!node_modules/").is_ok() {
2355            if let Ok(ov) = ob.build() {
2356                builder.overrides(ov);
2357            }
2358        }
2359    }
2360
2361    let mut out: Vec<String> = Vec::new();
2362    let mut truncated = false;
2363    for result in builder.build() {
2364        let entry = match result {
2365            Ok(e) => e,
2366            // Skip individual unreadable entries rather than failing the walk.
2367            Err(_) => continue,
2368        };
2369        // depth 0 is the search root itself — never a match.
2370        if entry.depth() == 0 {
2371            continue;
2372        }
2373        let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false);
2374        match opts.type_filter {
2375            FindType::Files if is_dir => continue,
2376            FindType::Dirs if !is_dir => continue,
2377            _ => {}
2378        }
2379        if let Some(re) = &pattern {
2380            let base = entry.file_name().to_string_lossy();
2381            if !re.is_match(&base) {
2382                continue;
2383            }
2384        }
2385        if out.len() >= opts.max_results {
2386            truncated = true;
2387            break;
2388        }
2389        let rel = entry
2390            .path()
2391            .strip_prefix(workspace_root)
2392            .unwrap_or_else(|_| entry.path());
2393        out.push(rel.to_string_lossy().replace('\\', "/"));
2394    }
2395    out.sort();
2396    out.dedup();
2397    Ok((out, truncated))
2398}
2399
2400/// Execute a single tool call and return the result string sent back to the model.
2401///
2402/// `run_command` is routed through agent-bridle's Caveats-confined, brush-backed
2403/// `shell` tool: the WHOLE command runs inside the leash (`echo ok && rm -rf /`
2404/// no longer slips `rm` past an `echo` grant — every external spawn passes the
2405/// interceptor's `before_exec` / `before_open` gate). The fs tools
2406/// (`read_file` / `write_file` / `list_dir`) keep enforcing the same `caveats`
2407/// via `permits_*` — rerouting them is out of scope.
2408///
2409/// `note_sink` backs the `save_note` tool (Step 19.3), `recall_source` the
2410/// `recall` tool (Step 17.5), and `memory_source` the `memory_fetch` tool
2411/// (progressive-disclosure memory, #319). `None` ⇒ the tool was never
2412/// advertised, so a call here is treated like any unknown tool.
2413///
2414/// `permission_gate` is the #263 prompted-grant seam: when present, a
2415/// capability denial consults the human (allow once / session allow / deny)
2416/// before failing; an allow re-executes the denied call under the gate's
2417/// freshly minted caveats. `None` (the default, and every headless caller)
2418/// keeps every denial exactly as it was — bit-for-bit. #721's
2419/// `request_permissions` tool also rides this gate: it lets the MODEL proactively
2420/// request a grant (vs. only reacting to a denial), and reports "no operator
2421/// available" when the gate is `None`.
2422///
2423/// INTERIM (#297): when [`ocap_disabled`] is asserted (`--disable-ocap` /
2424/// `--yolo` / `NEWT_DISABLE_OCAP=1`), `run_command` skips the confined shell
2425/// and runs on the plain host shell with the same venv/PATH prefix and an
2426/// envelope of the same shape — nothing is denied, so the #263 gate is never
2427/// consulted for exec. Every other tool (fs fence, `web_fetch` leash) is
2428/// unaffected. Removed when brush upstreams `CommandInterceptor`
2429/// (agent-bridle#20).
2430///
2431/// `exec_floor` (issue #307) is the **named-permission-preset clamp** acting as
2432/// a hard authority FLOOR over exec. `None` (every existing caller, and the
2433/// no-preset case) leaves the `--disable-ocap` bypass exactly as it was —
2434/// bit-for-bit. `Some(scope)` makes the bypass conditional: an out-of-floor
2435/// command does NOT take the unconfined host path, it falls through to the
2436/// confined shell, which enforces the already-clamped `caveats` and denies it.
2437/// This is what makes a deliberately-restricted on-call/triage mode win over a
2438/// `--yolo` flag — the preset clamp is consulted as a ceiling the bypass
2439/// cannot cross.
2440#[allow(clippy::too_many_arguments)]
2441pub async fn execute_tool(
2442    name: &str,
2443    args: &serde_json::Value,
2444    workspace: &str,
2445    color: bool,
2446    tool_output_lines: usize,
2447    caveats: &crate::caveats::Caveats,
2448    mcp: &mut dyn McpTools,
2449    build_check_cmd: Option<&str>,
2450    note_sink: Option<&mut dyn NoteSink>,
2451    recall_source: Option<&dyn RecallSource>,
2452    memory_source: Option<&dyn MemorySource>,
2453    permission_gate: Option<&mut dyn PermissionGate>,
2454    exec_floor: Option<&crate::caveats::Scope<String>>,
2455    git_tool: Option<&dyn GitTool>,
2456    crew_runner: Option<&dyn CrewRunner>,
2457    scratchpad_store: Option<&dyn super::scratchpad::ScratchpadStore>,
2458    code_search: Option<super::semantic::CodeSearch<'_>>,
2459    experience_store: Option<&dyn super::experiential::ExperienceStore>,
2460    step_ledger: Option<&dyn super::scheduled::StepLedger>,
2461) -> String {
2462    execute_tool_with_offload(
2463        name,
2464        args,
2465        workspace,
2466        color,
2467        tool_output_lines,
2468        caveats,
2469        mcp,
2470        build_check_cmd,
2471        note_sink,
2472        recall_source,
2473        memory_source,
2474        permission_gate,
2475        exec_floor,
2476        git_tool,
2477        crew_runner,
2478        scratchpad_store,
2479        code_search,
2480        experience_store,
2481        step_ledger,
2482        false,
2483        None,
2484    )
2485    .await
2486}
2487
2488#[allow(clippy::too_many_arguments)]
2489pub async fn execute_tool_with_offload(
2490    name: &str,
2491    args: &serde_json::Value,
2492    workspace: &str,
2493    color: bool,
2494    tool_output_lines: usize,
2495    caveats: &crate::caveats::Caveats,
2496    mcp: &mut dyn McpTools,
2497    build_check_cmd: Option<&str>,
2498    note_sink: Option<&mut dyn NoteSink>,
2499    recall_source: Option<&dyn RecallSource>,
2500    memory_source: Option<&dyn MemorySource>,
2501    permission_gate: Option<&mut dyn PermissionGate>,
2502    exec_floor: Option<&crate::caveats::Scope<String>>,
2503    git_tool: Option<&dyn GitTool>,
2504    crew_runner: Option<&dyn CrewRunner>,
2505    scratchpad_store: Option<&dyn super::scratchpad::ScratchpadStore>,
2506    code_search: Option<super::semantic::CodeSearch<'_>>,
2507    experience_store: Option<&dyn super::experiential::ExperienceStore>,
2508    step_ledger: Option<&dyn super::scheduled::StepLedger>,
2509    tool_offload: bool,
2510    spill_store: Option<&dyn SpillStore>,
2511) -> String {
2512    // Remote MCP tools (namespaced `server__tool`) route to their server before
2513    // the built-in match. They carry no Caveats leash in this build.
2514    if mcp.handles(name) {
2515        print_tool_call(name, &args.to_string(), color);
2516        let out = mcp.call(name, args).await;
2517        print_tool_output(&out, tool_output_lines, color);
2518        return out;
2519    }
2520
2521    // Step 27.1: resolve foreign / hallucinated tool names (str_replace_editor,
2522    // execute, bash, …) BEFORE the dispatch match. Compatible-arg aliases
2523    // rewrite to the canonical name and dispatch transparently; the rest return
2524    // a correction that names the right tool. Real names (and MCP `server__tool`
2525    // names, handled above) fall through unchanged.
2526    let name = match resolve_tool_alias(name) {
2527        Some(AliasOutcome::Rewrite(canonical)) => canonical,
2528        Some(AliasOutcome::Correct(msg)) => return msg,
2529        None => name,
2530    };
2531
2532    // facade P4 (#780): hidden tool-call routing. After alias normalization, a
2533    // `run_command` (or a shell alias rewritten to one) whose command is a
2534    // read-only reach (`cat`/`ls`/`find` + read-only `git`) is SILENTLY
2535    // rewritten to the governed built-in, so the model's instinctive shell
2536    // calls go through the SAME fs / git caveat checks they would by calling
2537    // the built-in directly — routing is NOT a bypass (§4.4). The route/gate
2538    // split is pure DATA ([`super::routing::RouteTable`]). State-modifying git
2539    // and everything else stay on the exec path (`RouteDecision::Exec`).
2540    //
2541    // `--no-route` / `NEWT_NO_ROUTE` ([`routing_disabled`]) turns this L2
2542    // convenience OFF — the command runs the normal exec path as-is — while the
2543    // L3 boundary (the confined shell below, the fs fence) STAYS. It is a switch
2544    // DISTINCT from `--disable-ocap` (§7-F5): the routing escape never disables
2545    // confinement.
2546    let routed: Option<(&'static str, serde_json::Value)> =
2547        if name == "run_command" && !routing_disabled() {
2548            let command = args.get("command").and_then(|v| v.as_str()).unwrap_or("");
2549            let decision = super::routing::RouteTable::builtin().classify(command);
2550            // §4.4: log every silent rewrite (the original command + the
2551            // governed built-in it routed to). `None` ⇒ nothing was rewritten.
2552            if let Some(line) = super::routing::audit_line(command, &decision) {
2553                tracing::debug!(target: "newt::routing", "{line}");
2554            }
2555            match decision {
2556                super::routing::RouteDecision::Route { tool, args } => Some((tool, args)),
2557                super::routing::RouteDecision::Exec => None,
2558            }
2559        } else {
2560            None
2561        };
2562    let (name, args): (&str, &serde_json::Value) = match &routed {
2563        Some((tool, routed_args)) => (*tool, routed_args),
2564        None => (name, args),
2565    };
2566
2567    match name {
2568        // Model-curated memory (Step 19.3): routes add / replace / remove
2569        // through the caller's NoteSink — the same MemoryManager → NoteStore
2570        // path as `/remember`, so the 19.1 char-cap curator error and the
2571        // 19.2 write-time security scan apply identically.
2572        "save_note" => match note_sink {
2573            Some(sink) => execute_save_note(args, sink, color, tool_output_lines),
2574            // Without a sink the tool was never advertised — a call here is
2575            // a model hallucination; answer like any unknown tool.
2576            None => "unknown tool: save_note (no note store in this session)".to_string(),
2577        },
2578
2579        // Cross-session recall (Step 17.5): searches PAST conversations via
2580        // the caller's RecallSource — workspace-fenced by the store, current
2581        // conversation excluded by the source implementation.
2582        "recall" => match recall_source {
2583            Some(source) => execute_recall(args, source, color, tool_output_lines),
2584            // Without a source the tool was never advertised — same
2585            // unknown-tool answer as the sink-less save_note path.
2586            None => "unknown tool: recall (no conversation store in this session)".to_string(),
2587        },
2588
2589        // Progressive-disclosure memory (Workstream A MVP, #319): pulls the
2590        // verbatim body of one ADDRESSED item (`note:<id>` / `turn:<conv>#<seq>`)
2591        // via the caller's MemorySource — workspace-fenced by the underlying
2592        // NoteStore / ConversationStore. Same presence-gating as `recall`.
2593        "memory_fetch" => match memory_source {
2594            Some(source) => execute_memory_fetch(args, source, color, tool_output_lines),
2595            // Without a source the tool was never advertised — same
2596            // unknown-tool answer as the source-less recall path.
2597            None => "unknown tool: memory_fetch (no memory source in this session)".to_string(),
2598        },
2599
2600        // Step 26.4 (#583): scratchpad state tools — presence-gated on the
2601        // injected store (advertised only when the `scratchpad` feature is on).
2602        "state_set" => match scratchpad_store {
2603            Some(s) => super::scratchpad::execute_state_set(args, s, color, tool_output_lines),
2604            None => "unknown tool: state_set (no scratchpad in this session)".to_string(),
2605        },
2606        "state_get" => match scratchpad_store {
2607            Some(s) => super::scratchpad::execute_state_get(args, s, color, tool_output_lines),
2608            None => "unknown tool: state_get (no scratchpad in this session)".to_string(),
2609        },
2610        "state_clear" => match scratchpad_store {
2611            Some(s) => super::scratchpad::execute_state_clear(s, color, tool_output_lines),
2612            None => "unknown tool: state_clear (no scratchpad in this session)".to_string(),
2613        },
2614
2615        // Step 26.5.5 (#582): semantic code search — presence-gated on the
2616        // injected searcher (advertised only when the `semantic` feature is on).
2617        "code_search" => match code_search {
2618            Some(search) => {
2619                super::semantic::execute_code_search(args, search, color, tool_output_lines).await
2620            }
2621            None => {
2622                "unknown tool: code_search (semantic retrieval is off this session)".to_string()
2623            }
2624        },
2625
2626        // Step 26.6a (#585): experiential record/recall — presence-gated on the
2627        // store (advertised only when the `experiential` feature is on).
2628        "experience_record" => match experience_store {
2629            Some(s) => {
2630                super::experiential::execute_experience_record(args, s, color, tool_output_lines)
2631            }
2632            None => "unknown tool: experience_record (experiential memory is off)".to_string(),
2633        },
2634        "experience_recall" => match experience_store {
2635            Some(s) => super::experiential::execute_experience_recall(
2636                args,
2637                s,
2638                super::experiential::EXPERIENCE_TOP_K,
2639                color,
2640                tool_output_lines,
2641            ),
2642            None => "unknown tool: experience_recall (experiential memory is off)".to_string(),
2643        },
2644
2645        // Step 26.6b (#586) / #715 PR2: scheduled update_plan — the single plan
2646        // WRITE tool, presence-gated on the ledger (advertised only when the
2647        // `scheduled` feature is on). Replaces plan_set + plan_advance.
2648        "update_plan" => match step_ledger {
2649            Some(l) => super::scheduled::execute_update_plan(args, l, color, tool_output_lines),
2650            None => "unknown tool: update_plan (scheduled planning is off)".to_string(),
2651        },
2652        // #716: read-only plan view (the alias target for "what was I doing?"
2653        // probes) — same presence gate as update_plan.
2654        "plan_get" => match step_ledger {
2655            Some(l) => super::scheduled::execute_plan_get(l, color, tool_output_lines),
2656            None => "unknown tool: plan_get (scheduled planning is off)".to_string(),
2657        },
2658
2659        // #714: self-scoped resume recovery — reads THIS conversation's recent
2660        // turns (via the RecallSource's this_conversation_recent, the opposite
2661        // of recall's filter), the <plan>, and the <state>. Advertised ALWAYS,
2662        // so it reuses the already-present recall_source / step_ledger /
2663        // scratchpad_store params and degrades gracefully when they are None.
2664        "resume_context" => super::resume::execute_resume_context(
2665            recall_source,
2666            step_ledger,
2667            scratchpad_store,
2668            color,
2669            tool_output_lines,
2670        ),
2671
2672        // #721: the capability-GRANT request — rides the SAME #263 gate a denial
2673        // would consult. Advertised always; `permission_gate` is `None` for
2674        // headless / eval / ACP, where it answers "no operator available" rather
2675        // than blocking. Consumes the gate (mutually exclusive with the
2676        // run_command / fs arms that also use it — only one arm runs per call).
2677        "request_permissions" => {
2678            execute_request_permissions(args, permission_gate, color, tool_output_lines)
2679        }
2680
2681        // #728: the GENERIC ask-the-human tool — surfaces a free-text question to
2682        // the operator via the SAME #263 human-interface gate (`ask_question`)
2683        // and returns the answer. Advertised always; `permission_gate` is `None`
2684        // for headless / eval / ACP, where it answers "no human available this
2685        // session" rather than blocking. Consumes the gate (mutually exclusive
2686        // with the run_command / fs / request_permissions arms that also use it —
2687        // only one arm runs per call).
2688        "request_user_input" => {
2689            execute_request_user_input(args, permission_gate, color, tool_output_lines)
2690        }
2691
2692        // #725: tool discovery — search THIS session's advertised catalog by
2693        // intent so a model that half-remembers a capability finds the real tool
2694        // name instead of fabricating one. Advertised always; the catalog is
2695        // rebuilt here from the live presence sources (the `with_*` flags derive
2696        // from which optional capabilities this call was handed), so the search
2697        // reflects exactly what was advertised — built-ins, presence-gated tools,
2698        // AND the connected MCP `server__tool` entries. The matcher
2699        // ([`super::tool_search::execute_tool_search`]) is pure; the print + the
2700        // catalog build live here.
2701        "tool_search" => {
2702            let query = args.get("query").and_then(|v| v.as_str()).unwrap_or("");
2703            print_tool_call("tool_search", query, color);
2704            let catalog = merged_tool_definitions(
2705                &*mcp,
2706                note_sink.is_some(),
2707                recall_source.is_some(),
2708                memory_source.is_some(),
2709                git_tool.is_some(),
2710                crew_runner.is_some(),
2711                scratchpad_store.is_some(),
2712                code_search.is_some(),
2713                experience_store.is_some(),
2714                step_ledger.is_some(),
2715            );
2716            let out = super::tool_search::execute_tool_search(query, &catalog);
2717            print_tool_output(&out, tool_output_lines, color);
2718            out
2719        }
2720
2721        // Embedded git (PR4, #461): dispatch through the injected GitTool
2722        // (newt-git's LocalGitTool). `GitCaveats::from_session` projects the
2723        // session's authority onto the git surface (fail-closed: a read-only
2724        // session can read but not commit). Same presence-gating as `recall` —
2725        // without an injected impl the tool was never advertised.
2726        "git" => match git_tool {
2727            Some(tool) => {
2728                let gc = crate::git_caveats::GitCaveats::from_session(caveats);
2729                let op = args.get("op").and_then(|v| v.as_str()).unwrap_or("");
2730                print_tool_call("git", op, color);
2731                let out = match tool.dispatch(op, args, &gc) {
2732                    Ok(rendered) => rendered,
2733                    // Denials + engine errors surface verbatim so the model
2734                    // sees WHY (e.g. "denied: commit" on a read-only session).
2735                    Err(e) => format!("error: {e}"),
2736                };
2737                print_tool_output(&out, tool_output_lines, color);
2738                out
2739            }
2740            None => "unknown tool: git (no git surface in this session)".to_string(),
2741        },
2742
2743        // Agent-callable orchestration (#479): compose_roster proposes a crew
2744        // roster from the live environment; crew dispatches a crew/team on a task
2745        // and returns the diff + verify status for the overseer to review. Both
2746        // route through the injected CrewRunner, which runs spawned crews under
2747        // `meet`-attenuated caveats. Same presence-gating as `git` (the `/team`
2748        // toggle) — without an injected impl the tools were never advertised.
2749        "compose_roster" | "crew" => match crew_runner {
2750            Some(runner) => {
2751                print_tool_call(name, &args.to_string(), color);
2752                let out = match runner.dispatch(name, args, caveats).await {
2753                    Ok(rendered) => rendered,
2754                    Err(e) => format!("error: {e}"),
2755                };
2756                print_tool_output(&out, tool_output_lines, color);
2757                out
2758            }
2759            // #479 (G4): replace the flat dead-end with a recoverable coach —
2760            // name the operator gesture (NEWT_TEAM) + a real solo alternative.
2761            None => crew_off_recovery_result(name),
2762        },
2763
2764        "run_command" => {
2765            let cmd = args["command"].as_str().unwrap_or("");
2766
2767            // Corrective guard: the model tried to call a tool as a shell binary.
2768            // Return a correction so the model can retry with the right tool call.
2769            // #898: git NETWORK ops (push/fetch/pull/clone) are NOT bounced — the
2770            // embedded git tool can't do them, so they fall through to the shell
2771            // (net-gated), letting the model push a branch and open a PR.
2772            if let Some(tool) = run_command_redirect(cmd) {
2773                return format!(
2774                    "error: '{tool}' is a tool, not a shell command. \
2775                     Call it as a separate tool invocation — \
2776                     do not pass '{tool}' as a command argument to run_command."
2777                );
2778            }
2779
2780            print_tool_call("run_command", cmd, color);
2781
2782            // Route the WHOLE command through agent-bridle's confined shell
2783            // (free-form `cmd` mode) under the SAME Caveats the TUI resolved from
2784            // `[tui].permissions`. The confined-exec core is shared with the
2785            // `lifecycle` arm (#891) so both honor identical exec caveats.
2786            exec_confined_command(
2787                cmd,
2788                workspace,
2789                color,
2790                tool_output_lines,
2791                caveats,
2792                exec_floor,
2793                permission_gate,
2794                tool_offload,
2795                spill_store,
2796            )
2797            .await
2798        }
2799
2800        // #891: the model-facing lifecycle surface over the #880 system. Resolve
2801        // THIS repo's command for the named phase (`.newt/config.toml
2802        // [lifecycle]` → matching tooling packs) and run it through the SAME
2803        // confined exec path as run_command. `action=list` returns the resolved
2804        // command WITHOUT running it — a pure discovery read.
2805        "lifecycle" => {
2806            let phase_key = args.get("phase").and_then(|v| v.as_str()).unwrap_or("");
2807            let Some(phase) = crate::tooling::Phase::from_key(phase_key) else {
2808                let valid = crate::tooling::Phase::ALL
2809                    .iter()
2810                    .map(|p| p.as_str())
2811                    .collect::<Vec<_>>()
2812                    .join(", ");
2813                return format!(
2814                    "error: unknown lifecycle phase '{phase_key}'. Valid phases: {valid}."
2815                );
2816            };
2817            let action = args.get("action").and_then(|v| v.as_str()).unwrap_or("run");
2818            // Resolve from the `[lifecycle]` override → matching tooling packs.
2819            // Several toolchains may each contribute a command; join with `&&`
2820            // so the confined shell runs them in sequence and short-circuits on
2821            // the first failure (the safe-subset engine supports `&&`).
2822            let cmds =
2823                crate::tooling::resolved_phase_commands(std::path::Path::new(workspace), phase);
2824            if cmds.is_empty() {
2825                return format!(
2826                    "no command configured for lifecycle phase '{}'. Set it in \
2827                     .newt/config.toml [lifecycle] or a tooling pack; `lifecycle` \
2828                     only runs commands the project declares.",
2829                    phase.as_str()
2830                );
2831            }
2832            let joined = cmds.join(" && ");
2833            match action {
2834                "list" => format!("lifecycle {} → {joined}", phase.as_str()),
2835                "run" => {
2836                    print_tool_call(
2837                        "lifecycle",
2838                        &format!("{} → {joined}", phase.as_str()),
2839                        color,
2840                    );
2841                    exec_confined_command(
2842                        &joined,
2843                        workspace,
2844                        color,
2845                        tool_output_lines,
2846                        caveats,
2847                        exec_floor,
2848                        permission_gate,
2849                        tool_offload,
2850                        spill_store,
2851                    )
2852                    .await
2853                }
2854                other => format!(
2855                    "error: unknown lifecycle action '{other}'. Use 'run' (default) or 'list'."
2856                ),
2857            }
2858        }
2859
2860        "read_file" => {
2861            let path = args["path"].as_str().unwrap_or("");
2862            let full = std::path::Path::new(workspace).join(path);
2863            let full_str = full.to_string_lossy();
2864            if !tui_permits_path(&caveats.fs_read, &full_str) {
2865                // #263: the gate may grant the read; deny (or no gate) keeps
2866                // the standard denial text bit-for-bit.
2867                let allowed = permission_gate.is_some_and(|gate| {
2868                    fs_gate_allows(gate, "read_file", DenialKind::FsRead, &full_str, |c| {
2869                        &c.fs_read
2870                    })
2871                });
2872                if !allowed {
2873                    let msg = denied_fs_result("fs_read", path);
2874                    print_denied("fs_read", path, color);
2875                    return msg;
2876                }
2877            }
2878            print_tool_call("read_file", path, color);
2879            match std::fs::read_to_string(&full) {
2880                Ok(contents) => {
2881                    // #719: window + cap the MODEL-facing payload (the on-screen
2882                    // display is capped separately) so one read of a large file
2883                    // can't saturate the context window and abandon the task.
2884                    let offset = args["offset"].as_u64().map(|n| n as usize);
2885                    let limit = args["limit"].as_u64().map(|n| n as usize);
2886                    // #726: char backstop now derives from the shared token
2887                    // budget so read_file and run_command share one cap.
2888                    let out = paginate_read(&contents, offset, limit, max_output_tokens());
2889                    print_tool_output(&out, tool_output_lines, color);
2890                    out
2891                }
2892                Err(e) => format!("error reading {path}: {e}"),
2893            }
2894        }
2895
2896        "write_file" => {
2897            let path = args["path"].as_str().unwrap_or("");
2898            let content = args["content"].as_str().unwrap_or("");
2899            let full = std::path::Path::new(workspace).join(path);
2900            let full_str = full.to_string_lossy();
2901            if !tui_permits_path(&caveats.fs_write, &full_str) {
2902                // #263: the gate may grant the write (the human's choice at
2903                // the prompt is the consent — the y/N confirm below stays
2904                // governed by the original scope shape, which a denial here
2905                // proves is `Only`, i.e. no second confirm).
2906                let allowed = permission_gate.is_some_and(|gate| {
2907                    fs_gate_allows(gate, "write_file", DenialKind::FsWrite, &full_str, |c| {
2908                        &c.fs_write
2909                    })
2910                });
2911                if !allowed {
2912                    let msg = denied_fs_result("fs_write", path);
2913                    print_denied("fs_write", path, color);
2914                    return msg;
2915                }
2916            }
2917
2918            // Shrink guard: refuse if the proposed write removes > 30% of
2919            // lines AND > 30 lines absolute. This catches the failure mode
2920            // where a model replaces an entire large file with a small
2921            // fragment (observed in the wild: 4,247 → 107 lines).
2922            if let Ok(existing) = std::fs::read_to_string(&full) {
2923                let orig_lines = existing.lines().count();
2924                let new_lines = content.lines().count();
2925                let removed = orig_lines.saturating_sub(new_lines);
2926                if removed > 30 && new_lines < orig_lines * 7 / 10 {
2927                    let pct = removed * 100 / orig_lines.max(1);
2928                    let msg = format!(
2929                        "error: write_file would shrink {path} from {orig_lines} → {new_lines} lines \
2930                         (-{pct}%). This is likely unintentional. Use edit_file to make targeted \
2931                         changes, or ensure your content includes the full file."
2932                    );
2933                    print_denied("shrink-guard", path, color);
2934                    return msg;
2935                }
2936            }
2937
2938            print_tool_call(
2939                "write_file",
2940                &format!("{path} ({} bytes)", content.len()),
2941                color,
2942            );
2943
2944            // Show first 20 lines as preview.
2945            let preview: String = content.lines().take(20).collect::<Vec<_>>().join("\n");
2946            let has_more = content.lines().count() > 20;
2947            print_tool_output(
2948                &format!("{preview}{}", if has_more { "\n…" } else { "" }),
2949                tool_output_lines,
2950                color,
2951            );
2952
2953            // Auto-write when the caveat explicitly scopes fs_write (the
2954            // preset itself is the user's consent).  Ask y/N only under
2955            // full_access / custom where fs_write == Scope::All.
2956            let needs_confirm = matches!(caveats.fs_write, crate::caveats::Scope::All);
2957
2958            let confirmed = if needs_confirm {
2959                print!("Write this file? [y/N] ");
2960                use std::io::Write as _;
2961                std::io::stdout().flush().ok();
2962                let mut answer = String::new();
2963                std::io::stdin().read_line(&mut answer).is_ok()
2964                    && answer.trim().eq_ignore_ascii_case("y")
2965            } else {
2966                true
2967            };
2968
2969            if confirmed {
2970                let full = std::path::Path::new(workspace).join(path);
2971                if let Some(parent) = full.parent() {
2972                    let _ = std::fs::create_dir_all(parent);
2973                }
2974                match std::fs::write(&full, content) {
2975                    Ok(_) => {
2976                        let line_count = content.lines().count();
2977                        println!("✓ wrote {path} ({line_count} lines)");
2978                        let check = build_check_cmd
2979                            .map(|cmd| run_build_check(cmd, workspace))
2980                            .unwrap_or_default();
2981                        format!("wrote {path} ({line_count} lines){check}")
2982                    }
2983                    Err(e) => format!("error writing {path}: {e}"),
2984                }
2985            } else {
2986                println!("skipped");
2987                format!("user declined to write {path}")
2988            }
2989        }
2990
2991        "edit_file" => {
2992            let path = args["path"].as_str().unwrap_or("");
2993            let old_string = args["old_string"].as_str().unwrap_or("");
2994            let new_string = args["new_string"].as_str().unwrap_or("");
2995            let full = std::path::Path::new(workspace).join(path);
2996            let full_str = full.to_string_lossy();
2997            if !tui_permits_path(&caveats.fs_write, &full_str) {
2998                // #263: same prompted-grant path as write_file.
2999                let allowed = permission_gate.is_some_and(|gate| {
3000                    fs_gate_allows(gate, "edit_file", DenialKind::FsWrite, &full_str, |c| {
3001                        &c.fs_write
3002                    })
3003                });
3004                if !allowed {
3005                    let msg = denied_fs_result("fs_write", path);
3006                    print_denied("fs_write", path, color);
3007                    return msg;
3008                }
3009            }
3010            if old_string.is_empty() {
3011                return "error: old_string must not be empty — use write_file to create new files"
3012                    .to_string();
3013            }
3014            let existing = match std::fs::read_to_string(&full) {
3015                Ok(s) => s,
3016                Err(e) => return format!("error reading {path}: {e}"),
3017            };
3018            let count = existing.matches(old_string).count();
3019            if count == 0 {
3020                // Show the file's actual head so the model can copy the exact
3021                // text and self-correct on the next call — instead of guessing
3022                // old_string blind and looping (the failure mode that left a
3023                // model unable to add a header comment). The content is already
3024                // in hand from the read above; no extra round needed.
3025                const HEAD: usize = 40;
3026                let total = existing.lines().count();
3027                let head: String = existing
3028                    .lines()
3029                    .take(HEAD)
3030                    .map(|l| format!("  {l}"))
3031                    .collect::<Vec<_>>()
3032                    .join("\n");
3033                let more = if total > HEAD {
3034                    format!("\n  … ({} more line(s))", total - HEAD)
3035                } else {
3036                    String::new()
3037                };
3038                return format!(
3039                    "error: old_string not found in {path} — do not guess again. Copy the \
3040                     EXACT text (including leading whitespace) from the contents below, then \
3041                     retry. To add a header/first line, set old_string to the shown first \
3042                     line and put your header + that line in new_string; to create a new \
3043                     file use write_file.\n--- {path} (first {shown} of {total} line(s)) ---\n{head}{more}",
3044                    shown = total.min(HEAD),
3045                );
3046            }
3047            if count > 1 {
3048                return format!(
3049                    "error: old_string matches {count} locations in {path}. \
3050                     Add more surrounding context to make it unique."
3051                );
3052            }
3053            let updated = existing.replacen(old_string, new_string, 1);
3054            let old_lines = existing.lines().count();
3055            let new_lines = updated.lines().count();
3056            let delta = new_lines as i64 - old_lines as i64;
3057            let delta_str = if delta >= 0 {
3058                format!("+{delta}")
3059            } else {
3060                format!("{delta}")
3061            };
3062            print_tool_call("edit_file", &format!("{path} ({delta_str} lines)"), color);
3063            match std::fs::write(&full, &updated) {
3064                Ok(_) => {
3065                    println!("✓ edited {path} ({delta_str} lines, now {new_lines} total)");
3066                    let check = build_check_cmd
3067                        .map(|cmd| run_build_check(cmd, workspace))
3068                        .unwrap_or_default();
3069                    format!("edited {path} ({delta_str} lines, now {new_lines} total){check}")
3070                }
3071                Err(e) => format!("error writing {path}: {e}"),
3072            }
3073        }
3074
3075        "list_dir" => {
3076            let path = args["path"].as_str().unwrap_or(".");
3077            let full = std::path::Path::new(workspace).join(path);
3078            let full_str = full.to_string_lossy();
3079            if !tui_permits_path(&caveats.fs_read, &full_str) {
3080                // #263: same prompted-grant path as read_file.
3081                let allowed = permission_gate.is_some_and(|gate| {
3082                    fs_gate_allows(gate, "list_dir", DenialKind::FsRead, &full_str, |c| {
3083                        &c.fs_read
3084                    })
3085                });
3086                if !allowed {
3087                    let msg = denied_fs_result("fs_read", path);
3088                    print_denied("fs_read", path, color);
3089                    return msg;
3090                }
3091            }
3092            print_tool_call("list_dir", path, color);
3093            match std::fs::read_dir(&full) {
3094                Ok(entries) => {
3095                    let mut names: Vec<String> = entries
3096                        .flatten()
3097                        .map(|e| e.file_name().to_string_lossy().into_owned())
3098                        .collect();
3099                    names.sort();
3100                    let listing = names.join("\n");
3101                    print_tool_output(&listing, tool_output_lines, color);
3102                    listing
3103                }
3104                Err(e) => format!("error: {e}"),
3105            }
3106        }
3107
3108        // #496: embedded, shell-free file search. The reported breakage was an
3109        // agent that needed `find` but the build's shell tool was unavailable;
3110        // this arm walks the workspace with the `ignore` crate (no subprocess),
3111        // gated by the same fs_read caveat as list_dir/read_file.
3112        "find" => {
3113            let path = args["path"].as_str().unwrap_or(".");
3114            let full = std::path::Path::new(workspace).join(path);
3115            let full_str = full.to_string_lossy();
3116            if !tui_permits_path(&caveats.fs_read, &full_str) {
3117                let allowed = permission_gate.is_some_and(|gate| {
3118                    fs_gate_allows(gate, "find", DenialKind::FsRead, &full_str, |c| &c.fs_read)
3119                });
3120                if !allowed {
3121                    let msg = denied_fs_result("fs_read", path);
3122                    print_denied("fs_read", path, color);
3123                    return msg;
3124                }
3125            }
3126            let opts = FindOpts {
3127                name: args["name"].as_str(),
3128                type_filter: match args["type"].as_str() {
3129                    Some("f") => FindType::Files,
3130                    Some("d") => FindType::Dirs,
3131                    _ => FindType::Any,
3132                },
3133                max_depth: args["max_depth"].as_u64().map(|d| d as usize),
3134                max_results: args["max_results"]
3135                    .as_u64()
3136                    .map(|m| m as usize)
3137                    .unwrap_or(1000),
3138                respect_gitignore: args["respect_gitignore"].as_bool().unwrap_or(true),
3139                case_sensitive: args["case_sensitive"].as_bool().unwrap_or(true),
3140            };
3141            // #529: echo the active filters, not a bare `find: .` — two very
3142            // different searches must not render identically in the trace.
3143            print_tool_call("find", &find_detail(path, &opts), color);
3144            if !full.exists() {
3145                return format!("error: no such path '{path}'");
3146            }
3147            // Defence-in-depth for a *recursive* read: refuse a root that
3148            // canonicalises outside the workspace (e.g. via `..`). `find` never
3149            // follows symlinks, so descent can't escape either.
3150            if let (Ok(ws_canon), Ok(root_canon)) = (
3151                std::path::Path::new(workspace).canonicalize(),
3152                full.canonicalize(),
3153            ) {
3154                if !root_canon.starts_with(&ws_canon) {
3155                    let msg = denied_fs_result("fs_read", path);
3156                    print_denied("fs_read", path, color);
3157                    return msg;
3158                }
3159            }
3160            match find_walk(&full, std::path::Path::new(workspace), &opts) {
3161                Ok((hits, truncated)) => {
3162                    let mut listing = if hits.is_empty() {
3163                        "no matches".to_string()
3164                    } else {
3165                        hits.join("\n")
3166                    };
3167                    if truncated {
3168                        listing
3169                            .push_str(&format!("\n… (truncated at {} matches)", opts.max_results));
3170                    }
3171                    print_tool_output(&listing, tool_output_lines, color);
3172                    listing
3173                }
3174                Err(e) => format!("error: {e}"),
3175            }
3176        }
3177
3178        "use_skill" => {
3179            let skill_name = args["name"].as_str().unwrap_or("");
3180            print_tool_call("use_skill", skill_name, color);
3181            // Reads from the configured skill search path. This is a read of
3182            // trusted operator config (procedural knowledge), not an exec of
3183            // arbitrary code, so it is NOT leash-gated — any SCRIPTS the skill
3184            // bundles still run through `run_command`'s confined shell and are
3185            // governed by the session caveats. The same first-directory-wins
3186            // precedence as the index means we load the copy the model was
3187            // actually shown.
3188            let dirs = crate::Config::resolve()
3189                .map(|c| c.skill_search_dirs())
3190                .unwrap_or_default();
3191            match newt_skills::load_body_from(&dirs, skill_name) {
3192                Ok(body) => {
3193                    print_tool_output(&body, tool_output_lines, color);
3194                    body
3195                }
3196                Err(e) => format!("error: {e}"),
3197            }
3198        }
3199
3200        "web_fetch" => {
3201            let url = args["url"].as_str().unwrap_or("");
3202            print_tool_call("web_fetch", url, color);
3203
3204            // Route through agent-bridle's `web_fetch` tool under the SAME
3205            // Caveats. The `net` axis gates which hosts are reachable (host
3206            // allowlist + SSRF screen); an out-of-scope host is denied by the
3207            // leash, surfaced via the dispatch error. The tool returns extracted
3208            // markdown (`{ url, final_url, status, title, markdown }`) — the body
3209            // is untrusted page content, not a command result.
3210            let mut fetch_args = serde_json::json!({ "url": url });
3211            if let Some(max_bytes) = args.get("max_bytes").and_then(serde_json::Value::as_u64) {
3212                fetch_args["max_bytes"] = serde_json::json!(max_bytes);
3213            }
3214            // #263: with a gate present, pre-check the host against the `net`
3215            // axis so an out-of-allowlist host becomes a prompt instead of a
3216            // leash error. Allow ⇒ dispatch under the gate's minted caveats;
3217            // deny (or no gate, or an unparseable URL) ⇒ dispatch under the
3218            // ORIGINAL caveats — the leash produces today's denial verbatim.
3219            let widened_for_net = match (permission_gate, host_of_url(url)) {
3220                (Some(gate), Some(host)) if !caveats.permits_net(&host) => {
3221                    let request = PermissionRequest {
3222                        tool: "web_fetch".to_string(),
3223                        kind: DenialKind::Net,
3224                        target: host.clone(),
3225                        reason: format!("net does not permit '{host}'"),
3226                    };
3227                    match gate.ask(std::slice::from_ref(&request)) {
3228                        PermissionDecision::Allow(widened) => Some(widened),
3229                        PermissionDecision::Deny => None,
3230                    }
3231                }
3232                _ => None,
3233            };
3234            let effective_caveats = widened_for_net.as_ref().unwrap_or(caveats);
3235            match agent_bridle::registry()
3236                .dispatch("web_fetch", fetch_args, effective_caveats)
3237                .await
3238            {
3239                Ok(result) => {
3240                    let markdown = result
3241                        .get("markdown")
3242                        .and_then(serde_json::Value::as_str)
3243                        .unwrap_or("");
3244                    let title = result
3245                        .get("title")
3246                        .and_then(serde_json::Value::as_str)
3247                        .unwrap_or("");
3248                    let final_url = result
3249                        .get("final_url")
3250                        .and_then(serde_json::Value::as_str)
3251                        .unwrap_or(url);
3252                    let out = if title.is_empty() {
3253                        format!("{final_url}\n\n{markdown}")
3254                    } else {
3255                        format!("# {title}\n{final_url}\n\n{markdown}")
3256                    };
3257                    print_tool_output(&out, tool_output_lines, color);
3258                    out
3259                }
3260                // A `net`-axis leash denial, or a fetch error (SSRF screen,
3261                // timeout, non-2xx) — surface the reason; Display is safe.
3262                Err(e) => format!("error: {e}"),
3263            }
3264        }
3265
3266        other => unknown_tool_message(other),
3267    }
3268}
3269
3270/// Classify an [`execute_tool`] result string as success or failure for the
3271/// turn's recorded tool events (Step 17.6, #246). Best-effort by necessity —
3272/// tool results are plain strings fed back to the model — so this mirrors
3273/// the failure prefixes this module (and `McpTools::call`) actually emit:
3274/// `error:`, `capability denied:`, and `unknown tool`. A successful
3275/// `run_command` whose *output* happens to start with one of these is
3276/// misclassified; the recorded event is an outcome claim, not a gate.
3277pub(crate) fn tool_result_ok(result: &str) -> bool {
3278    let r = result.trim_start();
3279    !(r.starts_with("error:")
3280        || r.starts_with("capability denied:")
3281        || r.starts_with("unknown tool"))
3282}
3283
3284#[cfg(test)]
3285mod tests {
3286    use super::*;
3287    use crate::agentic::NoMcp;
3288
3289    // ---- #717: classify_phantom_reach (pure, no fs) ----
3290
3291    #[test]
3292    fn classify_phantom_rewrite_alias() {
3293        // A shell alias resolves to the canonical run_command rewrite.
3294        let got = classify_phantom_reach("bash", &serde_json::json!({"command": "ls"}), "ok", true);
3295        assert_eq!(
3296            got,
3297            Some(crate::PhantomResolution::Rewrite("run_command".into()))
3298        );
3299    }
3300
3301    #[test]
3302    fn classify_phantom_correct_alias() {
3303        // An edit alias with the wrong arg shape returns Correct guidance.
3304        let got = classify_phantom_reach(
3305            "str_replace_editor",
3306            &serde_json::json!({}),
3307            "ignored",
3308            false,
3309        );
3310        match got {
3311            Some(crate::PhantomResolution::Correct(msg)) => {
3312                assert!(msg.contains("edit_file"), "guidance names the tool: {msg}");
3313            }
3314            other => panic!("expected Correct, got {other:?}"),
3315        }
3316    }
3317
3318    #[test]
3319    fn classify_phantom_unknown_name() {
3320        // A foreign name with no alias is a true phantom tool. (Note: #716 turned
3321        // the plan/crew/workflow notions into recognized aliases, so this uses a
3322        // name no family claims.)
3323        let got = classify_phantom_reach(
3324            "summon_kraken",
3325            &serde_json::json!({}),
3326            "unknown tool: summon_kraken",
3327            false,
3328        );
3329        assert_eq!(got, Some(crate::PhantomResolution::Unknown));
3330    }
3331
3332    #[test]
3333    fn classify_phantom_plan_alias_is_correct() {
3334        // #716 + #717: a foreign plan notion now resolves through the alias seam,
3335        // so the telemetry classifier records it as a Correct (coach) reach — the
3336        // new arms get phantom-reach telemetry for free.
3337        let got = classify_phantom_reach("make_plan", &serde_json::json!({}), "ignored", false);
3338        match got {
3339            Some(crate::PhantomResolution::Correct(msg)) => {
3340                assert!(
3341                    msg.contains("update_plan"),
3342                    "guidance names the tool: {msg}"
3343                );
3344            }
3345            other => panic!("expected Correct, got {other:?}"),
3346        }
3347    }
3348
3349    #[test]
3350    fn classify_phantom_state_get_miss() {
3351        // state_get on an unset key is an empty-by-design real-tool miss.
3352        let got = classify_phantom_reach(
3353            "state_get",
3354            &serde_json::json!({"key": "nope"}),
3355            "no such key: nope",
3356            true,
3357        );
3358        assert_eq!(
3359            got,
3360            Some(crate::PhantomResolution::RealToolMiss(
3361                "state_get on an unset key".into()
3362            ))
3363        );
3364    }
3365
3366    #[test]
3367    fn classify_phantom_recall_miss() {
3368        // recall with no hits is an empty-by-design real-tool miss.
3369        let got = classify_phantom_reach(
3370            "recall",
3371            &serde_json::json!({"query": "zzz"}),
3372            "no matches in past conversations for \"zzz\" — try different keywords",
3373            true,
3374        );
3375        assert_eq!(
3376            got,
3377            Some(crate::PhantomResolution::RealToolMiss(
3378                "recall returned no matches".into()
3379            ))
3380        );
3381    }
3382
3383    #[test]
3384    fn classify_phantom_resume_reach_is_a_rewrite() {
3385        // #714 + #717: a "where were we" reach resolves through the alias seam to
3386        // a Rewrite, so the telemetry already captures it (no new wiring needed).
3387        let got = classify_phantom_reach("where_were_we", &serde_json::json!({}), "ignored", false);
3388        assert_eq!(
3389            got,
3390            Some(crate::PhantomResolution::Rewrite("resume_context".into()))
3391        );
3392    }
3393
3394    #[test]
3395    fn classify_phantom_real_success_is_none() {
3396        // An ordinary successful real tool call is not phantom telemetry.
3397        let got = classify_phantom_reach(
3398            "read_file",
3399            &serde_json::json!({"path": "src/lib.rs"}),
3400            "line 1\nline 2\n",
3401            true,
3402        );
3403        assert_eq!(got, None);
3404    }
3405
3406    // ---- #725: tool_search discovery (alias + name registry) ----
3407
3408    #[test]
3409    fn tool_search_is_a_real_tool_name() {
3410        // It must be in the canonical registry so a model calling it is never
3411        // treated as a hallucination.
3412        assert!(ALL_TOOL_NAMES.contains(&"tool_search"));
3413    }
3414
3415    #[test]
3416    fn discovery_verbs_alias_to_tool_search() {
3417        // The instinctive "which tool does X?" reaches silently Rewrite to the
3418        // real tool_search.
3419        for verb in [
3420            "find_tool",
3421            "search_tools",
3422            "list_tools",
3423            "which_tool",
3424            "available_tools",
3425            "what_tools",
3426            "tools",
3427        ] {
3428            match resolve_tool_alias(verb) {
3429                Some(AliasOutcome::Rewrite(c)) => assert_eq!(c, "tool_search", "verb: {verb}"),
3430                other => panic!(
3431                    "expected Rewrite(tool_search) for {verb}, got something else: {}",
3432                    other.is_some()
3433                ),
3434            }
3435        }
3436    }
3437
3438    #[test]
3439    fn tool_search_is_not_an_alias_of_itself() {
3440        // The real name must fall through unchanged (no recursive rewrite).
3441        assert!(resolve_tool_alias("tool_search").is_none());
3442    }
3443
3444    #[test]
3445    fn classify_phantom_discovery_reach_is_a_rewrite() {
3446        // #725 + #717: a discovery reach resolves through the alias seam to a
3447        // Rewrite, so the phantom telemetry captures it for free.
3448        let got = classify_phantom_reach("find_tool", &serde_json::json!({}), "ignored", false);
3449        assert_eq!(
3450            got,
3451            Some(crate::PhantomResolution::Rewrite("tool_search".into()))
3452        );
3453    }
3454
3455    #[test]
3456    fn classify_phantom_tool_search_real_call_is_none() {
3457        // A real tool_search call is not phantom telemetry.
3458        let got = classify_phantom_reach(
3459            "tool_search",
3460            &serde_json::json!({"query": "read"}),
3461            "Tools matching \"read\":\n- read_file — Read a file",
3462            true,
3463        );
3464        assert_eq!(got, None);
3465    }
3466
3467    #[test]
3468    fn tool_search_is_not_a_hallucination() {
3469        assert!(!is_hallucination(
3470            "tool_search",
3471            &serde_json::json!({"query": "x"})
3472        ));
3473    }
3474
3475    // ---- #719: read_file payload window/cap/pagination (pure, no fs) ----
3476
3477    #[test]
3478    fn paginate_read_caps_a_large_file_to_the_default_window() {
3479        // A 15k-line file must NOT flood the model: default window is 2000 lines
3480        // with a footer to continue (regression for the 12.5k→168k saturation).
3481        let body: String = (1..=15_057)
3482            .map(|n| format!("line {n}"))
3483            .collect::<Vec<_>>()
3484            .join("\n");
3485        let out = paginate_read(&body, None, None, DEFAULT_MAX_OUTPUT_TOKENS);
3486        let lines: Vec<&str> = out.lines().collect();
3487        assert_eq!(lines[0], "line 1");
3488        assert_eq!(lines[1999], "line 2000");
3489        assert!(
3490            !out.contains("line 2001"),
3491            "window stops at 2000: {:?}",
3492            &out[..40]
3493        );
3494        assert!(out.contains("of 15057"), "footer names the total");
3495        assert!(
3496            out.contains("offset=2001"),
3497            "footer points at the next window"
3498        );
3499    }
3500
3501    #[test]
3502    fn paginate_read_offset_and_limit_return_just_that_window() {
3503        let body: String = (1..=100)
3504            .map(|n| format!("L{n}"))
3505            .collect::<Vec<_>>()
3506            .join("\n");
3507        let out = paginate_read(&body, Some(10), Some(5), DEFAULT_MAX_OUTPUT_TOKENS);
3508        assert!(out.starts_with("L10\nL11\nL12\nL13\nL14"), "{out:?}");
3509        assert!(out.contains("offset=15"), "continues at line 15: {out:?}");
3510    }
3511
3512    #[test]
3513    fn paginate_read_small_file_is_returned_verbatim_without_a_footer() {
3514        // Whole-file read that fits both caps → exact bytes, no footer.
3515        assert_eq!(
3516            paginate_read("a\nb\nc\n", None, None, DEFAULT_MAX_OUTPUT_TOKENS),
3517            "a\nb\nc\n"
3518        );
3519    }
3520
3521    #[test]
3522    fn paginate_read_char_backstop_tracks_the_token_budget() {
3523        // #726: the char backstop is now token-derived (budget × chars/token),
3524        // NOT a hardcoded 100k. One enormous line: the line window can't help;
3525        // the token-derived char backstop must. With a 1000-token budget the
3526        // backstop is ~4000 chars, so a 50k-char line is truncated near there.
3527        let budget = 1_000;
3528        let max_chars = crate::tokens::TokenEstimation::default().chars_for_tokens(budget);
3529        let body = "x".repeat(50_000);
3530        let out = paginate_read(&body, None, None, budget);
3531        assert!(
3532            out.len() < max_chars + 300,
3533            "char-capped to the token budget (~{max_chars} chars): {} bytes",
3534            out.len()
3535        );
3536        assert!(out.contains("truncated"), "marks the truncation");
3537        assert!(
3538            out.contains("~1000 tokens"),
3539            "footer names the token budget: {out:?}"
3540        );
3541
3542        // A LARGER budget keeps more of the same line — the backstop tracks the
3543        // budget rather than a fixed constant.
3544        let wide = paginate_read(&body, None, None, 4_000);
3545        assert!(
3546            wide.len() > out.len(),
3547            "a wider token budget keeps more chars: {} vs {}",
3548            wide.len(),
3549            out.len()
3550        );
3551    }
3552
3553    #[test]
3554    fn paginate_read_zero_budget_disables_the_char_backstop() {
3555        // #726: max_output_tokens == 0 means "no cap" — only the line window
3556        // applies, so a single huge line comes back verbatim.
3557        let body = "y".repeat(500_000);
3558        let out = paginate_read(&body, None, None, 0);
3559        assert_eq!(out, body, "zero budget = no char backstop");
3560    }
3561
3562    #[test]
3563    fn paginate_read_offset_past_end_is_a_clear_message() {
3564        let out = paginate_read("a\nb", Some(99), None, DEFAULT_MAX_OUTPUT_TOKENS);
3565        assert!(out.contains("past end"), "{out:?}");
3566    }
3567
3568    // ---- #726: shared token-based model-facing output cap ----
3569
3570    #[test]
3571    fn cap_model_output_passes_small_output_through_unchanged() {
3572        // Well under budget → exact bytes, no marker.
3573        let small = "hello\nworld\n";
3574        assert_eq!(cap_model_output(small, DEFAULT_MAX_OUTPUT_TOKENS), small);
3575    }
3576
3577    #[test]
3578    fn cap_model_output_truncates_over_budget_as_head_tail() {
3579        let big = format!("HEAD_MARKER\n{}\nTAIL_MARKER", "middle\n".repeat(20_000));
3580        let out = cap_model_output_with_handle(&big, 1_000, 100, None);
3581        assert!(out.len() < big.len(), "must shrink: {} bytes", out.len());
3582        assert!(out.contains("HEAD_MARKER"), "head dropped: {out:?}");
3583        assert!(out.contains("TAIL_MARKER"), "tail dropped: {out:?}");
3584        assert!(out.contains("head+tail shown"), "marker present: {out:?}");
3585        assert!(
3586            !out.contains(&"middle\n".repeat(1_000)),
3587            "middle should be elided"
3588        );
3589    }
3590
3591    #[test]
3592    fn cap_model_output_truncates_at_a_char_boundary() {
3593        // A multi-byte char straddling the cut must not be split — the body must
3594        // stay valid UTF-8 (no panic, no replacement char).
3595        let budget = 10; // ~40 chars
3596        let body = "é".repeat(1_000); // 2 bytes each
3597        let out = cap_model_output(&body, budget);
3598        assert!(out.is_char_boundary(out.len()), "valid boundary");
3599        assert!(
3600            out.chars()
3601                .all(|c| c == 'é' || !c.is_control() || c == '\n'),
3602            "no split char: {out:?}"
3603        );
3604    }
3605
3606    #[test]
3607    fn cap_model_output_zero_budget_is_no_cap() {
3608        let body = "z".repeat(500_000);
3609        assert_eq!(cap_model_output(&body, 0), body);
3610    }
3611
3612    #[test]
3613    fn token_to_char_math_uses_the_default_four_chars_per_token() {
3614        // The budget→char conversion is the default 4 chars/token (the shared
3615        // estimator constant), so a 10k-token budget is a 40k-char backstop.
3616        let est = crate::tokens::TokenEstimation::default();
3617        assert_eq!(est.chars_for_tokens(DEFAULT_MAX_OUTPUT_TOKENS), 40_000);
3618    }
3619
3620    #[test]
3621    fn find_detail_bare_path_has_no_filters() {
3622        let opts = FindOpts {
3623            name: None,
3624            type_filter: FindType::Any,
3625            max_depth: None,
3626            max_results: 1000,
3627            respect_gitignore: true,
3628            case_sensitive: true,
3629        };
3630        assert_eq!(find_detail(".", &opts), ".");
3631    }
3632
3633    #[test]
3634    fn find_detail_shows_only_non_default_filters() {
3635        let opts = FindOpts {
3636            name: Some("*.rs"),
3637            type_filter: FindType::Files,
3638            max_depth: Some(2),
3639            max_results: 50,
3640            respect_gitignore: false,
3641            case_sensitive: false,
3642        };
3643        assert_eq!(
3644            find_detail("src", &opts),
3645            "src (name=*.rs, type=f, depth=2, max=50, no-gitignore, icase)"
3646        );
3647    }
3648
3649    #[test]
3650    fn find_detail_omits_each_default_independently() {
3651        let opts = FindOpts {
3652            name: None,
3653            type_filter: FindType::Dirs,
3654            max_depth: None,
3655            max_results: 1000,
3656            respect_gitignore: true,
3657            case_sensitive: true,
3658        };
3659        assert_eq!(find_detail(".", &opts), ". (type=d)");
3660    }
3661
3662    #[test]
3663    fn use_skill_tool_is_advertised_in_definitions() {
3664        let defs = tool_definitions();
3665        let names: Vec<&str> = defs
3666            .as_array()
3667            .unwrap()
3668            .iter()
3669            .filter_map(|d| d["function"]["name"].as_str())
3670            .collect();
3671        assert!(names.contains(&"use_skill"), "got: {names:?}");
3672    }
3673
3674    #[test]
3675    fn merged_tool_definitions_with_empty_mcp_is_builtin_set() {
3676        let merged = merged_tool_definitions(
3677            &NoMcp, false, false, false, false, false, false, false, false, false,
3678        );
3679        let names: Vec<&str> = merged
3680            .as_array()
3681            .unwrap()
3682            .iter()
3683            .filter_map(|d| d["function"]["name"].as_str())
3684            .collect();
3685        assert_eq!(
3686            names,
3687            vec![
3688                "run_command",
3689                "read_file",
3690                "write_file",
3691                "edit_file",
3692                "list_dir",
3693                "find",
3694                "use_skill",
3695                "web_fetch",
3696                // #721: advertised ALWAYS (core capability-grant request, no
3697                // presence gate) — part of the base tool_definitions() set.
3698                "request_permissions",
3699                // #714: advertised ALWAYS (no presence gate), so it joins the
3700                // base set even with every `with_*` flag off.
3701                "resume_context",
3702                // #725: advertised ALWAYS (a discovery tool must always be
3703                // present), so it too joins the base set with every flag off.
3704                "tool_search",
3705                // #727: advertised ALWAYS (read-only budget self-read, no
3706                // presence gate), pushed right after resume_context.
3707                "get_context_remaining",
3708                // #728: advertised ALWAYS (a model must always be able to ask the
3709                // human; degrades honestly headless), pushed last.
3710                "request_user_input",
3711                // #891: advertised ALWAYS (the model-facing lifecycle surface;
3712                // degrades honestly with "no command configured"), pushed after
3713                // request_user_input.
3714                "lifecycle",
3715            ]
3716        );
3717    }
3718
3719    /// #894: each registry entry's schema-builder produces the SAME name the
3720    /// entry declares — catches a copy-paste where the `ToolSpec.name` and the
3721    /// `*_tool_definition()` disagree.
3722    #[test]
3723    fn registry_specs_match_their_definition_names() {
3724        for spec in EXTENDED_TOOL_REGISTRY {
3725            let def = (spec.definition)();
3726            assert_eq!(
3727                def["function"]["name"].as_str(),
3728                Some(spec.name),
3729                "ToolSpec name {:?} != definition name",
3730                spec.name
3731            );
3732        }
3733    }
3734
3735    /// #894: no built-in tool name is declared twice across the base array and
3736    /// the registry (a dup would double-advertise and confuse dispatch).
3737    #[test]
3738    fn builtin_tool_names_are_unique() {
3739        let mut seen = std::collections::HashSet::new();
3740        for name in ALL_TOOL_NAMES.iter() {
3741            assert!(seen.insert(*name), "duplicate built-in tool name: {name}");
3742        }
3743    }
3744
3745    /// #894 anti-drift (the payoff): with EVERY gate on, the advertised set from
3746    /// `merged_tool_definitions` equals `ALL_TOOL_NAMES` in BOTH directions. This
3747    /// is the test that would have caught the `lifecycle` drift — a tool
3748    /// advertised/dispatched but missing from the real-name set (or vice versa)
3749    /// fails here.
3750    #[test]
3751    fn advertised_set_matches_all_tool_names_both_directions() {
3752        let all =
3753            merged_tool_definitions(&NoMcp, true, true, true, true, true, true, true, true, true);
3754        let advertised: std::collections::HashSet<&str> = all
3755            .as_array()
3756            .unwrap()
3757            .iter()
3758            .filter_map(|d| d["function"]["name"].as_str())
3759            .collect();
3760        let names: std::collections::HashSet<&str> = ALL_TOOL_NAMES.iter().copied().collect();
3761        // Every advertised tool is a real (non-hallucinated) name...
3762        for a in &advertised {
3763            assert!(
3764                names.contains(a),
3765                "advertised but not in ALL_TOOL_NAMES: {a}"
3766            );
3767        }
3768        // ...and every real name is actually advertised when its gate is on.
3769        for n in &names {
3770            assert!(
3771                advertised.contains(n),
3772                "in ALL_TOOL_NAMES but never advertised: {n}"
3773            );
3774        }
3775    }
3776
3777    /// #894: `BASE_TOOL_NAMES` mirrors the names inlined in `tool_definitions()`
3778    /// exactly and in order — the one hand-kept mirror, guarded here.
3779    #[test]
3780    fn base_tool_names_match_tool_definitions() {
3781        let defs = tool_definitions();
3782        let base: Vec<&str> = defs
3783            .as_array()
3784            .unwrap()
3785            .iter()
3786            .filter_map(|d| d["function"]["name"].as_str())
3787            .collect();
3788        assert_eq!(base, BASE_TOOL_NAMES);
3789    }
3790
3791    /// #894 regression for the concrete drift that motivated the registry: the
3792    /// `lifecycle` tool (#891) is advertised + dispatched, so it MUST be a real
3793    /// name — otherwise every legitimate `lifecycle` call is miscounted as a
3794    /// hallucination (inflating the anti-loop counter). Before the registry it
3795    /// was missing from `ALL_TOOL_NAMES`; the derivation makes that impossible.
3796    #[test]
3797    fn lifecycle_is_a_real_tool_name_not_a_hallucination() {
3798        assert!(
3799            ALL_TOOL_NAMES.contains(&"lifecycle"),
3800            "lifecycle must be a real tool name"
3801        );
3802        assert!(
3803            !is_hallucination("lifecycle", &serde_json::json!({"phase": "test"})),
3804            "a real lifecycle call must not be flagged as a hallucination"
3805        );
3806    }
3807
3808    #[test]
3809    fn lifecycle_definition_enum_matches_phase_vocabulary() {
3810        // The schema's phase enum is built from `Phase::ALL`, so it can never
3811        // drift from the vocabulary the executor parses with `Phase::from_key`.
3812        let def = lifecycle_tool_definition();
3813        assert_eq!(def["function"]["name"], "lifecycle");
3814        let enum_vals: Vec<&str> = def["function"]["parameters"]["properties"]["phase"]["enum"]
3815            .as_array()
3816            .unwrap()
3817            .iter()
3818            .map(|v| v.as_str().unwrap())
3819            .collect();
3820        let vocab: Vec<&str> = crate::tooling::Phase::ALL
3821            .iter()
3822            .map(|p| p.as_str())
3823            .collect();
3824        assert_eq!(enum_vals, vocab);
3825    }
3826
3827    #[test]
3828    fn run_phase_aliases_route_to_lifecycle() {
3829        for a in ["run_phase", "run_lifecycle", "lifecycle_run"] {
3830            assert!(
3831                matches!(
3832                    resolve_tool_alias(a),
3833                    Some(AliasOutcome::Rewrite("lifecycle"))
3834                ),
3835                "{a} should rewrite to lifecycle"
3836            );
3837        }
3838        // The canonical name is NOT an alias — it dispatches directly.
3839        assert!(resolve_tool_alias("lifecycle").is_none());
3840    }
3841
3842    #[tokio::test]
3843    async fn lifecycle_unknown_phase_lists_valid_phases() {
3844        // An unknown phase returns before any fs/subprocess touch, so this is a
3845        // fully-mocked unit test.
3846        let caveats = crate::caveats::Caveats::top();
3847        let args = serde_json::json!({ "phase": "deploy" });
3848        let out = execute_tool(
3849            "lifecycle",
3850            &args,
3851            ".",
3852            false,
3853            20,
3854            &caveats,
3855            &mut NoMcp,
3856            None,
3857            None,
3858            None,
3859            None,
3860            None,
3861            None,
3862            None,
3863            None,
3864            None,
3865            None,
3866            None,
3867            None,
3868        )
3869        .await;
3870        assert!(
3871            out.starts_with("error: unknown lifecycle phase 'deploy'"),
3872            "{out}"
3873        );
3874        assert!(out.contains("check"), "should name valid phases: {out}");
3875    }
3876
3877    /// `save_note` is sink-gated: absent from the base `tool_definitions`
3878    /// (headless/eval callers see no memory tool) and from the merged set
3879    /// without a sink; present in the merged set when a sink exists.
3880    #[test]
3881    fn save_note_advertised_only_with_a_sink() {
3882        fn names(defs: &serde_json::Value) -> Vec<&str> {
3883            defs.as_array()
3884                .unwrap()
3885                .iter()
3886                .filter_map(|d| d["function"]["name"].as_str())
3887                .collect()
3888        }
3889        // Headless/eval callers see no memory tool in the base set …
3890        let base = tool_definitions();
3891        assert!(!names(&base).contains(&"save_note"), "got: {base}");
3892        // … nor in the merged set without a sink …
3893        let without = merged_tool_definitions(
3894            &NoMcp, false, false, false, false, false, false, false, false, false,
3895        );
3896        assert!(!names(&without).contains(&"save_note"));
3897        // … but a sink advertises it.
3898        let with = merged_tool_definitions(
3899            &NoMcp, true, false, false, false, false, false, false, false, false,
3900        );
3901        assert!(names(&with).contains(&"save_note"), "got: {with}");
3902    }
3903
3904    /// `recall` is source-gated exactly like `save_note` is sink-gated
3905    /// (Step 17.5): absent from the base set and from the merged set
3906    /// without a source; present when one exists.
3907    #[test]
3908    fn recall_advertised_only_with_a_source() {
3909        fn names(defs: &serde_json::Value) -> Vec<&str> {
3910            defs.as_array()
3911                .unwrap()
3912                .iter()
3913                .filter_map(|d| d["function"]["name"].as_str())
3914                .collect()
3915        }
3916        let base = tool_definitions();
3917        assert!(!names(&base).contains(&"recall"), "got: {base}");
3918        let without = merged_tool_definitions(
3919            &NoMcp, false, false, false, false, false, false, false, false, false,
3920        );
3921        assert!(!names(&without).contains(&"recall"));
3922        let with = merged_tool_definitions(
3923            &NoMcp, false, true, false, false, false, false, false, false, false,
3924        );
3925        assert!(names(&with).contains(&"recall"), "got: {with}");
3926        // The two gates are independent: both on advertises both.
3927        let both = merged_tool_definitions(
3928            &NoMcp, true, true, false, false, false, false, false, false, false,
3929        );
3930        assert!(names(&both).contains(&"save_note"));
3931        assert!(names(&both).contains(&"recall"));
3932    }
3933
3934    /// `memory_fetch` is source-gated exactly like `recall` (#319): absent
3935    /// from the base set and from the merged set without a `MemorySource`;
3936    /// present when one exists. The flag is independent of the others.
3937    #[test]
3938    fn memory_fetch_advertised_only_with_a_source() {
3939        fn names(defs: &serde_json::Value) -> Vec<&str> {
3940            defs.as_array()
3941                .unwrap()
3942                .iter()
3943                .filter_map(|d| d["function"]["name"].as_str())
3944                .collect()
3945        }
3946        let base = tool_definitions();
3947        assert!(!names(&base).contains(&"memory_fetch"), "got: {base}");
3948        // Flag off (every existing caller, the inert default) → not advertised.
3949        let without = merged_tool_definitions(
3950            &NoMcp, false, false, false, false, false, false, false, false, false,
3951        );
3952        assert!(!names(&without).contains(&"memory_fetch"));
3953        // Flag on → advertised.
3954        let with = merged_tool_definitions(
3955            &NoMcp, false, false, true, false, false, false, false, false, false,
3956        );
3957        assert!(names(&with).contains(&"memory_fetch"), "got: {with}");
3958        // Independent of the save_note / recall gates: all three on lists all.
3959        let all = merged_tool_definitions(
3960            &NoMcp, true, true, true, false, false, false, false, false, false,
3961        );
3962        assert!(names(&all).contains(&"save_note"));
3963        assert!(names(&all).contains(&"recall"));
3964        assert!(names(&all).contains(&"memory_fetch"));
3965    }
3966
3967    /// `is_hallucination` correctly identifies tool-name-as-command and unknown
3968    /// tool names, and correctly skips MCP-namespaced tools.
3969    #[test]
3970    fn hallucination_detection_coverage() {
3971        // tool name passed to run_command → hallucination
3972        assert!(is_hallucination(
3973            "run_command",
3974            &serde_json::json!({"command": "list_dir ."})
3975        ));
3976        // normal shell command → not a hallucination
3977        assert!(!is_hallucination(
3978            "run_command",
3979            &serde_json::json!({"command": "cargo test"})
3980        ));
3981        // unknown tool → hallucination
3982        assert!(is_hallucination(
3983            "definitely_not_a_real_tool",
3984            &serde_json::json!({})
3985        ));
3986        // MCP-namespaced tool → not a hallucination
3987        assert!(!is_hallucination(
3988            "my_server__some_tool",
3989            &serde_json::json!({})
3990        ));
3991        // known direct tools → not hallucinations when called correctly
3992        for t in [
3993            "list_dir",
3994            "read_file",
3995            "write_file",
3996            "use_skill",
3997            "web_fetch",
3998            "save_note",
3999            "recall",
4000        ] {
4001            assert!(!is_hallucination(t, &serde_json::json!({"path": "."})));
4002        }
4003    }
4004
4005    /// #898: `run_command_redirect` bounces LOCAL git ops (and other direct
4006    /// tools) to their embedded tool, but lets git NETWORK ops fall through to
4007    /// the shell — otherwise a model can never `git push` (the pushless embedded
4008    /// git tool + the blanket bounce were a hard dead-end).
4009    #[test]
4010    fn run_command_redirect_lets_git_network_ops_through() {
4011        // Network ops the embedded git tool cannot do → fall through (None).
4012        for cmd in [
4013            "git push origin fix/foo",
4014            "git push",
4015            "git fetch origin",
4016            "git pull",
4017            "git clone https://example.com/r.git",
4018        ] {
4019            assert_eq!(run_command_redirect(cmd), None, "{cmd} must fall through");
4020        }
4021        // Local ops the embedded git tool handles → still redirect.
4022        for cmd in [
4023            "git status",
4024            "git log --oneline",
4025            "git add .",
4026            "git commit -m x",
4027        ] {
4028            assert_eq!(
4029                run_command_redirect(cmd),
4030                Some("git"),
4031                "{cmd} must redirect"
4032            );
4033        }
4034        // Other direct tools still redirect; plain shell commands run as-is.
4035        assert_eq!(run_command_redirect("read_file foo.txt"), Some("read_file"));
4036        assert_eq!(run_command_redirect("list_dir ."), Some("list_dir"));
4037        assert_eq!(run_command_redirect("cargo test"), None);
4038        assert_eq!(run_command_redirect("gh pr create --fill"), None);
4039        assert_eq!(run_command_redirect(""), None);
4040    }
4041
4042    /// #898 regression: a real `git push` at run_command must NOT be counted as
4043    /// a hallucination (it now runs), while a local `git status` still is.
4044    #[test]
4045    fn is_hallucination_allows_git_network_ops() {
4046        assert!(!is_hallucination(
4047            "run_command",
4048            &serde_json::json!({"command": "git push origin fix/foo"})
4049        ));
4050        assert!(!is_hallucination(
4051            "run_command",
4052            &serde_json::json!({"command": "git fetch"})
4053        ));
4054        assert!(is_hallucination(
4055            "run_command",
4056            &serde_json::json!({"command": "git status"})
4057        ));
4058    }
4059
4060    /// #898: the forge PR/MR-creation URL is extracted from git's push output
4061    /// (GitHub and GitLab), and ordinary URLs do not false-positive.
4062    #[test]
4063    fn pr_creation_url_extracts_github_and_gitlab() {
4064        let github = "remote: Create a pull request for 'fix/foo' on GitHub by visiting:\n\
4065                      remote:      https://github.com/OWNER/REPO/pull/new/fix/foo\n";
4066        assert_eq!(
4067            pr_creation_url(github),
4068            Some("https://github.com/OWNER/REPO/pull/new/fix/foo")
4069        );
4070        let gitlab = "remote: To create a merge request for topic, visit:\n\
4071                      remote:   https://gitlab.com/g/p/-/merge_requests/new?x=topic\n";
4072        assert_eq!(
4073            pr_creation_url(gitlab),
4074            Some("https://gitlab.com/g/p/-/merge_requests/new?x=topic")
4075        );
4076        // No PR URL present → None (ordinary fetch/clone output, plain links).
4077        assert_eq!(pr_creation_url("Already up to date.\n"), None);
4078        assert_eq!(
4079            pr_creation_url("see https://github.com/OWNER/REPO/issues/1"),
4080            None
4081        );
4082    }
4083
4084    /// #898: after a push whose output carries a PR-creation URL,
4085    /// `shell_envelope_output` appends the `gh pr create` next-step hint (and the
4086    /// URL survives), while ordinary command output is left untouched.
4087    #[test]
4088    fn shell_envelope_output_appends_pr_hint_on_push() {
4089        let push = serde_json::json!({
4090            "exit_code": 0,
4091            "stdout": "",
4092            "stderr": "remote: Create a pull request for 'fix/foo' on GitHub by visiting:\n\
4093                       remote:      https://github.com/OWNER/REPO/pull/new/fix/foo\n",
4094        });
4095        let out = shell_envelope_output(&push, 50, false, false, None);
4096        assert!(out.contains("gh pr create --fill"), "hint missing: {out}");
4097        assert!(
4098            out.contains("https://github.com/OWNER/REPO/pull/new/fix/foo"),
4099            "url dropped: {out}"
4100        );
4101
4102        // Ordinary output: no hint, payload unchanged.
4103        let plain = serde_json::json!({ "exit_code": 0, "stdout": "hello\n", "stderr": "" });
4104        let out = shell_envelope_output(&plain, 50, false, false, None);
4105        assert!(!out.contains("gh pr create"), "spurious hint: {out}");
4106        assert_eq!(out, "hello\n");
4107    }
4108
4109    #[test]
4110    fn shell_envelope_output_spills_full_output_before_head_tail_cap() {
4111        let full = format!(
4112            "HEAD_ONLY_MARKER\n{}\nMIDDLE_ONLY_MARKER\n{}\nTAIL_ONLY_MARKER\n",
4113            "alpha\n".repeat(10_000),
4114            "omega\n".repeat(10_000)
4115        );
4116        let envelope = serde_json::json!({
4117            "exit_code": 0,
4118            "stdout": full,
4119            "stderr": "",
4120        });
4121        let store = spill::SessionSpillStore::default();
4122        let out = shell_envelope_output(&envelope, 50, false, true, Some(&store));
4123
4124        assert!(out.contains("HEAD_ONLY_MARKER"), "head dropped: {out}");
4125        assert!(out.contains("TAIL_ONLY_MARKER"), "tail dropped: {out}");
4126        assert!(
4127            out.contains("memory_fetch(\"spill:s0\")"),
4128            "spill handle missing: {out}"
4129        );
4130        assert!(
4131            out.contains("grep=\"<pattern>\""),
4132            "search affordance missing: {out}"
4133        );
4134        let stored = store.fetch("s0").expect("full output stored");
4135        assert!(
4136            stored.contains("MIDDLE_ONLY_MARKER"),
4137            "spilled payload was capped before storage"
4138        );
4139        assert!(stored.ends_with("TAIL_ONLY_MARKER\n"));
4140    }
4141
4142    #[test]
4143    fn envelope_denied_reads_structured_flag_only() {
4144        assert!(envelope_denied(&serde_json::json!({"denied": true})));
4145        assert!(!envelope_denied(&serde_json::json!({"denied": false})));
4146        assert!(!envelope_denied(&serde_json::json!({})));
4147        // A non-bool `denied` is treated as not-denied, never a panic.
4148        assert!(!envelope_denied(&serde_json::json!({"denied": "yes"})));
4149    }
4150
4151    #[test]
4152    fn envelope_denial_reason_joins_or_falls_back() {
4153        let multi = serde_json::json!({
4154            "denials": [
4155                {"kind": "exec", "target": "rm", "reason": "exec rm denied"},
4156                {"kind": "open", "target": "/etc/shadow", "reason": "open denied"}
4157            ]
4158        });
4159        assert_eq!(
4160            envelope_denial_reason(&multi),
4161            "exec rm denied; open denied"
4162        );
4163        // Missing or empty denials → the generic message, never a panic.
4164        let generic = "denied: the capability leash refused an operation";
4165        assert_eq!(envelope_denial_reason(&serde_json::json!({})), generic);
4166        assert_eq!(
4167            envelope_denial_reason(&serde_json::json!({"denials": []})),
4168            generic
4169        );
4170        // Entries without a string `reason` are skipped.
4171        assert_eq!(
4172            envelope_denial_reason(&serde_json::json!({"denials": [{"kind": "exec"}]})),
4173            generic
4174        );
4175    }
4176
4177    #[test]
4178    fn exec_allowlist_name_takes_basename() {
4179        assert_eq!(exec_allowlist_name("env"), "env");
4180        assert_eq!(exec_allowlist_name("/usr/bin/env"), "env");
4181        assert_eq!(exec_allowlist_name("/usr/bin/"), "bin");
4182        assert_eq!(exec_allowlist_name("C:\\tools\\env.exe"), "env.exe");
4183    }
4184
4185    /// #775 (§2.5): the human exec-denial NOTICE shows the BARE command
4186    /// target(s), never the reason sentence — restoring [`print_denied`]'s
4187    /// `'{target}'` contract. Stuffing the full reason there produced the
4188    /// field-report garble `capability denied: exec does not permit '<whole
4189    /// reason sentence>'`.
4190    #[test]
4191    fn exec_denial_target_label_is_the_bare_command_not_the_reason() {
4192        let one = serde_json::json!({
4193            "denied": true,
4194            "denials": [{
4195                "kind": "exec",
4196                "target": "export",
4197                "reason": "exec of \"export\" is not within the granted authority"
4198            }]
4199        });
4200        let label = exec_denial_target_label(&one);
4201        assert_eq!(label, "export");
4202        // It is the bare command — NEVER the reason sentence (which, in the
4203        // `'{target}'` slot, was the nested garble).
4204        assert!(!label.contains("is not within the granted authority"));
4205        // Multiple targets join cleanly; an envelope with no target falls back
4206        // to a generic label so the notice still prints one clean line.
4207        let multi = serde_json::json!({
4208            "denials": [
4209                {"kind": "exec", "target": "export", "reason": "r"},
4210                {"kind": "exec", "target": "set", "reason": "r"}
4211            ]
4212        });
4213        assert_eq!(exec_denial_target_label(&multi), "export, set");
4214        assert_eq!(
4215            exec_denial_target_label(&serde_json::json!({})),
4216            "a command"
4217        );
4218    }
4219
4220    #[test]
4221    fn host_of_url_extracts_hosts_conservatively() {
4222        assert_eq!(host_of_url("https://docs.rs/serde"), Some("docs.rs".into()));
4223        assert_eq!(host_of_url("http://Docs.RS"), Some("docs.rs".into()));
4224        assert_eq!(
4225            host_of_url("https://user:pw@example.com:8443/p?q#f"),
4226            Some("example.com".into())
4227        );
4228        assert_eq!(host_of_url("https://[::1]:8080/x"), Some("::1".into()));
4229        // Unparseable / non-http inputs skip the pre-check (None) rather
4230        // than guessing — enforcement stays with the leash either way.
4231        assert_eq!(host_of_url("not a url"), None);
4232        assert_eq!(host_of_url("ftp://example.com"), None);
4233        assert_eq!(host_of_url("https:///path-only"), None);
4234    }
4235
4236    #[test]
4237    fn exec_denial_requests_lifts_only_pure_exec_envelopes() {
4238        // The promptable case: every entry is an exec denial with a target;
4239        // the request target is the allowlist basename (the grantable name).
4240        let exec_only = serde_json::json!({
4241            "denied": true,
4242            "denials": [
4243                {"kind": "exec", "target": "/usr/bin/npm", "reason": "exec npm denied"},
4244                {"kind": "exec", "target": "node", "reason": "exec node denied"}
4245            ]
4246        });
4247        let reqs = exec_denial_requests(&exec_only).expect("promptable");
4248        assert_eq!(reqs.len(), 2);
4249        assert_eq!(reqs[0].tool, "run_command");
4250        assert_eq!(reqs[0].kind, DenialKind::Exec);
4251        assert_eq!(
4252            reqs[0].target, "npm",
4253            "basename, same rule as the config hint"
4254        );
4255        assert_eq!(reqs[0].reason, "exec npm denied");
4256        assert_eq!(reqs[1].target, "node");
4257
4258        // A non-exec entry anywhere keeps the standard denial: mapping an
4259        // opaque `open` onto an fs axis would over-grant.
4260        let mixed = serde_json::json!({
4261            "denials": [
4262                {"kind": "exec", "target": "npm", "reason": "r"},
4263                {"kind": "open", "target": "/etc/shadow", "reason": "r"}
4264            ]
4265        });
4266        assert!(exec_denial_requests(&mixed).is_none());
4267
4268        // Missing/empty pieces are never promptable.
4269        assert!(exec_denial_requests(&serde_json::json!({})).is_none());
4270        assert!(exec_denial_requests(&serde_json::json!({"denials": []})).is_none());
4271        let empty_target = serde_json::json!({
4272            "denials": [{"kind": "exec", "target": "", "reason": "r"}]
4273        });
4274        assert!(exec_denial_requests(&empty_target).is_none());
4275        let no_target = serde_json::json!({
4276            "denials": [{"kind": "exec", "reason": "r"}]
4277        });
4278        assert!(exec_denial_requests(&no_target).is_none());
4279    }
4280
4281    /// #905: a NET denial envelope (agent-bridle #196 shape) lifts to a per-host
4282    /// net PermissionRequest; the target is the CONNECT host verbatim (no
4283    /// basename mangling). Non-net / mixed / empty batches stay flat.
4284    #[test]
4285    fn net_denial_requests_lifts_only_pure_net_envelopes() {
4286        let net_only = serde_json::json!({
4287            "denied": true,
4288            "denials": [
4289                {"kind": "net", "target": "github.com", "reason": "net does not permit 'github.com'"},
4290                {"kind": "net", "target": "api.github.com", "reason": "net does not permit 'api.github.com'"}
4291            ]
4292        });
4293        let reqs = net_denial_requests(&net_only).expect("promptable");
4294        assert_eq!(reqs.len(), 2);
4295        assert_eq!(reqs[0].tool, "run_command");
4296        assert_eq!(reqs[0].kind, DenialKind::Net);
4297        assert_eq!(
4298            reqs[0].target, "github.com",
4299            "host verbatim, not a basename"
4300        );
4301        assert_eq!(reqs[0].reason, "net does not permit 'github.com'");
4302        assert_eq!(reqs[1].target, "api.github.com");
4303
4304        // A non-net entry anywhere → not net-promptable (exec lifter handles exec).
4305        let mixed = serde_json::json!({
4306            "denials": [
4307                {"kind": "net", "target": "github.com", "reason": "r"},
4308                {"kind": "exec", "target": "npm", "reason": "r"}
4309            ]
4310        });
4311        assert!(net_denial_requests(&mixed).is_none());
4312        // Exec-only is not net-promptable; empty/missing targets never are.
4313        let exec_only = serde_json::json!({"denials": [{"kind": "exec", "target": "npm"}]});
4314        assert!(net_denial_requests(&exec_only).is_none());
4315        assert!(net_denial_requests(&serde_json::json!({"denials": []})).is_none());
4316        let empty_target = serde_json::json!({"denials": [{"kind": "net", "target": ""}]});
4317        assert!(net_denial_requests(&empty_target).is_none());
4318    }
4319
4320    /// #905: the human denial NOTICE labels a pure-net refusal `net` (not `exec`),
4321    /// so it never reads "exec does not permit '<host>'". Exec / mixed stay `exec`.
4322    #[test]
4323    fn denial_axis_label_is_net_only_for_pure_net() {
4324        let net = serde_json::json!({"denials": [{"kind": "net", "target": "github.com"}]});
4325        assert_eq!(denial_axis_label(&net), "net");
4326        let exec = serde_json::json!({"denials": [{"kind": "exec", "target": "rm"}]});
4327        assert_eq!(denial_axis_label(&exec), "exec");
4328        let mixed = serde_json::json!({
4329            "denials": [{"kind": "net", "target": "h"}, {"kind": "exec", "target": "rm"}]
4330        });
4331        assert_eq!(denial_axis_label(&mixed), "exec", "mixed defaults to exec");
4332        assert_eq!(denial_axis_label(&serde_json::json!({})), "exec");
4333    }
4334
4335    #[test]
4336    fn tui_permits_path_prefix_semantics() {
4337        use crate::caveats::Scope;
4338        assert!(tui_permits_path(&Scope::All, "/anything/at/all"));
4339        assert!(!tui_permits_path(&Scope::<String>::none(), "/ws/file"));
4340        let only = Scope::only(["/ws".to_string()]);
4341        assert!(tui_permits_path(&only, "/ws/sub/file.rs"));
4342        assert!(tui_permits_path(&only, "/ws"), "the workspace root itself");
4343        assert!(!tui_permits_path(&only, "/elsewhere/file.rs"));
4344        // `..` traversal must NOT escape: a path that lexically resolves outside
4345        // the workspace is denied even though it textually begins with it.
4346        assert!(
4347            !tui_permits_path(&only, "/ws/../etc/passwd"),
4348            "`..` traversal escapes the workspace"
4349        );
4350        assert!(
4351            !tui_permits_path(&only, "/ws/../../etc/passwd"),
4352            "repeated `..` traversal escapes the workspace"
4353        );
4354        // A sibling dir that merely shares the string prefix is not under /ws.
4355        assert!(
4356            !tui_permits_path(&only, "/ws-secret/file.rs"),
4357            "sibling-prefix collision escapes the workspace"
4358        );
4359        // A `..` that stays inside the workspace is still permitted.
4360        assert!(tui_permits_path(&only, "/ws/sub/../file.rs"));
4361    }
4362
4363    /// Ratchet for the OPEN `fs-canonical-containment` deviation (issue #522,
4364    /// `docs/security/ocap-deviations.md`). `tui_permits_path` is string-lexical:
4365    /// it collapses `..` but does NOT resolve symlinks, so a link *inside* the
4366    /// workspace pointing OUT is permitted even though the OS would read the
4367    /// outside target. This test builds the path the call sites do
4368    /// (`workspace.join(model_path)`) over a REAL symlink and PINS that residual.
4369    ///
4370    /// When canonicalize-then-contain lands (the deviation's closure criterion),
4371    /// the gate will deny the symlinked path and this assertion MUST flip to
4372    /// `!tui_permits_path(...)` — that break is the signal to close the deviation.
4373    /// Unix-only: Windows symlinks need privileges (mirrors
4374    /// `find_does_not_follow_symlinks_out_of_workspace`).
4375    #[cfg(unix)]
4376    #[test]
4377    fn tui_permits_path_symlink_escape_is_the_known_residual() {
4378        use crate::caveats::Scope;
4379        let outside = tempfile::TempDir::new().unwrap();
4380        std::fs::write(outside.path().join("secret"), b"x").unwrap();
4381        let ws = tempfile::TempDir::new().unwrap();
4382        // A symlink under the workspace whose target is OUTSIDE it.
4383        std::os::unix::fs::symlink(outside.path(), ws.path().join("link")).unwrap();
4384
4385        let only = Scope::only([ws.path().to_string_lossy().into_owned()]);
4386
4387        // What the read/write call sites feed the gate for model path "link/secret".
4388        let via_link = ws.path().join("link").join("secret");
4389        // RESIDUAL: permitted today — the gate can't see through the symlink.
4390        // Flip to `!` when the gate canonicalizes (closes fs-canonical-containment).
4391        assert!(
4392            tui_permits_path(&only, &via_link.to_string_lossy()),
4393            "string gate permits a symlinked escape — known residual (#522)"
4394        );
4395
4396        // Contrast: a plain `..` escape through the SAME root is already denied
4397        // (lexical containment, the part #502 did fix) — so this isn't a blanket
4398        // hole, only the symlink-resolution gap.
4399        let dotdot = ws.path().join("..").join("etc").join("passwd");
4400        assert!(
4401            !tui_permits_path(&only, &dotdot.to_string_lossy()),
4402            "`..` escape is denied even though symlink escape is not"
4403        );
4404    }
4405
4406    // --- PR4: the `git` tool is presence-gated -----------------------------
4407
4408    #[test]
4409    fn git_tool_advertised_only_with_the_presence_gate() {
4410        fn names(defs: &serde_json::Value) -> Vec<&str> {
4411            defs.as_array()
4412                .unwrap()
4413                .iter()
4414                .filter_map(|d| d["function"]["name"].as_str())
4415                .collect()
4416        }
4417        let with = merged_tool_definitions(
4418            &NoMcp, false, false, false, true, false, false, false, false, false,
4419        );
4420        assert!(names(&with).contains(&"git"), "with_git advertises git");
4421        let without = merged_tool_definitions(
4422            &NoMcp, false, false, false, false, false, false, false, false, false,
4423        );
4424        assert!(!names(&without).contains(&"git"), "no git without the gate");
4425        // #479: the /team toggle advertises both crew tools, and only then.
4426        let team = merged_tool_definitions(
4427            &NoMcp, false, false, false, false, true, false, false, false, false,
4428        );
4429        assert!(
4430            names(&team).contains(&"crew") && names(&team).contains(&"compose_roster"),
4431            "with_team advertises crew + compose_roster"
4432        );
4433        assert!(
4434            !names(&without).contains(&"crew"),
4435            "no crew without the gate"
4436        );
4437        // Step 26.4 (#583): the scratchpad state tools, only with the gate on.
4438        let scratch = merged_tool_definitions(
4439            &NoMcp, false, false, false, false, false, true, false, false, false,
4440        );
4441        for t in ["state_set", "state_get", "state_clear"] {
4442            assert!(
4443                names(&scratch).contains(&t),
4444                "{t} advertised with_scratchpad"
4445            );
4446            assert!(!names(&without).contains(&t), "{t} hidden without the gate");
4447            assert!(
4448                !is_hallucination(t, &serde_json::json!({})),
4449                "{t} is a real tool"
4450            );
4451        }
4452        // Step 26.5.5 (#582): the code_search tool, only with its gate on.
4453        let code = merged_tool_definitions(
4454            &NoMcp, false, false, false, false, false, false, true, false, false,
4455        );
4456        assert!(
4457            names(&code).contains(&"code_search"),
4458            "code_search advertised"
4459        );
4460        assert!(
4461            !names(&without).contains(&"code_search"),
4462            "code_search hidden without the gate"
4463        );
4464        assert!(!is_hallucination("code_search", &serde_json::json!({})));
4465        // Step 26.6a (#585): the experiential record/recall tools, only with the gate.
4466        let exp = merged_tool_definitions(
4467            &NoMcp, false, false, false, false, false, false, false, true, false,
4468        );
4469        for t in ["experience_record", "experience_recall"] {
4470            assert!(names(&exp).contains(&t), "{t} advertised with_experiential");
4471            assert!(!names(&without).contains(&t), "{t} hidden without the gate");
4472            assert!(
4473                !is_hallucination(t, &serde_json::json!({})),
4474                "{t} is a real tool"
4475            );
4476        }
4477        // Step 26.6b (#586) / #715 PR2: the scheduled update_plan + plan_get tools,
4478        // only with the gate (plan_set/plan_advance collapsed into update_plan).
4479        let sched = merged_tool_definitions(
4480            &NoMcp, false, false, false, false, false, false, false, false, true,
4481        );
4482        for t in ["update_plan", "plan_get"] {
4483            assert!(names(&sched).contains(&t), "{t} advertised with_scheduled");
4484            assert!(!names(&without).contains(&t), "{t} hidden without the gate");
4485            assert!(
4486                !is_hallucination(t, &serde_json::json!({})),
4487                "{t} is a real tool"
4488            );
4489        }
4490    }
4491
4492    #[tokio::test]
4493    async fn state_tools_dispatch_only_with_a_store() {
4494        use crate::agentic::scratchpad::{ScratchpadStore, SessionScratchpadStore};
4495        let caveats = crate::caveats::Caveats::top();
4496        let args = serde_json::json!({ "key": "k", "value": "v" });
4497        // Step 26.4: without a store the tool was never advertised → unknown.
4498        let none = execute_tool(
4499            "state_set",
4500            &args,
4501            ".",
4502            false,
4503            20,
4504            &caveats,
4505            &mut NoMcp,
4506            None,
4507            None,
4508            None,
4509            None,
4510            None,
4511            None,
4512            None,
4513            None,
4514            None,
4515            None,
4516            None,
4517            None,
4518        )
4519        .await;
4520        assert!(none.starts_with("unknown tool: state_set"), "{none}");
4521        // With a store → routes to the executor and mutates it.
4522        let store = SessionScratchpadStore::default();
4523        let set = execute_tool(
4524            "state_set",
4525            &args,
4526            ".",
4527            false,
4528            20,
4529            &caveats,
4530            &mut NoMcp,
4531            None,
4532            None,
4533            None,
4534            None,
4535            None,
4536            None,
4537            None,
4538            None,
4539            Some(&store as &dyn ScratchpadStore),
4540            None,
4541            None,
4542            None,
4543        )
4544        .await;
4545        assert_eq!(set, "stored: k");
4546        assert_eq!(store.get("k").as_deref(), Some("v"));
4547    }
4548
4549    #[tokio::test]
4550    async fn code_search_dispatch_only_with_a_searcher() {
4551        use crate::agentic::semantic::{CodeSearch, Embedder, SessionSemanticIndex};
4552        struct E;
4553        #[async_trait::async_trait]
4554        impl Embedder for E {
4555            async fn embed(&self, _t: &str) -> anyhow::Result<Vec<f32>> {
4556                Ok(vec![1.0])
4557            }
4558        }
4559        let caveats = crate::caveats::Caveats::top();
4560        let args = serde_json::json!({ "query": "find it" });
4561        // Step 26.5.5: no searcher → unknown tool (presence-gate parity).
4562        let none = execute_tool(
4563            "code_search",
4564            &args,
4565            ".",
4566            false,
4567            20,
4568            &caveats,
4569            &mut NoMcp,
4570            None,
4571            None,
4572            None,
4573            None,
4574            None,
4575            None,
4576            None,
4577            None,
4578            None,
4579            None,
4580            None,
4581            None,
4582        )
4583        .await;
4584        assert!(none.starts_with("unknown tool: code_search"), "{none}");
4585        // with a searcher (empty index) → routes to the executor (labelled no-match).
4586        let idx = SessionSemanticIndex::default();
4587        let search = CodeSearch {
4588            embedder: &E,
4589            index: &idx,
4590            top_k: 1,
4591        };
4592        let out = execute_tool(
4593            "code_search",
4594            &args,
4595            ".",
4596            false,
4597            20,
4598            &caveats,
4599            &mut NoMcp,
4600            None,
4601            None,
4602            None,
4603            None,
4604            None,
4605            None,
4606            None,
4607            None,
4608            None,
4609            Some(search),
4610            None,
4611            None,
4612        )
4613        .await;
4614        assert!(out.contains("no code matched"), "{out}");
4615    }
4616
4617    #[tokio::test]
4618    async fn experiential_dispatch_only_with_a_store() {
4619        use crate::agentic::experiential::{ExperienceStore, SessionExperienceStore};
4620        let caveats = crate::caveats::Caveats::top();
4621        let args = serde_json::json!({
4622            "task": "ci flake", "outcome": "fixed", "lesson": "pin the seed for the fuzz test"
4623        });
4624        // Step 26.6a: no store → unknown tool for BOTH arms (presence-gate parity).
4625        for name in ["experience_record", "experience_recall"] {
4626            let out = execute_tool(
4627                name, &args, ".", false, 20, &caveats, &mut NoMcp, None, None, None, None, None,
4628                None, None, None, None, None, None, None,
4629            )
4630            .await;
4631            assert!(out.starts_with(&format!("unknown tool: {name}")), "{out}");
4632        }
4633        // with a store → record routes to the executor and mutates it.
4634        let store = SessionExperienceStore::default();
4635        let out = execute_tool(
4636            "experience_record",
4637            &args,
4638            ".",
4639            false,
4640            20,
4641            &caveats,
4642            &mut NoMcp,
4643            None,
4644            None,
4645            None,
4646            None,
4647            None,
4648            None,
4649            None,
4650            None,
4651            None,
4652            None,
4653            Some(&store as &dyn ExperienceStore),
4654            None,
4655        )
4656        .await;
4657        assert_eq!(out, "recorded experience");
4658        assert_eq!(store.count(), 1);
4659    }
4660
4661    #[tokio::test]
4662    async fn scheduled_dispatch_only_with_a_ledger() {
4663        use crate::agentic::scheduled::{SessionStepLedger, StepLedger};
4664        let caveats = crate::caveats::Caveats::top();
4665        let args = serde_json::json!({ "plan": [
4666            { "step": "a", "status": "in_progress" },
4667            { "step": "b", "status": "pending" },
4668        ] });
4669        // Step 26.6b / #716 / #715 PR2: no ledger → unknown tool for ALL plan arms
4670        // (presence-gate parity, including the read-only plan_get).
4671        for name in ["update_plan", "plan_get"] {
4672            let out = execute_tool(
4673                name, &args, ".", false, 20, &caveats, &mut NoMcp, None, None, None, None, None,
4674                None, None, None, None, None, None, None,
4675            )
4676            .await;
4677            assert!(out.starts_with(&format!("unknown tool: {name}")), "{out}");
4678        }
4679        // with a ledger → update_plan routes to the executor and mutates it.
4680        let ledger = SessionStepLedger::default();
4681        let out = execute_tool(
4682            "update_plan",
4683            &args,
4684            ".",
4685            false,
4686            20,
4687            &caveats,
4688            &mut NoMcp,
4689            None,
4690            None,
4691            None,
4692            None,
4693            None,
4694            None,
4695            None,
4696            None,
4697            None,
4698            None,
4699            None,
4700            Some(&ledger as &dyn StepLedger),
4701        )
4702        .await;
4703        assert!(out.starts_with("<plan>\n"), "{out}");
4704        assert_eq!(ledger.count(), 2);
4705        // #716: plan_get with a ledger renders the <plan> block, read-only.
4706        let got = execute_tool(
4707            "plan_get",
4708            &serde_json::json!({}),
4709            ".",
4710            false,
4711            20,
4712            &caveats,
4713            &mut NoMcp,
4714            None,
4715            None,
4716            None,
4717            None,
4718            None,
4719            None,
4720            None,
4721            None,
4722            None,
4723            None,
4724            None,
4725            Some(&ledger as &dyn StepLedger),
4726        )
4727        .await;
4728        assert!(got.starts_with("<plan>\n"), "{got}");
4729        assert_eq!(ledger.count(), 2, "plan_get is read-only");
4730    }
4731
4732    #[tokio::test]
4733    async fn resume_context_dispatch_degrades_without_a_recall_source() {
4734        // #714: advertised ALWAYS, so dispatch never reports "unknown tool" —
4735        // with no recall_source (headless) it returns the clear no-history line.
4736        let caveats = crate::caveats::Caveats::top();
4737        let out = execute_tool(
4738            "resume_context",
4739            &serde_json::json!({}),
4740            ".",
4741            false,
4742            20,
4743            &caveats,
4744            &mut NoMcp,
4745            None, // build_check_cmd
4746            None, // note_sink
4747            None, // recall_source
4748            None, // memory_source
4749            None, // permission_gate
4750            None, // exec_floor
4751            None, // git_tool
4752            None, // crew_runner
4753            None, // scratchpad_store
4754            None, // code_search
4755            None, // experience_store
4756            None, // step_ledger
4757        )
4758        .await;
4759        assert!(
4760            out.contains("no conversation history available this session"),
4761            "{out}"
4762        );
4763        assert!(!out.starts_with("unknown tool"), "{out}");
4764    }
4765
4766    #[test]
4767    fn run_build_check_reports_pass_fail_and_spawn_error() {
4768        let ws = tempfile::TempDir::new().unwrap();
4769        let ws_str = ws.path().to_string_lossy();
4770        assert_eq!(
4771            run_build_check(passing_build_check_cmd(), &ws_str),
4772            "  ✓ build check passed"
4773        );
4774        let failed = run_build_check(&failing_build_check_cmd("boom"), &ws_str);
4775        assert!(failed.contains("✗ build check failed"), "got: {failed}");
4776        assert!(failed.contains("boom"), "stderr excerpt shown: {failed}");
4777        // A nonexistent workspace dir → the command can't even spawn.
4778        let err = run_build_check(passing_build_check_cmd(), "/definitely/not/a/dir");
4779        assert!(err.contains("⚠ build check could not run"), "got: {err}");
4780    }
4781}
4782
4783// ---------------------------------------------------------------------------
4784// execute_tool branch tests — edit_file / shrink guard / denial paths
4785// ---------------------------------------------------------------------------
4786
4787#[cfg(test)]
4788mod execute_tool_branch_tests {
4789    use super::super::NoMcp;
4790    use super::*;
4791    use crate::caveats::{Caveats, CountBound, Scope};
4792
4793    /// fs read everywhere, fs write scoped to the workspace (skips the y/N
4794    /// confirm — the scoped preset is the consent), nothing else.
4795    fn caveats_rw(ws: &std::path::Path) -> Caveats {
4796        Caveats {
4797            fs_read: Scope::All,
4798            fs_write: Scope::only([ws.to_string_lossy().into_owned()]),
4799            exec: Scope::none(),
4800            net: Scope::none(),
4801            max_calls: CountBound::Unlimited,
4802            valid_for_generation: Scope::All,
4803        }
4804    }
4805
4806    // --- PR4: the `git` tool arm in execute_tool ---------------------------
4807
4808    /// A stub GitTool: echoes the op, and refuses `commit` when the projected
4809    /// GitCaveats deny it — exercises the arm's caveat projection without a repo.
4810    struct StubGit;
4811    impl crate::agentic::GitTool for StubGit {
4812        fn dispatch(
4813            &self,
4814            op: &str,
4815            _args: &serde_json::Value,
4816            caps: &crate::git_caveats::GitCaveats,
4817        ) -> Result<String, String> {
4818            match op {
4819                "status" => Ok("on branch main (HEAD abc123)".to_string()),
4820                "commit" if !caps.permits_commit() => {
4821                    Err("capability denied: git commit not permitted".to_string())
4822                }
4823                "commit" => Ok("committed abc123: msg".to_string()),
4824                other => Err(format!("unknown git op '{other}'")),
4825            }
4826        }
4827    }
4828
4829    async fn run_git(
4830        op: &str,
4831        caveats: &Caveats,
4832        git: Option<&dyn crate::agentic::GitTool>,
4833    ) -> String {
4834        let ws = tempfile::TempDir::new().unwrap();
4835        execute_tool(
4836            "git",
4837            &serde_json::json!({ "op": op }),
4838            &ws.path().to_string_lossy(),
4839            false,
4840            20,
4841            caveats,
4842            &mut NoMcp,
4843            None,
4844            None,
4845            None,
4846            None,
4847            None,
4848            None,
4849            git,
4850            None, // crew_runner
4851            None, // scratchpad_store
4852            None, // code_search
4853            None, // experience_store
4854            None, // step_ledger
4855        )
4856        .await
4857    }
4858
4859    #[tokio::test]
4860    async fn git_arm_dispatches_when_injected() {
4861        let ws = tempfile::TempDir::new().unwrap();
4862        let out = run_git("status", &caveats_rw(ws.path()), Some(&StubGit)).await;
4863        assert!(out.contains("on branch main"), "got: {out}");
4864    }
4865
4866    #[tokio::test]
4867    async fn git_arm_surfaces_denials_from_projected_caveats() {
4868        // A session with no fs_write → from_session denies commit_local.
4869        let ws = tempfile::TempDir::new().unwrap();
4870        let read_only = Caveats {
4871            fs_write: Scope::none(),
4872            ..caveats_rw(ws.path())
4873        };
4874        let out = run_git("commit", &read_only, Some(&StubGit)).await;
4875        assert!(
4876            out.contains("error:") && out.contains("commit"),
4877            "got: {out}"
4878        );
4879        // The same session can still run a read op.
4880        let out = run_git("status", &read_only, Some(&StubGit)).await;
4881        assert!(out.contains("on branch main"), "got: {out}");
4882    }
4883
4884    #[tokio::test]
4885    async fn git_arm_unknown_op_is_an_error_not_a_panic() {
4886        let ws = tempfile::TempDir::new().unwrap();
4887        let out = run_git("frobnicate", &caveats_rw(ws.path()), Some(&StubGit)).await;
4888        assert!(
4889            out.contains("error:") && out.contains("unknown git op"),
4890            "got: {out}"
4891        );
4892    }
4893
4894    #[tokio::test]
4895    async fn git_arm_without_injection_is_unknown_tool() {
4896        let ws = tempfile::TempDir::new().unwrap();
4897        let out = run_git("status", &caveats_rw(ws.path()), None).await;
4898        assert!(out.contains("unknown tool: git"), "got: {out}");
4899    }
4900
4901    // #479: the agent-callable crew/compose_roster tools route through the
4902    // injected CrewRunner — same presence-gating + dispatch shape as `git`.
4903    struct StubCrew;
4904    #[async_trait::async_trait]
4905    impl crate::agentic::CrewRunner for StubCrew {
4906        async fn dispatch(
4907            &self,
4908            op: &str,
4909            _args: &serde_json::Value,
4910            _caveats: &Caveats,
4911        ) -> Result<String, String> {
4912            match op {
4913                "compose_roster" => Ok("proposed roster: planner <- qwen3-coder:30b".to_string()),
4914                "crew" => Ok("crew ran: diff +1/-0, status PASS".to_string()),
4915                other => Err(format!("unknown op: {other}")),
4916            }
4917        }
4918    }
4919
4920    async fn run_crew_tool(
4921        name: &str,
4922        args: serde_json::Value,
4923        crew: Option<&dyn crate::agentic::CrewRunner>,
4924    ) -> String {
4925        let ws = tempfile::TempDir::new().unwrap();
4926        execute_tool(
4927            name,
4928            &args,
4929            &ws.path().to_string_lossy(),
4930            false,
4931            20,
4932            &caveats_rw(ws.path()),
4933            &mut NoMcp,
4934            None, // build_check_cmd
4935            None, // note_sink
4936            None, // recall_source
4937            None, // memory_source
4938            None, // permission_gate
4939            None, // exec_floor
4940            None, // git_tool
4941            crew,
4942            None, // scratchpad_store
4943            None, // code_search
4944            None, // experience_store
4945            None, // step_ledger
4946        )
4947        .await
4948    }
4949
4950    #[tokio::test]
4951    async fn crew_arm_dispatches_when_injected() {
4952        let out = run_crew_tool(
4953            "crew",
4954            serde_json::json!({ "task": "do X" }),
4955            Some(&StubCrew),
4956        )
4957        .await;
4958        assert!(
4959            out.contains("crew ran") && out.contains("PASS"),
4960            "got: {out}"
4961        );
4962        let out = run_crew_tool(
4963            "compose_roster",
4964            serde_json::json!({ "mode": "crew" }),
4965            Some(&StubCrew),
4966        )
4967        .await;
4968        assert!(out.contains("proposed roster"), "got: {out}");
4969    }
4970
4971    /// #479 (G4): with no `CrewRunner` injected (the OFF default), the dispatch
4972    /// arm coaches recovery instead of the old flat `unknown tool` dead-end — it
4973    /// names the operator gesture (`NEWT_TEAM`) and a real solo alternative, and
4974    /// must NOT read as "unknown tool".
4975    #[tokio::test]
4976    async fn crew_arm_without_injection_coaches_recovery() {
4977        for name in ["crew", "compose_roster"] {
4978            let out = run_crew_tool(name, serde_json::json!({ "task": "x" }), None).await;
4979            assert!(out.contains("NEWT_TEAM"), "{name}: {out}");
4980            assert!(out.contains("read_file"), "{name}: {out}");
4981            assert!(!out.contains("unknown tool"), "{name}: {out}");
4982        }
4983    }
4984
4985    /// #479 (G4): the factored coach helper names the gate + a real alternative
4986    /// and never reads as "unknown tool" — the regression point for the wording.
4987    #[test]
4988    fn crew_off_recovery_result_names_gate_and_alternative() {
4989        let out = crew_off_recovery_result("crew");
4990        assert!(out.contains("'crew'"), "{out}");
4991        assert!(out.contains("NEWT_TEAM"), "{out}");
4992        // A real, always-available solo alternative is offered.
4993        assert!(out.contains("write_file"), "{out}");
4994        assert!(!out.contains("unknown tool"), "{out}");
4995    }
4996
4997    /// #479 (G4): the gated-off telemetry seam. A `crew`/`compose_roster` reach
4998    /// with the surface OFF records a `GatedOff` phantom; the same names with the
4999    /// surface ON record nothing (they dispatch normally), and a non-crew name is
5000    /// never gated-off.
5001    #[test]
5002    fn classify_gated_off_reach_only_fires_for_off_crew_names() {
5003        for name in ["crew", "compose_roster"] {
5004            assert_eq!(
5005                classify_gated_off_reach(name, false),
5006                Some(crate::PhantomResolution::GatedOff(
5007                    "crew/team surface off (NEWT_TEAM)".into()
5008                )),
5009                "{name} OFF should record GatedOff"
5010            );
5011            assert_eq!(
5012                classify_gated_off_reach(name, true),
5013                None,
5014                "{name} ON dispatches normally — no phantom"
5015            );
5016        }
5017        // A non-crew tool is never a gated-off reach, OFF or ON.
5018        assert_eq!(classify_gated_off_reach("read_file", false), None);
5019        assert_eq!(classify_gated_off_reach("read_file", true), None);
5020    }
5021
5022    /// #479 (G4) guard: the OFF-state changes do not touch `is_hallucination`
5023    /// (crew/compose_roster stay real names) or `classify_phantom_reach` for the
5024    /// crew names — both kept exactly so the ON path stays a normal dispatch.
5025    #[test]
5026    fn crew_names_stay_real_and_unflagged_by_existing_seams() {
5027        for name in ["crew", "compose_roster"] {
5028            assert!(
5029                !is_hallucination(name, &serde_json::json!({ "task": "x" })),
5030                "{name} must stay a real tool name"
5031            );
5032            assert_eq!(
5033                classify_phantom_reach(name, &serde_json::json!({ "task": "x" }), "ok", true),
5034                None,
5035                "{name} must not be flagged by classify_phantom_reach"
5036            );
5037        }
5038    }
5039
5040    // --- #496: the embedded `find` tool -----------------------------------
5041
5042    /// Convenience for `find` calls through the real dispatch under a
5043    /// read-everything session.
5044    async fn run_find(args: serde_json::Value, ws: &std::path::Path) -> String {
5045        run_tool("find", args, ws, &caveats_rw(ws), None).await
5046    }
5047
5048    fn touch(root: &std::path::Path, rel: &str) {
5049        let p = root.join(rel);
5050        std::fs::create_dir_all(p.parent().unwrap()).unwrap();
5051        std::fs::write(p, b"x").unwrap();
5052    }
5053
5054    /// Regression for #496: an agent needed `find . -name pyo3_module.rs` but
5055    /// the build's shell tool was unavailable. The embedded tool must locate the
5056    /// file by basename, ignoring decoys, and return its workspace-relative path
5057    /// (no shell, no `| sort`). Fails before this tool existed (`unknown tool:
5058    /// find`).
5059    #[tokio::test]
5060    async fn find_locates_file_by_name_issue_496() {
5061        let ws = tempfile::TempDir::new().unwrap();
5062        touch(ws.path(), "newt-core/src/pyo3_module.rs");
5063        touch(ws.path(), "newt-data/src/other.rs");
5064        touch(ws.path(), "docs/pyo3_module.md"); // decoy: wrong extension
5065        let out = run_find(serde_json::json!({ "name": "pyo3_module.rs" }), ws.path()).await;
5066        assert_eq!(out, "newt-core/src/pyo3_module.rs", "got: {out}");
5067    }
5068
5069    /// The other call the blocked agent reached for:
5070    /// `find examples -maxdepth 2 -type f -name '*.py'`. Exercises glob + type
5071    /// filter + max_depth together, and confirms output is pre-sorted.
5072    #[tokio::test]
5073    async fn find_glob_type_and_maxdepth_together() {
5074        let ws = tempfile::TempDir::new().unwrap();
5075        touch(ws.path(), "examples/a.py"); // depth 1 — match
5076        touch(ws.path(), "examples/sub/b.py"); // depth 2 — match
5077        touch(ws.path(), "examples/sub/deep/c.py"); // depth 3 — too deep
5078        touch(ws.path(), "examples/readme.md"); // wrong extension
5079        std::fs::create_dir_all(ws.path().join("examples/empty_dir")).unwrap();
5080        let out = run_find(
5081            serde_json::json!({
5082                "path": "examples", "name": "*.py", "type": "f", "max_depth": 2
5083            }),
5084            ws.path(),
5085        )
5086        .await;
5087        // Pre-sorted, exactly the two in-depth .py files, no dir, no .md, no
5088        // depth-3 file — and no shell `| sort` needed.
5089        assert_eq!(out, "examples/a.py\nexamples/sub/b.py", "got: {out}");
5090    }
5091
5092    /// Output is sorted ascending regardless of filesystem/creation order.
5093    #[tokio::test]
5094    async fn find_output_is_sorted() {
5095        let ws = tempfile::TempDir::new().unwrap();
5096        for f in ["m.txt", "a.txt", "z.txt", "c.txt"] {
5097            touch(ws.path(), f);
5098        }
5099        let out = run_find(serde_json::json!({ "name": "*.txt" }), ws.path()).await;
5100        let lines: Vec<&str> = out.lines().collect();
5101        assert_eq!(
5102            lines,
5103            vec!["a.txt", "c.txt", "m.txt", "z.txt"],
5104            "got: {out}"
5105        );
5106    }
5107
5108    /// `type` restricts to files or directories.
5109    #[tokio::test]
5110    async fn find_type_filter() {
5111        let ws = tempfile::TempDir::new().unwrap();
5112        touch(ws.path(), "pkg/file.rs");
5113        std::fs::create_dir_all(ws.path().join("pkg/sub")).unwrap();
5114        let dirs = run_find(serde_json::json!({ "type": "d" }), ws.path()).await;
5115        assert!(
5116            dirs.contains("pkg") && dirs.contains("pkg/sub"),
5117            "got: {dirs}"
5118        );
5119        assert!(!dirs.contains("file.rs"), "dirs-only leaked a file: {dirs}");
5120        let files = run_find(serde_json::json!({ "type": "f" }), ws.path()).await;
5121        assert!(files.contains("pkg/file.rs"), "got: {files}");
5122        assert!(
5123            !files.lines().any(|l| l == "pkg" || l == "pkg/sub"),
5124            "files-only leaked a dir: {files}"
5125        );
5126    }
5127
5128    /// .gitignore + the default build/dep skips are honoured by default and
5129    /// can be disabled with `respect_gitignore=false`.
5130    #[tokio::test]
5131    async fn find_gitignore_and_default_skips() {
5132        let ws = tempfile::TempDir::new().unwrap();
5133        std::fs::write(ws.path().join(".gitignore"), "ignored.txt\n").unwrap();
5134        touch(ws.path(), "kept.txt");
5135        touch(ws.path(), "ignored.txt");
5136        touch(ws.path(), "target/build_artifact.txt");
5137        touch(ws.path(), "node_modules/dep.txt");
5138
5139        let on = run_find(serde_json::json!({ "name": "*.txt" }), ws.path()).await;
5140        assert!(on.contains("kept.txt"), "got: {on}");
5141        assert!(!on.contains("ignored.txt"), "gitignore not honoured: {on}");
5142        assert!(!on.contains("target/"), "target not skipped: {on}");
5143        assert!(
5144            !on.contains("node_modules/"),
5145            "node_modules not skipped: {on}"
5146        );
5147
5148        let off = run_find(
5149            serde_json::json!({ "name": "*.txt", "respect_gitignore": false }),
5150            ws.path(),
5151        )
5152        .await;
5153        assert!(off.contains("ignored.txt"), "opt-out should show it: {off}");
5154        assert!(off.contains("target/build_artifact.txt"), "got: {off}");
5155    }
5156
5157    /// `max_results` caps output and the result notes the truncation.
5158    #[tokio::test]
5159    async fn find_max_results_caps_and_notes_truncation() {
5160        let ws = tempfile::TempDir::new().unwrap();
5161        for i in 0..10 {
5162            touch(ws.path(), &format!("f{i}.txt"));
5163        }
5164        let out = run_find(
5165            serde_json::json!({ "name": "*.txt", "max_results": 3 }),
5166            ws.path(),
5167        )
5168        .await;
5169        let body: Vec<&str> = out.lines().filter(|l| l.ends_with(".txt")).collect();
5170        assert_eq!(body.len(), 3, "should cap at 3: {out}");
5171        assert!(out.contains("truncated at 3"), "got: {out}");
5172    }
5173
5174    /// A missing root is a clear error, and an empty match set says so.
5175    #[tokio::test]
5176    async fn find_missing_root_and_no_matches() {
5177        let ws = tempfile::TempDir::new().unwrap();
5178        touch(ws.path(), "a.txt");
5179        let missing = run_find(serde_json::json!({ "path": "does/not/exist" }), ws.path()).await;
5180        assert!(missing.starts_with("error:"), "got: {missing}");
5181        let empty = run_find(serde_json::json!({ "name": "*.nope" }), ws.path()).await;
5182        assert_eq!(empty, "no matches", "got: {empty}");
5183    }
5184
5185    /// fs_read denial: no scope + no prompt gate ⇒ capability denied (same UX
5186    /// as list_dir/read_file).
5187    #[tokio::test]
5188    async fn find_denied_without_fs_read() {
5189        let ws = tempfile::TempDir::new().unwrap();
5190        touch(ws.path(), "secret.txt");
5191        let denied = Caveats {
5192            fs_read: Scope::none(),
5193            ..caveats_rw(ws.path())
5194        };
5195        let out = run_tool(
5196            "find",
5197            serde_json::json!({ "name": "*" }),
5198            ws.path(),
5199            &denied,
5200            None,
5201        )
5202        .await;
5203        assert!(out.starts_with("capability denied"), "got: {out}");
5204    }
5205
5206    /// A `..` root that escapes the workspace is refused even when the session
5207    /// grants fs_read everywhere (defence-in-depth for a recursive read).
5208    #[tokio::test]
5209    async fn find_refuses_root_outside_workspace() {
5210        let parent = tempfile::TempDir::new().unwrap();
5211        std::fs::write(parent.path().join("outside.txt"), b"x").unwrap();
5212        let ws = parent.path().join("ws");
5213        std::fs::create_dir_all(&ws).unwrap();
5214        // fs_read: All, so the only thing that can stop the escape is the
5215        // canonical-root containment check.
5216        let out = run_find(serde_json::json!({ "path": ".." }), &ws).await;
5217        assert!(out.starts_with("capability denied"), "got: {out}");
5218    }
5219
5220    /// An empty `name` is treated as "match everything" (the `!g.is_empty()`
5221    /// guard routes `Some("")` to the no-filter path; without it the glob would
5222    /// compile to `^$` and match nothing).
5223    #[tokio::test]
5224    async fn find_empty_name_matches_everything() {
5225        let ws = tempfile::TempDir::new().unwrap();
5226        touch(ws.path(), "a.txt");
5227        touch(ws.path(), "sub/b.rs");
5228        let out = run_find(serde_json::json!({ "name": "" }), ws.path()).await;
5229        for expected in ["a.txt", "sub", "sub/b.rs"] {
5230            assert!(
5231                out.lines().any(|l| l == expected),
5232                "empty name should match `{expected}`: {out}"
5233            );
5234        }
5235    }
5236
5237    /// Hidden entries (dotfiles / dotdirs) are pruned by default and surface
5238    /// only when `respect_gitignore=false` — relevant because dotfiles can hold
5239    /// secrets (.env, .ssh). Pins the `.hidden(respect_gitignore)` branch.
5240    #[tokio::test]
5241    async fn find_hidden_entries_gated_by_respect_gitignore() {
5242        let ws = tempfile::TempDir::new().unwrap();
5243        touch(ws.path(), "visible.txt");
5244        touch(ws.path(), ".hidden.txt");
5245        touch(ws.path(), ".config/secret.txt");
5246
5247        let default = run_find(serde_json::json!({ "name": "*" }), ws.path()).await;
5248        assert!(
5249            default.lines().any(|l| l == "visible.txt"),
5250            "got: {default}"
5251        );
5252        assert!(
5253            !default.contains(".hidden") && !default.contains(".config"),
5254            "hidden entries must be skipped by default: {default}"
5255        );
5256
5257        let all = run_find(
5258            serde_json::json!({ "name": "*", "respect_gitignore": false }),
5259            ws.path(),
5260        )
5261        .await;
5262        assert!(all.contains(".hidden.txt"), "opt-out should show it: {all}");
5263        assert!(all.contains(".config/secret.txt"), "got: {all}");
5264    }
5265
5266    /// Security boundary: `find` never follows symlinked directories, so a link
5267    /// pointing outside the workspace cannot leak the target's contents (pins
5268    /// `.follow_links(false)`). Unix-only — Windows symlinks need privileges.
5269    #[cfg(unix)]
5270    #[tokio::test]
5271    async fn find_does_not_follow_symlinks_out_of_workspace() {
5272        let outside = tempfile::TempDir::new().unwrap();
5273        std::fs::write(outside.path().join("secret.txt"), b"x").unwrap();
5274        let ws = tempfile::TempDir::new().unwrap();
5275        touch(ws.path(), "inside.txt");
5276        std::os::unix::fs::symlink(outside.path(), ws.path().join("link")).unwrap();
5277
5278        // The symlink is present but is NOT descended into.
5279        let leaked = run_find(serde_json::json!({ "name": "secret.txt" }), ws.path()).await;
5280        assert_eq!(
5281            leaked, "no matches",
5282            "symlink was followed out of ws: {leaked}"
5283        );
5284        // Sanity: a real in-workspace file is still found.
5285        let found = run_find(serde_json::json!({ "name": "inside.txt" }), ws.path()).await;
5286        assert_eq!(found, "inside.txt", "got: {found}");
5287    }
5288
5289    #[test]
5290    fn glob_to_regex_anchors_and_escapes() {
5291        // '*' is a wildcard; '.' is literal (not "any char").
5292        let re = glob_to_regex("*.py", true).unwrap();
5293        assert!(re.is_match("foo.py"));
5294        assert!(!re.is_match("foo.pyc")); // anchored at end
5295        assert!(!re.is_match("fooxpy")); // '.' is literal
5296                                         // Exact basename, '?' = single char, case-sensitivity honoured.
5297        assert!(glob_to_regex("a?c", true).unwrap().is_match("abc"));
5298        assert!(!glob_to_regex("a?c", true).unwrap().is_match("ac"));
5299        assert!(glob_to_regex("readme.md", false)
5300            .unwrap()
5301            .is_match("README.MD"));
5302        assert!(!glob_to_regex("readme.md", true)
5303            .unwrap()
5304            .is_match("README.MD"));
5305    }
5306
5307    async fn run_tool(
5308        name: &str,
5309        args: serde_json::Value,
5310        ws: &std::path::Path,
5311        caveats: &Caveats,
5312        build_check: Option<&str>,
5313    ) -> String {
5314        execute_tool(
5315            name,
5316            &args,
5317            &ws.to_string_lossy(),
5318            false,
5319            20,
5320            caveats,
5321            &mut NoMcp,
5322            build_check,
5323            None,
5324            None,
5325            None, // memory_source
5326            None,
5327            None,
5328            None, // git_tool
5329            None, // crew_runner
5330            None, // scratchpad_store
5331            None, // code_search
5332            None, // experience_store
5333            None, // step_ledger
5334        )
5335        .await
5336    }
5337
5338    #[tokio::test]
5339    async fn edit_file_replaces_unique_match_and_reports_delta() {
5340        let ws = tempfile::TempDir::new().unwrap();
5341        std::fs::write(ws.path().join("f.txt"), "hello world\nsecond line\n").unwrap();
5342        let caveats = caveats_rw(ws.path());
5343        let out = run_tool(
5344            "edit_file",
5345            serde_json::json!({
5346                "path": "f.txt",
5347                "old_string": "world",
5348                "new_string": "rust\nand more"
5349            }),
5350            ws.path(),
5351            &caveats,
5352            None,
5353        )
5354        .await;
5355        assert!(out.starts_with("edited f.txt (+1 lines"), "got: {out}");
5356        assert_eq!(
5357            std::fs::read_to_string(ws.path().join("f.txt")).unwrap(),
5358            "hello rust\nand more\nsecond line\n"
5359        );
5360    }
5361
5362    #[tokio::test]
5363    async fn edit_file_rejects_empty_missing_and_ambiguous_old_string() {
5364        let ws = tempfile::TempDir::new().unwrap();
5365        std::fs::write(ws.path().join("f.txt"), "dup\ndup\n").unwrap();
5366        let caveats = caveats_rw(ws.path());
5367
5368        let out = run_tool(
5369            "edit_file",
5370            serde_json::json!({"path": "f.txt", "old_string": "", "new_string": "x"}),
5371            ws.path(),
5372            &caveats,
5373            None,
5374        )
5375        .await;
5376        assert!(out.contains("old_string must not be empty"), "got: {out}");
5377
5378        let out = run_tool(
5379            "edit_file",
5380            serde_json::json!({"path": "f.txt", "old_string": "absent", "new_string": "x"}),
5381            ws.path(),
5382            &caveats,
5383            None,
5384        )
5385        .await;
5386        assert!(out.contains("old_string not found in f.txt"), "got: {out}");
5387        // The miss error now shows the file's actual contents so the model can
5388        // copy the exact text instead of blind-guessing old_string again.
5389        assert!(out.contains("do not guess again"), "got: {out}");
5390        assert!(
5391            out.contains("dup"),
5392            "miss error must include the file content: {out}"
5393        );
5394
5395        let out = run_tool(
5396            "edit_file",
5397            serde_json::json!({"path": "f.txt", "old_string": "dup", "new_string": "x"}),
5398            ws.path(),
5399            &caveats,
5400            None,
5401        )
5402        .await;
5403        assert!(out.contains("matches 2 locations"), "got: {out}");
5404        // The ambiguous edit must NOT have touched the file.
5405        assert_eq!(
5406            std::fs::read_to_string(ws.path().join("f.txt")).unwrap(),
5407            "dup\ndup\n"
5408        );
5409    }
5410
5411    #[tokio::test]
5412    async fn edit_file_denied_outside_fs_write_scope_and_missing_file() {
5413        let ws = tempfile::TempDir::new().unwrap();
5414        let caveats = Caveats {
5415            fs_write: Scope::none(),
5416            ..caveats_rw(ws.path())
5417        };
5418        let out = run_tool(
5419            "edit_file",
5420            serde_json::json!({"path": "f.txt", "old_string": "a", "new_string": "b"}),
5421            ws.path(),
5422            &caveats,
5423            None,
5424        )
5425        .await;
5426        assert!(
5427            out.contains("capability denied: fs_write"),
5428            "denied before any fs access, got: {out}"
5429        );
5430
5431        let caveats = caveats_rw(ws.path());
5432        let out = run_tool(
5433            "edit_file",
5434            serde_json::json!({"path": "missing.txt", "old_string": "a", "new_string": "b"}),
5435            ws.path(),
5436            &caveats,
5437            None,
5438        )
5439        .await;
5440        assert!(out.contains("error reading missing.txt"), "got: {out}");
5441    }
5442
5443    #[tokio::test]
5444    async fn edit_file_appends_build_check_result() {
5445        let ws = tempfile::TempDir::new().unwrap();
5446        std::fs::write(ws.path().join("f.txt"), "old\n").unwrap();
5447        let caveats = caveats_rw(ws.path());
5448        let out = run_tool(
5449            "edit_file",
5450            serde_json::json!({"path": "f.txt", "old_string": "old", "new_string": "new"}),
5451            ws.path(),
5452            &caveats,
5453            Some(passing_build_check_cmd()),
5454        )
5455        .await;
5456        assert!(out.contains("✓ build check passed"), "got: {out}");
5457
5458        let failing_check = failing_build_check_cmd("broke");
5459        let out = run_tool(
5460            "edit_file",
5461            serde_json::json!({"path": "f.txt", "old_string": "new", "new_string": "newer"}),
5462            ws.path(),
5463            &caveats,
5464            Some(&failing_check),
5465        )
5466        .await;
5467        assert!(out.contains("✗ build check failed"), "got: {out}");
5468        assert!(out.contains("broke"), "model sees the failure text: {out}");
5469    }
5470
5471    #[tokio::test]
5472    async fn write_file_shrink_guard_refuses_large_deletion() {
5473        let ws = tempfile::TempDir::new().unwrap();
5474        let big: String = (0..100).map(|i| format!("line {i}\n")).collect();
5475        std::fs::write(ws.path().join("big.txt"), &big).unwrap();
5476        let caveats = caveats_rw(ws.path());
5477        let out = run_tool(
5478            "write_file",
5479            serde_json::json!({"path": "big.txt", "content": "tiny\n"}),
5480            ws.path(),
5481            &caveats,
5482            None,
5483        )
5484        .await;
5485        assert!(
5486            out.contains("would shrink big.txt from 100 → 1 lines"),
5487            "got: {out}"
5488        );
5489        assert!(out.contains("edit_file"), "points at the safer tool: {out}");
5490        // The guard refused — the original file must be intact.
5491        assert_eq!(
5492            std::fs::read_to_string(ws.path().join("big.txt")).unwrap(),
5493            big
5494        );
5495    }
5496
5497    #[tokio::test]
5498    async fn write_file_creates_parent_directories() {
5499        let ws = tempfile::TempDir::new().unwrap();
5500        let caveats = caveats_rw(ws.path());
5501        let out = run_tool(
5502            "write_file",
5503            serde_json::json!({"path": "a/b/c.txt", "content": "nested"}),
5504            ws.path(),
5505            &caveats,
5506            None,
5507        )
5508        .await;
5509        assert!(out.starts_with("wrote a/b/c.txt"), "got: {out}");
5510        assert_eq!(
5511            std::fs::read_to_string(ws.path().join("a/b/c.txt")).unwrap(),
5512            "nested"
5513        );
5514    }
5515
5516    #[tokio::test]
5517    async fn read_file_denial_and_missing_file_errors() {
5518        let ws = tempfile::TempDir::new().unwrap();
5519        std::fs::write(ws.path().join("secret.txt"), "x").unwrap();
5520        let denied = Caveats {
5521            fs_read: Scope::none(),
5522            ..caveats_rw(ws.path())
5523        };
5524        let out = run_tool(
5525            "read_file",
5526            serde_json::json!({"path": "secret.txt"}),
5527            ws.path(),
5528            &denied,
5529            None,
5530        )
5531        .await;
5532        assert!(out.contains("capability denied: fs_read"), "got: {out}");
5533
5534        let caveats = caveats_rw(ws.path());
5535        let out = run_tool(
5536            "read_file",
5537            serde_json::json!({"path": "nope.txt"}),
5538            ws.path(),
5539            &caveats,
5540            None,
5541        )
5542        .await;
5543        assert!(out.contains("error reading nope.txt"), "got: {out}");
5544    }
5545
5546    #[tokio::test]
5547    async fn list_dir_denial_and_missing_dir_errors() {
5548        let ws = tempfile::TempDir::new().unwrap();
5549        let denied = Caveats {
5550            fs_read: Scope::none(),
5551            ..caveats_rw(ws.path())
5552        };
5553        let out = run_tool(
5554            "list_dir",
5555            serde_json::json!({"path": "."}),
5556            ws.path(),
5557            &denied,
5558            None,
5559        )
5560        .await;
5561        assert!(out.contains("capability denied: fs_read"), "got: {out}");
5562
5563        let caveats = caveats_rw(ws.path());
5564        let out = run_tool(
5565            "list_dir",
5566            serde_json::json!({"path": "not-a-dir"}),
5567            ws.path(),
5568            &caveats,
5569            None,
5570        )
5571        .await;
5572        assert!(out.starts_with("error:"), "got: {out}");
5573    }
5574
5575    #[tokio::test]
5576    async fn unknown_tool_name_is_reported_not_executed() {
5577        let ws = tempfile::TempDir::new().unwrap();
5578        let caveats = caveats_rw(ws.path());
5579        let out = run_tool(
5580            "definitely_not_a_tool",
5581            serde_json::json!({}),
5582            ws.path(),
5583            &caveats,
5584            None,
5585        )
5586        .await;
5587        // Step 27.1: the bare "unknown tool: X" is now a corrective message that
5588        // still leads with the same prefix but also names the real catalog.
5589        assert!(
5590            out.starts_with("unknown tool: definitely_not_a_tool"),
5591            "got: {out}"
5592        );
5593        assert!(out.contains("Available tools include:"), "got: {out}");
5594    }
5595
5596    // -- Step 27.1: tool-alias resolution + corrective feedback -------------
5597
5598    #[test]
5599    fn alias_rewrites_shell_names_to_run_command() {
5600        for n in [
5601            "execute",
5602            "exec",
5603            "bash",
5604            "shell",
5605            "sh",
5606            "zsh",
5607            "terminal",
5608            "run_shell_command",
5609            "shell_command",
5610            "system",
5611        ] {
5612            assert!(
5613                matches!(
5614                    resolve_tool_alias(n),
5615                    Some(AliasOutcome::Rewrite("run_command"))
5616                ),
5617                "{n} should rewrite to run_command"
5618            );
5619        }
5620    }
5621
5622    #[test]
5623    fn alias_corrects_edit_and_create_names() {
5624        for n in [
5625            "str_replace_editor",
5626            "str_replace",
5627            "apply_patch",
5628            "edit",
5629            "replace_in_file",
5630        ] {
5631            let Some(AliasOutcome::Correct(msg)) = resolve_tool_alias(n) else {
5632                panic!("{n} should produce a Correct outcome");
5633            };
5634            assert!(msg.contains("edit_file"), "{n}: {msg}");
5635            assert!(msg.contains("write_file"), "{n}: {msg}");
5636        }
5637        for n in ["create_file", "new_file", "touch"] {
5638            let Some(AliasOutcome::Correct(msg)) = resolve_tool_alias(n) else {
5639                panic!("{n} should produce a Correct outcome");
5640            };
5641            assert!(msg.contains("write_file"), "{n}: {msg}");
5642        }
5643    }
5644
5645    #[test]
5646    fn alias_coaches_mkdir_to_write_file() {
5647        // #721: newt has no directory-creation tool — coach to write_file, which
5648        // does create_dir_all on the parent. Turns the issue's `mkdir -p …/src`
5649        // dead-end into a self-correcting tool call.
5650        for n in [
5651            "mkdir",
5652            "make_dir",
5653            "makedirs",
5654            "mkdirs",
5655            "create_dir",
5656            "create_directory",
5657        ] {
5658            let Some(AliasOutcome::Correct(msg)) = resolve_tool_alias(n) else {
5659                panic!("{n} should produce a Correct outcome");
5660            };
5661            assert!(msg.contains("write_file"), "{n}: {msg}");
5662            assert!(msg.contains("create_dir_all"), "{n}: {msg}");
5663        }
5664        // `touch` is intentionally NOT in the mkdir arm — it stays a create-file
5665        // alias (→ write_file), so there is no duplicate match arm / collision.
5666        let Some(AliasOutcome::Correct(msg)) = resolve_tool_alias("touch") else {
5667            panic!("touch should still be a create-file Correct outcome");
5668        };
5669        assert!(msg.contains("write_file"), "touch: {msg}");
5670    }
5671
5672    #[test]
5673    fn alias_passes_through_real_and_mcp_names() {
5674        for n in [
5675            "run_command",
5676            "read_file",
5677            "write_file",
5678            "edit_file",
5679            "git",
5680            "update_plan",
5681            "plan_get",
5682            "server__do_thing",
5683        ] {
5684            assert!(
5685                resolve_tool_alias(n).is_none(),
5686                "{n} must dispatch unchanged"
5687            );
5688        }
5689    }
5690
5691    // -- #716: plan / plan-read / crew / workflow alias families --------------
5692
5693    #[test]
5694    fn alias_corrects_plan_names_to_update_plan() {
5695        for n in [
5696            "enter_plan",
5697            "enter_plan_mode",
5698            "plan_mode",
5699            "start_plan",
5700            "begin_plan",
5701            "make_plan",
5702            "create_plan",
5703            "plan",
5704            "planning",
5705            "todo",
5706            "todos",
5707            "todo_write",
5708        ] {
5709            let Some(AliasOutcome::Correct(msg)) = resolve_tool_alias(n) else {
5710                panic!("{n} should produce a Correct outcome");
5711            };
5712            assert!(msg.contains("update_plan"), "{n}: {msg}");
5713        }
5714        // #715 PR2: the advance-ish verbs coach update_plan + "completed" too.
5715        for n in [
5716            "next_step",
5717            "complete_step",
5718            "finish_step",
5719            "mark_done",
5720            "step_done",
5721        ] {
5722            let Some(AliasOutcome::Correct(msg)) = resolve_tool_alias(n) else {
5723                panic!("{n} should produce a Correct outcome");
5724            };
5725            assert!(msg.contains("update_plan"), "{n}: {msg}");
5726            assert!(msg.contains("completed"), "{n}: {msg}");
5727        }
5728        // #715 PR2: update_plan is the REAL tool now → not an alias (returns None),
5729        // exactly like the resume_context fix; the old set_plan name is gone too.
5730        assert!(
5731            resolve_tool_alias("update_plan").is_none(),
5732            "update_plan must dispatch as the real tool, not a self-alias"
5733        );
5734    }
5735
5736    #[test]
5737    fn alias_rewrites_plan_read_names_to_plan_get() {
5738        for n in [
5739            "get_plan",
5740            "show_plan",
5741            "read_plan",
5742            "current_plan",
5743            "what_was_i_doing",
5744        ] {
5745            assert!(
5746                matches!(
5747                    resolve_tool_alias(n),
5748                    Some(AliasOutcome::Rewrite("plan_get"))
5749                ),
5750                "{n} should rewrite to plan_get"
5751            );
5752        }
5753    }
5754
5755    #[test]
5756    fn alias_rewrites_resume_reaches_to_resume_context() {
5757        // #714: the instinctive "where did we leave off" reaches redirect to the
5758        // self-recovery tool, not plan_get.
5759        for n in [
5760            "resume",
5761            "where_were_we",
5762            "where_did_we_leave_off",
5763            "catch_me_up",
5764            "recap",
5765        ] {
5766            assert!(
5767                matches!(
5768                    resolve_tool_alias(n),
5769                    Some(AliasOutcome::Rewrite("resume_context"))
5770                ),
5771                "{n} should rewrite to resume_context"
5772            );
5773        }
5774        // The REAL tool name is not an alias: it returns None so a direct
5775        // resume_context call dispatches as a real tool and is NOT logged as a
5776        // phantom Rewrite by #717 telemetry (real names must return None).
5777        assert!(
5778            resolve_tool_alias("resume_context").is_none(),
5779            "the real tool name must return None, not a self-Rewrite"
5780        );
5781        // No regression: `what_was_i_doing` still asks specifically for the plan.
5782        assert!(
5783            matches!(
5784                resolve_tool_alias("what_was_i_doing"),
5785                Some(AliasOutcome::Rewrite("plan_get"))
5786            ),
5787            "what_was_i_doing must stay → plan_get"
5788        );
5789    }
5790
5791    #[test]
5792    fn alias_corrects_crew_names_and_flags_team_gating() {
5793        for n in [
5794            "delegate",
5795            "spawn_agent",
5796            "subagent",
5797            "sub_agent",
5798            "crew_dispatch",
5799            "run_crew",
5800            "dispatch_crew",
5801            "fork_agent",
5802            "assign",
5803            "team",
5804        ] {
5805            let Some(AliasOutcome::Correct(msg)) = resolve_tool_alias(n) else {
5806                panic!("{n} should produce a Correct outcome");
5807            };
5808            // Names the real targets...
5809            assert!(msg.contains("compose_roster"), "{n}: {msg}");
5810            assert!(msg.contains("crew"), "{n}: {msg}");
5811            // ...but makes clear the model cannot self-enable the /team surface.
5812            assert!(msg.contains("/team"), "{n}: {msg}");
5813            assert!(
5814                msg.contains("human enables") || msg.contains("cannot turn it on yourself"),
5815                "crew correction must not imply the model can invoke it: {msg}"
5816            );
5817        }
5818    }
5819
5820    #[test]
5821    fn alias_corrects_workflow_names_to_plan_plus_crew() {
5822        for n in ["workflow", "run_workflow", "start_workflow", "pipeline"] {
5823            let Some(AliasOutcome::Correct(msg)) = resolve_tool_alias(n) else {
5824                panic!("{n} should produce a Correct outcome");
5825            };
5826            assert!(msg.contains("no workflow tool"), "{n}: {msg}");
5827            assert!(msg.contains("update_plan"), "{n}: {msg}");
5828        }
5829    }
5830
5831    #[test]
5832    fn levenshtein_matches_known_distances() {
5833        assert_eq!(levenshtein("kitten", "sitting"), 3);
5834        assert_eq!(levenshtein("read_file", "read_file"), 0);
5835        assert_eq!(levenshtein("read_fil", "read_file"), 1);
5836        assert_eq!(levenshtein("", "abc"), 3);
5837    }
5838
5839    #[test]
5840    fn nearest_tool_name_suggests_close_only() {
5841        assert_eq!(nearest_tool_name("read_fil"), Some("read_file"));
5842        assert_eq!(nearest_tool_name("edit_fil"), Some("edit_file"));
5843        assert_eq!(nearest_tool_name("memory_fetchh"), Some("memory_fetch"));
5844        assert_eq!(nearest_tool_name("definitely_not_a_tool"), None);
5845    }
5846
5847    #[test]
5848    fn unknown_tool_message_names_catalog_and_suggestion() {
5849        let m = unknown_tool_message("read_fil");
5850        assert!(m.starts_with("unknown tool: read_fil"), "{m}");
5851        assert!(m.contains("Did you mean 'read_file'"), "{m}");
5852        assert!(m.contains("Available tools include:"), "{m}");
5853
5854        let m2 = unknown_tool_message("zzzzzzzzzzzz");
5855        assert!(m2.starts_with("unknown tool: zzzzzzzzzzzz"), "{m2}");
5856        assert!(!m2.contains("Did you mean"), "{m2}");
5857        assert!(m2.contains("Available tools include:"), "{m2}");
5858    }
5859
5860    /// An incompatible-arg alias is corrected (not dead-ended) by execute_tool:
5861    /// a model that emits `str_replace_editor` is told to use edit_file. The
5862    /// correction returns before any fs/caveat work, so this is deterministic.
5863    #[tokio::test]
5864    async fn execute_tool_corrects_str_replace_editor_alias() {
5865        let ws = tempfile::TempDir::new().unwrap();
5866        let caveats = caveats_rw(ws.path());
5867        let out = run_tool(
5868            "str_replace_editor",
5869            serde_json::json!({"command": "str_replace", "path": "f.txt"}),
5870            ws.path(),
5871            &caveats,
5872            None,
5873        )
5874        .await;
5875        assert!(out.contains("edit_file"), "got: {out}");
5876        assert!(!out.starts_with("unknown tool"), "got: {out}");
5877    }
5878
5879    // -- #263 prompted permission grants through execute_tool ---------------
5880
5881    /// Scripted gate: records every request it is asked about and answers
5882    /// allow (with caveats widened by exactly the requested grants) or deny.
5883    struct MockGate {
5884        allow: bool,
5885        base: Caveats,
5886        asks: Vec<(String, String)>,
5887    }
5888
5889    impl MockGate {
5890        fn new(allow: bool, base: &Caveats) -> Self {
5891            Self {
5892                allow,
5893                base: base.clone(),
5894                asks: Vec::new(),
5895            }
5896        }
5897    }
5898
5899    impl super::PermissionGate for MockGate {
5900        fn ask(&mut self, requests: &[super::PermissionRequest]) -> super::PermissionDecision {
5901            for r in requests {
5902                self.asks
5903                    .push((r.tool.clone(), format!("{}:{}", r.kind.as_str(), r.target)));
5904            }
5905            if self.allow {
5906                let grants: Vec<_> = requests
5907                    .iter()
5908                    .map(|r| (r.kind, r.target.clone()))
5909                    .collect();
5910                super::PermissionDecision::Allow(crate::agentic::widen_caveats(&self.base, &grants))
5911            } else {
5912                super::PermissionDecision::Deny
5913            }
5914        }
5915        // #728: this gate exercises the GRANT path only; it has no human to
5916        // answer free-text questions, so it returns None (a trivial impl).
5917        fn ask_question(&mut self, _question: &str) -> Option<String> {
5918            None
5919        }
5920    }
5921
5922    #[allow(clippy::too_many_arguments)]
5923    async fn run_tool_gated(
5924        name: &str,
5925        args: serde_json::Value,
5926        ws: &std::path::Path,
5927        caveats: &Caveats,
5928        gate: &mut MockGate,
5929    ) -> String {
5930        execute_tool(
5931            name,
5932            &args,
5933            &ws.to_string_lossy(),
5934            false,
5935            20,
5936            caveats,
5937            &mut NoMcp,
5938            None,
5939            None,
5940            None,
5941            None, // memory_source
5942            Some(gate),
5943            None,
5944            None, // git_tool
5945            None, // crew_runner
5946            None, // scratchpad_store
5947            None, // code_search
5948            None, // experience_store
5949            None, // step_ledger
5950        )
5951        .await
5952    }
5953
5954    // -- #721 recoverable denials + request_permissions ---------------------
5955
5956    #[test]
5957    fn exec_denial_is_recoverable_not_a_dead_end() {
5958        // #721 + #775: the exec denial the MODEL sees is ONE clean level —
5959        // `capability denied: <bare reason>. <recovery hint>` — leading to the
5960        // model-actionable request_permissions path, NOT the stale `extra_exec`
5961        // config edit (which #721 superseded and the model cannot perform
5962        // mid-turn).
5963        let envelope = serde_json::json!({
5964            "denied": true,
5965            "denials": [{
5966                "kind": "exec",
5967                "target": "mkdir",
5968                "reason": "exec of \"mkdir\" is not within the granted authority"
5969            }]
5970        });
5971        let out = denied_run_command_result(&envelope, false);
5972        assert!(out.starts_with("capability denied:"), "got: {out}");
5973        assert!(out.contains("request_permissions"), "got: {out}");
5974        // #775: the stale `extra_exec` config hint is GONE from the model-facing
5975        // message (it leaked in before; the human transcript carries the bare
5976        // command via print_denied instead).
5977        assert!(
5978            !out.contains("extra_exec"),
5979            "the model message must not carry the stale config hint: {out}"
5980        );
5981    }
5982
5983    /// #775 (§2.5) regression: the model-facing `run_command` denial is ONE
5984    /// clean level and never a denial sentence NESTED inside another. Before
5985    /// the fix, `denied_run_command_result` appended the `extra_exec` config
5986    /// hint to the reason (and `print_denied` stuffed that whole sentence into
5987    /// its bare `'{target}'` slot), yielding `capability denied: exec does not
5988    /// permit '<reason> - add it via …>'`. The model-facing return now carries
5989    /// exactly one `capability denied:`, the bare reason, and the recovery hint.
5990    #[test]
5991    fn run_command_denial_is_single_level_not_nested() {
5992        let envelope = serde_json::json!({
5993            "denied": true,
5994            "denials": [{
5995                "kind": "exec",
5996                "target": "export",
5997                "reason": "exec of \"export\" is not within the granted authority"
5998            }]
5999        });
6000        let out = denied_run_command_result(&envelope, false);
6001        // Exactly one denial prefix — never a `capability denied:` inside another.
6002        assert_eq!(
6003            out.matches("capability denied:").count(),
6004            1,
6005            "exactly one denial level: {out}"
6006        );
6007        // RED on today: the stale config hint was glued onto the model message.
6008        assert!(!out.contains("add it via"), "stale config hint: {out}");
6009        assert!(!out.contains("extra_exec"), "stale config hint: {out}");
6010        // No reason sentence nested inside a `does not permit '…'` slot.
6011        assert!(
6012            !out.contains("does not permit 'exec of"),
6013            "nested denial sentence: {out}"
6014        );
6015        // The bare reason and the #721 recovery hint are both present.
6016        assert!(
6017            out.contains("exec of \"export\" is not within the granted authority"),
6018            "got: {out}"
6019        );
6020        assert!(out.contains("request_permissions"), "got: {out}");
6021    }
6022
6023    #[test]
6024    fn parse_capability_maps_synonyms_and_rejects_unknown() {
6025        assert_eq!(parse_capability("exec"), Some(DenialKind::Exec));
6026        assert_eq!(parse_capability("shell"), Some(DenialKind::Exec));
6027        assert_eq!(parse_capability("FS_READ"), Some(DenialKind::FsRead));
6028        assert_eq!(parse_capability("write"), Some(DenialKind::FsWrite));
6029        assert_eq!(parse_capability("network"), Some(DenialKind::Net));
6030        assert_eq!(parse_capability("gpu"), None);
6031        assert_eq!(parse_capability(""), None);
6032    }
6033
6034    #[test]
6035    fn request_permissions_grant_deny_and_no_gate() {
6036        let base = Caveats::top();
6037
6038        // Mock gate ALLOWS → "granted" + the retry coaching; the gate was asked
6039        // with the parsed axis + target.
6040        let mut gate = MockGate::new(true, &base);
6041        let out = execute_request_permissions(
6042            &serde_json::json!({"capability": "exec", "target": "mkdir", "reason": "make a dir"}),
6043            Some(&mut gate),
6044            false,
6045            20,
6046        );
6047        assert!(out.starts_with("granted:"), "got: {out}");
6048        assert!(out.contains("Retry the original operation"), "got: {out}");
6049        assert_eq!(gate.asks.len(), 1);
6050        assert_eq!(
6051            gate.asks[0],
6052            ("request_permissions".to_string(), "exec:mkdir".to_string())
6053        );
6054
6055        // Mock gate DENIES → "denied" + don't-retry coaching.
6056        let mut gate = MockGate::new(false, &base);
6057        let out = execute_request_permissions(
6058            &serde_json::json!({"capability": "fs_write", "target": "/tmp/x", "reason": "w"}),
6059            Some(&mut gate),
6060            false,
6061            20,
6062        );
6063        assert!(out.starts_with("denied:"), "got: {out}");
6064        assert!(out.contains("different approach"), "got: {out}");
6065
6066        // NO gate (headless / eval) → "no operator available" — recoverable,
6067        // never a hang or a config-only dead end.
6068        let out = execute_request_permissions(
6069            &serde_json::json!({"capability": "net", "target": "docs.rs", "reason": "fetch"}),
6070            None,
6071            false,
6072            20,
6073        );
6074        assert!(out.contains("no operator available"), "got: {out}");
6075    }
6076
6077    #[test]
6078    fn request_permissions_coaches_bad_inputs() {
6079        // Unknown capability → coach listing the valid axes (no gate consulted).
6080        let out = execute_request_permissions(
6081            &serde_json::json!({"capability": "gpu", "target": "x", "reason": "y"}),
6082            None,
6083            false,
6084            20,
6085        );
6086        assert!(out.contains("unknown capability"), "got: {out}");
6087        assert!(out.contains("fs_read"), "got: {out}");
6088        // Missing target → coach.
6089        let out = execute_request_permissions(
6090            &serde_json::json!({"capability": "exec", "reason": "y"}),
6091            None,
6092            false,
6093            20,
6094        );
6095        assert!(out.contains("'target' is required"), "got: {out}");
6096    }
6097
6098    #[test]
6099    fn request_permissions_is_a_real_tool_not_a_phantom() {
6100        // #721: a real, always-advertised tool — never an alias / hallucination.
6101        assert!(resolve_tool_alias("request_permissions").is_none());
6102        assert!(ALL_TOOL_NAMES.contains(&"request_permissions"));
6103        assert!(classify_phantom_reach(
6104            "request_permissions",
6105            &serde_json::json!({"capability": "exec", "target": "mkdir", "reason": "r"}),
6106            "granted: the operator allowed exec for 'mkdir'.",
6107            true,
6108        )
6109        .is_none());
6110    }
6111
6112    // -- #728 request_user_input (generic ask-the-human) --------------------
6113
6114    /// A gate that answers a free-text question with a scripted answer (or None
6115    /// for "no human"). Its grant path (`ask`) is irrelevant here — it denies.
6116    struct AskGate {
6117        answer: Option<String>,
6118        asked: Vec<String>,
6119    }
6120    impl AskGate {
6121        fn new(answer: Option<&str>) -> Self {
6122            Self {
6123                answer: answer.map(str::to_string),
6124                asked: Vec::new(),
6125            }
6126        }
6127    }
6128    impl super::PermissionGate for AskGate {
6129        fn ask(&mut self, _requests: &[super::PermissionRequest]) -> super::PermissionDecision {
6130            super::PermissionDecision::Deny
6131        }
6132        fn ask_question(&mut self, question: &str) -> Option<String> {
6133            self.asked.push(question.to_string());
6134            self.answer.clone()
6135        }
6136    }
6137
6138    #[test]
6139    fn request_user_input_returns_the_human_answer() {
6140        // A gate whose ask_question returns Some(answer) → the tool returns that
6141        // answer verbatim, and the gate was asked the exact question.
6142        let mut gate = AskGate::new(Some("postgres"));
6143        let out = execute_request_user_input(
6144            &serde_json::json!({"question": "which database should I target?"}),
6145            Some(&mut gate),
6146            false,
6147            20,
6148        );
6149        assert_eq!(out, "postgres");
6150        assert_eq!(
6151            gate.asked,
6152            vec!["which database should I target?".to_string()]
6153        );
6154    }
6155
6156    #[test]
6157    fn request_user_input_no_gate_reports_headless_never_hangs() {
6158        // No gate (headless / eval / ACP) → the recoverable "no human available"
6159        // message — never a hang. (This test completing IS the no-hang proof: it
6160        // touches no real stdin.)
6161        let out = execute_request_user_input(
6162            &serde_json::json!({"question": "are you sure?"}),
6163            None,
6164            false,
6165            20,
6166        );
6167        assert_eq!(out, HEADLESS_NO_HUMAN);
6168        assert!(out.contains("no human available"), "got: {out}");
6169    }
6170
6171    #[test]
6172    fn request_user_input_gate_with_no_human_reports_headless() {
6173        // A gate present but with no human to consult (ask_question → None) →
6174        // the SAME headless message, not a hang or an empty answer.
6175        let mut gate = AskGate::new(None);
6176        let out = execute_request_user_input(
6177            &serde_json::json!({"question": "pick one"}),
6178            Some(&mut gate),
6179            false,
6180            20,
6181        );
6182        assert_eq!(out, HEADLESS_NO_HUMAN);
6183    }
6184
6185    #[test]
6186    fn request_user_input_requires_a_question() {
6187        // Missing / blank question → coach; the gate is never consulted.
6188        let mut gate = AskGate::new(Some("unused"));
6189        let out = execute_request_user_input(
6190            &serde_json::json!({"question": "   "}),
6191            Some(&mut gate),
6192            false,
6193            20,
6194        );
6195        assert!(out.contains("'question' is required"), "got: {out}");
6196        assert!(
6197            gate.asked.is_empty(),
6198            "gate not consulted for a blank question"
6199        );
6200    }
6201
6202    #[test]
6203    fn request_user_input_is_a_real_tool_not_a_phantom() {
6204        // #728: a real, always-advertised tool — never an alias of itself or a
6205        // hallucination.
6206        assert!(resolve_tool_alias("request_user_input").is_none());
6207        assert!(ALL_TOOL_NAMES.contains(&"request_user_input"));
6208        assert!(classify_phantom_reach(
6209            "request_user_input",
6210            &serde_json::json!({"question": "which db?"}),
6211            "postgres",
6212            true,
6213        )
6214        .is_none());
6215        // The always-advertised def rides in every session (empty MCP).
6216        let defs = merged_tool_definitions(
6217            &NoMcp, false, false, false, false, false, false, false, false, false,
6218        );
6219        let names: Vec<&str> = defs
6220            .as_array()
6221            .unwrap()
6222            .iter()
6223            .filter_map(|d| d["function"]["name"].as_str())
6224            .collect();
6225        assert!(names.contains(&"request_user_input"), "got: {names:?}");
6226    }
6227
6228    #[test]
6229    fn ask_verbs_rewrite_to_request_user_input() {
6230        // #728: the instinctive ask-the-human verbs resolve to the real tool.
6231        for verb in [
6232            "ask_user",
6233            "ask_human",
6234            "prompt_user",
6235            "get_user_input",
6236            "ask_question",
6237            "clarify",
6238            "ask",
6239        ] {
6240            match resolve_tool_alias(verb) {
6241                Some(AliasOutcome::Rewrite(c)) => {
6242                    assert_eq!(c, "request_user_input", "verb: {verb}");
6243                }
6244                _ => panic!("expected Rewrite(request_user_input) for {verb}"),
6245            }
6246        }
6247    }
6248
6249    #[tokio::test]
6250    async fn request_user_input_dispatches_through_execute_tool() {
6251        // End-to-end through the dispatcher: the question reaches the gate and
6252        // the answer flows back. Fully mocked (AskGate, no real stdin).
6253        let ws = tempfile::TempDir::new().unwrap();
6254        let caveats = Caveats::top();
6255        let mut gate = AskGate::new(Some("the answer"));
6256        let out = execute_tool(
6257            "request_user_input",
6258            &serde_json::json!({"question": "what now?"}),
6259            &ws.path().to_string_lossy(),
6260            false,
6261            20,
6262            &caveats,
6263            &mut NoMcp,
6264            None,
6265            None,
6266            None,
6267            None, // memory_source
6268            Some(&mut gate),
6269            None,
6270            None, // git_tool
6271            None, // crew_runner
6272            None, // scratchpad_store
6273            None, // code_search
6274            None, // experience_store
6275            None, // step_ledger
6276        )
6277        .await;
6278        assert_eq!(out, "the answer");
6279        assert_eq!(gate.asked, vec!["what now?".to_string()]);
6280    }
6281
6282    #[test]
6283    fn get_context_remaining_is_a_real_tool_not_a_phantom() {
6284        // #727: real, always-advertised, no-arg budget read — never treated as
6285        // an alias of itself or a hallucination.
6286        assert!(resolve_tool_alias("get_context_remaining").is_none());
6287        assert!(ALL_TOOL_NAMES.contains(&"get_context_remaining"));
6288        assert!(classify_phantom_reach(
6289            "get_context_remaining",
6290            &serde_json::json!({}),
6291            "Context budget: ~10 tokens used of an input ceiling of ~80 (80% of num_ctx 100).",
6292            true,
6293        )
6294        .is_none());
6295        // The always-advertised def rides in every session (empty MCP).
6296        let defs = merged_tool_definitions(
6297            &NoMcp, false, false, false, false, false, false, false, false, false,
6298        );
6299        assert!(defs
6300            .as_array()
6301            .unwrap()
6302            .iter()
6303            .any(|d| d["function"]["name"] == "get_context_remaining"));
6304    }
6305
6306    #[test]
6307    fn budget_verbs_rewrite_to_get_context_remaining() {
6308        // #727: the instinctive "how much context is left" reaches all resolve
6309        // to the canonical no-arg read (safe silent Rewrite — matching arg shape).
6310        for n in [
6311            "context_remaining",
6312            "tokens_left",
6313            "remaining_tokens",
6314            "budget",
6315            "how_much_context",
6316            "context_budget",
6317            "token_budget",
6318        ] {
6319            assert!(
6320                matches!(
6321                    resolve_tool_alias(n),
6322                    Some(AliasOutcome::Rewrite("get_context_remaining"))
6323                ),
6324                "{n} must rewrite to get_context_remaining"
6325            );
6326            // A Rewrite alias is mined by the #717 telemetry as a Rewrite.
6327            assert!(
6328                is_context_remaining_call(n),
6329                "{n} must be recognized as a budget call by the loop"
6330            );
6331        }
6332        // The canonical name is recognized by the loop but is NOT an alias.
6333        assert!(is_context_remaining_call("get_context_remaining"));
6334        assert!(resolve_tool_alias("get_context_remaining").is_none());
6335        // An unrelated name is neither.
6336        assert!(!is_context_remaining_call("read_file"));
6337    }
6338
6339    /// FLAG OFF (no gate): the denial is deterministic and still DENIES every
6340    /// fs op (the #263 default-deny posture is intact) — now in the #721
6341    /// recoverable form (`denied_fs_result`, carrying the request_permissions
6342    /// path), pinned via the shared helper so the wording can't drift.
6343    #[tokio::test]
6344    async fn no_gate_denials_are_bit_for_bit_unchanged() {
6345        let ws = tempfile::TempDir::new().unwrap();
6346        std::fs::write(ws.path().join("secret.txt"), "x").unwrap();
6347        let denied = Caveats {
6348            fs_read: Scope::none(),
6349            fs_write: Scope::none(),
6350            ..caveats_rw(ws.path())
6351        };
6352        let out = run_tool(
6353            "read_file",
6354            serde_json::json!({"path": "secret.txt"}),
6355            ws.path(),
6356            &denied,
6357            None,
6358        )
6359        .await;
6360        assert_eq!(out, denied_fs_result("fs_read", "secret.txt"));
6361        let out = run_tool(
6362            "list_dir",
6363            serde_json::json!({"path": "."}),
6364            ws.path(),
6365            &denied,
6366            None,
6367        )
6368        .await;
6369        assert_eq!(out, denied_fs_result("fs_read", "."));
6370        let out = run_tool(
6371            "write_file",
6372            serde_json::json!({"path": "a.txt", "content": "c"}),
6373            ws.path(),
6374            &denied,
6375            None,
6376        )
6377        .await;
6378        assert_eq!(out, denied_fs_result("fs_write", "a.txt"));
6379        let out = run_tool(
6380            "edit_file",
6381            serde_json::json!({"path": "a.txt", "old_string": "a", "new_string": "b"}),
6382            ws.path(),
6383            &denied,
6384            None,
6385        )
6386        .await;
6387        assert_eq!(out, denied_fs_result("fs_write", "a.txt"));
6388        // #721: every fs denial now carries the model-actionable recovery path.
6389        assert!(out.contains("request_permissions"), "got: {out}");
6390    }
6391
6392    /// Gate allows an fs_read denial → the read proceeds and returns the
6393    /// real contents; the gate was consulted with the tool + axis + full
6394    /// path it would be granting.
6395    #[tokio::test]
6396    async fn gate_allow_turns_fs_read_denial_into_the_real_result() {
6397        let ws = tempfile::TempDir::new().unwrap();
6398        std::fs::write(ws.path().join("secret.txt"), "the contents").unwrap();
6399        let denied = Caveats {
6400            fs_read: Scope::none(),
6401            ..caveats_rw(ws.path())
6402        };
6403        let mut gate = MockGate::new(true, &denied);
6404        let out = run_tool_gated(
6405            "read_file",
6406            serde_json::json!({"path": "secret.txt"}),
6407            ws.path(),
6408            &denied,
6409            &mut gate,
6410        )
6411        .await;
6412        assert_eq!(out, "the contents");
6413        let full = ws.path().join("secret.txt").to_string_lossy().into_owned();
6414        assert_eq!(
6415            gate.asks,
6416            vec![("read_file".to_string(), format!("fs_read:{full}"))]
6417        );
6418    }
6419
6420    /// Gate denies → the result is the standard denial, bit-for-bit equal to
6421    /// the no-gate path (#263: deny = the current denial result).
6422    #[tokio::test]
6423    async fn gate_deny_keeps_the_standard_denial_bit_for_bit() {
6424        let ws = tempfile::TempDir::new().unwrap();
6425        std::fs::write(ws.path().join("secret.txt"), "x").unwrap();
6426        let denied = Caveats {
6427            fs_read: Scope::none(),
6428            ..caveats_rw(ws.path())
6429        };
6430        let mut gate = MockGate::new(false, &denied);
6431        let gated = run_tool_gated(
6432            "read_file",
6433            serde_json::json!({"path": "secret.txt"}),
6434            ws.path(),
6435            &denied,
6436            &mut gate,
6437        )
6438        .await;
6439        let ungated = run_tool(
6440            "read_file",
6441            serde_json::json!({"path": "secret.txt"}),
6442            ws.path(),
6443            &denied,
6444            None,
6445        )
6446        .await;
6447        assert_eq!(gated, ungated);
6448        assert_eq!(gated, denied_fs_result("fs_read", "secret.txt"));
6449        assert_eq!(gate.asks.len(), 1, "the human was asked exactly once");
6450    }
6451
6452    /// Gate allows fs_write denials → write_file and edit_file proceed.
6453    #[tokio::test]
6454    async fn gate_allow_turns_fs_write_denials_into_real_writes() {
6455        let ws = tempfile::TempDir::new().unwrap();
6456        std::fs::write(ws.path().join("f.txt"), "old\n").unwrap();
6457        let denied = Caveats {
6458            fs_write: Scope::none(),
6459            ..caveats_rw(ws.path())
6460        };
6461        let mut gate = MockGate::new(true, &denied);
6462        let out = run_tool_gated(
6463            "write_file",
6464            serde_json::json!({"path": "new.txt", "content": "fresh"}),
6465            ws.path(),
6466            &denied,
6467            &mut gate,
6468        )
6469        .await;
6470        assert!(out.starts_with("wrote new.txt"), "got: {out}");
6471        assert_eq!(
6472            std::fs::read_to_string(ws.path().join("new.txt")).unwrap(),
6473            "fresh"
6474        );
6475        let out = run_tool_gated(
6476            "edit_file",
6477            serde_json::json!({"path": "f.txt", "old_string": "old", "new_string": "new"}),
6478            ws.path(),
6479            &denied,
6480            &mut gate,
6481        )
6482        .await;
6483        assert!(out.starts_with("edited f.txt"), "got: {out}");
6484        assert_eq!(gate.asks.len(), 2);
6485        assert_eq!(gate.asks[0].0, "write_file");
6486        assert!(
6487            gate.asks[1].1.starts_with("fs_write:"),
6488            "got: {:?}",
6489            gate.asks[1]
6490        );
6491    }
6492
6493    /// list_dir consults the gate on an fs_read denial like read_file does.
6494    #[tokio::test]
6495    async fn gate_allow_turns_list_dir_denial_into_the_listing() {
6496        let ws = tempfile::TempDir::new().unwrap();
6497        std::fs::write(ws.path().join("seen.txt"), "x").unwrap();
6498        let denied = Caveats {
6499            fs_read: Scope::none(),
6500            ..caveats_rw(ws.path())
6501        };
6502        let mut gate = MockGate::new(true, &denied);
6503        let out = run_tool_gated(
6504            "list_dir",
6505            serde_json::json!({"path": "."}),
6506            ws.path(),
6507            &denied,
6508            &mut gate,
6509        )
6510        .await;
6511        assert!(out.contains("seen.txt"), "got: {out}");
6512    }
6513
6514    /// A buggy/hostile gate answering Allow with caveats that STILL don't
6515    /// cover the path must not bypass enforcement: the widened authority is
6516    /// re-checked, never assumed (fs_gate_allows' re-check).
6517    #[tokio::test]
6518    async fn gate_allow_without_real_coverage_is_still_denied() {
6519        struct LyingGate;
6520        impl super::PermissionGate for LyingGate {
6521            fn ask(&mut self, _requests: &[super::PermissionRequest]) -> super::PermissionDecision {
6522                // "Allow", but the caveats grant nothing at all.
6523                super::PermissionDecision::Allow(Caveats {
6524                    fs_read: Scope::none(),
6525                    fs_write: Scope::none(),
6526                    exec: Scope::none(),
6527                    net: Scope::none(),
6528                    max_calls: CountBound::Unlimited,
6529                    valid_for_generation: Scope::All,
6530                })
6531            }
6532            fn ask_question(&mut self, _question: &str) -> Option<String> {
6533                None
6534            }
6535        }
6536        let ws = tempfile::TempDir::new().unwrap();
6537        std::fs::write(ws.path().join("secret.txt"), "x").unwrap();
6538        let denied = Caveats {
6539            fs_read: Scope::none(),
6540            ..caveats_rw(ws.path())
6541        };
6542        let mut gate = LyingGate;
6543        let out = execute_tool(
6544            "read_file",
6545            &serde_json::json!({"path": "secret.txt"}),
6546            &ws.path().to_string_lossy(),
6547            false,
6548            20,
6549            &denied,
6550            &mut NoMcp,
6551            None,
6552            None,
6553            None,
6554            None, // memory_source
6555            Some(&mut gate),
6556            None,
6557            None, // git_tool
6558            None, // crew_runner
6559            None, // scratchpad_store
6560            None, // code_search
6561            None, // experience_store
6562            None, // step_ledger
6563        )
6564        .await;
6565        assert_eq!(out, denied_fs_result("fs_read", "secret.txt"));
6566    }
6567
6568    /// web_fetch with a gate: an out-of-allowlist host consults the gate
6569    /// with the parsed host; on deny the dispatch runs under the ORIGINAL
6570    /// caveats, so the leash produces today's denial (an `error:` result —
6571    /// nothing is fetched).
6572    #[tokio::test]
6573    async fn web_fetch_gate_deny_dispatches_under_original_caveats() {
6574        let ws = tempfile::TempDir::new().unwrap();
6575        let caveats = caveats_rw(ws.path()); // net: Scope::none()
6576        let mut gate = MockGate::new(false, &caveats);
6577        let out = run_tool_gated(
6578            "web_fetch",
6579            serde_json::json!({"url": "https://denied.example.com:8443/page"}),
6580            ws.path(),
6581            &caveats,
6582            &mut gate,
6583        )
6584        .await;
6585        assert!(out.starts_with("error:"), "leash denial surfaces: {out}");
6586        assert_eq!(
6587            gate.asks,
6588            vec![(
6589                "web_fetch".to_string(),
6590                "net:denied.example.com".to_string()
6591            )]
6592        );
6593    }
6594
6595    /// Regression for the field report: github.com is outside the default net
6596    /// scope, so a TUI-provided gate must be consulted before the bridle leash
6597    /// returns the denial to the model.
6598    #[tokio::test]
6599    async fn web_fetch_github_denial_consults_permission_gate() {
6600        let ws = tempfile::TempDir::new().unwrap();
6601        let caveats = caveats_rw(ws.path()); // net: Scope::none()
6602        let mut gate = MockGate::new(false, &caveats);
6603        let out = run_tool_gated(
6604            "web_fetch",
6605            serde_json::json!({"url": "https://github.com/openai/codex"}),
6606            ws.path(),
6607            &caveats,
6608            &mut gate,
6609        )
6610        .await;
6611        assert!(out.starts_with("error:"), "leash denial surfaces: {out}");
6612        assert_eq!(
6613            gate.asks,
6614            vec![("web_fetch".to_string(), "net:github.com".to_string())]
6615        );
6616    }
6617
6618    /// An unparseable URL skips the net pre-check entirely — the gate is
6619    /// never consulted and the dispatch (with the original caveats) answers.
6620    #[tokio::test]
6621    async fn web_fetch_unparseable_url_never_prompts() {
6622        let ws = tempfile::TempDir::new().unwrap();
6623        let caveats = caveats_rw(ws.path());
6624        let mut gate = MockGate::new(true, &caveats);
6625        let out = run_tool_gated(
6626            "web_fetch",
6627            serde_json::json!({"url": "not-a-url"}),
6628            ws.path(),
6629            &caveats,
6630            &mut gate,
6631        )
6632        .await;
6633        assert!(out.starts_with("error:"), "got: {out}");
6634        assert!(gate.asks.is_empty(), "no prompt for an unparseable URL");
6635    }
6636
6637    // -- save_note dispatch through execute_tool (Step 19.3) ----------------
6638
6639    #[tokio::test]
6640    async fn save_note_without_sink_is_unknown_tool() {
6641        let ws = tempfile::TempDir::new().unwrap();
6642        let caveats = caveats_rw(ws.path());
6643        // run_tool passes note_sink: None — the no-sink (headless) shape.
6644        let out = run_tool(
6645            "save_note",
6646            serde_json::json!({"action": "add", "text": "a fact"}),
6647            ws.path(),
6648            &caveats,
6649            None,
6650        )
6651        .await;
6652        assert!(out.starts_with("unknown tool: save_note"), "got: {out}");
6653    }
6654
6655    #[tokio::test]
6656    async fn save_note_with_sink_routes_through_execute_tool() {
6657        use crate::agentic::note_sink::tests::MockSink;
6658        let ws = tempfile::TempDir::new().unwrap();
6659        let caveats = caveats_rw(ws.path());
6660        let mut sink = MockSink::default();
6661        let out = execute_tool(
6662            "save_note",
6663            &serde_json::json!({"action": "add", "text": "workspace builds with just check"}),
6664            &ws.path().to_string_lossy(),
6665            false,
6666            20,
6667            &caveats,
6668            &mut NoMcp,
6669            None,
6670            Some(&mut sink),
6671            None,
6672            None, // memory_source
6673            None,
6674            None,
6675            None, // git_tool
6676            None, // crew_runner
6677            None, // scratchpad_store
6678            None, // code_search
6679            None, // experience_store
6680            None, // step_ledger
6681        )
6682        .await;
6683        assert_eq!(sink.calls, vec!["add:workspace builds with just check"]);
6684        assert!(
6685            out.starts_with("note saved: workspace builds"),
6686            "got: {out}"
6687        );
6688    }
6689
6690    // -- recall dispatch through execute_tool (Step 17.5) -------------------
6691
6692    #[tokio::test]
6693    async fn recall_without_source_is_unknown_tool() {
6694        let ws = tempfile::TempDir::new().unwrap();
6695        let caveats = caveats_rw(ws.path());
6696        // run_tool passes recall_source: None — the no-store (headless) shape.
6697        let out = run_tool(
6698            "recall",
6699            serde_json::json!({"query": "tokio panic"}),
6700            ws.path(),
6701            &caveats,
6702            None,
6703        )
6704        .await;
6705        assert!(out.starts_with("unknown tool: recall"), "got: {out}");
6706    }
6707
6708    #[tokio::test]
6709    async fn recall_with_source_routes_through_execute_tool() {
6710        use crate::agentic::recall::tests::{hit, MockSource};
6711        let ws = tempfile::TempDir::new().unwrap();
6712        let caveats = caveats_rw(ws.path());
6713        let source = MockSource {
6714            hits: vec![hit(
6715                "123456789012-abcd",
6716                "past work",
6717                3,
6718                ">>>tokio<<< panic",
6719            )],
6720            ..Default::default()
6721        };
6722        let out = execute_tool(
6723            "recall",
6724            &serde_json::json!({"query": "tokio panic"}),
6725            &ws.path().to_string_lossy(),
6726            false,
6727            20,
6728            &caveats,
6729            &mut NoMcp,
6730            None,
6731            None,
6732            Some(&source),
6733            None, // memory_source
6734            None,
6735            None,
6736            None, // git_tool
6737            None, // crew_runner
6738            None, // scratchpad_store
6739            None, // code_search
6740            None, // experience_store
6741            None, // step_ledger
6742        )
6743        .await;
6744        assert_eq!(
6745            *source.calls.lock().unwrap(),
6746            vec![("tokio panic".to_string(), 5)]
6747        );
6748        assert!(out.contains("«tokio» panic"), "got: {out}");
6749        assert!(out.contains("past work"), "got: {out}");
6750    }
6751
6752    // -- memory_fetch dispatch through execute_tool (#319) ------------------
6753
6754    /// FLAG OFF (no source): a `memory_fetch` call is treated like any unknown
6755    /// tool — the inert-by-default shape (the tool was never advertised, so a
6756    /// call here is a hallucination). Mirrors `recall_without_source`.
6757    #[tokio::test]
6758    async fn memory_fetch_without_source_is_unknown_tool() {
6759        let ws = tempfile::TempDir::new().unwrap();
6760        let caveats = caveats_rw(ws.path());
6761        // run_tool passes memory_source: None — the no-source (headless) shape.
6762        let out = run_tool(
6763            "memory_fetch",
6764            serde_json::json!({"address": "note:1"}),
6765            ws.path(),
6766            &caveats,
6767            None,
6768        )
6769        .await;
6770        assert!(out.starts_with("unknown tool: memory_fetch"), "got: {out}");
6771    }
6772
6773    /// FLAG ON (source present): a `memory_fetch` call routes through the
6774    /// injected `MemorySource` and returns its body. Mirrors
6775    /// `recall_with_source_routes_through_execute_tool`.
6776    #[tokio::test]
6777    async fn memory_fetch_with_source_routes_through_execute_tool() {
6778        use crate::agentic::memory_fetch::tests::MockSource;
6779        use crate::agentic::MemAddr;
6780        let ws = tempfile::TempDir::new().unwrap();
6781        let caveats = caveats_rw(ws.path());
6782        let source = MockSource {
6783            body: Some("the exact note body".to_string()),
6784            ..Default::default()
6785        };
6786        let out = execute_tool(
6787            "memory_fetch",
6788            &serde_json::json!({"address": "note:1"}),
6789            &ws.path().to_string_lossy(),
6790            false,
6791            20,
6792            &caveats,
6793            &mut NoMcp,
6794            None,
6795            None,
6796            None,
6797            Some(&source),
6798            None,
6799            None,
6800            None, // git_tool
6801            None, // crew_runner
6802            None, // scratchpad_store
6803            None, // code_search
6804            None, // experience_store
6805            None, // step_ledger
6806        )
6807        .await;
6808        assert_eq!(out, "the exact note body");
6809        assert_eq!(
6810            *source.calls.lock().unwrap(),
6811            vec![MemAddr::Note { id: "1".into() }]
6812        );
6813    }
6814}
6815
6816// ---------------------------------------------------------------------------
6817// INTERIM (#297) --disable-ocap / --yolo tests — the exec escape hatch.
6818// Removed with the bypass when brush upstreams CommandInterceptor
6819// (agent-bridle#20).
6820// ---------------------------------------------------------------------------
6821
6822#[cfg(test)]
6823mod disable_ocap_tests {
6824    use super::super::NoMcp;
6825    use super::*;
6826    use crate::caveats::{Caveats, CountBound, Scope};
6827    use tokio::sync::{Mutex, MutexGuard};
6828
6829    /// Serializes every test that reads or writes `NEWT_DISABLE_OCAP` (and
6830    /// the venv vars the bypass forwards): the process environment is shared
6831    /// across the parallel test runner. Async-aware (tokio) so the guard may
6832    /// be held across the `execute_tool` awaits; no poisoning — the `EnvVar`
6833    /// guards below restore the environment even on panic.
6834    static ENV_LOCK: Mutex<()> = Mutex::const_new(());
6835
6836    async fn env_lock() -> MutexGuard<'static, ()> {
6837        ENV_LOCK.lock().await
6838    }
6839
6840    /// RAII env override: set/unset `key` for the test body, restore the
6841    /// previous value on drop — including on a failed assertion, so yolo can
6842    /// never leak into a neighboring test.
6843    struct EnvVar {
6844        key: &'static str,
6845        saved: Option<String>,
6846    }
6847
6848    impl EnvVar {
6849        fn set(key: &'static str, value: &str) -> Self {
6850            let saved = std::env::var(key).ok();
6851            std::env::set_var(key, value);
6852            Self { key, saved }
6853        }
6854
6855        fn unset(key: &'static str) -> Self {
6856            let saved = std::env::var(key).ok();
6857            std::env::remove_var(key);
6858            Self { key, saved }
6859        }
6860    }
6861
6862    impl Drop for EnvVar {
6863        fn drop(&mut self) {
6864            match self.saved.take() {
6865                Some(v) => std::env::set_var(self.key, v),
6866                None => std::env::remove_var(self.key),
6867            }
6868        }
6869    }
6870
6871    /// Workspace-fenced fs, NO exec, NO net — the shape under which the
6872    /// confined shell denies (real build) or fails closed (stub build).
6873    fn caveats_no_exec(ws: &std::path::Path) -> Caveats {
6874        Caveats {
6875            fs_read: Scope::only([ws.to_string_lossy().into_owned()]),
6876            fs_write: Scope::only([ws.to_string_lossy().into_owned()]),
6877            exec: Scope::none(),
6878            net: Scope::none(),
6879            max_calls: CountBound::Unlimited,
6880            valid_for_generation: Scope::All,
6881        }
6882    }
6883
6884    async fn run_tool(
6885        name: &str,
6886        args: serde_json::Value,
6887        ws: &std::path::Path,
6888        caveats: &Caveats,
6889    ) -> String {
6890        run_tool_with_floor(name, args, ws, caveats, None).await
6891    }
6892
6893    /// #307: like [`run_tool`] but with an explicit exec FLOOR (the active
6894    /// named-permission-preset clamp). `Some(scope)` makes the `--disable-ocap`
6895    /// bypass conditional on the floor permitting the command; `None` is the
6896    /// pre-#307 behavior.
6897    async fn run_tool_with_floor(
6898        name: &str,
6899        args: serde_json::Value,
6900        ws: &std::path::Path,
6901        caveats: &Caveats,
6902        exec_floor: Option<&Scope<String>>,
6903    ) -> String {
6904        execute_tool(
6905            name,
6906            &args,
6907            &ws.to_string_lossy(),
6908            false,
6909            20,
6910            caveats,
6911            &mut NoMcp,
6912            None,
6913            None,
6914            None,
6915            None, // memory_source
6916            None,
6917            exec_floor,
6918            None, // git_tool
6919            None, // crew_runner
6920            None, // scratchpad_store
6921            None, // code_search
6922            None, // experience_store
6923            None, // step_ledger
6924        )
6925        .await
6926    }
6927
6928    /// The switch reads fail-closed: ONLY the exact value `1` (the value the
6929    /// CLI exports and the issue documents) asserts the bypass. This is also
6930    /// the env-var-equivalence half of the #297 test list — the flag and the
6931    /// env var are one mechanism (`--disable-ocap` just exports the var).
6932    #[test]
6933    fn ocap_disabled_requires_exactly_1() {
6934        let _l = ENV_LOCK.blocking_lock();
6935        {
6936            let _unset = EnvVar::unset("NEWT_DISABLE_OCAP");
6937            assert!(!ocap_disabled(), "absent ⇒ confinement stays on");
6938        }
6939        for (value, expected) in [
6940            ("1", true),
6941            ("0", false),
6942            ("", false),
6943            ("true", false),
6944            ("yes", false),
6945            ("YOLO", false),
6946        ] {
6947            let _set = EnvVar::set("NEWT_DISABLE_OCAP", value);
6948            assert_eq!(
6949                ocap_disabled(),
6950                expected,
6951                "NEWT_DISABLE_OCAP={value:?} must read as {expected}"
6952            );
6953        }
6954    }
6955
6956    /// Same fail-closed contract for the `--full-access` preset override:
6957    /// ONLY the exact value `1` asserts it (the flag and the env var are one
6958    /// mechanism — `--full-access` just exports the var).
6959    #[test]
6960    fn full_access_requested_requires_exactly_1() {
6961        let _l = ENV_LOCK.blocking_lock();
6962        {
6963            let _unset = EnvVar::unset("NEWT_FULL_ACCESS");
6964            assert!(!full_access_requested(), "absent ⇒ configured preset rules");
6965        }
6966        for (value, expected) in [
6967            ("1", true),
6968            ("0", false),
6969            ("", false),
6970            ("true", false),
6971            ("yes", false),
6972            ("FULL", false),
6973        ] {
6974            let _set = EnvVar::set("NEWT_FULL_ACCESS", value);
6975            assert_eq!(
6976                full_access_requested(),
6977                expected,
6978                "NEWT_FULL_ACCESS={value:?} must read as {expected}"
6979            );
6980        }
6981    }
6982
6983    /// FLAG OFF ⇒ the command goes to the confined dispatch, which governs it.
6984    /// Built against the agent-bridle env-seam branch (#783), the bridle ships
6985    /// the REAL safe-subset shell (not the old fail-closed stub), so an
6986    /// ungranted `echo` under `exec = none` is DENIED by the L3 boundary. This
6987    /// is the "when the real shell returns" case the prior stub-build note
6988    /// anticipated; the "unavailable in this build" stub error is retired.
6989    #[tokio::test]
6990    async fn flag_off_run_command_keeps_the_confined_dispatch_verbatim() {
6991        let _l = env_lock().await;
6992        let _off = EnvVar::unset("NEWT_DISABLE_OCAP");
6993        let ws = tempfile::TempDir::new().unwrap();
6994        let caveats = caveats_no_exec(ws.path());
6995        let out = run_tool(
6996            "run_command",
6997            serde_json::json!({"command": "echo hi"}),
6998            ws.path(),
6999            &caveats,
7000        )
7001        .await;
7002        assert!(
7003            out.contains("capability denied"),
7004            "flag off ⇒ the confined dispatch must govern (deny) the command, got: {out}"
7005        );
7006    }
7007
7008    /// FLAG ON: a command the confined shell fails closed on now runs on the
7009    /// host shell and returns its real output through the SAME envelope
7010    /// formatter (`shell_envelope_output`).
7011    #[cfg(unix)]
7012    #[tokio::test]
7013    async fn yolo_runs_the_denied_command_on_the_host_shell() {
7014        let _l = env_lock().await;
7015        let _on = EnvVar::set("NEWT_DISABLE_OCAP", "1");
7016        let ws = tempfile::TempDir::new().unwrap();
7017        let caveats = caveats_no_exec(ws.path());
7018        let out = run_tool(
7019            "run_command",
7020            serde_json::json!({"command": "echo yolo-ok"}),
7021            ws.path(),
7022            &caveats,
7023        )
7024        .await;
7025        assert_eq!(out, "yolo-ok\n");
7026
7027        // No output ⇒ the same `(exit N)` shape the bridle path produces.
7028        let out = run_tool(
7029            "run_command",
7030            serde_json::json!({"command": "exit 3"}),
7031            ws.path(),
7032            &caveats,
7033        )
7034        .await;
7035        assert_eq!(out, "(exit 3)");
7036    }
7037
7038    /// #726/#945: a verbose `run_command` MUST NOT flood the model's context
7039    /// window, but MUST still surface both ends of the output — a command's
7040    /// summary/failure/exit status lives at the TAIL, and #726's original
7041    /// head-only cap silently dropped exactly that (the gap #945 closed). Runs
7042    /// through the real host shell (yolo path) so it exercises the actual
7043    /// `shell_envelope_output` → cap composition. The global budget is the
7044    /// default 10k in the test binary (nothing raises it above default), so
7045    /// the assertions are upper-bounded and robust regardless of a smaller
7046    /// racing value. This test goes through the legacy `execute_tool` path
7047    /// (`run_tool`/`run_tool_with_floor` below), which has no spill store —
7048    /// the no-spill-id elision marker branch, not the `spill:<id>` one.
7049    #[cfg(unix)]
7050    #[tokio::test]
7051    async fn run_command_output_over_budget_is_token_capped() {
7052        let _l = env_lock().await;
7053        let _on = EnvVar::set("NEWT_DISABLE_OCAP", "1");
7054        let ws = tempfile::TempDir::new().unwrap();
7055        let caveats = caveats_no_exec(ws.path());
7056        // ~350k chars of output — well over the default ~40k-char budget.
7057        let out = run_tool(
7058            "run_command",
7059            serde_json::json!({"command": "seq 1 60000"}),
7060            ws.path(),
7061            &caveats,
7062        )
7063        .await;
7064        assert!(
7065            out.len() < 41_500,
7066            "model-facing output capped near the ~40k-char budget, got {} bytes",
7067            out.len()
7068        );
7069        assert!(
7070            out.contains("chars elided (head+tail shown"),
7071            "carries the head+tail elision marker: {:?}",
7072            &out[..out.len().min(400)]
7073        );
7074        // #945: the HEAD survives — the earliest lines are still visible.
7075        assert!(
7076            out.starts_with("1\n2\n3\n"),
7077            "head preserved: {:?}",
7078            &out[..out.len().min(160)]
7079        );
7080        // #945 (the regression this test now guards): the TAIL survives too —
7081        // under the old head-only cap this was the first assertion to break
7082        // (it asserted the OPPOSITE: `!out.contains("60000")`).
7083        assert!(
7084            out.trim_end().ends_with("60000"),
7085            "tail preserved, not dropped by the cap: {:?}",
7086            &out[out.len().saturating_sub(160)..]
7087        );
7088    }
7089
7090    // --- #307 floor property: preset clamp WINS over --disable-ocap -------
7091
7092    /// Unit-cover every branch of the bypass-floor predicate.
7093    #[test]
7094    fn exec_floor_permits_covers_each_branch() {
7095        use crate::caveats::Scope;
7096        // No floor ⇒ always permit (bit-for-bit pre-#307).
7097        assert!(exec_floor_permits(None, "rm -rf /"));
7098        // Empty command ⇒ let it through to the normal path.
7099        let only_echo = Scope::only(["echo".to_string()]);
7100        assert!(exec_floor_permits(Some(&only_echo), ""));
7101        // In-floor simple command ⇒ permitted.
7102        assert!(exec_floor_permits(Some(&only_echo), "echo hi"));
7103        // Out-of-floor program ⇒ refused.
7104        assert!(!exec_floor_permits(Some(&only_echo), "rm hi"));
7105        // Compound command ⇒ refused even with an allow-listed leading token.
7106        assert!(!exec_floor_permits(Some(&only_echo), "echo hi && rm x"));
7107        assert!(!exec_floor_permits(Some(&only_echo), "echo a | tee b"));
7108        assert!(!exec_floor_permits(Some(&only_echo), "echo $(rm x)"));
7109        // `Scope::All` floor permits any simple command.
7110        let all: Scope<String> = Scope::All;
7111        assert!(exec_floor_permits(Some(&all), "anything goes"));
7112        assert!(!exec_floor_permits(Some(&all), "anything; sneaky"));
7113    }
7114
7115    /// ADVERSARIAL PROBE (review #312): exhaustively attack `exec_floor_permits`
7116    /// with EVERY shell injection / compound form so the floor is proven against
7117    /// more than just `&&`. An `echo`-only floor must refuse to bypass for any
7118    /// form that could chain or substitute a second program.
7119    #[test]
7120    fn exec_floor_refuses_every_metacharacter_form() {
7121        use crate::caveats::Scope;
7122        let echo = Scope::only(["echo".to_string()]);
7123        // Each of these begins with the allow-listed `echo` but smuggles or
7124        // could smuggle a second program. None may bypass.
7125        let attacks = [
7126            "echo ok && rm -rf /tmp/x", // && and
7127            "echo ok || rm -rf /tmp/x", // || or
7128            "echo ok ; rm -rf /tmp/x",  // ; sequence
7129            "echo ok | sh",             // | pipe
7130            "echo ok|sh",               // | no spaces
7131            "echo $(rm x)",             // $() command substitution
7132            "echo ${IFS}rm",            // ${} parameter expansion
7133            "echo `rm x`",              // backtick substitution
7134            "echo ok & rm x",           // & background
7135            "echo ok > /etc/passwd",    // > redirect out
7136            "echo ok >> /etc/passwd",   // >> append
7137            "echo < /etc/shadow",       // < redirect in
7138            "echo ok 2> err",           // 2> fd redirect (contains >)
7139            "(rm x)",                   // ( subshell
7140            "echo ok\nrm -rf /tmp/x",   // newline-separated
7141            "echo ok\nrm x\n",          // trailing newline
7142        ];
7143        for a in attacks {
7144            assert!(
7145                !exec_floor_permits(Some(&echo), a),
7146                "metacharacter form must NOT bypass the floor: {a:?}"
7147            );
7148        }
7149        // Forms with NO shell metacharacter that should still be refused because
7150        // the LEADING TOKEN is not the allow-listed program:
7151        let leading_token_attacks = [
7152            "rm -rf /tmp/x", // plain out-of-floor program
7153            "FOO=bar rm x",  // env-prefix: leading token `FOO=bar` ∉ floor
7154            "/bin/echo ok",  // path form: `/bin/echo` ≠ `echo` (exact match)
7155            "  rm x",        // leading whitespace, still `rm`
7156            "env rm x",      // `env` wrapper, leading token `env` ∉ floor
7157            "bash -c rm",    // `bash` ∉ floor
7158        ];
7159        for a in leading_token_attacks {
7160            assert!(
7161                !exec_floor_permits(Some(&echo), a),
7162                "out-of-floor leading token must be refused: {a:?}"
7163            );
7164        }
7165        // Sanity: a bare in-floor command with only a benign arg DOES bypass —
7166        // the floor is a ceiling, not a blanket off-switch. (A dangerous arg to
7167        // a permitted program is the user's accepted risk: they allow-listed it.)
7168        assert!(exec_floor_permits(Some(&echo), "echo hello world"));
7169        assert!(exec_floor_permits(Some(&echo), "echo -n trailing"));
7170    }
7171
7172    /// FLOOR TEST (a) — the security contract: with `--disable-ocap` asserted,
7173    /// an exec FLOOR that denies the command must STOP the unconfined bypass.
7174    /// `echo` is outside a readonly floor (`exec = none`), so even with yolo on
7175    /// it does NOT run on the host shell — it falls through to the confined
7176    /// dispatch, which (env-seam real shell) DENIES it. A deliberately
7177    /// restricted triage mode is NOT un-clamped by `--yolo`.
7178    #[tokio::test]
7179    async fn floor_blocks_disable_ocap_for_a_denied_exec() {
7180        let _l = env_lock().await;
7181        let _on = EnvVar::set("NEWT_DISABLE_OCAP", "1");
7182        let ws = tempfile::TempDir::new().unwrap();
7183        let caveats = caveats_no_exec(ws.path());
7184        // A readonly-triage preset clamp: exec denies everything.
7185        let floor = crate::NamedPermissionPreset {
7186            readonly: true,
7187            ..Default::default()
7188        }
7189        .clamp();
7190        let out = run_tool_with_floor(
7191            "run_command",
7192            serde_json::json!({"command": "echo should-not-run"}),
7193            ws.path(),
7194            &caveats,
7195            Some(&floor.exec),
7196        )
7197        .await;
7198        // The bypass did NOT fire: the command never reached the host shell, so
7199        // it fell to the confined dispatch and was denied, not `should-not-run\n`.
7200        assert_ne!(out, "should-not-run\n", "the floor must block the bypass");
7201        assert!(
7202            out.contains("capability denied"),
7203            "fell to confined dispatch and was denied, got: {out}"
7204        );
7205    }
7206
7207    /// FLOOR TEST (a, positive) — a command INSIDE the floor still takes the
7208    /// fast unconfined path under `--disable-ocap`. The floor is a ceiling, not
7209    /// a blanket off-switch: an explicitly allow-listed command runs.
7210    #[cfg(unix)]
7211    #[tokio::test]
7212    async fn floor_allows_disable_ocap_for_an_in_floor_exec() {
7213        let _l = env_lock().await;
7214        let _on = EnvVar::set("NEWT_DISABLE_OCAP", "1");
7215        let ws = tempfile::TempDir::new().unwrap();
7216        let caveats = caveats_no_exec(ws.path());
7217        // A triage preset that allow-lists `echo`.
7218        let floor = crate::NamedPermissionPreset {
7219            readonly: true,
7220            exec_allow: vec!["echo".to_string()],
7221            ..Default::default()
7222        }
7223        .clamp();
7224        let out = run_tool_with_floor(
7225            "run_command",
7226            serde_json::json!({"command": "echo in-floor-ok"}),
7227            ws.path(),
7228            &caveats,
7229            Some(&floor.exec),
7230        )
7231        .await;
7232        assert_eq!(out, "in-floor-ok\n", "in-floor command runs unconfined");
7233    }
7234
7235    /// FLOOR conservatism — a COMPOUND command never bypasses under an active
7236    /// floor, even if its leading token is allow-listed: `echo ok && rm -rf /`
7237    /// must not smuggle `rm` past an `echo` grant. It falls to the confined
7238    /// shell (env-seam real shell ⇒ denied), which gates each spawn.
7239    #[tokio::test]
7240    async fn floor_refuses_bypass_for_a_compound_command() {
7241        let _l = env_lock().await;
7242        let _on = EnvVar::set("NEWT_DISABLE_OCAP", "1");
7243        let ws = tempfile::TempDir::new().unwrap();
7244        let caveats = caveats_no_exec(ws.path());
7245        // `echo` is allow-listed, but the `&&` chains an unlisted `rm`.
7246        let floor = crate::NamedPermissionPreset {
7247            readonly: true,
7248            exec_allow: vec!["echo".to_string()],
7249            ..Default::default()
7250        }
7251        .clamp();
7252        let out = run_tool_with_floor(
7253            "run_command",
7254            serde_json::json!({"command": "echo ok && rm -rf /tmp/x"}),
7255            ws.path(),
7256            &caveats,
7257            Some(&floor.exec),
7258        )
7259        .await;
7260        assert_ne!(out, "ok\n", "a compound command must not bypass the floor");
7261        // Compound ⇒ never bypasses; it falls to the confined shell, which
7262        // (env-seam real shell) denies the ungranted command under `exec = none`.
7263        assert!(
7264            out.contains("capability denied"),
7265            "fell to confined dispatch and was denied, got: {out}"
7266        );
7267    }
7268
7269    /// FLOOR TEST (c) — `None` floor is bit-for-bit the pre-#307 bypass: a
7270    /// denied-by-caveats command still runs unconfined under `--disable-ocap`,
7271    /// proving the floor is opt-in and the no-preset case is unchanged.
7272    #[cfg(unix)]
7273    #[tokio::test]
7274    async fn no_floor_keeps_disable_ocap_bit_for_bit() {
7275        let _l = env_lock().await;
7276        let _on = EnvVar::set("NEWT_DISABLE_OCAP", "1");
7277        let ws = tempfile::TempDir::new().unwrap();
7278        let caveats = caveats_no_exec(ws.path());
7279        let out = run_tool_with_floor(
7280            "run_command",
7281            serde_json::json!({"command": "echo no-floor-ok"}),
7282            ws.path(),
7283            &caveats,
7284            None,
7285        )
7286        .await;
7287        assert_eq!(out, "no-floor-ok\n", "no floor ⇒ bypass unchanged");
7288    }
7289
7290    /// Envelope parity (#297): the host-shell envelope is structurally
7291    /// identical to the bridle one — `exit_code` / `stdout` / `stderr` /
7292    /// `sandbox_kind`, `denied`/`denials` omitted (⇒ not denied) — so the
7293    /// existing envelope readers apply unchanged.
7294    #[cfg(unix)]
7295    #[tokio::test]
7296    async fn host_shell_envelope_matches_the_bridle_shape() {
7297        let ws = tempfile::TempDir::new().unwrap();
7298        let envelope = host_shell_dispatch(
7299            "echo out; echo err >&2; exit 3",
7300            &ws.path().to_string_lossy(),
7301        )
7302        .await
7303        .expect("host shell runs");
7304        assert_eq!(envelope["exit_code"], 3);
7305        assert_eq!(envelope["stdout"], "out\n");
7306        assert_eq!(envelope["stderr"], "err\n");
7307        assert_eq!(envelope["sandbox_kind"], "none");
7308        // Omitted exactly as the bridle envelope omits them on the
7309        // nothing-was-denied path — `envelope_denied` reads it natively.
7310        assert!(envelope.get("denied").is_none(), "got: {envelope}");
7311        assert!(envelope.get("denials").is_none(), "got: {envelope}");
7312        assert!(!envelope_denied(&envelope));
7313        // And the shared formatter renders it like any confined result.
7314        assert_eq!(
7315            shell_envelope_output(&envelope, 20, false, false, None),
7316            "out\nerr\n"
7317        );
7318    }
7319
7320    #[test]
7321    fn decode_shell_stream_preserves_valid_utf8() {
7322        let text = "// ── Model — test ──\n";
7323        assert_eq!(decode_shell_stream(text.as_bytes()), text);
7324    }
7325
7326    #[test]
7327    fn decode_shell_stream_repairs_bsd_cat_v_utf8_notation() {
7328        // This is what macOS/BSD `cat -v` emits for "─ —\n" in a UTF-8
7329        // locale: the leading e2 byte is raw, while continuation bytes are
7330        // rendered as M-^T/M-^@ etc. A lossy decode would display
7331        // "�M-^TM-^@ �M-^@M-^T".
7332        let cat_v = b"\xe2M-^TM-^@ \xe2M-^@M-^T\n";
7333        assert_eq!(decode_shell_stream(cat_v), "─ —\n");
7334    }
7335
7336    #[test]
7337    fn decode_shell_stream_repairs_two_byte_bsd_cat_v_notation() {
7338        // "é" is c3 a9; BSD `cat -v` leaves c3 raw and renders a9 as M-).
7339        let cat_v = b"caf\xc3M-)\n";
7340        assert_eq!(decode_shell_stream(cat_v), "café\n");
7341    }
7342
7343    /// The venv/PATH prefix logic rides the HOST-BYPASS path unchanged: the
7344    /// `export VIRTUAL_ENV=…; export PATH=…;` prefix is prepended to the
7345    /// `--yolo` command, which runs on a real `/bin/sh` where `export` works.
7346    /// (The confined path no longer gets the prefix — it uses the env seam;
7347    /// see `confined_dispatch_uses_env_seam_not_export_prefix_783`.)
7348    #[cfg(unix)]
7349    #[tokio::test]
7350    async fn yolo_keeps_the_venv_prefix_logic() {
7351        let _l = env_lock().await;
7352        let _on = EnvVar::set("NEWT_DISABLE_OCAP", "1");
7353        let _venv = EnvVar::set("NEWT_VENV", "/opt/fake-venv");
7354        let _virtual = EnvVar::unset("VIRTUAL_ENV");
7355        let _paths = EnvVar::unset("NEWT_EXEC_PATHS");
7356        let ws = tempfile::TempDir::new().unwrap();
7357        let caveats = caveats_no_exec(ws.path());
7358        let out = run_tool(
7359            "run_command",
7360            serde_json::json!({"command": "echo \"$VIRTUAL_ENV\""}),
7361            ws.path(),
7362            &caveats,
7363        )
7364        .await;
7365        assert_eq!(out, "/opt/fake-venv\n");
7366    }
7367
7368    /// #783 regression (Bug A): the confined-shell dispatch carries the RAW
7369    /// user command and the venv via agent-bridle's structured `env` seam — NOT
7370    /// an `export …;` prefix on `cmd`. The old code built
7371    /// `{ "cmd": cmd_with_venv, "cwd": … }` (the prefixed form), and the
7372    /// confined safe-subset engine refuses an `export` builtin on a compound
7373    /// command, which is the bug. Pure: builds the dispatch args only, no spawn.
7374    #[tokio::test]
7375    async fn confined_dispatch_uses_env_seam_not_export_prefix_783() {
7376        let _l = env_lock().await;
7377        let _venv = EnvVar::set("NEWT_VENV", "/opt/fake-venv");
7378        let _virtual = EnvVar::unset("VIRTUAL_ENV");
7379        let _paths = EnvVar::unset("NEWT_EXEC_PATHS");
7380
7381        // The literal failing case from #783.
7382        let cmd = "hostname; sw_vers 2>/dev/null | head -1; uname -s";
7383        let args = confined_dispatch_args(cmd, "/work/dir");
7384
7385        // The command is passed RAW — no `export …;` prefix smuggled in.
7386        assert_eq!(args["cmd"], cmd);
7387        assert!(
7388            !args["cmd"]
7389                .as_str()
7390                .expect("cmd is a string")
7391                .contains("export "),
7392            "confined cmd must not carry an export prefix: {args}"
7393        );
7394        assert_eq!(args["cwd"], "/work/dir");
7395
7396        // The venv rides the env seam: VIRTUAL_ENV + venv bin prepended to PATH.
7397        assert_eq!(args["env"]["VIRTUAL_ENV"], "/opt/fake-venv");
7398        let path = args["env"]["PATH"].as_str().expect("PATH in the env seam");
7399        assert!(
7400            path.starts_with("/opt/fake-venv/bin"),
7401            "venv bin must be prepended to PATH: {path}"
7402        );
7403    }
7404
7405    /// #783: with neither venv input set, the env seam is empty (no spurious
7406    /// VIRTUAL_ENV / PATH keys) — the no-venv invocation is unaffected.
7407    #[tokio::test]
7408    async fn confined_dispatch_env_seam_empty_without_venv_783() {
7409        let _l = env_lock().await;
7410        let _venv = EnvVar::unset("NEWT_VENV");
7411        let _virtual = EnvVar::unset("VIRTUAL_ENV");
7412        let _paths = EnvVar::unset("NEWT_EXEC_PATHS");
7413
7414        let args = confined_dispatch_args("ls -la", "/work/dir");
7415        assert_eq!(args["cmd"], "ls -la");
7416        assert_eq!(
7417            args["env"],
7418            serde_json::json!({}),
7419            "no venv inputs ⇒ empty env map: {args}"
7420        );
7421    }
7422
7423    /// fs fence under yolo (#297): the newt-native workspace fence is NOT
7424    /// bypassed — a write/read outside the granted scope keeps the standard
7425    /// denial bit-for-bit. Yolo is unconfined exec, never authority-off.
7426    #[tokio::test]
7427    async fn yolo_keeps_the_fs_workspace_fence() {
7428        let _l = env_lock().await;
7429        let _on = EnvVar::set("NEWT_DISABLE_OCAP", "1");
7430        let ws = tempfile::TempDir::new().unwrap();
7431        let caveats = caveats_no_exec(ws.path());
7432        let escape = "/definitely-outside-the-fence/escape.txt";
7433        let out = run_tool(
7434            "write_file",
7435            serde_json::json!({"path": escape, "content": "nope"}),
7436            ws.path(),
7437            &caveats,
7438        )
7439        .await;
7440        assert_eq!(out, denied_fs_result("fs_write", escape));
7441        assert!(!std::path::Path::new(escape).exists());
7442
7443        let out = run_tool(
7444            "read_file",
7445            serde_json::json!({"path": "/etc/hostname"}),
7446            ws.path(),
7447            &caveats,
7448        )
7449        .await;
7450        assert_eq!(out, denied_fs_result("fs_read", "/etc/hostname"));
7451    }
7452
7453    /// Precedence (#297): with both `--disable-ocap` and a #263 gate present,
7454    /// exec never prompts — nothing is denied, so the gate is structurally
7455    /// unreachable for run_command. (fs prompting stays live; the fs-fence
7456    /// test above and the #263 suite cover that axis.)
7457    #[cfg(unix)]
7458    #[tokio::test]
7459    async fn yolo_never_consults_the_permission_gate_for_exec() {
7460        struct PanicGate;
7461        impl super::PermissionGate for PanicGate {
7462            fn ask(&mut self, requests: &[super::PermissionRequest]) -> super::PermissionDecision {
7463                panic!("yolo exec must never prompt, but the gate was asked: {requests:?}");
7464            }
7465            fn ask_question(&mut self, question: &str) -> Option<String> {
7466                panic!("yolo exec must never prompt, but the gate was asked: {question:?}");
7467            }
7468        }
7469        let _l = env_lock().await;
7470        let _on = EnvVar::set("NEWT_DISABLE_OCAP", "1");
7471        let ws = tempfile::TempDir::new().unwrap();
7472        let caveats = caveats_no_exec(ws.path());
7473        let mut gate = PanicGate;
7474        let out = execute_tool(
7475            "run_command",
7476            &serde_json::json!({"command": "echo no-prompt"}),
7477            &ws.path().to_string_lossy(),
7478            false,
7479            20,
7480            &caveats,
7481            &mut NoMcp,
7482            None,
7483            None,
7484            None,
7485            None, // memory_source
7486            Some(&mut gate),
7487            None,
7488            None, // git_tool
7489            None, // crew_runner
7490            None, // scratchpad_store
7491            None, // code_search
7492            None, // experience_store
7493            None, // step_ledger
7494        )
7495        .await;
7496        assert_eq!(out, "no-prompt\n");
7497    }
7498
7499    /// The corrective tool-name guard still answers BEFORE the bypass: yolo
7500    /// changes where commands run, not what counts as a command.
7501    #[tokio::test]
7502    async fn yolo_keeps_the_tool_name_corrective_guard() {
7503        let _l = env_lock().await;
7504        let _on = EnvVar::set("NEWT_DISABLE_OCAP", "1");
7505        let ws = tempfile::TempDir::new().unwrap();
7506        let caveats = caveats_no_exec(ws.path());
7507        let out = run_tool(
7508            "run_command",
7509            serde_json::json!({"command": "read_file foo.txt"}),
7510            ws.path(),
7511            &caveats,
7512        )
7513        .await;
7514        assert!(out.contains("is a tool, not a shell command"), "got: {out}");
7515    }
7516
7517    // --- facade P4 (#780): hidden tool-call routing dispatch ---------------
7518
7519    /// A git stub that proves *which path served the call*: a routed
7520    /// `git status` lands here as op `status`; a routed write op would surface
7521    /// the unexpected-op error (so a test can assert it was NOT routed).
7522    struct RoutingStubGit;
7523    impl crate::agentic::GitTool for RoutingStubGit {
7524        fn dispatch(
7525            &self,
7526            op: &str,
7527            _args: &serde_json::Value,
7528            _caps: &crate::git_caveats::GitCaveats,
7529        ) -> Result<String, String> {
7530            match op {
7531                "status" => Ok("on branch main (routed via git built-in)".to_string()),
7532                other => Err(format!("unexpected routed git op '{other}'")),
7533            }
7534        }
7535    }
7536
7537    async fn run_routed_with_git(command: &str, ws: &std::path::Path, caveats: &Caveats) -> String {
7538        execute_tool(
7539            "run_command",
7540            &serde_json::json!({ "command": command }),
7541            &ws.to_string_lossy(),
7542            false,
7543            20,
7544            caveats,
7545            &mut NoMcp,
7546            None,
7547            None,
7548            None,
7549            None, // memory_source
7550            None, // permission_gate
7551            None, // exec_floor
7552            Some(&RoutingStubGit as &dyn crate::agentic::GitTool),
7553            None, // crew_runner
7554            None, // scratchpad_store
7555            None, // code_search
7556            None, // experience_store
7557            None, // step_ledger
7558        )
7559        .await
7560    }
7561
7562    /// The routing switch reads fail-closed (only the exact `1`), and it is a
7563    /// DISTINCT mechanism from `ocap_disabled` (§7-F5): asserting `NEWT_NO_ROUTE`
7564    /// never moves `ocap_disabled`, and asserting `NEWT_DISABLE_OCAP` never moves
7565    /// `routing_disabled`. The two switches can never alias.
7566    #[test]
7567    fn routing_disabled_requires_exactly_1_and_is_independent_of_ocap() {
7568        let _l = ENV_LOCK.blocking_lock();
7569        let _no_ocap = EnvVar::unset("NEWT_DISABLE_OCAP");
7570        {
7571            let _unset = EnvVar::unset("NEWT_NO_ROUTE");
7572            assert!(!routing_disabled(), "absent ⇒ routing stays on");
7573        }
7574        for (value, expected) in [("1", true), ("0", false), ("", false), ("true", false)] {
7575            let _set = EnvVar::set("NEWT_NO_ROUTE", value);
7576            assert_eq!(routing_disabled(), expected, "NEWT_NO_ROUTE={value:?}");
7577            // F5: turning routing off NEVER turns on the L3-off unconfine.
7578            assert!(
7579                !ocap_disabled(),
7580                "NEWT_NO_ROUTE must not imply --disable-ocap"
7581            );
7582        }
7583        // And the inverse: --disable-ocap must not imply --no-route.
7584        let _unset_route = EnvVar::unset("NEWT_NO_ROUTE");
7585        let _on_ocap = EnvVar::set("NEWT_DISABLE_OCAP", "1");
7586        assert!(ocap_disabled());
7587        assert!(
7588            !routing_disabled(),
7589            "--disable-ocap must not imply --no-route"
7590        );
7591    }
7592
7593    /// TDD: a routed read goes through the SAME fs floor — routing is NOT a
7594    /// bypass. An out-of-scope `cat /etc/shadow` routes to `read_file` and is
7595    /// denied by `fs_read` exactly as a direct `read_file` would be (the denial
7596    /// short-circuits before any real fs access). The `fs_read` denial wording
7597    /// also proves it reached the `read_file` arm (vs. the exec/shell path).
7598    #[tokio::test]
7599    async fn routed_cat_goes_through_the_fs_floor_not_a_bypass() {
7600        let _l = env_lock().await;
7601        let _route_on = EnvVar::unset("NEWT_NO_ROUTE");
7602        let _ocap_off = EnvVar::unset("NEWT_DISABLE_OCAP");
7603        let ws = tempfile::TempDir::new().unwrap();
7604        let caveats = caveats_no_exec(ws.path()); // fs_read scoped to ws only
7605        let out = run_tool(
7606            "run_command",
7607            serde_json::json!({ "command": "cat /etc/shadow" }),
7608            ws.path(),
7609            &caveats,
7610        )
7611        .await;
7612        assert!(
7613            out.contains("capability denied: fs_read does not permit")
7614                && out.contains("/etc/shadow"),
7615            "routed cat must hit the fs floor, not run unconfined; got: {out}"
7616        );
7617    }
7618
7619    /// TDD: read-only `git status` is silently routed to the governed `git`
7620    /// built-in (the stub proves the built-in served it). Revert the routing
7621    /// promotion and this is red — the command would instead hit the run_command
7622    /// corrective guard.
7623    #[tokio::test]
7624    async fn routed_git_status_dispatches_through_the_git_builtin() {
7625        let _l = env_lock().await;
7626        let _route_on = EnvVar::unset("NEWT_NO_ROUTE");
7627        let _ocap_off = EnvVar::unset("NEWT_DISABLE_OCAP");
7628        let ws = tempfile::TempDir::new().unwrap();
7629        let caveats = caveats_no_exec(ws.path());
7630        let out = run_routed_with_git("git status", ws.path(), &caveats).await;
7631        assert!(
7632            out.contains("routed via git built-in"),
7633            "git status must route to the governed git built-in; got: {out}"
7634        );
7635    }
7636
7637    /// TDD: state-modifying `git add` is GATED as exec — NOT silently routed
7638    /// (owner decision 2). It never reaches the git built-in (no unexpected-op
7639    /// error from the stub); it falls through to the normal run_command path.
7640    #[tokio::test]
7641    async fn state_modifying_git_add_is_not_routed() {
7642        let _l = env_lock().await;
7643        let _route_on = EnvVar::unset("NEWT_NO_ROUTE");
7644        let _ocap_off = EnvVar::unset("NEWT_DISABLE_OCAP");
7645        let ws = tempfile::TempDir::new().unwrap();
7646        let caveats = caveats_no_exec(ws.path());
7647        let out = run_routed_with_git("git add a.txt", ws.path(), &caveats).await;
7648        assert!(
7649            !out.contains("routed"),
7650            "git add must NOT route to the git built-in; got: {out}"
7651        );
7652        // It falls through to the run_command path (git ∈ DIRECT_TOOL_NAMES ⇒
7653        // the existing corrective guard), never silently routed.
7654        assert!(out.contains("is a tool, not a shell command"), "got: {out}");
7655    }
7656
7657    /// F5 (§7-F5): `--no-route` bypasses routing but NEVER disables L3. With
7658    /// `NEWT_NO_ROUTE=1`, the same out-of-bounds `cat` is no longer routed to
7659    /// `read_file` (no `fs_read` denial), yet it does NOT run unconfined — it
7660    /// falls to the confined shell (env-seam real shell ⇒ denied), and
7661    /// `ocap_disabled()` stays false. The boundary holds.
7662    #[tokio::test]
7663    async fn no_route_bypasses_routing_but_keeps_l3() {
7664        let _l = env_lock().await;
7665        let _route_off = EnvVar::set("NEWT_NO_ROUTE", "1");
7666        let _ocap_off = EnvVar::unset("NEWT_DISABLE_OCAP");
7667        assert!(routing_disabled() && !ocap_disabled(), "L2 off, L3 on");
7668        let ws = tempfile::TempDir::new().unwrap();
7669        let caveats = caveats_no_exec(ws.path());
7670        let out = run_tool(
7671            "run_command",
7672            serde_json::json!({ "command": "cat /etc/shadow" }),
7673            ws.path(),
7674            &caveats,
7675        )
7676        .await;
7677        // Routing was OFF ⇒ NOT rewritten to read_file (no fs_read denial)…
7678        assert!(
7679            !out.contains("fs_read does not permit"),
7680            "--no-route must not route to read_file; got: {out}"
7681        );
7682        // …and the command did NOT run unconfined: it took the confined shell
7683        // (env-seam real shell ⇒ denied — the L3 boundary held).
7684        assert!(
7685            out.contains("capability denied"),
7686            "the L3 confined dispatch must still gate the command; got: {out}"
7687        );
7688    }
7689}