Skip to main content

supercode_harness/tools/
builtins.rs

1//! Built-in tools: file read/write/edit, directory listing, glob, content
2//! search, and shell execution.
3
4use std::path::{Path, PathBuf};
5use std::time::Duration;
6
7use async_trait::async_trait;
8use serde::de::DeserializeOwned;
9use serde::Deserialize;
10use serde_json::{json, Value};
11
12use crate::error::{Error, Result};
13use crate::tools::convert::{is_notebook_path, is_pdf_path, notebook_markdown, pdf_markdown};
14use crate::tools::{
15    image_mime_for, is_image_path, network_checked_redirect_policy, Tool, ToolContext,
16    MULTIMODAL_IMAGE_MARKER, NOTEBOOK_EXTENSION,
17};
18
19/// `read_file` truncates (never errors) at this many bytes; the agent
20/// additionally caps every tool result at `Config::max_tool_output_bytes`
21/// (default 100 KB), so the model receives `min` of the two — see
22/// `Agent::cap_tool_output`.
23pub(crate) const MAX_READ_BYTES: usize = 400_000;
24const DEFAULT_BASH_TIMEOUT_MS: u64 = 120_000;
25
26pub(crate) fn parse_args<T: DeserializeOwned>(tool: &str, args: Value) -> Result<T> {
27    serde_json::from_value(args).map_err(|e| Error::InvalidArguments {
28        tool: tool.to_string(),
29        message: e.to_string(),
30    })
31}
32
33fn rel(ctx: &ToolContext, p: &Path) -> String {
34    p.strip_prefix(&ctx.cwd)
35        .unwrap_or(p)
36        .to_string_lossy()
37        .into_owned()
38}
39
40/// P4c (S1.2 `core.tools.read_file.multimodal` / `view_image`): hand-rolled
41/// standard base64 (no external dependency — same "small parser over a
42/// crate" precedent as `config::glob_match`/`tools::url_host`). Used ONLY
43/// to build a `data:` URL for an image tool result.
44pub(crate) fn base64_encode(bytes: &[u8]) -> String {
45    const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
46    let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
47    for chunk in bytes.chunks(3) {
48        let b0 = chunk[0];
49        let b1 = chunk.get(1).copied();
50        let b2 = chunk.get(2).copied();
51        out.push(ALPHABET[(b0 >> 2) as usize] as char);
52        out.push(ALPHABET[(((b0 & 0x03) << 4) | (b1.unwrap_or(0) >> 4)) as usize] as char);
53        match b1 {
54            Some(b1) => {
55                out.push(ALPHABET[(((b1 & 0x0f) << 2) | (b2.unwrap_or(0) >> 6)) as usize] as char)
56            }
57            None => out.push('='),
58        }
59        match b2 {
60            Some(b2) => out.push(ALPHABET[(b2 & 0x3f) as usize] as char),
61            None => out.push('='),
62        }
63    }
64    out
65}
66
67/// BP-2 (catalog:27): the PDF half of the multimodal read — a PDF's text
68/// layer, extracted page by page, or an honest structured summary when the
69/// document has none (see `tools::convert::pdf_markdown`).
70fn pdf_tool_result(path: &Path, bytes: &[u8]) -> String {
71    pdf_markdown(
72        &path.file_name().unwrap_or_default().to_string_lossy(),
73        bytes,
74    )
75}
76
77/// BP-2: the `.ipynb` half of the same row — cells rendered with outputs.
78fn notebook_tool_result(path: &Path, bytes: &[u8]) -> String {
79    notebook_markdown(
80        &path.file_name().unwrap_or_default().to_string_lossy(),
81        bytes,
82    )
83}
84
85/// P4c: build the `MULTIMODAL_IMAGE_MARKER`-prefixed tool result for a
86/// successfully-read image file — `Agent::run_loop` detects this prefix and
87/// turns it into a `content_parts` image block instead of plain text.
88fn image_tool_result(path: &Path, bytes: &[u8]) -> String {
89    let mime = image_mime_for(path);
90    let b64 = base64_encode(bytes);
91    format!("{MULTIMODAL_IMAGE_MARKER}data:{mime};base64,{b64}")
92}
93
94/// P4c (S1.4 `core.nested_instructions`, deferred from P4b): if
95/// [`ToolContext::nested_instructions`] is on and `touched` lives in a
96/// subdirectory (other than `ctx.cwd` itself, already loaded at session
97/// start) that carries its own `CLAUDE.md`/`AGENTS.md` and hasn't been
98/// injected yet this conversation, return a banner to append to the calling
99/// tool's result. Reuses `agent::import_target_is_contained` — the SAME
100/// canonicalize+containment check P4b's `@`-import expansion uses — so a
101/// symlinked subdirectory cannot walk the injection outside `ctx.cwd`.
102fn nested_instructions_notice(ctx: &ToolContext, touched: &Path) -> Option<String> {
103    if !ctx.nested_instructions {
104        return None;
105    }
106    let dir = if touched.is_dir() {
107        touched.to_path_buf()
108    } else {
109        touched.parent()?.to_path_buf()
110    };
111    if !crate::agent::import_target_is_contained(&dir, &ctx.cwd) {
112        return None;
113    }
114    let root = std::fs::canonicalize(&ctx.cwd).unwrap_or_else(|_| ctx.cwd.clone());
115    let real_dir = std::fs::canonicalize(&dir).ok()?;
116    if real_dir == root {
117        // Already loaded globally at session start by `load_project_context`.
118        return None;
119    }
120    let mut found: Option<(PathBuf, String)> = None;
121    for name in ["CLAUDE.md", "AGENTS.md"] {
122        let candidate = dir.join(name);
123        if let Ok(content) = std::fs::read_to_string(&candidate) {
124            found = Some((candidate, content));
125            break;
126        }
127    }
128    let (candidate, content) = found?;
129    {
130        let mut seen = ctx.injected_instruction_dirs.lock().ok()?;
131        if !seen.insert(real_dir) {
132            // Already injected this conversation — deduped (catalog:84).
133            return None;
134        }
135    }
136    let shown = rel(ctx, &candidate);
137    Some(format!(
138        "\n\n[nested instructions from {shown}]\n{}",
139        content.trim()
140    ))
141}
142
143/// BP-5 (catalog D2 "Path-scoped rules": *rule files activated only when
144/// matching files are touched*; cc§2 "`paths:` frontmatter scopes a rule to
145/// file globs so it loads only when Claude touches matching files"): every
146/// `paths:`-scoped rule whose selector matches `touched` and that has not
147/// already been injected this conversation, as a banner to append to the
148/// calling tool's result.
149///
150/// The SAME door [`nested_instructions_notice`] uses — a tool result is
151/// where "you just touched a file this instruction is about" can be said —
152/// with the selector swapped from the touched file's own directory to the
153/// rule's declared globs. Empty (and free of any work) when
154/// `[core.path_rules]` is off, because the list is then empty.
155fn path_rules_notice(ctx: &ToolContext, touched: &Path) -> Option<String> {
156    if ctx.path_rules.is_empty() {
157        return None;
158    }
159    let mut out = String::new();
160    for rule in ctx.path_rules.iter().filter(|r| r.is_scoped()) {
161        if !rule.matches(touched, &ctx.cwd) {
162            continue;
163        }
164        {
165            let mut seen = ctx.injected_rule_files.lock().ok()?;
166            if !seen.insert(rule.path.clone()) {
167                continue;
168            }
169        }
170        out.push_str("\n\n");
171        out.push_str(&rule.render());
172    }
173    (!out.is_empty()).then_some(out)
174}
175
176/// BP-2 (§1.2 `core.tools.read_file.line_numbers`, catalog:26 "Dedicated
177/// read with offset/limit, `cat -n` style output"): render one slice of a
178/// file for the model. With the knob off this is the raw slice, exactly as
179/// before; with it on, every line carries its REAL file line number
180/// (`first_line` is the 1-based number of `slice`'s first line, so an
181/// `offset` read numbers from the offset, not from 1) in a right-aligned
182/// six-column gutter followed by a tab — the gutter Claude Code's Read
183/// emits and its models cite line numbers from.
184fn render_read_slice(ctx: &ToolContext, slice: &str, first_line: usize) -> String {
185    if !ctx.read_line_numbers {
186        return slice.to_string();
187    }
188    number_lines(slice, first_line)
189}
190
191/// `cat -n` gutter over `text`, numbering from `first_line`.
192pub(crate) fn number_lines(text: &str, first_line: usize) -> String {
193    if text.is_empty() {
194        return String::new();
195    }
196    let trailing_newline = text.ends_with('\n');
197    let mut out = String::with_capacity(text.len() + 8);
198    for (offset, line) in text.lines().enumerate() {
199        out.push_str(&format!("{:>6}\t{line}\n", first_line + offset));
200    }
201    if !trailing_newline {
202        out.pop();
203    }
204    out
205}
206
207// ---- read -----------------------------------------------------------------
208
209/// Read a UTF-8 text file.
210pub struct ReadFileTool;
211
212#[derive(Deserialize)]
213struct ReadArgs {
214    path: String,
215    #[serde(default)]
216    offset: Option<usize>,
217    #[serde(default)]
218    limit: Option<usize>,
219}
220
221#[async_trait]
222impl Tool for ReadFileTool {
223    fn name(&self) -> &str {
224        "read_file"
225    }
226    fn description(&self) -> &str {
227        "Read the contents of a UTF-8 text file. Output may be line-numbered `cat -n` style (a right-aligned number and a tab before each line, numbered from `offset`); the numbers are the gutter, not file content. Large files are returned truncated from the start (with a notice stating the true size); pass `offset` (1-based start line) and/or `limit` (number of lines) to read further slices. PDFs come back as extracted text and Jupyter notebooks as their cells with outputs."
228    }
229    fn parameters(&self) -> Value {
230        json!({
231            "type": "object",
232            "properties": {
233                "path": {"type": "string", "description": "File path, absolute or relative to the working directory."},
234                "offset": {"type": "integer", "description": "1-based line to start at."},
235                "limit": {"type": "integer", "description": "Maximum number of lines to return."}
236            },
237            "required": ["path"],
238            "additionalProperties": false
239        })
240    }
241    async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<String> {
242        let a: ReadArgs = parse_args(self.name(), args)?;
243        let path = ctx.resolve(&a.path);
244        let bytes = tokio::fs::read(&path)
245            .await
246            .map_err(|e| Error::tool(self.name(), format!("{}: {e}", path.display())))?;
247        // P4c (S1.2 `core.tools.read_file.multimodal`): a recognized image
248        // file, with the knob on, returns as a model-visible image content
249        // block instead of being lossy-decoded as UTF-8 text. Read-tracking
250        // (below) still applies; nested-instructions injection is skipped
251        // for this branch (the whole result is a machine-parsed marker, not
252        // a place to append prose — see `nested_instructions_notice`'s
253        // caller below).
254        if ctx.multimodal_read && is_image_path(&path) {
255            ctx.mark_read_bytes(&path, &bytes);
256            return Ok(image_tool_result(&path, &bytes));
257        }
258        // BP-2 (§1.2 `core.tools.read_file.multimodal`, catalog:27): with
259        // the same knob on, a PDF comes back as extracted text pages and a
260        // Jupyter notebook as its cells rendered WITH their outputs, rather
261        // than as the UTF-8-lossy binary/JSON soup a raw decode produces.
262        // Both branches still mark the path read and are still capped by
263        // the agent's own `max_tool_output_bytes`.
264        if ctx.multimodal_read && is_pdf_path(&path) {
265            ctx.mark_read_bytes(&path, &bytes);
266            return Ok(pdf_tool_result(&path, &bytes));
267        }
268        if ctx.multimodal_read && is_notebook_path(&path) {
269            ctx.mark_read_bytes(&path, &bytes);
270            return Ok(notebook_tool_result(&path, &bytes));
271        }
272        let text = String::from_utf8_lossy(&bytes);
273        let result = if a.offset.is_none() && a.limit.is_none() {
274            if bytes.len() > MAX_READ_BYTES {
275                let total = bytes.len();
276                // Find the largest char boundary <= MAX_READ_BYTES.
277                let mut end = MAX_READ_BYTES.min(text.len());
278                while end > 0 && !text.is_char_boundary(end) {
279                    end -= 1;
280                }
281                // Prefer to back off further to the previous newline, so the
282                // cut lands on a whole line, as long as one exists in the head.
283                if let Some(nl) = text[..end].rfind('\n') {
284                    end = nl + 1;
285                }
286                let shown = end;
287                let lines = text[..end].matches('\n').count();
288                let notice = format!(
289                    "[read_file: file is {total} bytes; showing first {shown} bytes ({lines} lines). Pass offset/limit to read more.]\n"
290                );
291                notice + &render_read_slice(ctx, &text[..end], 1)
292            } else {
293                render_read_slice(ctx, &text, 1)
294            }
295        } else {
296            let start = a.offset.unwrap_or(1).saturating_sub(1);
297            let limit = a.limit.unwrap_or(usize::MAX);
298            let sliced: Vec<&str> = text.lines().skip(start).take(limit).collect();
299            render_read_slice(ctx, &sliced.join("\n"), start + 1)
300        };
301        // P4c (S1.2 `core.tools.edit_file.require_read_before_edit`): record
302        // this read unconditionally (cheap; only ever CONSULTED when the
303        // knob is on — see `ToolContext::mark_read`'s doc comment). BP-2:
304        // stamped with the bytes just read, so the staleness check compares
305        // against exactly what the model was shown.
306        ctx.mark_read_bytes(&path, &bytes);
307        let mut result = result;
308        // P4c (S1.4 `core.nested_instructions`).
309        if let Some(notice) = nested_instructions_notice(ctx, &path) {
310            result.push_str(&notice);
311        }
312        // BP-5 (catalog D2 "Path-scoped rules").
313        if let Some(notice) = path_rules_notice(ctx, &path) {
314            result.push_str(&notice);
315        }
316        Ok(result)
317    }
318}
319
320// ---- view_image -------------------------------------------------------------
321
322/// P4c (S1.2 `view_image`, SPLIT CX row `view_image`, catalog:28): a
323/// dedicated image-input tool, distinct from `read_file`'s `multimodal`
324/// mode — needed as an image pathway when `read_file` itself is disabled
325/// (cx-parity, S12). NOT registered by default; only reachable as the
326/// optional fifth name in `[core.tools] enabled` (see
327/// `ToolRegistry::from_config`).
328pub struct ViewImageTool;
329
330#[derive(Deserialize)]
331struct ViewImageArgs {
332    path: String,
333}
334
335#[async_trait]
336impl Tool for ViewImageTool {
337    fn name(&self) -> &str {
338        "view_image"
339    }
340    fn description(&self) -> &str {
341        "Read a local image file and return it as a model-visible image content block."
342    }
343    fn parameters(&self) -> Value {
344        json!({
345            "type": "object",
346            "properties": {
347                "path": {"type": "string", "description": "Image file path, absolute or relative to the working directory."}
348            },
349            "required": ["path"],
350            "additionalProperties": false
351        })
352    }
353    async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<String> {
354        let a: ViewImageArgs = parse_args(self.name(), args)?;
355        let path = ctx.resolve(&a.path);
356        if !is_image_path(&path) {
357            return Err(Error::tool(
358                self.name(),
359                format!(
360                    "{} is not a recognized image file (expected one of: png, jpg, jpeg, gif, webp, bmp)",
361                    path.display()
362                ),
363            ));
364        }
365        let bytes = tokio::fs::read(&path)
366            .await
367            .map_err(|e| Error::tool(self.name(), format!("{}: {e}", path.display())))?;
368        ctx.mark_read(&path);
369        Ok(image_tool_result(&path, &bytes))
370    }
371}
372
373// ---- write ----------------------------------------------------------------
374
375/// Create or overwrite a file.
376pub struct WriteFileTool;
377
378#[derive(Deserialize)]
379struct WriteArgs {
380    path: String,
381    content: String,
382}
383
384#[async_trait]
385impl Tool for WriteFileTool {
386    fn name(&self) -> &str {
387        "write_file"
388    }
389    fn description(&self) -> &str {
390        "Create or overwrite a file with the given contents. Parent directories are created as needed."
391    }
392    fn parameters(&self) -> Value {
393        json!({
394            "type": "object",
395            "properties": {
396                "path": {"type": "string", "description": "File path to write."},
397                "content": {"type": "string", "description": "Full file contents."}
398            },
399            "required": ["path", "content"],
400            "additionalProperties": false
401        })
402    }
403    async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<String> {
404        let a: WriteArgs = parse_args(self.name(), args)?;
405        let path = ctx.resolve(&a.path);
406        ctx.check_write(&path)?;
407        // P5-9 (D-5 write-path interception seam): pre-image capture
408        // BEFORE the mutation — see `ToolContext::write_observer`'s doc
409        // comment. `None` (checkpoint off, the default) is a no-op.
410        if let Some(obs) = &ctx.write_observer {
411            obs.before_write(&path).await;
412        }
413        if let Some(parent) = path.parent() {
414            tokio::fs::create_dir_all(parent).await.ok();
415        }
416        tokio::fs::write(&path, a.content.as_bytes())
417            .await
418            .map_err(|e| Error::tool(self.name(), format!("{}: {e}", path.display())))?;
419        // P5-11 (§2 modules 28/29, C10): the observer chain's `after_write`
420        // may hand back an annotation (a formatter's diff-back, LSP
421        // diagnostics, or both) — appended to the result text the model
422        // sees, never silently dropped. `None` (both modules off, the
423        // default) leaves this byte-identical to pre-P5-11 behavior.
424        let mut annotation = String::new();
425        if let Some(obs) = &ctx.write_observer {
426            if let Some(note) = obs.after_write(&path).await {
427                annotation = format!("\n\n{note}");
428            }
429        }
430        Ok(format!(
431            "Wrote {} bytes to {}{}",
432            a.content.len(),
433            rel(ctx, &path),
434            annotation
435        ))
436    }
437}
438
439// ---- edit -----------------------------------------------------------------
440
441/// Replace an exact substring in a file.
442pub struct EditFileTool;
443
444#[derive(Deserialize)]
445struct EditArgs {
446    path: String,
447    #[serde(default)]
448    old_string: String,
449    #[serde(default)]
450    new_string: String,
451    #[serde(default)]
452    replace_all: bool,
453    /// P4c (S1.2 `core.tools.edit_file.notebook_aware`): 0-based Jupyter
454    /// cell index. Presence (with `cell_op`) switches this call into the
455    /// notebook cell-surgery branch instead of exact-string replace.
456    #[serde(default)]
457    cell_index: Option<usize>,
458    /// P4c: `"replace"` | `"insert"` | `"delete"`.
459    #[serde(default)]
460    cell_op: Option<String>,
461    /// P4c: the cell's new source text (required for `replace`/`insert`).
462    #[serde(default)]
463    cell_source: Option<String>,
464    /// P4c: cell type for `insert` — `"code"` (default) or `"markdown"`.
465    #[serde(default)]
466    cell_type: Option<String>,
467}
468
469#[async_trait]
470impl Tool for EditFileTool {
471    fn name(&self) -> &str {
472        "edit_file"
473    }
474    fn description(&self) -> &str {
475        "Replace an exact substring in a file. By default `old_string` must occur exactly once; set `replace_all` to replace every occurrence. When notebook-aware editing is enabled, pass `cell_index`/`cell_op` (`replace`|`insert`|`delete`) instead to edit a Jupyter `.ipynb` cell."
476    }
477    fn parameters(&self) -> Value {
478        json!({
479            "type": "object",
480            "properties": {
481                "path": {"type": "string"},
482                "old_string": {"type": "string", "description": "Exact text to replace."},
483                "new_string": {"type": "string", "description": "Replacement text."},
484                "replace_all": {"type": "boolean", "description": "Replace every occurrence instead of requiring uniqueness."},
485                "cell_index": {"type": "integer", "description": "0-based Jupyter cell index (notebook-aware mode only)."},
486                "cell_op": {"type": "string", "enum": ["replace", "insert", "delete"], "description": "Notebook cell operation (notebook-aware mode only)."},
487                "cell_source": {"type": "string", "description": "New cell source text (notebook-aware `replace`/`insert`)."},
488                "cell_type": {"type": "string", "enum": ["code", "markdown"], "description": "Cell type for notebook-aware `insert` (default `code`)."}
489            },
490            "required": ["path"],
491            "additionalProperties": false
492        })
493    }
494    async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<String> {
495        let a: EditArgs = parse_args(self.name(), args)?;
496        let path = ctx.resolve(&a.path);
497        ctx.check_write(&path)?;
498        // P5-9 (D-5 write-path interception seam): one capture point ahead
499        // of BOTH branches below (notebook cell-surgery and plain-text
500        // replace) — whichever this call takes, the pre-image is captured
501        // exactly once, before either mutates anything.
502        if let Some(obs) = &ctx.write_observer {
503            obs.before_write(&path).await;
504        }
505
506        // P4c (S1.2 `core.tools.edit_file.require_read_before_edit`, UNIQUE
507        // CC row): refuse unless `read_file` has already read this exact
508        // path this conversation. `false` (the default) never consults
509        // `ToolContext::read_state` at all — byte-identical to today.
510        //
511        // BP-2 (catalog:32, the "AND UNCHANGED" half): a path read earlier
512        // and then modified on disk is refused too. Editing against a view
513        // the file no longer has is how an exact-string replace silently
514        // clobbers someone else's write — CC refuses it, and so must this.
515        if ctx.require_read_before_edit {
516            match ctx.read_state(&path) {
517                crate::tools::ReadState::Fresh => {}
518                crate::tools::ReadState::NeverRead => {
519                    return Err(Error::tool(
520                        self.name(),
521                        format!(
522                            "{} must be read with `read_file` before it can be edited this conversation",
523                            path.display()
524                        ),
525                    ))
526                }
527                crate::tools::ReadState::Stale => {
528                    return Err(Error::tool(
529                        self.name(),
530                        format!(
531                            "{} has changed on disk since it was read; read it again before editing",
532                            path.display()
533                        ),
534                    ))
535                }
536            }
537        }
538
539        // P4c (S1.2 `core.tools.edit_file.notebook_aware`): a Jupyter
540        // cell-surgery call is routed here BEFORE the exact-string-replace
541        // path — `false` (the default), or a `.ipynb` path with no
542        // `cell_op`, falls straight through unchanged.
543        if ctx.notebook_aware
544            && a.cell_op.is_some()
545            && path.extension().and_then(|e| e.to_str()) == Some(NOTEBOOK_EXTENSION)
546        {
547            let result = edit_notebook_cell(self.name(), ctx, &path, &a).await?;
548            // BP-2: same re-stamp as the exact-string branch below.
549            ctx.mark_read(&path);
550            let mut annotation = String::new();
551            if let Some(obs) = &ctx.write_observer {
552                if let Some(note) = obs.after_write(&path).await {
553                    annotation = format!("\n\n{note}");
554                }
555            }
556            return Ok(format!("{result}{annotation}"));
557        }
558
559        if a.old_string.is_empty() {
560            // An empty needle matches at every char boundary; with replace_all
561            // that would interleave new_string through the whole file.
562            return Err(Error::tool(self.name(), "old_string must not be empty"));
563        }
564        let original = tokio::fs::read_to_string(&path)
565            .await
566            .map_err(|e| Error::tool(self.name(), format!("{}: {e}", path.display())))?;
567        let count = original.matches(&a.old_string).count();
568        if count == 0 {
569            return Err(Error::tool(self.name(), "old_string not found in file"));
570        }
571        if count > 1 && !a.replace_all {
572            return Err(Error::tool(
573                self.name(),
574                format!("old_string occurs {count} times; pass replace_all or add more context"),
575            ));
576        }
577        let updated = if a.replace_all {
578            original.replace(&a.old_string, &a.new_string)
579        } else {
580            original.replacen(&a.old_string, &a.new_string, 1)
581        };
582        tokio::fs::write(&path, updated.as_bytes())
583            .await
584            .map_err(|e| Error::tool(self.name(), format!("{}: {e}", path.display())))?;
585        // BP-2: the model's view of this file is now the bytes IT just
586        // wrote, so re-stamp the read record — otherwise the staleness
587        // check above would refuse the model's own next edit of the same
588        // file (CC's rule is "unchanged BY SOMEONE ELSE", not "never
589        // written"). A write observer's diff-back (formatters, module 28)
590        // lands after this, which is exactly the C10 case CC re-reads for:
591        // it is a real change to the model's view and stays detected.
592        ctx.mark_read_bytes(&path, updated.as_bytes());
593        let mut result = format!(
594            "Replaced {} occurrence(s) in {}",
595            if a.replace_all { count } else { 1 },
596            rel(ctx, &path)
597        );
598        // P5-11: see `WriteFileTool::execute`'s matching comment above.
599        if let Some(obs) = &ctx.write_observer {
600            if let Some(note) = obs.after_write(&path).await {
601                result.push_str("\n\n");
602                result.push_str(&note);
603            }
604        }
605        if let Some(notice) = nested_instructions_notice(ctx, &path) {
606            result.push_str(&notice);
607        }
608        // BP-5 (catalog D2 "Path-scoped rules").
609        if let Some(notice) = path_rules_notice(ctx, &path) {
610            result.push_str(&notice);
611        }
612        Ok(result)
613    }
614}
615
616/// P4c (S1.2 `core.tools.edit_file.notebook_aware`): Jupyter cell
617/// replace/insert/delete over the notebook's `cells` array. The notebook is
618/// parsed/re-serialized as generic JSON (`serde_json::Value`) rather than a
619/// typed nbformat model — the smallest form that satisfies "cell surgery",
620/// matching the catalog's S-sized classification for this row (not a full
621/// nbformat crate/schema).
622async fn edit_notebook_cell(
623    tool_name: &str,
624    ctx: &ToolContext,
625    path: &Path,
626    a: &EditArgs,
627) -> Result<String> {
628    let text = tokio::fs::read_to_string(path)
629        .await
630        .map_err(|e| Error::tool(tool_name, format!("{}: {e}", path.display())))?;
631    let mut doc: Value = serde_json::from_str(&text).map_err(|e| {
632        Error::tool(
633            tool_name,
634            format!("{}: not valid notebook JSON: {e}", path.display()),
635        )
636    })?;
637    let cells = doc
638        .get_mut("cells")
639        .and_then(|c| c.as_array_mut())
640        .ok_or_else(|| Error::tool(tool_name, format!("{}: no `cells` array", path.display())))?;
641    let index = a
642        .cell_index
643        .ok_or_else(|| Error::tool(tool_name, "cell_index is required for notebook cell edits"))?;
644    let op = a.cell_op.as_deref().unwrap_or("replace");
645    let summary = match op {
646        "delete" => {
647            if index >= cells.len() {
648                return Err(Error::tool(
649                    tool_name,
650                    format!("cell_index {index} out of range (0..{})", cells.len()),
651                ));
652            }
653            cells.remove(index);
654            format!("Deleted cell {index}")
655        }
656        "insert" => {
657            let source = a
658                .cell_source
659                .clone()
660                .ok_or_else(|| Error::tool(tool_name, "cell_source is required for insert"))?;
661            let cell_type = a.cell_type.as_deref().unwrap_or("code");
662            let new_cell = json!({
663                "cell_type": cell_type,
664                "metadata": {},
665                "source": [source],
666                "outputs": if cell_type == "code" { json!([]) } else { json!(null) },
667                "execution_count": json!(null),
668            });
669            if index > cells.len() {
670                return Err(Error::tool(
671                    tool_name,
672                    format!("cell_index {index} out of range (0..={})", cells.len()),
673                ));
674            }
675            cells.insert(index, new_cell);
676            format!("Inserted a {cell_type} cell at {index}")
677        }
678        "replace" => {
679            let source = a
680                .cell_source
681                .clone()
682                .ok_or_else(|| Error::tool(tool_name, "cell_source is required for replace"))?;
683            let len = cells.len();
684            let cell = cells.get_mut(index).ok_or_else(|| {
685                Error::tool(
686                    tool_name,
687                    format!("cell_index {index} out of range (0..{len})"),
688                )
689            })?;
690            cell["source"] = json!([source]);
691            format!("Replaced source of cell {index}")
692        }
693        other => {
694            return Err(Error::tool(
695                tool_name,
696                format!("unknown cell_op `{other}` (expected replace|insert|delete)"),
697            ))
698        }
699    };
700    let rendered =
701        serde_json::to_string_pretty(&doc).map_err(|e| Error::tool(tool_name, e.to_string()))?;
702    tokio::fs::write(path, rendered.as_bytes())
703        .await
704        .map_err(|e| Error::tool(tool_name, format!("{}: {e}", path.display())))?;
705    Ok(format!("{summary} in {}", rel(ctx, path)))
706}
707
708// ---- list -----------------------------------------------------------------
709
710/// List directory entries.
711pub struct ListDirTool;
712
713#[derive(Deserialize)]
714struct ListArgs {
715    #[serde(default)]
716    path: Option<String>,
717}
718
719#[async_trait]
720impl Tool for ListDirTool {
721    fn name(&self) -> &str {
722        "list_dir"
723    }
724    fn description(&self) -> &str {
725        "List the entries of a directory (defaults to the working directory). Directories are suffixed with `/`."
726    }
727    fn parameters(&self) -> Value {
728        json!({
729            "type": "object",
730            "properties": {
731                "path": {"type": "string", "description": "Directory to list. Defaults to the working directory."}
732            },
733            "additionalProperties": false
734        })
735    }
736    async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<String> {
737        let a: ListArgs = parse_args(self.name(), args)?;
738        let dir = match a.path {
739            Some(p) => ctx.resolve(&p),
740            None => ctx.cwd.clone(),
741        };
742        let mut rd = tokio::fs::read_dir(&dir)
743            .await
744            .map_err(|e| Error::tool(self.name(), format!("{}: {e}", dir.display())))?;
745        let mut entries = Vec::new();
746        while let Some(e) = rd
747            .next_entry()
748            .await
749            .map_err(|e| Error::tool(self.name(), e.to_string()))?
750        {
751            let name = e.file_name().to_string_lossy().into_owned();
752            let is_dir = e.file_type().await.map(|t| t.is_dir()).unwrap_or(false);
753            entries.push(if is_dir { format!("{name}/") } else { name });
754        }
755        entries.sort();
756        if entries.is_empty() {
757            Ok("(empty directory)".to_string())
758        } else {
759            Ok(entries.join("\n"))
760        }
761    }
762}
763
764// ---- glob -----------------------------------------------------------------
765
766/// Match files by glob pattern.
767pub struct GlobTool;
768
769#[derive(Deserialize)]
770struct GlobArgs {
771    pattern: String,
772}
773
774#[async_trait]
775impl Tool for GlobTool {
776    fn name(&self) -> &str {
777        "glob"
778    }
779    fn description(&self) -> &str {
780        "Find files matching a glob pattern (e.g. `src/**/*.rs`), relative to the working directory."
781    }
782    fn parameters(&self) -> Value {
783        json!({
784            "type": "object",
785            "properties": {
786                "pattern": {"type": "string", "description": "Glob pattern, e.g. **/*.rs"}
787            },
788            "required": ["pattern"],
789            "additionalProperties": false
790        })
791    }
792    async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<String> {
793        let a: GlobArgs = parse_args(self.name(), args)?;
794        let cwd = ctx.cwd.clone();
795        let full = if PathBuf::from(&a.pattern).is_absolute() {
796            a.pattern.clone()
797        } else {
798            cwd.join(&a.pattern).to_string_lossy().into_owned()
799        };
800        let cwd2 = cwd.clone();
801        let matches = tokio::task::spawn_blocking(move || {
802            let mut out = Vec::new();
803            if let Ok(paths) = glob::glob(&full) {
804                for p in paths.flatten() {
805                    let display = p
806                        .strip_prefix(&cwd2)
807                        .unwrap_or(&p)
808                        .to_string_lossy()
809                        .into_owned();
810                    out.push(display);
811                }
812            }
813            out
814        })
815        .await
816        .map_err(|e| Error::tool("glob", e.to_string()))?;
817        if matches.is_empty() {
818            Ok("(no matches)".to_string())
819        } else {
820            Ok(matches.join("\n"))
821        }
822    }
823}
824
825// ---- search ---------------------------------------------------------------
826
827/// Regex search file contents (respecting .gitignore).
828pub struct SearchTool;
829
830#[derive(Deserialize)]
831struct SearchArgs {
832    pattern: String,
833    #[serde(default)]
834    path: Option<String>,
835    #[serde(default)]
836    max_results: Option<usize>,
837}
838
839#[async_trait]
840impl Tool for SearchTool {
841    fn name(&self) -> &str {
842        "search"
843    }
844    fn description(&self) -> &str {
845        "Search file contents with a regular expression, respecting .gitignore. Returns `path:line: text` matches."
846    }
847    fn parameters(&self) -> Value {
848        json!({
849            "type": "object",
850            "properties": {
851                "pattern": {"type": "string", "description": "Regular expression to search for."},
852                "path": {"type": "string", "description": "Directory or file to search. Defaults to the working directory."},
853                "max_results": {"type": "integer", "description": "Cap on the number of matches (default 200)."}
854            },
855            "required": ["pattern"],
856            "additionalProperties": false
857        })
858    }
859    async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<String> {
860        let a: SearchArgs = parse_args(self.name(), args)?;
861        let re = regex::Regex::new(&a.pattern)
862            .map_err(|e| Error::tool(self.name(), format!("invalid regex: {e}")))?;
863        let root = match a.path {
864            Some(p) => ctx.resolve(&p),
865            None => ctx.cwd.clone(),
866        };
867        let cwd = ctx.cwd.clone();
868        let cap = a.max_results.unwrap_or(200);
869        let results = tokio::task::spawn_blocking(move || {
870            let mut out: Vec<String> = Vec::new();
871            let walker = ignore::WalkBuilder::new(&root).build();
872            'outer: for entry in walker.flatten() {
873                if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
874                    continue;
875                }
876                let path = entry.path();
877                let Ok(content) = std::fs::read_to_string(path) else {
878                    continue; // skip binary / unreadable
879                };
880                for (i, line) in content.lines().enumerate() {
881                    if re.is_match(line) {
882                        let rel = path.strip_prefix(&cwd).unwrap_or(path);
883                        out.push(format!("{}:{}: {}", rel.display(), i + 1, line.trim_end()));
884                        if out.len() >= cap {
885                            break 'outer;
886                        }
887                    }
888                }
889            }
890            out
891        })
892        .await
893        .map_err(|e| Error::tool("search", e.to_string()))?;
894        if results.is_empty() {
895            Ok("(no matches)".to_string())
896        } else {
897            Ok(results.join("\n"))
898        }
899    }
900}
901
902// ---- bash -----------------------------------------------------------------
903
904/// Run a shell command via `sh -c`.
905pub struct BashTool {
906    default_timeout_ms: u64,
907}
908
909/// Owns the process group created for one [`BashTool`] invocation.
910///
911/// The explicit calls clean up before we await pipe readers or reap the direct
912/// child. The `Drop` backstop also covers cancellation of the tool future: a
913/// cancelled request must not detach the shell's workers from Supercode.
914#[cfg(unix)]
915struct BashProcessTreeGuard(Option<u32>);
916
917#[cfg(windows)]
918struct BashProcessTreeGuard(Option<usize>);
919
920#[cfg(not(any(unix, windows)))]
921struct BashProcessTreeGuard;
922
923impl BashProcessTreeGuard {
924    #[cfg(unix)]
925    fn prepare() -> std::io::Result<Self> {
926        Ok(Self(None))
927    }
928
929    #[cfg(windows)]
930    fn prepare() -> std::io::Result<Self> {
931        use windows_sys::Win32::Foundation::CloseHandle;
932        use windows_sys::Win32::System::JobObjects::{
933            CreateJobObjectW, JobObjectExtendedLimitInformation, SetInformationJobObject,
934            JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
935        };
936
937        // A Job Object is Windows' process-tree ownership primitive. Closing
938        // this handle terminates every assigned descendant, including on an
939        // async future cancellation where no explicit timeout arm runs.
940        let job = unsafe { CreateJobObjectW(std::ptr::null(), std::ptr::null()) };
941        if job.is_null() {
942            return Err(std::io::Error::last_os_error());
943        }
944        let mut limits: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = unsafe { std::mem::zeroed() };
945        limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
946        let configured = unsafe {
947            SetInformationJobObject(
948                job,
949                JobObjectExtendedLimitInformation,
950                std::ptr::addr_of!(limits).cast(),
951                std::mem::size_of_val(&limits) as u32,
952            )
953        };
954        if configured == 0 {
955            let error = std::io::Error::last_os_error();
956            unsafe {
957                CloseHandle(job);
958            }
959            return Err(error);
960        }
961        Ok(Self(Some(job as usize)))
962    }
963
964    #[cfg(not(any(unix, windows)))]
965    fn prepare() -> std::io::Result<Self> {
966        Ok(Self)
967    }
968
969    fn configure_command(&self, command: &mut tokio::process::Command) {
970        #[cfg(unix)]
971        command.process_group(0);
972        #[cfg(windows)]
973        command.creation_flags(windows_sys::Win32::System::Threading::CREATE_SUSPENDED);
974    }
975
976    #[cfg(unix)]
977    fn attach_and_start(&mut self, child: &tokio::process::Child) -> std::io::Result<()> {
978        self.0 = child.id();
979        Ok(())
980    }
981
982    #[cfg(windows)]
983    fn attach_and_start(&mut self, child: &tokio::process::Child) -> std::io::Result<()> {
984        use windows_sys::Win32::System::JobObjects::AssignProcessToJobObject;
985
986        let job = self.0.ok_or_else(|| {
987            std::io::Error::new(
988                std::io::ErrorKind::BrokenPipe,
989                "command Job Object is closed",
990            )
991        })? as windows_sys::Win32::Foundation::HANDLE;
992        let process = child.raw_handle().ok_or_else(|| {
993            std::io::Error::new(
994                std::io::ErrorKind::BrokenPipe,
995                "suspended command has no process handle",
996            )
997        })?;
998        if unsafe { AssignProcessToJobObject(job, process.cast()) } == 0 {
999            return Err(std::io::Error::last_os_error());
1000        }
1001        Self::resume_primary_thread(child.id().ok_or_else(|| {
1002            std::io::Error::new(
1003                std::io::ErrorKind::BrokenPipe,
1004                "suspended command has no process id",
1005            )
1006        })?)
1007    }
1008
1009    #[cfg(windows)]
1010    fn resume_primary_thread(process_id: u32) -> std::io::Result<()> {
1011        use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE};
1012        use windows_sys::Win32::System::Diagnostics::ToolHelp::{
1013            CreateToolhelp32Snapshot, Thread32First, Thread32Next, TH32CS_SNAPTHREAD, THREADENTRY32,
1014        };
1015        use windows_sys::Win32::System::Threading::{
1016            OpenThread, ResumeThread, THREAD_SUSPEND_RESUME,
1017        };
1018
1019        let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0) };
1020        if snapshot == INVALID_HANDLE_VALUE {
1021            return Err(std::io::Error::last_os_error());
1022        }
1023        let result = (|| {
1024            let mut entry: THREADENTRY32 = unsafe { std::mem::zeroed() };
1025            entry.dwSize = std::mem::size_of::<THREADENTRY32>() as u32;
1026            let mut has_entry = unsafe { Thread32First(snapshot, &mut entry) } != 0;
1027            while has_entry {
1028                if entry.th32OwnerProcessID == process_id {
1029                    let thread =
1030                        unsafe { OpenThread(THREAD_SUSPEND_RESUME, 0, entry.th32ThreadID) };
1031                    if thread.is_null() {
1032                        return Err(std::io::Error::last_os_error());
1033                    }
1034                    let resumed = unsafe { ResumeThread(thread) };
1035                    unsafe {
1036                        CloseHandle(thread);
1037                    }
1038                    if resumed == u32::MAX {
1039                        return Err(std::io::Error::last_os_error());
1040                    }
1041                    return Ok(());
1042                }
1043                has_entry = unsafe { Thread32Next(snapshot, &mut entry) } != 0;
1044            }
1045            Err(std::io::Error::new(
1046                std::io::ErrorKind::NotFound,
1047                "suspended command's primary thread was not found",
1048            ))
1049        })();
1050        unsafe {
1051            CloseHandle(snapshot);
1052        }
1053        result
1054    }
1055
1056    #[cfg(not(any(unix, windows)))]
1057    fn attach_and_start(&mut self, _child: &tokio::process::Child) -> std::io::Result<()> {
1058        Ok(())
1059    }
1060
1061    fn kill(&mut self) {
1062        #[cfg(unix)]
1063        if let Some(pid) = self.0.take() {
1064            crate::lsp::kill_process_group(pid);
1065        }
1066        #[cfg(windows)]
1067        if let Some(job) = self.0.take() {
1068            use windows_sys::Win32::Foundation::CloseHandle;
1069            use windows_sys::Win32::System::JobObjects::TerminateJobObject;
1070            let job = job as windows_sys::Win32::Foundation::HANDLE;
1071            unsafe {
1072                TerminateJobObject(job, 1);
1073                CloseHandle(job);
1074            }
1075        }
1076    }
1077}
1078
1079impl Drop for BashProcessTreeGuard {
1080    fn drop(&mut self) {
1081        self.kill();
1082    }
1083}
1084
1085impl Default for BashTool {
1086    fn default() -> Self {
1087        BashTool {
1088            default_timeout_ms: DEFAULT_BASH_TIMEOUT_MS,
1089        }
1090    }
1091}
1092
1093#[derive(Deserialize)]
1094struct BashArgs {
1095    command: String,
1096    #[serde(default)]
1097    timeout_ms: Option<u64>,
1098    /// BP-10 (catalog row "Sandbox-escalation path", cx§4's own spelling):
1099    /// the model's request to run this ONE command outside the sandbox,
1100    /// after a confined attempt failed. See
1101    /// [`escalation_requested`] for how it is adjudicated — the request is
1102    /// not the grant.
1103    #[serde(default)]
1104    with_escalated_permissions: Option<bool>,
1105    /// BP-10: why the escalation is needed, in the model's own words —
1106    /// what the approval door shows the user (cx§4
1107    /// `sandbox_permissions: "require_escalated"` + justification).
1108    #[serde(default)]
1109    /// Read by the permissions gate from the RAW args (see `escalation_requested`);
1110    /// declared here so the schema and the parse agree on the field.
1111    #[allow(dead_code)]
1112    justification: Option<String>,
1113}
1114
1115/// BP-10 (catalog row "Sandbox-escalation path"): the ONE place that turns
1116/// a model-issued `with_escalated_permissions` argument into "run this
1117/// spawn unconfined", for every shell-family tool.
1118///
1119/// **The request is not the grant.** A call carrying this flag was already
1120/// forced to the permissions engine's `Ask` tier by
1121/// `crate::agent::Agent::permissions_gate_denial_impl` (which sees the same
1122/// argument, and hands the approval door the `justification` in the raw
1123/// args) — so if the tool is executing AT ALL, the escalation was approved
1124/// by the door, or refused and the tool never ran. That is why this
1125/// function does not prompt a second time: there is ONE gate, and it has
1126/// already spoken.
1127///
1128/// **Only where that gate exists.** When the permissions engine is off
1129/// (`capabilities.permissions.enabled = false`), no gate adjudicated the
1130/// flag, so honoring it would be an unapproved un-sandboxing — a model
1131/// argument silently switching the sandbox off. In that configuration the
1132/// flag is refused, loudly, rather than obeyed.
1133fn escalation_requested(tool: &str, requested: Option<bool>, ctx: &ToolContext) -> Result<bool> {
1134    if requested != Some(true) {
1135        return Ok(false);
1136    }
1137    if !ctx.permissions_engine_active() {
1138        return Err(Error::tool(
1139            tool,
1140            "with_escalated_permissions requires the permissions engine              (capabilities.permissions.enabled) — there is no approval door to grant it, and              an unadjudicated escalation would switch the sandbox off on the model's say-so",
1141        ));
1142    }
1143    Ok(true)
1144}
1145
1146/// BP-10 (catalog row "Sandbox-escalation path", the "fail in sandbox"
1147/// half): does this finished command look like it failed BECAUSE it was
1148/// confined? A heuristic over the combined output, consulted only for a
1149/// non-zero exit of a call that actually ran confined — so a false
1150/// positive costs one extra sentence of advice, never a behavior change.
1151///
1152/// The strings are the ones the two real backstops produce: macOS
1153/// seatbelt denies with `Operation not permitted` (and names itself in
1154/// `sandbox-exec` failures), Linux Landlock/`unshare` denies with the same
1155/// `EPERM` text, and a network cut-off surfaces as a resolver/connect
1156/// failure.
1157fn looks_sandbox_denied(output: &str) -> bool {
1158    const MARKERS: &[&str] = &[
1159        "operation not permitted",
1160        "permission denied",
1161        "read-only file system",
1162        "sandbox-exec",
1163        "eperm",
1164        "could not resolve host",
1165        "temporary failure in name resolution",
1166        "network is unreachable",
1167        "connection refused",
1168    ];
1169    let lower = output.to_ascii_lowercase();
1170    MARKERS.iter().any(|m| lower.contains(m))
1171}
1172
1173/// BP-10: the advice appended to a confined command's failed output — the
1174/// model's CHANNEL to ask for an unsandboxed rerun. Naming the exact
1175/// argument names is the point: without this line the model has no way to
1176/// discover that an escalation path exists at all, which is precisely the
1177/// half of the row that was missing.
1178fn escalation_hint(tool: &str) -> String {
1179    format!(
1180        "\n[{tool}: this command ran inside the sandbox and failed with an error that looks          like a sandbox denial. If it genuinely has to run outside the sandbox, call {tool}          again with `with_escalated_permissions: true` and a `justification` explaining why;          the user is asked before the unsandboxed rerun happens.]"
1181    )
1182}
1183
1184/// BP-2 (catalog:32 footnote "+Bash-view exemptions"): the single file a
1185/// bash command plainly SHOWED the model, or `None`.
1186///
1187/// Deliberately narrow, exactly as the Claude Code inventory states the
1188/// rule (`cat`/`head`/`tail`/`sed -n`/`grep`/`egrep`/`fgrep`, no pipes or
1189/// redirects): a shell composition can transform, filter or truncate what
1190/// the model saw, and an exemption granted on a partial view is how a
1191/// clobbering edit gets waved through. Anything with shell metacharacters,
1192/// anything but one existing-file argument, or a `sed` without `-n` is not
1193/// an exemption.
1194pub(crate) fn bash_view_target(command: &str) -> Option<String> {
1195    if command
1196        .chars()
1197        .any(|c| matches!(c, '|' | '>' | '<' | ';' | '&' | '`' | '\n'))
1198        || command.contains("$(")
1199    {
1200        return None;
1201    }
1202    let tokens: Vec<&str> = command.split_whitespace().collect();
1203    let (first, rest) = tokens.split_first()?;
1204    // Accept an absolute path to the same viewer (`/bin/cat`).
1205    let program = first.rsplit('/').next().unwrap_or(first);
1206    // Per program: whether the first operand is a PATTERN/script rather
1207    // than a path, and which flags consume the next token as their value
1208    // (`head -n 20 file`). Per-program because `sed -n` is a boolean flag
1209    // while `head -n` takes a count.
1210    let (takes_pattern, value_flags): (bool, &[&str]) = match program {
1211        "cat" => (false, &[]),
1212        "head" | "tail" => (false, &["-n", "-c"]),
1213        "sed" => (true, &["-e", "-f", "-i"]),
1214        "grep" | "egrep" | "fgrep" => (true, &["-e", "-f", "-m", "-A", "-B", "-C"]),
1215        _ => return None,
1216    };
1217    if program == "sed" && !rest.iter().any(|t| *t == "-n" || *t == "--quiet") {
1218        // Without `-n`, `sed` prints its own EDITED stream, not the file.
1219        return None;
1220    }
1221    // Every argument that is neither a flag nor a flag's value; what is
1222    // left after the pattern must be exactly ONE path, or this is a
1223    // multi-file view, which CC does not exempt either.
1224    let mut operands: Vec<String> = Vec::new();
1225    let mut skip_next = false;
1226    for token in rest {
1227        if skip_next {
1228            skip_next = false;
1229            continue;
1230        }
1231        if token.starts_with('-') {
1232            skip_next = value_flags.contains(token);
1233            continue;
1234        }
1235        let unquoted = token.trim_matches(|c| c == '\'' || c == '"');
1236        if unquoted.is_empty() {
1237            continue;
1238        }
1239        operands.push(unquoted.to_string());
1240    }
1241    if takes_pattern && !operands.is_empty() {
1242        operands.remove(0);
1243    }
1244    match operands.len() {
1245        1 => operands.pop(),
1246        _ => None,
1247    }
1248}
1249
1250#[async_trait]
1251impl Tool for BashTool {
1252    fn name(&self) -> &str {
1253        "bash"
1254    }
1255    fn description(&self) -> &str {
1256        "Execute a shell command via `sh -c` in the working directory and return its combined stdout/stderr and exit code."
1257    }
1258    fn parameters(&self) -> Value {
1259        json!({
1260            "type": "object",
1261            "properties": {
1262                "command": {"type": "string", "description": "Shell command to run."},
1263                "timeout_ms": {"type": "integer", "description": "Timeout in milliseconds (default 120000)."},
1264                "with_escalated_permissions": {"type": "boolean", "description": "Run this one command OUTSIDE the sandbox. Only use it after a sandboxed attempt failed for a sandbox reason; the user is asked first, and must supply a justification."},
1265                "justification": {"type": "string", "description": "Why this command needs to run outside the sandbox. Shown to the user with the approval request."}
1266            },
1267            "required": ["command"],
1268            "additionalProperties": false
1269        })
1270    }
1271    async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<String> {
1272        let a: BashArgs = parse_args(self.name(), args)?;
1273        // BP-10 (catalog row "Sandbox-escalation path"): an APPROVED
1274        // escalation runs this one command unconfined — see
1275        // `escalation_requested`'s doc comment for why the approval has
1276        // already happened by the time we are here.
1277        let escalated = escalation_requested(self.name(), a.with_escalated_permissions, ctx)?;
1278        // P4e (S3.1 `core.tools.bash.timeout_secs`, S14): a model-issued
1279        // `timeout_ms` argument always wins (unchanged); absent that, a
1280        // configured `ctx.bash_timeout_secs` REPLACES the built-in
1281        // `self.default_timeout_ms` fallback instead of stacking with it.
1282        // `ctx.bash_timeout_secs == None` (the default) makes this
1283        // byte-identical to the pre-P4e single-source fallback.
1284        let effective_default_ms = ctx
1285            .bash_timeout_secs
1286            .map(|s| s.saturating_mul(1000))
1287            .unwrap_or(self.default_timeout_ms);
1288        let timeout = Duration::from_millis(a.timeout_ms.unwrap_or(effective_default_ms));
1289        let deadline = tokio::time::Instant::now() + timeout;
1290
1291        // P4c (S1.2 `core.shell_env_snapshot`)/P5-10 (`env_policy`):
1292        // `build_sandboxed_sh` folds `ctx.shell_env` in via
1293        // `apply_sandbox_env_policy` (its own last step) — a no-op (byte-
1294        // identical spawn) when `ctx.shell_env` is `None` AND
1295        // `ctx.sandbox_env_policy` is `Inherit` (both defaults).
1296        let mut cmd = if escalated {
1297            build_unsandboxed_sh(&a.command, ctx)
1298        } else {
1299            build_sandboxed_sh(&a.command, ctx)?
1300        };
1301        cmd.current_dir(&ctx.cwd)
1302            .stdin(std::process::Stdio::null())
1303            .stdout(std::process::Stdio::piped())
1304            .stderr(std::process::Stdio::piped())
1305            .kill_on_drop(true);
1306        // Own the tree before it can execute: Unix creates a new process
1307        // group at spawn; Windows starts suspended, enters a preconfigured
1308        // kill-on-close Job Object, and only then resumes its primary thread.
1309        let mut process_tree = BashProcessTreeGuard::prepare()
1310            .map_err(|error| Error::tool(self.name(), error.to_string()))?;
1311        process_tree.configure_command(&mut cmd);
1312        let mut child = cmd
1313            .spawn()
1314            .map_err(|error| Error::tool(self.name(), error.to_string()))?;
1315        process_tree
1316            .attach_and_start(&child)
1317            .map_err(|error| Error::tool(self.name(), error.to_string()))?;
1318        let mut stdout = child
1319            .stdout
1320            .take()
1321            .ok_or_else(|| Error::tool(self.name(), "spawned command has no stdout"))?;
1322        let mut stderr = child
1323            .stderr
1324            .take()
1325            .ok_or_else(|| Error::tool(self.name(), "spawned command has no stderr"))?;
1326        let mut stdout_task = tokio::spawn(async move {
1327            let mut bytes = Vec::new();
1328            let result = stdout.read_to_end(&mut bytes).await;
1329            (result, bytes)
1330        });
1331        let mut stderr_task = tokio::spawn(async move {
1332            let mut bytes = Vec::new();
1333            let result = stderr.read_to_end(&mut bytes).await;
1334            (result, bytes)
1335        });
1336
1337        let status = match tokio::time::timeout_at(deadline, child.wait()).await {
1338            Ok(Ok(status)) => status,
1339            Ok(Err(error)) => {
1340                process_tree.kill();
1341                let _ = child.start_kill();
1342                let _ = tokio::time::timeout(Duration::from_secs(1), child.wait()).await;
1343                stdout_task.abort();
1344                stderr_task.abort();
1345                return Err(Error::tool(self.name(), error.to_string()));
1346            }
1347            Err(_) => {
1348                process_tree.kill();
1349                let _ = child.start_kill();
1350                let _ = tokio::time::timeout(Duration::from_secs(1), child.wait()).await;
1351                stdout_task.abort();
1352                stderr_task.abort();
1353                return Err(Error::tool(
1354                    self.name(),
1355                    format!("command timed out after {timeout:?}"),
1356                ));
1357            }
1358        };
1359        // A shell can exit successfully after backgrounding a worker. Bash
1360        // is the bounded foreground tool; durable work belongs in the
1361        // background tool. Reap any remaining member before reading EOF.
1362        process_tree.kill();
1363        let pipe_output = tokio::time::timeout_at(deadline, async {
1364            let (stdout_result, stdout) = (&mut stdout_task)
1365                .await
1366                .map_err(|error| Error::tool(self.name(), error.to_string()))?;
1367            stdout_result.map_err(|error| Error::tool(self.name(), error.to_string()))?;
1368            let (stderr_result, stderr) = (&mut stderr_task)
1369                .await
1370                .map_err(|error| Error::tool(self.name(), error.to_string()))?;
1371            stderr_result.map_err(|error| Error::tool(self.name(), error.to_string()))?;
1372            Ok::<_, Error>((stdout, stderr))
1373        })
1374        .await;
1375        let (stdout, stderr) = match pipe_output {
1376            Ok(result) => result?,
1377            Err(_) => {
1378                stdout_task.abort();
1379                stderr_task.abort();
1380                return Err(Error::tool(
1381                    self.name(),
1382                    format!("command timed out after {timeout:?}"),
1383                ));
1384            }
1385        };
1386
1387        let mut buf = String::new();
1388        let stdout = String::from_utf8_lossy(&stdout);
1389        let stderr = String::from_utf8_lossy(&stderr);
1390        if !stdout.is_empty() {
1391            buf.push_str(&stdout);
1392        }
1393        if !stderr.is_empty() {
1394            if !buf.is_empty() && !buf.ends_with('\n') {
1395                buf.push('\n');
1396            }
1397            buf.push_str(&stderr);
1398        }
1399        let code = status.code().unwrap_or(-1);
1400        // BP-2 (catalog:32 fn "+Bash-view exemptions", cc§1 Edit): CC
1401        // accepts a single-file `cat`/`head`/`tail`/`sed -n`/`grep` view as
1402        // satisfying read-before-edit, because the model HAS seen the file.
1403        // Only consulted when the enforcement knob is on, and only for a
1404        // command that succeeded — a failed view showed the model nothing.
1405        if ctx.require_read_before_edit && code == 0 {
1406            if let Some(target) = bash_view_target(&a.command) {
1407                let resolved = ctx.resolve(&target);
1408                if resolved.is_file() {
1409                    ctx.mark_read(&resolved);
1410                }
1411            }
1412        }
1413        if buf.is_empty() {
1414            buf.push_str("(no output)");
1415        }
1416        // BP-10: the "fail in sandbox → model justifies" half. Only for a
1417        // call that actually ran confined (an already-escalated rerun, or
1418        // an unconfined tier, has nothing to escalate to) and only on a
1419        // failure that looks like a confinement denial.
1420        if !escalated && code != 0 && ctx.os_sandbox_active() && looks_sandbox_denied(&buf) {
1421            buf.push_str(&escalation_hint(self.name()));
1422        }
1423        Ok(format!("exit code: {code}\n{buf}"))
1424    }
1425}
1426
1427/// P5-10 (§2 module 12): the resolved, platform-agnostic decision
1428/// [`build_sandboxed_sh`]/[`build_sandboxed_interactive_sh`] act on —
1429/// computed ONCE by [`resolve_sandbox_plan`] so both callers (and
1430/// `crate::agent::Agent::background_exec`, via the same shared function)
1431/// apply the identical fs/net posture, never two independently-computed
1432/// ones that could drift.
1433struct SandboxPlan {
1434    /// Apply real OS fs confinement (seatbelt on macOS, Landlock on
1435    /// Linux) for this call.
1436    confine_fs: bool,
1437    /// `true` for `WorkspaceWrite` (writes allowed under `cwd` + temp),
1438    /// `false` for `ReadOnly` (no writes at all). Only consulted when
1439    /// `confine_fs` is `true`.
1440    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1441    fs_allow_writes: bool,
1442    /// Apply a real coarse network cut-off (Linux network-namespace
1443    /// isolation) for this call.
1444    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1445    confine_net: bool,
1446}
1447
1448/// P5-10 (§2 module 12, cardinal rule "no silent-unsandboxed"): resolve
1449/// what [`build_sandboxed_sh`]/[`build_sandboxed_interactive_sh`] should
1450/// actually DO for one subprocess spawn — real availability probes
1451/// ([`crate::sandbox::landlock_available`]/[`crate::sandbox::
1452/// netns_available`], macOS's seatbelt treated as always-available,
1453/// matching its pre-P5-10 unconditional trigger) feed the PURE
1454/// `crate::sandbox::decide_fs`/`decide_net` functions, whose `Refuse`
1455/// outcome surfaces here as a real `Err` (the tool call fails — "refuse to
1456/// run the sandboxed subprocess") and whose `RunUnconfinedWithWarning`/
1457/// `GapWarn` outcomes print the loud one-time warning
1458/// (`crate::sandbox::warn_once`) and fall through to an UNCONFINED spawn
1459/// for that axis, rather than ever silently claiming confinement this
1460/// platform/kernel can't actually provide.
1461fn resolve_sandbox_plan(ctx: &ToolContext, subject: &str) -> Result<SandboxPlan> {
1462    use crate::sandbox::{decide_fs, decide_net, warn_once, FsDecision, NetDecision};
1463
1464    let fs_available = cfg!(target_os = "macos") || crate::sandbox::landlock_available();
1465    let approval = ctx.sandbox_approval_handler.as_deref();
1466    let fs_decision = decide_fs(
1467        ctx.sandbox,
1468        ctx.sandbox_os_enabled,
1469        fs_available,
1470        ctx.sandbox_escalation,
1471        approval,
1472        subject,
1473    );
1474    let confine_fs = match fs_decision {
1475        FsDecision::NotRequested => false,
1476        FsDecision::Confine => true,
1477        FsDecision::RunUnconfinedWithWarning { reason } => {
1478            warn_once(&reason);
1479            false
1480        }
1481        FsDecision::Refuse { reason } => return Err(Error::tool("sandbox", reason)),
1482    };
1483
1484    let network_enabled = ctx
1485        .network_policy
1486        .as_ref()
1487        .map(|p| p.enabled)
1488        .unwrap_or(false);
1489    let has_domain_rules = ctx
1490        .network_policy
1491        .as_ref()
1492        .map(|p| !p.allow_domains.is_empty() || !p.deny_domains.is_empty())
1493        .unwrap_or(false);
1494    // BP-10 (catalog row "Network sandbox / domain rules"): macOS has a
1495    // REAL coarse network primitive of its own — seatbelt's `(deny
1496    // network*)`, applied by `seatbelt_profile` below — so a coarse
1497    // cut-off is available here too, not only on a Linux kernel with
1498    // unprivileged network namespaces. Domain-level filtering is a
1499    // separate question `decide_net` still answers with `GapWarn` on both
1500    // platforms (see its own doc comment).
1501    let net_available = cfg!(target_os = "macos")
1502        || (cfg!(target_os = "linux") && crate::sandbox::netns_available());
1503    let net_decision = decide_net(network_enabled, has_domain_rules, net_available);
1504    let confine_net = match net_decision {
1505        NetDecision::NotRequested => false,
1506        NetDecision::Confine => true,
1507        NetDecision::GapWarn { reason } => {
1508            warn_once(&reason);
1509            false
1510        }
1511    };
1512
1513    Ok(SandboxPlan {
1514        confine_fs,
1515        fs_allow_writes: ctx.sandbox == crate::tools::SandboxPolicy::WorkspaceWrite,
1516        confine_net,
1517    })
1518}
1519
1520/// P5-10: apply [`SandboxPlan::confine_net`]/`.confine_fs` to `cmd` via a
1521/// real `pre_exec` closure — Linux only (see
1522/// [`crate::sandbox::apply_linux_confinement`]'s own doc comment for the
1523/// full real-enforcement story: runs in the FORKED CHILD, never touches
1524/// supercode itself). `cwd`/the system temp dir are resolved through
1525/// [`crate::safe_path::resolve_real`] (not a bare `std::fs::canonicalize`)
1526/// — the same dual lexical+resolved discipline every other containment
1527/// check in this crate uses, so a symlink'd working directory grants the
1528/// REAL target, not its lexical location. A no-op on non-Linux platforms
1529/// (macOS's confinement is the separate `seatbelt_profile` wrapper below;
1530/// every other platform has no primitive at all, which is exactly why
1531/// `resolve_sandbox_plan` never sets `confine_fs`/`confine_net` there).
1532#[cfg(target_os = "linux")]
1533fn apply_linux_plan(cmd: &mut tokio::process::Command, ctx: &ToolContext, plan: &SandboxPlan) {
1534    if !plan.confine_fs && !plan.confine_net {
1535        return;
1536    }
1537    let cwd = crate::safe_path::resolve_real(&ctx.cwd).unwrap_or_else(|| ctx.cwd.clone());
1538    let tmp_dir = std::env::temp_dir();
1539    let tmp = crate::safe_path::resolve_real(&tmp_dir).unwrap_or(tmp_dir);
1540    // BP-10: every granted `--add-dir` root joins the writable set, so the
1541    // kernel-level confinement agrees with `ToolContext::check_write`.
1542    let mut extra = vec![tmp];
1543    for root in &ctx.extra_roots {
1544        extra.push(crate::safe_path::resolve_real(root).unwrap_or_else(|| root.clone()));
1545    }
1546    crate::sandbox::apply_linux_confinement(
1547        cmd,
1548        plan.confine_fs,
1549        plan.fs_allow_writes,
1550        cwd,
1551        extra,
1552        plan.confine_net,
1553    );
1554}
1555
1556#[cfg(not(target_os = "linux"))]
1557fn apply_linux_plan(_cmd: &mut tokio::process::Command, _ctx: &ToolContext, _plan: &SandboxPlan) {}
1558
1559/// P5-10: env-policy pass — the LAST env-related step
1560/// [`BashTool::execute`]/`PersistentShellTool::execute`/`Agent::
1561/// background_exec` all apply, AFTER any `ctx.shell_env` snapshot has
1562/// already been folded in by the caller, so a `Filtered`/`None` policy
1563/// also strips a secret that arrived via the snapshot (not just the
1564/// process's own inherited environment) — computing the SAME effective
1565/// base independently here (rather than trying to introspect what a prior
1566/// `cmd.envs(...)` call already staged) and then `env_clear` + re-`envs`
1567/// the filtered result means call order never matters. `Inherit` (the
1568/// default) is a true no-op: `cmd` is never touched, so the spawn stays
1569/// byte-identical to pre-P5-10 behavior.
1570///
1571/// ALSO folds in `ctx.shell_env` (the `core.shell_env_snapshot` capture)
1572/// itself, for EVERY policy including `Inherit` — this is now the ONE place
1573/// that applies the snapshot, so callers must not separately call
1574/// `cmd.envs(ctx.shell_env...)` beforehand (that would let a secret from
1575/// the snapshot survive a `Filtered`/`None` policy by re-adding it after
1576/// this function's `env_clear()`, which is exactly the leak this
1577/// consolidation closes — see the git history for the P5-10 build's own
1578/// caught-in-review instance of that bug).
1579fn apply_sandbox_env_policy(cmd: &mut tokio::process::Command, ctx: &ToolContext) {
1580    if ctx.sandbox_env_policy == crate::sandbox::SandboxEnvPolicy::Inherit {
1581        // Byte-identical to pre-P5-10 behavior: don't touch the inherited
1582        // environment at all, just fold the snapshot on top if configured.
1583        if let Some(snapshot) = &ctx.shell_env {
1584            cmd.envs(snapshot.iter().map(|(k, v)| (k.as_str(), v.as_str())));
1585        }
1586        return;
1587    }
1588    let mut base: Vec<(String, String)> = std::env::vars().collect();
1589    if let Some(snapshot) = &ctx.shell_env {
1590        for (k, v) in snapshot.iter() {
1591            match base.iter_mut().find(|(bk, _)| bk == k) {
1592                Some(entry) => entry.1 = v.clone(),
1593                None => base.push((k.clone(), v.clone())),
1594            }
1595        }
1596    }
1597    let filtered = crate::sandbox::apply_env_policy(ctx.sandbox_env_policy, base);
1598    cmd.env_clear();
1599    cmd.envs(filtered);
1600}
1601
1602/// Build the `sh -c <command>` invocation, wrapped in real OS process
1603/// confinement when [`resolve_sandbox_plan`] says to (seatbelt on macOS,
1604/// Landlock + optional network-namespace isolation on Linux via
1605/// [`apply_linux_plan`]).
1606///
1607/// On macOS this uses `sandbox-exec` (seatbelt): `ReadOnly` denies all file
1608/// writes; `WorkspaceWrite` denies writes outside the working directory. On
1609/// Linux, Landlock gives the same real subprocess isolation (a `bash`
1610/// command cannot escape the policy via the kernel's own enforcement, not
1611/// just the file-tool confinement) — see `crate::sandbox`'s module doc
1612/// comment for the full fail-closed/escalation/gap-honesty story. Returns
1613/// `Err` when a confining tier was requested, this platform/kernel can't
1614/// provide it, and `escalation` says to refuse (the default) — "refuse to
1615/// run the sandboxed subprocess" rather than ever silently running
1616/// unconfined.
1617///
1618/// `pub(crate)` (P5-6, §2 module 4 `tools.background`, build brief "reuse
1619/// the bash tool's execution + sandbox path"): `crate::agent::Agent`'s
1620/// `background_exec` intrinsic calls this SAME function (re-exported via
1621/// `crate::tools::build_sandboxed_sh`) rather than reimplementing its own
1622/// spawn path, so a background command gets byte-identical sandboxing to a
1623/// foreground `bash` call — one enforcement point, not two that could
1624/// silently drift apart.
1625/// BP-10 (catalog row "Sandbox-escalation path"): the APPROVED unsandboxed
1626/// rerun — the same spawn `build_sandboxed_sh` builds, minus the
1627/// confinement wrapper. Deliberately a separate, named function rather than
1628/// a boolean threaded through `build_sandboxed_sh`: an unsandboxed spawn
1629/// should be greppable and should read as the exception it is. The env
1630/// policy still applies (an escalated command is unconfined, not
1631/// un-sanitized — a user who approved "run outside the sandbox" did not
1632/// thereby hand over their API keys).
1633pub(crate) fn build_unsandboxed_sh(command: &str, ctx: &ToolContext) -> tokio::process::Command {
1634    crate::sandbox::warn_once(&format!(
1635        "sandbox: running an APPROVED escalated command OUTSIDE the sandbox: {command}"
1636    ));
1637    let mut cmd = tokio::process::Command::new("sh");
1638    cmd.arg("-c").arg(command);
1639    apply_sandbox_env_policy(&mut cmd, ctx);
1640    cmd
1641}
1642
1643pub(crate) fn build_sandboxed_sh(
1644    command: &str,
1645    ctx: &ToolContext,
1646) -> Result<tokio::process::Command> {
1647    let plan = resolve_sandbox_plan(ctx, command)?;
1648    #[cfg(target_os = "macos")]
1649    {
1650        if plan.confine_fs || plan.confine_net {
1651            if let Some(profile) = seatbelt_profile(ctx, &plan) {
1652                let mut cmd = tokio::process::Command::new("sandbox-exec");
1653                cmd.arg("-p").arg(profile).arg("sh").arg("-c").arg(command);
1654                apply_sandbox_env_policy(&mut cmd, ctx);
1655                return Ok(cmd);
1656            }
1657        }
1658    }
1659    let mut cmd = tokio::process::Command::new("sh");
1660    cmd.arg("-c").arg(command);
1661    apply_linux_plan(&mut cmd, ctx, &plan);
1662    apply_sandbox_env_policy(&mut cmd, ctx);
1663    Ok(cmd)
1664}
1665
1666/// Like [`build_sandboxed_sh`] but for the *persistent* shell: an interactive
1667/// `sh` reading commands from its stdin (no `-c`). Without this the `shell`
1668/// tool would be an unsandboxed escape hatch around the policy that `bash`
1669/// honors.
1670fn build_sandboxed_interactive_sh(ctx: &ToolContext) -> Result<tokio::process::Command> {
1671    let plan = resolve_sandbox_plan(ctx, "<persistent shell>")?;
1672    #[cfg(target_os = "macos")]
1673    {
1674        if plan.confine_fs || plan.confine_net {
1675            if let Some(profile) = seatbelt_profile(ctx, &plan) {
1676                let mut cmd = tokio::process::Command::new("sandbox-exec");
1677                cmd.arg("-p").arg(profile).arg("sh");
1678                apply_sandbox_env_policy(&mut cmd, ctx);
1679                return Ok(cmd);
1680            }
1681        }
1682    }
1683    let mut cmd = tokio::process::Command::new("sh");
1684    apply_linux_plan(&mut cmd, ctx, &plan);
1685    apply_sandbox_env_policy(&mut cmd, ctx);
1686    Ok(cmd)
1687}
1688
1689/// A seatbelt profile string for the current sandbox plan, or `None` when
1690/// there is nothing at all to confine.
1691///
1692/// BP-10: the profile now carries BOTH axes of [`SandboxPlan`], not the fs
1693/// tier alone — `confine_net` contributes `(deny network*)`, macOS's real
1694/// coarse network cut-off (the analogue of the Linux network namespace
1695/// `crate::sandbox::apply_linux_confinement` uses), so
1696/// `capabilities.permissions.sandbox.network.enabled` is genuinely
1697/// enforced on this platform instead of degrading to a `GapWarn`. It also
1698/// grants every [`ToolContext::write_roots`] entry, so an `--add-dir` root
1699/// is writable inside the sandbox exactly as `ToolContext::check_write`
1700/// says it is.
1701#[cfg(target_os = "macos")]
1702fn seatbelt_profile(ctx: &ToolContext, plan: &SandboxPlan) -> Option<String> {
1703    use crate::tools::SandboxPolicy;
1704    if !plan.confine_fs && !plan.confine_net {
1705        return None;
1706    }
1707    let net = if plan.confine_net {
1708        "(deny network*)"
1709    } else {
1710        ""
1711    };
1712    let fs = if !plan.confine_fs {
1713        String::new()
1714    } else {
1715        match ctx.sandbox {
1716            SandboxPolicy::DangerFullAccess => String::new(),
1717            SandboxPolicy::ReadOnly => "(deny file-write*)".to_string(),
1718            SandboxPolicy::WorkspaceWrite => {
1719                // Allow writes only under the (real) working directory and
1720                // each granted extra root, plus the usual harmless
1721                // devices/temp. P5-10: reuses
1722                // `crate::safe_path::resolve_real` (the shared dual
1723                // lexical+resolved path primitive every other containment
1724                // check in this crate now goes through) instead of a bare
1725                // `std::fs::canonicalize` call, so a symlink'd root
1726                // resolves identically here and in the Linux Landlock path.
1727                let mut out = "(deny file-write*)".to_string();
1728                for root in ctx.write_roots() {
1729                    let real = crate::safe_path::resolve_real(&root).unwrap_or(root);
1730                    let dir = real.to_string_lossy().replace('"', "");
1731                    out.push_str(&format!("(allow file-write* (subpath \"{dir}\"))"));
1732                }
1733                out.push_str(
1734                    "(allow file-write* (literal \"/dev/null\") \
1735                     (literal \"/dev/dtracehelper\") (literal \"/dev/tty\"))",
1736                );
1737                out
1738            }
1739        }
1740    };
1741    if fs.is_empty() && net.is_empty() {
1742        return None;
1743    }
1744    Some(format!("(version 1)(allow default){fs}{net}"))
1745}
1746
1747// ---- apply_patch ----------------------------------------------------------
1748
1749/// Apply a Codex-style `apply_patch` envelope — Codex's primary edit
1750/// mechanism, richer than `edit_file`'s single string replace. Supports
1751/// `Add File`, `Delete File`, and `Update File` (with `-`/`+`/context hunks and
1752/// an optional `*** Move to:` rename) in one atomic-ish call.
1753pub struct ApplyPatchTool;
1754
1755#[derive(Deserialize)]
1756struct ApplyPatchArgs {
1757    /// The full `*** Begin Patch … *** End Patch` text.
1758    patch: String,
1759}
1760
1761/// One file operation parsed from a patch.
1762enum PatchOp {
1763    Add {
1764        path: String,
1765        body: String,
1766    },
1767    Delete {
1768        path: String,
1769    },
1770    Update {
1771        path: String,
1772        move_to: Option<String>,
1773        hunks: Vec<Hunk>,
1774    },
1775}
1776
1777/// A single update hunk: lines to match (context + removed) and the replacement
1778/// (context + added), in order. `anchor` is the optional text after the `@@`
1779/// header — it scopes where the hunk applies (and where a pure insertion goes).
1780#[derive(Default)]
1781struct Hunk {
1782    old: Vec<String>,
1783    new: Vec<String>,
1784    anchor: Option<String>,
1785}
1786
1787fn parse_patch(patch: &str) -> Result<Vec<PatchOp>> {
1788    let err = |m: &str| Error::tool("apply_patch", m.to_string());
1789    let lines: Vec<&str> = patch.lines().collect();
1790    let mut i = 0;
1791    // Skip to Begin Patch.
1792    while i < lines.len() && lines[i].trim() != "*** Begin Patch" {
1793        i += 1;
1794    }
1795    if i == lines.len() {
1796        return Err(err("missing '*** Begin Patch'"));
1797    }
1798    i += 1;
1799
1800    let mut ops = Vec::new();
1801    while i < lines.len() {
1802        let line = lines[i];
1803        let t = line.trim_end();
1804        if t == "*** End Patch" {
1805            return Ok(ops);
1806        } else if let Some(p) = t.strip_prefix("*** Add File: ") {
1807            i += 1;
1808            let mut body = Vec::new();
1809            while i < lines.len() && lines[i].starts_with('+') {
1810                body.push(&lines[i][1..]);
1811                i += 1;
1812            }
1813            ops.push(PatchOp::Add {
1814                path: p.to_string(),
1815                body: body.join("\n"),
1816            });
1817        } else if let Some(p) = t.strip_prefix("*** Delete File: ") {
1818            ops.push(PatchOp::Delete {
1819                path: p.to_string(),
1820            });
1821            i += 1;
1822        } else if let Some(p) = t.strip_prefix("*** Update File: ") {
1823            i += 1;
1824            let mut move_to = None;
1825            if i < lines.len() {
1826                if let Some(m) = lines[i].trim_end().strip_prefix("*** Move to: ") {
1827                    move_to = Some(m.to_string());
1828                    i += 1;
1829                }
1830            }
1831            let mut hunks = Vec::new();
1832            let mut cur = Hunk::default();
1833            let mut started = false;
1834            while i < lines.len() {
1835                let l = lines[i];
1836                let lt = l.trim_end();
1837                if lt.starts_with("*** ") {
1838                    break; // next section
1839                }
1840                if let Some(anchor) = lt.strip_prefix("@@") {
1841                    if started && (!cur.old.is_empty() || !cur.new.is_empty()) {
1842                        hunks.push(std::mem::take(&mut cur));
1843                    }
1844                    // The text after `@@` (e.g. `@@ def foo():`) anchors the hunk.
1845                    let anchor = anchor.trim();
1846                    cur.anchor = (!anchor.is_empty()).then(|| anchor.to_string());
1847                    started = true;
1848                    i += 1;
1849                    continue;
1850                }
1851                started = true;
1852                if let Some(rest) = l.strip_prefix('+') {
1853                    cur.new.push(rest.to_string());
1854                } else if let Some(rest) = l.strip_prefix('-') {
1855                    cur.old.push(rest.to_string());
1856                } else {
1857                    // context line (leading space, or bare)
1858                    let ctx = l.strip_prefix(' ').unwrap_or(l).to_string();
1859                    cur.old.push(ctx.clone());
1860                    cur.new.push(ctx);
1861                }
1862                i += 1;
1863            }
1864            if !cur.old.is_empty() || !cur.new.is_empty() {
1865                hunks.push(cur);
1866            }
1867            ops.push(PatchOp::Update {
1868                path: p.to_string(),
1869                move_to,
1870                hunks,
1871            });
1872        } else {
1873            // Stray line between sections — skip.
1874            i += 1;
1875        }
1876    }
1877    Err(err("missing '*** End Patch'"))
1878}
1879
1880/// P5-1 F4 (Fable-5 adversarial review): every file path an `apply_patch`
1881/// envelope's operations target — an `Add`/`Delete`/`Update` op's own
1882/// `path`, plus an `Update`'s `*** Move to:` destination when present.
1883/// `crate::agent`'s permissions gate uses this to check a patch's write
1884/// surface against `protected_paths` rules BEFORE the patch is applied —
1885/// module 13's protection previously only ever reached `read()`/`write()`
1886/// pseudo-tool calls, never an `apply_patch` envelope (whose args carry a
1887/// patch BODY, not a path, so the gate's ordinary `args.get("path")` lookup
1888/// never fires for it). Reuses [`parse_patch`] rather than re-deriving the
1889/// envelope grammar — one parser, one source of truth. `Err` propagates a
1890/// malformed envelope so the gate can fail closed on it too (never silently
1891/// skip the check just because the patch didn't parse).
1892pub(crate) fn patch_target_paths(patch: &str) -> Result<Vec<String>> {
1893    let ops = parse_patch(patch)?;
1894    let mut paths = Vec::with_capacity(ops.len());
1895    for op in ops {
1896        match op {
1897            PatchOp::Add { path, .. } | PatchOp::Delete { path } => paths.push(path),
1898            PatchOp::Update { path, move_to, .. } => {
1899                paths.push(path);
1900                if let Some(m) = move_to {
1901                    paths.push(m);
1902                }
1903            }
1904        }
1905    }
1906    Ok(paths)
1907}
1908
1909fn apply_update(original: &str, hunks: &[Hunk], tool: &str) -> Result<String> {
1910    let mut text = original.to_string();
1911    for h in hunks {
1912        // Resolve the `@@` anchor (if any) to a byte offset to search from, so
1913        // the hunk applies at the right place and an ambiguous old-block isn't
1914        // matched at the wrong (first) occurrence.
1915        let from = match &h.anchor {
1916            Some(a) => {
1917                let Some(pos) = text.find(a.as_str()) else {
1918                    return Err(Error::tool(tool, format!("@@ anchor not found: {a}")));
1919                };
1920                // Start just past the end of the anchor's line.
1921                text[pos..]
1922                    .find('\n')
1923                    .map(|nl| pos + nl + 1)
1924                    .unwrap_or(text.len())
1925            }
1926            None => 0,
1927        };
1928
1929        let new_block = h.new.join("\n");
1930
1931        if h.old.is_empty() {
1932            // Pure insertion. With an anchor, insert right after it; otherwise
1933            // append at EOF (the only sensible place with no location info).
1934            if h.anchor.is_some() {
1935                let needs_lead_nl = from > 0 && text.as_bytes()[from - 1] != b'\n';
1936                let payload = if needs_lead_nl {
1937                    format!("\n{new_block}\n")
1938                } else {
1939                    format!("{new_block}\n")
1940                };
1941                text.insert_str(from, &payload);
1942            } else {
1943                if !text.is_empty() && !text.ends_with('\n') {
1944                    text.push('\n');
1945                }
1946                text.push_str(&new_block);
1947            }
1948            continue;
1949        }
1950
1951        let old_block = h.old.join("\n");
1952        let region = &text[from..];
1953        let count = region.matches(&old_block).count();
1954        match count {
1955            0 => {
1956                return Err(Error::tool(
1957                    tool,
1958                    format!("hunk did not match file contents:\n{old_block}"),
1959                ))
1960            }
1961            1 => {
1962                let rel = region.find(&old_block).unwrap();
1963                let start = from + rel;
1964                text.replace_range(start..start + old_block.len(), &new_block);
1965            }
1966            _ => {
1967                return Err(Error::tool(
1968                    tool,
1969                    format!(
1970                        "hunk matches file contents {count} times; add more context lines or a more specific @@ anchor to disambiguate:\n{old_block}"
1971                    ),
1972                ))
1973            }
1974        }
1975    }
1976    Ok(text)
1977}
1978
1979#[async_trait]
1980impl Tool for ApplyPatchTool {
1981    fn name(&self) -> &str {
1982        "apply_patch"
1983    }
1984    fn description(&self) -> &str {
1985        "Apply a patch in the apply_patch envelope format (*** Begin Patch / *** End Patch) with Add File, Delete File, and Update File operations. Update hunks use leading '+'/'-'/' ' on each line and may include '@@' context headers and an optional '*** Move to:' rename."
1986    }
1987    fn parameters(&self) -> Value {
1988        json!({
1989            "type": "object",
1990            "properties": {
1991                "patch": {"type": "string", "description": "The full *** Begin Patch … *** End Patch text."}
1992            },
1993            "required": ["patch"],
1994            "additionalProperties": false
1995        })
1996    }
1997    async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<String> {
1998        let a: ApplyPatchArgs = parse_args(self.name(), args)?;
1999        let ops = parse_patch(&a.patch)?;
2000        let mut summary = Vec::new();
2001        // P5-11 (§2 modules 28/29, C10): `apply_patch` is multi-file, so
2002        // annotations from every op's `after_write` are collected here and
2003        // appended once at the end (rather than interleaved into `summary`)
2004        // — keeps the per-op summary lines a clean, stable format while
2005        // still surfacing every formatter diff-back / LSP diagnostics block
2006        // the model needs to see.
2007        let mut annotations: Vec<String> = Vec::new();
2008        for op in ops {
2009            match op {
2010                PatchOp::Add { path, body } => {
2011                    let full = ctx.resolve(&path);
2012                    ctx.check_write(&full)?;
2013                    // P5-9 (D-5 seam): capture BEFORE each op's own mutation
2014                    // — `apply_patch` is multi-file, so this fires once per
2015                    // path actually touched, not once for the whole call.
2016                    if let Some(obs) = &ctx.write_observer {
2017                        obs.before_write(&full).await;
2018                    }
2019                    if let Some(parent) = full.parent() {
2020                        tokio::fs::create_dir_all(parent).await.ok();
2021                    }
2022                    tokio::fs::write(&full, body.as_bytes())
2023                        .await
2024                        .map_err(|e| {
2025                            Error::tool(self.name(), format!("{}: {e}", full.display()))
2026                        })?;
2027                    if let Some(obs) = &ctx.write_observer {
2028                        if let Some(note) = obs.after_write(&full).await {
2029                            annotations.push(note);
2030                        }
2031                    }
2032                    summary.push(format!("A {}", rel(ctx, &full)));
2033                }
2034                PatchOp::Delete { path } => {
2035                    let full = ctx.resolve(&path);
2036                    ctx.check_write(&full)?;
2037                    if let Some(obs) = &ctx.write_observer {
2038                        obs.before_write(&full).await;
2039                    }
2040                    tokio::fs::remove_file(&full).await.map_err(|e| {
2041                        Error::tool(self.name(), format!("{}: {e}", full.display()))
2042                    })?;
2043                    if let Some(obs) = &ctx.write_observer {
2044                        // A deleted file has nothing to format/diagnose —
2045                        // still call the hook (some future observer might
2046                        // care about deletions) but a formatter/lsp
2047                        // observer's `after_write` is a no-op on a path
2048                        // that no longer exists, so this is never expected
2049                        // to produce an annotation in practice.
2050                        if let Some(note) = obs.after_write(&full).await {
2051                            annotations.push(note);
2052                        }
2053                    }
2054                    summary.push(format!("D {}", rel(ctx, &full)));
2055                }
2056                PatchOp::Update {
2057                    path,
2058                    move_to,
2059                    hunks,
2060                } => {
2061                    let full = ctx.resolve(&path);
2062                    let dest_for_check = move_to
2063                        .as_ref()
2064                        .map(|m| ctx.resolve(m))
2065                        .unwrap_or_else(|| full.clone());
2066                    ctx.check_write(&dest_for_check)?;
2067                    // Capture BOTH the source (`full` — read then possibly
2068                    // deleted on a move) and, when a move targets a
2069                    // DIFFERENT path, the destination's own pre-image too
2070                    // (it may already exist and be about to be overwritten).
2071                    if let Some(obs) = &ctx.write_observer {
2072                        obs.before_write(&full).await;
2073                        if dest_for_check != full {
2074                            obs.before_write(&dest_for_check).await;
2075                        }
2076                    }
2077                    let original = tokio::fs::read_to_string(&full).await.map_err(|e| {
2078                        Error::tool(self.name(), format!("{}: {e}", full.display()))
2079                    })?;
2080                    let updated = apply_update(&original, &hunks, self.name())?;
2081                    let dest = match &move_to {
2082                        Some(m) => ctx.resolve(m),
2083                        None => full.clone(),
2084                    };
2085                    if let Some(parent) = dest.parent() {
2086                        tokio::fs::create_dir_all(parent).await.ok();
2087                    }
2088                    tokio::fs::write(&dest, updated.as_bytes())
2089                        .await
2090                        .map_err(|e| {
2091                            Error::tool(self.name(), format!("{}: {e}", dest.display()))
2092                        })?;
2093                    if move_to.is_some() && dest != full {
2094                        tokio::fs::remove_file(&full).await.ok();
2095                        if let Some(obs) = &ctx.write_observer {
2096                            if let Some(note) = obs.after_write(&dest).await {
2097                                annotations.push(note);
2098                            }
2099                        }
2100                        summary.push(format!("M {} -> {}", rel(ctx, &full), rel(ctx, &dest)));
2101                    } else {
2102                        if let Some(obs) = &ctx.write_observer {
2103                            if let Some(note) = obs.after_write(&full).await {
2104                                annotations.push(note);
2105                            }
2106                        }
2107                        summary.push(format!("U {}", rel(ctx, &full)));
2108                    }
2109                }
2110            }
2111        }
2112        let annotation = if annotations.is_empty() {
2113            String::new()
2114        } else {
2115            format!("\n\n{}", annotations.join("\n\n"))
2116        };
2117        if summary.is_empty() {
2118            Ok("(empty patch)".to_string())
2119        } else {
2120            Ok(format!(
2121                "Applied patch:\n{}{annotation}",
2122                summary.join("\n")
2123            ))
2124        }
2125    }
2126}
2127
2128// ---- persistent shell -----------------------------------------------------
2129
2130use tokio::io::{AsyncReadExt, AsyncWriteExt};
2131use tokio::sync::Mutex as AsyncMutex;
2132
2133/// A long-lived shell whose state (working directory, environment variables,
2134/// shell functions) persists across calls — unlike the one-shot [`BashTool`].
2135/// Also supports `write_stdin` to feed raw input to the shell, for driving
2136/// interactive programs. This is the analog of Codex's persistent exec session.
2137///
2138/// Known limitation (documented, not fixed — see UX-29.md "Known
2139/// limitations"): if a REPL turn is hard-cancelled (`race_ctrl_c` in
2140/// `crates/cli/src/main.rs`) while `execute()` below is mid-`.await` waiting
2141/// for the completion sentinel, only that `.await` is dropped — the `child`
2142/// held in `ShellState` is untouched (by design: it must survive to serve
2143/// the NEXT call in this REPL session) and keeps running the in-flight
2144/// command to completion on its own schedule. The cancelled command's
2145/// eventual output (and sentinel line) still lands in the shared stdout
2146/// pipe, so the next `shell` call in the same session can either block
2147/// behind the stale command finishing or read a garbage prefix ahead of its
2148/// own sentinel. This is no worse than the pre-existing hard-kill behavior
2149/// this ticket replaced (which would have torn down the whole process), and
2150/// the on-disk session file itself is unaffected — only the persistent
2151/// shell's own child-process state can go stale.
2152pub struct PersistentShellTool {
2153    state: AsyncMutex<Option<ShellState>>,
2154    default_timeout_ms: u64,
2155}
2156
2157impl Default for PersistentShellTool {
2158    fn default() -> Self {
2159        PersistentShellTool {
2160            state: AsyncMutex::new(None),
2161            default_timeout_ms: DEFAULT_BASH_TIMEOUT_MS,
2162        }
2163    }
2164}
2165
2166struct ShellState {
2167    // Held to keep the shell process alive for the tool's lifetime; dropping it
2168    // would terminate the persistent shell.
2169    #[allow(dead_code)]
2170    child: tokio::process::Child,
2171    stdin: tokio::process::ChildStdin,
2172    stdout: tokio::io::BufReader<tokio::process::ChildStdout>,
2173}
2174
2175const SHELL_SENTINEL: &str = "__SC_SHELL_DONE__";
2176
2177use std::sync::atomic::{AtomicU64, Ordering};
2178
2179static SHELL_SENTINEL_SEQ: AtomicU64 = AtomicU64::new(0);
2180
2181/// Per-invocation completion sentinel: base string + unguessable hex token.
2182/// RandomState is seeded from OS entropy once per process; hashing
2183/// (pid, per-call counter, wall-clock nanos) through it yields a token a
2184/// user command cannot predict, with no new dependencies.
2185fn shell_sentinel() -> String {
2186    use std::hash::BuildHasher;
2187    let seq = SHELL_SENTINEL_SEQ.fetch_add(1, Ordering::Relaxed);
2188    let nanos = std::time::SystemTime::now()
2189        .duration_since(std::time::UNIX_EPOCH)
2190        .map(|d| d.as_nanos())
2191        .unwrap_or(0);
2192    let hash =
2193        std::collections::hash_map::RandomState::new().hash_one((std::process::id(), seq, nanos));
2194    format!("{SHELL_SENTINEL}_{hash:016x}{seq:04x}")
2195}
2196
2197#[derive(Deserialize)]
2198struct ShellArgs {
2199    #[serde(default)]
2200    command: Option<String>,
2201    #[serde(default)]
2202    write_stdin: Option<String>,
2203    #[serde(default)]
2204    timeout_ms: Option<u64>,
2205}
2206
2207impl PersistentShellTool {
2208    async fn ensure_started(
2209        &self,
2210        state: &mut Option<ShellState>,
2211        ctx: &ToolContext,
2212    ) -> Result<()> {
2213        if state.is_some() {
2214            return Ok(());
2215        }
2216        let mut child = build_sandboxed_interactive_sh(ctx)?
2217            .current_dir(&ctx.cwd)
2218            .stdin(std::process::Stdio::piped())
2219            .stdout(std::process::Stdio::piped())
2220            .stderr(std::process::Stdio::piped())
2221            // Don't leave the shell (or its sandbox-exec wrapper) running if the
2222            // tool is dropped without an explicit shutdown.
2223            .kill_on_drop(true)
2224            .spawn()
2225            .map_err(|e| Error::tool("shell", format!("spawn sh: {e}")))?;
2226        let stdin = child
2227            .stdin
2228            .take()
2229            .ok_or_else(|| Error::tool("shell", "no stdin"))?;
2230        let stdout = tokio::io::BufReader::new(
2231            child
2232                .stdout
2233                .take()
2234                .ok_or_else(|| Error::tool("shell", "no stdout"))?,
2235        );
2236        *state = Some(ShellState {
2237            child,
2238            stdin,
2239            stdout,
2240        });
2241        Ok(())
2242    }
2243}
2244
2245#[async_trait]
2246impl Tool for PersistentShellTool {
2247    fn name(&self) -> &str {
2248        "shell"
2249    }
2250    fn description(&self) -> &str {
2251        "Run a command in a PERSISTENT shell whose working directory, environment, and shell functions survive across calls (unlike one-shot bash). Pass `command` to run to completion (returns exit code), or `write_stdin` to feed raw input to the shell (for interactive programs)."
2252    }
2253    fn parameters(&self) -> Value {
2254        json!({
2255            "type": "object",
2256            "properties": {
2257                "command": {"type": "string", "description": "Command to run in the persistent shell."},
2258                "write_stdin": {"type": "string", "description": "Raw text to write to the shell's stdin instead of running a command."},
2259                "timeout_ms": {"type": "integer", "description": "Timeout in milliseconds (default 120000)."}
2260            },
2261            "additionalProperties": false
2262        })
2263    }
2264    async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<String> {
2265        let a: ShellArgs = parse_args(self.name(), args)?;
2266        let timeout = Duration::from_millis(a.timeout_ms.unwrap_or(self.default_timeout_ms));
2267        let mut guard = self.state.lock().await;
2268        self.ensure_started(&mut guard, ctx).await?;
2269        let st = guard.as_mut().expect("started");
2270
2271        if let Some(input) = a.write_stdin {
2272            st.stdin
2273                .write_all(input.as_bytes())
2274                .await
2275                .map_err(|e| Error::tool(self.name(), format!("write_stdin: {e}")))?;
2276            st.stdin.flush().await.ok();
2277            // Best-effort: read whatever output arrives within a short window.
2278            let out = read_available(&mut st.stdout, Duration::from_millis(800)).await;
2279            return Ok(if out.is_empty() {
2280                "(no output)".into()
2281            } else {
2282                out
2283            });
2284        }
2285
2286        let command = a
2287            .command
2288            .ok_or_else(|| Error::tool(self.name(), "provide `command` or `write_stdin`"))?;
2289        // Wrap so the command's stderr merges into stdout, then emit a sentinel
2290        // line carrying the exit code. The sentinel is unguessable per-call so a
2291        // command that echoes the base string can't spoof completion.
2292        let sentinel = shell_sentinel();
2293        let wrapped = format!("{{ {command}\n}} 2>&1\nprintf '%s %d\\n' '{sentinel}' \"$?\"\n");
2294        st.stdin
2295            .write_all(wrapped.as_bytes())
2296            .await
2297            .map_err(|e| Error::tool(self.name(), format!("write: {e}")))?;
2298        st.stdin.flush().await.ok();
2299
2300        // Accumulate bytes until the sentinel line appears. Byte-based (not
2301        // line-based) so a flush that splits mid-line can't stall us.
2302        let mut acc = String::new();
2303        let mut code = -1;
2304        let read_fut = async {
2305            let mut chunk = [0u8; 4096];
2306            loop {
2307                let n = st.stdout.read(&mut chunk).await.unwrap_or(0);
2308                if n == 0 {
2309                    break; // EOF
2310                }
2311                acc.push_str(&String::from_utf8_lossy(&chunk[..n]));
2312                if let Some(pos) = acc.find(&sentinel) {
2313                    let after = &acc[pos + sentinel.len()..];
2314                    if let Some(nl) = after.find('\n') {
2315                        code = after[..nl].trim().parse().unwrap_or(-1);
2316                        acc.truncate(pos);
2317                        break;
2318                    }
2319                }
2320            }
2321        };
2322        if tokio::time::timeout(timeout, read_fut).await.is_err() {
2323            return Err(Error::tool(
2324                self.name(),
2325                format!("command timed out after {timeout:?}"),
2326            ));
2327        }
2328        let output = if acc.trim().is_empty() {
2329            "(no output)".to_string()
2330        } else {
2331            acc.trim_end().to_string()
2332        };
2333        Ok(format!("exit code: {code}\n{output}"))
2334    }
2335}
2336
2337/// Read whatever bytes are available on a reader within `window`, returning the
2338/// decoded text. Used by `write_stdin` where there is no completion sentinel.
2339async fn read_available<R: AsyncReadExt + Unpin>(reader: &mut R, window: Duration) -> String {
2340    let mut buf = Vec::new();
2341    let mut chunk = [0u8; 4096];
2342    loop {
2343        match tokio::time::timeout(window, reader.read(&mut chunk)).await {
2344            Ok(Ok(0)) => break, // EOF
2345            Ok(Ok(n)) => buf.extend_from_slice(&chunk[..n]),
2346            Ok(Err(_)) => break,
2347            Err(_) => break, // window elapsed
2348        }
2349    }
2350    String::from_utf8_lossy(&buf).into_owned()
2351}
2352
2353// ---- update_plan ----------------------------------------------------------
2354
2355/// A simple plan / task tracker. The model calls it to record or update a
2356/// checklist of steps (the analog of Codex `update_plan` / Claude's plan mode).
2357///
2358/// BP-8 (catalog:156 "Todos/plan persisted per session"): the plan itself
2359/// lives on [`ToolContext::plan`], not in this tool. A tool-local mutex was
2360/// unreadable by the agent, so `[capabilities.todos] persist = true` had
2361/// nothing to persist and a plan died with the process; on the shared
2362/// context, the agent journals every change and a resume restores it.
2363#[derive(Default)]
2364pub struct UpdatePlanTool;
2365
2366#[derive(Deserialize, Clone)]
2367struct PlanStep {
2368    step: String,
2369    #[serde(default = "default_status")]
2370    status: String,
2371}
2372fn default_status() -> String {
2373    "pending".to_string()
2374}
2375
2376#[derive(Deserialize)]
2377struct PlanArgs {
2378    plan: Vec<PlanStep>,
2379}
2380
2381#[async_trait]
2382impl Tool for UpdatePlanTool {
2383    fn name(&self) -> &str {
2384        "update_plan"
2385    }
2386    fn description(&self) -> &str {
2387        "Record or update the task plan: a checklist of steps with statuses (pending/in_progress/completed). Replaces the current plan. Use it to track multi-step work."
2388    }
2389    fn parameters(&self) -> Value {
2390        json!({
2391            "type": "object",
2392            "properties": {
2393                "plan": {
2394                    "type": "array",
2395                    "items": {
2396                        "type": "object",
2397                        "properties": {
2398                            "step": {"type": "string"},
2399                            "status": {"type": "string", "enum": ["pending", "in_progress", "completed"]}
2400                        },
2401                        "required": ["step"]
2402                    }
2403                }
2404            },
2405            "required": ["plan"],
2406            "additionalProperties": false
2407        })
2408    }
2409    async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<String> {
2410        let a: PlanArgs = parse_args(self.name(), args)?;
2411        ctx.set_plan(
2412            a.plan
2413                .iter()
2414                .map(|s| crate::session_journal::PlanEntry {
2415                    step: s.step.clone(),
2416                    status: s.status.clone(),
2417                })
2418                .collect(),
2419        );
2420        let rendered = a
2421            .plan
2422            .iter()
2423            .map(|s| {
2424                let mark = match s.status.as_str() {
2425                    "completed" => "[x]",
2426                    "in_progress" => "[~]",
2427                    _ => "[ ]",
2428                };
2429                format!("{mark} {}", s.step)
2430            })
2431            .collect::<Vec<_>>()
2432            .join("\n");
2433        Ok(if rendered.is_empty() {
2434            "(empty plan)".into()
2435        } else {
2436            format!("Plan updated:\n{rendered}")
2437        })
2438    }
2439}
2440
2441/// P4c-review (LOW follow-up): render a `reqwest::Error` together with its
2442/// full `source()` chain. `reqwest::Error`'s own `Display` for a redirect
2443/// blocked by [`network_checked_redirect_policy`] shows only "error
2444/// following redirect for url (...)" — the actually useful reason (this
2445/// crate's own "host `x` is denied by the active network policy" message)
2446/// lives one level down the `source()` chain and would otherwise be
2447/// silently dropped, leaving a vague tool error for the model to act on.
2448fn describe_reqwest_error(e: &reqwest::Error) -> String {
2449    let mut out = e.to_string();
2450    let mut source = std::error::Error::source(e);
2451    while let Some(s) = source {
2452        out.push_str(": ");
2453        out.push_str(&s.to_string());
2454        source = s.source();
2455    }
2456    out
2457}
2458
2459// ---- web_fetch / web_search (S2 module 5 `tools.web`) --------------------
2460
2461/// P4c (S2 module 5 `tools.web`, S4a "trivially addable... single tool
2462/// each, no loop changes"): fetch a URL and return it AS MARKDOWN (BP-2,
2463/// catalog:44 "Fetch a URL, convert to markdown, return to model"), served
2464/// from an on-disk cache within [`WEB_FETCH_TTL_SECS`] of the last fetch —
2465/// CC's WebFetch is markdown-converting and cached, and a model that
2466/// re-fetches the same page mid-task should not pay for it twice. NOT
2467/// registered by default — only reached via `[capabilities.tools_web]`
2468/// (module 5), off by default (§3.1). SECURITY (S2.1 S17): honors
2469/// `ToolContext::network_policy` when one is configured (see
2470/// `ToolContext::check_network`) — a caller (SDK embedder) that has set up
2471/// a sandbox/network policy on the context gets it enforced here too; with
2472/// no policy configured (today's honest default), the fetch is
2473/// unrestricted, same posture as every other network-capable path in this
2474/// crate today (C3).
2475pub struct WebFetchTool;
2476
2477/// Cap the fetched body at this many bytes before returning it to the model
2478/// — the same order of magnitude as `MAX_READ_BYTES`.
2479const MAX_FETCH_BYTES: usize = 200_000;
2480
2481/// BP-2: how long a cached fetch stays servable. CC's WebFetch keeps a
2482/// self-cleaning 15-minute cache; same window.
2483pub(crate) const WEB_FETCH_TTL_SECS: u64 = 900;
2484
2485/// BP-2: directory override for the fetch cache (tests, and an operator who
2486/// wants it somewhere specific).
2487pub const WEB_CACHE_DIR_ENV: &str = "SUPERCODE_WEB_CACHE_DIR";
2488
2489/// BP-2: where cached fetches live — `$SUPERCODE_WEB_CACHE_DIR`, else
2490/// `$SUPERCODE_HOME`/`~/.supercode/web-cache`, else a temp dir. Same
2491/// resolution ladder as `runtime::supercode_runtime_root`.
2492pub(crate) fn web_cache_root() -> PathBuf {
2493    if let Some(dir) = std::env::var_os(WEB_CACHE_DIR_ENV) {
2494        return PathBuf::from(dir);
2495    }
2496    std::env::var_os("SUPERCODE_HOME")
2497        .map(PathBuf::from)
2498        .or_else(|| {
2499            std::env::var_os("HOME")
2500                .map(PathBuf::from)
2501                .map(|home| home.join(".supercode"))
2502        })
2503        .unwrap_or_else(|| std::env::temp_dir().join("supercode"))
2504        .join("web-cache")
2505}
2506
2507fn web_cache_path(url: &str) -> PathBuf {
2508    web_cache_root().join(format!("{}.md", blake3::hash(url.as_bytes()).to_hex()))
2509}
2510
2511fn unix_secs() -> u64 {
2512    std::time::SystemTime::now()
2513        .duration_since(std::time::UNIX_EPOCH)
2514        .map(|d| d.as_secs())
2515        .unwrap_or(0)
2516}
2517
2518/// BP-2: a still-fresh cached body for `url`, with its age in seconds.
2519///
2520/// The entry's first line is `<unix-secs> <url>`; the url is stored so a
2521/// hash collision (or a hand-edited cache dir) can never serve one page's
2522/// content under another's name.
2523pub(crate) fn web_cache_get(url: &str) -> Option<(String, u64)> {
2524    let text = std::fs::read_to_string(web_cache_path(url)).ok()?;
2525    let (header, body) = text.split_once('\n')?;
2526    let (stamp, cached_url) = header.split_once(' ')?;
2527    if cached_url != url {
2528        return None;
2529    }
2530    let stamped: u64 = stamp.parse().ok()?;
2531    let age = unix_secs().saturating_sub(stamped);
2532    if age > WEB_FETCH_TTL_SECS {
2533        return None;
2534    }
2535    Some((body.to_string(), age))
2536}
2537
2538/// BP-2: record `body` as `url`'s cached rendering. Best-effort — a cache
2539/// that cannot be written must never fail the fetch.
2540pub(crate) fn web_cache_put(url: &str, body: &str) {
2541    let path = web_cache_path(url);
2542    if let Some(parent) = path.parent() {
2543        let _ = std::fs::create_dir_all(parent);
2544    }
2545    let _ = std::fs::write(path, format!("{} {url}\n{body}", unix_secs()));
2546}
2547
2548/// BP-2: does this response look like HTML (so it should be converted)?
2549fn is_html_response(content_type: Option<&str>, body: &str) -> bool {
2550    if let Some(ct) = content_type {
2551        let ct = ct.to_ascii_lowercase();
2552        if ct.contains("html") {
2553            return true;
2554        }
2555        if ct.contains("json") || ct.contains("text/plain") || ct.contains("markdown") {
2556            return false;
2557        }
2558    }
2559    let head = body.trim_start();
2560    let head = &head[..head.len().min(512)].to_ascii_lowercase();
2561    head.starts_with("<!doctype html") || head.starts_with("<html") || head.contains("<body")
2562}
2563
2564/// BP-2: cut `text` to at most `MAX_FETCH_BYTES` on a char boundary.
2565fn cap_fetch_body(text: &str) -> (&str, bool) {
2566    let mut end = MAX_FETCH_BYTES.min(text.len());
2567    while end > 0 && !text.is_char_boundary(end) {
2568        end -= 1;
2569    }
2570    (&text[..end], text.len() > MAX_FETCH_BYTES)
2571}
2572
2573#[derive(Deserialize)]
2574struct WebFetchArgs {
2575    url: String,
2576}
2577
2578#[async_trait]
2579impl Tool for WebFetchTool {
2580    fn name(&self) -> &str {
2581        "web_fetch"
2582    }
2583    fn description(&self) -> &str {
2584        "Fetch a URL over HTTP(S) and return its content as markdown (HTML is converted; other content types are returned as text, truncated if large). Recent fetches of the same URL are served from a local cache."
2585    }
2586    fn parameters(&self) -> Value {
2587        json!({
2588            "type": "object",
2589            "properties": {
2590                "url": {"type": "string", "description": "The http:// or https:// URL to fetch."}
2591            },
2592            "required": ["url"],
2593            "additionalProperties": false
2594        })
2595    }
2596    async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<String> {
2597        let a: WebFetchArgs = parse_args(self.name(), args)?;
2598        ctx.check_network(&a.url)?;
2599        if !a.url.starts_with("http://") && !a.url.starts_with("https://") {
2600            return Err(Error::tool(self.name(), "url must be http:// or https://"));
2601        }
2602        // BP-2 (catalog:44 "cached"): a repeat fetch inside the TTL is
2603        // served from disk. The notice says so, so the model can tell a
2604        // cached page from a live one.
2605        if let Some((body, age)) = web_cache_get(&a.url) {
2606            return Ok(format!("[web_fetch: cached {age}s ago]\n{body}"));
2607        }
2608        // P4c-review (MEDIUM/LOW follow-up): re-run the same host check on
2609        // every redirect hop, not just this initial url — closes the SSRF
2610        // gap where a denied host reachable only via an allowed host's HTTP
2611        // redirect would otherwise slip past `check_network` above (see
2612        // `network_checked_redirect_policy`'s doc comment).
2613        let client = reqwest::Client::builder()
2614            .timeout(Duration::from_secs(30))
2615            .redirect(network_checked_redirect_policy(
2616                ctx.network_policy.clone(),
2617                ctx.permission_rules.clone(),
2618            ))
2619            .build()
2620            .map_err(|e| Error::tool(self.name(), e.to_string()))?;
2621        let resp = client.get(&a.url).send().await.map_err(|e| {
2622            Error::tool(
2623                self.name(),
2624                format!("fetch failed: {}", describe_reqwest_error(&e)),
2625            )
2626        })?;
2627        let status = resp.status();
2628        let content_type = resp
2629            .headers()
2630            .get(reqwest::header::CONTENT_TYPE)
2631            .and_then(|v| v.to_str().ok())
2632            .map(|v| v.to_string());
2633        let body = resp
2634            .text()
2635            .await
2636            .map_err(|e| Error::tool(self.name(), format!("failed to read response body: {e}")))?;
2637        // BP-2: HTML becomes markdown; anything else is returned as-is —
2638        // converting JSON or plain text would destroy it.
2639        let (rendered, converted) = if is_html_response(content_type.as_deref(), &body) {
2640            (crate::tools::convert::html_to_markdown(&body), true)
2641        } else {
2642            (body, false)
2643        };
2644        let (shown, truncated) = cap_fetch_body(&rendered);
2645        if status.is_success() {
2646            web_cache_put(&a.url, shown);
2647        }
2648        let form = if converted { "markdown" } else { "text" };
2649        Ok(if truncated {
2650            format!(
2651                "[web_fetch: HTTP {status}; {form}, {} bytes, showing first {}]\n{shown}",
2652                rendered.len(),
2653                shown.len()
2654            )
2655        } else {
2656            format!("[web_fetch: HTTP {status}; {form}]\n{shown}")
2657        })
2658    }
2659}
2660
2661/// P4c (S2 module 5 `tools.web`): perform a web search.
2662///
2663/// BP-2 (catalog:45, "supercode bundles no search backend" closed): the
2664/// tool works out of the box against DuckDuckGo's public HTML endpoint
2665/// ([`DEFAULT_WEB_SEARCH_URL`]) — the same "no key, no account" surface a
2666/// browser gets — and an operator can point it anywhere else with
2667/// [`WEB_SEARCH_URL_ENV`], which keeps its existing `<url>?q=<query>`
2668/// contract. Results are extracted into a numbered title/url/snippet list;
2669/// a page whose markup this does not recognize falls back to the page as
2670/// markdown rather than to a claim of zero hits. A backend that cannot be
2671/// reached says so, naming the endpoint it tried and the override.
2672pub struct WebSearchTool;
2673
2674/// Environment variable naming the search endpoint `WebSearchTool` queries.
2675pub const WEB_SEARCH_URL_ENV: &str = "SUPERCODE_WEB_SEARCH_URL";
2676
2677/// BP-2: the backend used when no operator endpoint is configured.
2678pub const DEFAULT_WEB_SEARCH_URL: &str = "https://html.duckduckgo.com/html/";
2679
2680/// BP-2: the endpoint this call will query, and whether it is the built-in
2681/// default (as opposed to an operator's override).
2682pub(crate) fn web_search_endpoint() -> (String, bool) {
2683    match std::env::var(WEB_SEARCH_URL_ENV) {
2684        Ok(url) if !url.trim().is_empty() => (url, false),
2685        _ => (DEFAULT_WEB_SEARCH_URL.to_string(), true),
2686    }
2687}
2688
2689/// BP-2: HTML endpoints reject an unidentified client; identify honestly.
2690const WEB_SEARCH_USER_AGENT: &str = concat!("supercode/", env!("CARGO_PKG_VERSION"));
2691
2692#[derive(Deserialize)]
2693struct WebSearchArgs {
2694    query: String,
2695}
2696
2697#[async_trait]
2698impl Tool for WebSearchTool {
2699    fn name(&self) -> &str {
2700        "web_search"
2701    }
2702    fn description(&self) -> &str {
2703        "Search the web and return the top results as a numbered list of titles, URLs and snippets."
2704    }
2705    fn parameters(&self) -> Value {
2706        json!({
2707            "type": "object",
2708            "properties": {
2709                "query": {"type": "string", "description": "Search query."}
2710            },
2711            "required": ["query"],
2712            "additionalProperties": false
2713        })
2714    }
2715    async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<String> {
2716        let a: WebSearchArgs = parse_args(self.name(), args)?;
2717        let (endpoint, is_default) = web_search_endpoint();
2718        ctx.check_network(&endpoint)?;
2719        // P4c-review: same redirect-hop re-check as `WebFetchTool` — see
2720        // `network_checked_redirect_policy`'s doc comment.
2721        let client = reqwest::Client::builder()
2722            .timeout(Duration::from_secs(30))
2723            .redirect(network_checked_redirect_policy(
2724                ctx.network_policy.clone(),
2725                ctx.permission_rules.clone(),
2726            ))
2727            .build()
2728            .map_err(|e| Error::tool(self.name(), e.to_string()))?;
2729        let resp = client
2730            .get(&endpoint)
2731            .header(reqwest::header::USER_AGENT, WEB_SEARCH_USER_AGENT)
2732            .query(&[("q", &a.query)])
2733            .send()
2734            .await
2735            .map_err(|e| {
2736                let source = if is_default {
2737                    format!(
2738                        "the built-in search backend ({endpoint}) is unreachable: {}. Set {WEB_SEARCH_URL_ENV} to use a different search endpoint.",
2739                        describe_reqwest_error(&e)
2740                    )
2741                } else {
2742                    format!(
2743                        "the configured search endpoint ({endpoint}, from {WEB_SEARCH_URL_ENV}) is unreachable: {}",
2744                        describe_reqwest_error(&e)
2745                    )
2746                };
2747                Error::tool(self.name(), format!("search failed: {source}"))
2748            })?;
2749        let status = resp.status();
2750        let body = resp
2751            .text()
2752            .await
2753            .map_err(|e| Error::tool(self.name(), format!("failed to read response body: {e}")))?;
2754        let results = crate::tools::convert::parse_html_search_results(&body);
2755        if !results.is_empty() {
2756            return Ok(crate::tools::convert::render_search_results(
2757                &a.query, &results,
2758            ));
2759        }
2760        // Nothing recognizable: hand back what the endpoint actually said,
2761        // as markdown when it is a page, rather than claiming zero hits.
2762        let rendered = if is_html_response(None, &body) {
2763            crate::tools::convert::html_to_markdown(&body)
2764        } else {
2765            body
2766        };
2767        let (shown, _) = cap_fetch_body(&rendered);
2768        Ok(format!(
2769            "[web_search: HTTP {status} from {endpoint}; no recognizable result list — the raw response follows]\n{shown}"
2770        ))
2771    }
2772}