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::{
14    image_mime_for, is_image_path, network_checked_redirect_policy, Tool, ToolContext,
15    MULTIMODAL_IMAGE_MARKER, NOTEBOOK_EXTENSION,
16};
17
18/// `read_file` truncates (never errors) at this many bytes; the agent
19/// additionally caps every tool result at `Config::max_tool_output_bytes`
20/// (default 100 KB), so the model receives `min` of the two — see
21/// `Agent::cap_tool_output`.
22pub(crate) const MAX_READ_BYTES: usize = 400_000;
23const DEFAULT_BASH_TIMEOUT_MS: u64 = 120_000;
24
25fn parse_args<T: DeserializeOwned>(tool: &str, args: Value) -> Result<T> {
26    serde_json::from_value(args).map_err(|e| Error::InvalidArguments {
27        tool: tool.to_string(),
28        message: e.to_string(),
29    })
30}
31
32fn rel(ctx: &ToolContext, p: &Path) -> String {
33    p.strip_prefix(&ctx.cwd)
34        .unwrap_or(p)
35        .to_string_lossy()
36        .into_owned()
37}
38
39/// P4c (S1.2 `core.tools.read_file.multimodal` / `view_image`): hand-rolled
40/// standard base64 (no external dependency — same "small parser over a
41/// crate" precedent as `config::glob_match`/`tools::url_host`). Used ONLY
42/// to build a `data:` URL for an image tool result.
43fn base64_encode(bytes: &[u8]) -> String {
44    const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
45    let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
46    for chunk in bytes.chunks(3) {
47        let b0 = chunk[0];
48        let b1 = chunk.get(1).copied();
49        let b2 = chunk.get(2).copied();
50        out.push(ALPHABET[(b0 >> 2) as usize] as char);
51        out.push(ALPHABET[(((b0 & 0x03) << 4) | (b1.unwrap_or(0) >> 4)) as usize] as char);
52        match b1 {
53            Some(b1) => {
54                out.push(ALPHABET[(((b1 & 0x0f) << 2) | (b2.unwrap_or(0) >> 6)) as usize] as char)
55            }
56            None => out.push('='),
57        }
58        match b2 {
59            Some(b2) => out.push(ALPHABET[(b2 & 0x3f) as usize] as char),
60            None => out.push('='),
61        }
62    }
63    out
64}
65
66/// P4c: build the `MULTIMODAL_IMAGE_MARKER`-prefixed tool result for a
67/// successfully-read image file — `Agent::run_loop` detects this prefix and
68/// turns it into a `content_parts` image block instead of plain text.
69fn image_tool_result(path: &Path, bytes: &[u8]) -> String {
70    let mime = image_mime_for(path);
71    let b64 = base64_encode(bytes);
72    format!("{MULTIMODAL_IMAGE_MARKER}data:{mime};base64,{b64}")
73}
74
75/// P4c (S1.4 `core.nested_instructions`, deferred from P4b): if
76/// [`ToolContext::nested_instructions`] is on and `touched` lives in a
77/// subdirectory (other than `ctx.cwd` itself, already loaded at session
78/// start) that carries its own `CLAUDE.md`/`AGENTS.md` and hasn't been
79/// injected yet this conversation, return a banner to append to the calling
80/// tool's result. Reuses `agent::import_target_is_contained` — the SAME
81/// canonicalize+containment check P4b's `@`-import expansion uses — so a
82/// symlinked subdirectory cannot walk the injection outside `ctx.cwd`.
83fn nested_instructions_notice(ctx: &ToolContext, touched: &Path) -> Option<String> {
84    if !ctx.nested_instructions {
85        return None;
86    }
87    let dir = if touched.is_dir() {
88        touched.to_path_buf()
89    } else {
90        touched.parent()?.to_path_buf()
91    };
92    if !crate::agent::import_target_is_contained(&dir, &ctx.cwd) {
93        return None;
94    }
95    let root = std::fs::canonicalize(&ctx.cwd).unwrap_or_else(|_| ctx.cwd.clone());
96    let real_dir = std::fs::canonicalize(&dir).ok()?;
97    if real_dir == root {
98        // Already loaded globally at session start by `load_project_context`.
99        return None;
100    }
101    let mut found: Option<(PathBuf, String)> = None;
102    for name in ["CLAUDE.md", "AGENTS.md"] {
103        let candidate = dir.join(name);
104        if let Ok(content) = std::fs::read_to_string(&candidate) {
105            found = Some((candidate, content));
106            break;
107        }
108    }
109    let (candidate, content) = found?;
110    {
111        let mut seen = ctx.injected_instruction_dirs.lock().ok()?;
112        if !seen.insert(real_dir) {
113            // Already injected this conversation — deduped (catalog:84).
114            return None;
115        }
116    }
117    let shown = rel(ctx, &candidate);
118    Some(format!(
119        "\n\n[nested instructions from {shown}]\n{}",
120        content.trim()
121    ))
122}
123
124// ---- read -----------------------------------------------------------------
125
126/// Read a UTF-8 text file.
127pub struct ReadFileTool;
128
129#[derive(Deserialize)]
130struct ReadArgs {
131    path: String,
132    #[serde(default)]
133    offset: Option<usize>,
134    #[serde(default)]
135    limit: Option<usize>,
136}
137
138#[async_trait]
139impl Tool for ReadFileTool {
140    fn name(&self) -> &str {
141        "read_file"
142    }
143    fn description(&self) -> &str {
144        "Read the contents of a UTF-8 text file. 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."
145    }
146    fn parameters(&self) -> Value {
147        json!({
148            "type": "object",
149            "properties": {
150                "path": {"type": "string", "description": "File path, absolute or relative to the working directory."},
151                "offset": {"type": "integer", "description": "1-based line to start at."},
152                "limit": {"type": "integer", "description": "Maximum number of lines to return."}
153            },
154            "required": ["path"],
155            "additionalProperties": false
156        })
157    }
158    async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<String> {
159        let a: ReadArgs = parse_args(self.name(), args)?;
160        let path = ctx.resolve(&a.path);
161        let bytes = tokio::fs::read(&path)
162            .await
163            .map_err(|e| Error::tool(self.name(), format!("{}: {e}", path.display())))?;
164        // P4c (S1.2 `core.tools.read_file.multimodal`): a recognized image
165        // file, with the knob on, returns as a model-visible image content
166        // block instead of being lossy-decoded as UTF-8 text. Read-tracking
167        // (below) still applies; nested-instructions injection is skipped
168        // for this branch (the whole result is a machine-parsed marker, not
169        // a place to append prose — see `nested_instructions_notice`'s
170        // caller below).
171        if ctx.multimodal_read && is_image_path(&path) {
172            ctx.mark_read(&path);
173            return Ok(image_tool_result(&path, &bytes));
174        }
175        let text = String::from_utf8_lossy(&bytes);
176        let result = if a.offset.is_none() && a.limit.is_none() {
177            if bytes.len() > MAX_READ_BYTES {
178                let total = bytes.len();
179                // Find the largest char boundary <= MAX_READ_BYTES.
180                let mut end = MAX_READ_BYTES.min(text.len());
181                while end > 0 && !text.is_char_boundary(end) {
182                    end -= 1;
183                }
184                // Prefer to back off further to the previous newline, so the
185                // cut lands on a whole line, as long as one exists in the head.
186                if let Some(nl) = text[..end].rfind('\n') {
187                    end = nl + 1;
188                }
189                let shown = end;
190                let lines = text[..end].matches('\n').count();
191                let notice = format!(
192                    "[read_file: file is {total} bytes; showing first {shown} bytes ({lines} lines). Pass offset/limit to read more.]\n"
193                );
194                notice + &text[..end]
195            } else {
196                text.into_owned()
197            }
198        } else {
199            let start = a.offset.unwrap_or(1).saturating_sub(1);
200            let limit = a.limit.unwrap_or(usize::MAX);
201            let sliced: Vec<&str> = text.lines().skip(start).take(limit).collect();
202            sliced.join("\n")
203        };
204        // P4c (S1.2 `core.tools.edit_file.require_read_before_edit`): record
205        // this read unconditionally (cheap; only ever CONSULTED when the
206        // knob is on — see `ToolContext::mark_read`'s doc comment).
207        ctx.mark_read(&path);
208        let mut result = result;
209        // P4c (S1.4 `core.nested_instructions`).
210        if let Some(notice) = nested_instructions_notice(ctx, &path) {
211            result.push_str(&notice);
212        }
213        Ok(result)
214    }
215}
216
217// ---- view_image -------------------------------------------------------------
218
219/// P4c (S1.2 `view_image`, SPLIT CX row `view_image`, catalog:28): a
220/// dedicated image-input tool, distinct from `read_file`'s `multimodal`
221/// mode — needed as an image pathway when `read_file` itself is disabled
222/// (cx-parity, S12). NOT registered by default; only reachable as the
223/// optional fifth name in `[core.tools] enabled` (see
224/// `ToolRegistry::from_config`).
225pub struct ViewImageTool;
226
227#[derive(Deserialize)]
228struct ViewImageArgs {
229    path: String,
230}
231
232#[async_trait]
233impl Tool for ViewImageTool {
234    fn name(&self) -> &str {
235        "view_image"
236    }
237    fn description(&self) -> &str {
238        "Read a local image file and return it as a model-visible image content block."
239    }
240    fn parameters(&self) -> Value {
241        json!({
242            "type": "object",
243            "properties": {
244                "path": {"type": "string", "description": "Image file path, absolute or relative to the working directory."}
245            },
246            "required": ["path"],
247            "additionalProperties": false
248        })
249    }
250    async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<String> {
251        let a: ViewImageArgs = parse_args(self.name(), args)?;
252        let path = ctx.resolve(&a.path);
253        if !is_image_path(&path) {
254            return Err(Error::tool(
255                self.name(),
256                format!(
257                    "{} is not a recognized image file (expected one of: png, jpg, jpeg, gif, webp, bmp)",
258                    path.display()
259                ),
260            ));
261        }
262        let bytes = tokio::fs::read(&path)
263            .await
264            .map_err(|e| Error::tool(self.name(), format!("{}: {e}", path.display())))?;
265        ctx.mark_read(&path);
266        Ok(image_tool_result(&path, &bytes))
267    }
268}
269
270// ---- write ----------------------------------------------------------------
271
272/// Create or overwrite a file.
273pub struct WriteFileTool;
274
275#[derive(Deserialize)]
276struct WriteArgs {
277    path: String,
278    content: String,
279}
280
281#[async_trait]
282impl Tool for WriteFileTool {
283    fn name(&self) -> &str {
284        "write_file"
285    }
286    fn description(&self) -> &str {
287        "Create or overwrite a file with the given contents. Parent directories are created as needed."
288    }
289    fn parameters(&self) -> Value {
290        json!({
291            "type": "object",
292            "properties": {
293                "path": {"type": "string", "description": "File path to write."},
294                "content": {"type": "string", "description": "Full file contents."}
295            },
296            "required": ["path", "content"],
297            "additionalProperties": false
298        })
299    }
300    async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<String> {
301        let a: WriteArgs = parse_args(self.name(), args)?;
302        let path = ctx.resolve(&a.path);
303        ctx.check_write(&path)?;
304        // P5-9 (D-5 write-path interception seam): pre-image capture
305        // BEFORE the mutation — see `ToolContext::write_observer`'s doc
306        // comment. `None` (checkpoint off, the default) is a no-op.
307        if let Some(obs) = &ctx.write_observer {
308            obs.before_write(&path).await;
309        }
310        if let Some(parent) = path.parent() {
311            tokio::fs::create_dir_all(parent).await.ok();
312        }
313        tokio::fs::write(&path, a.content.as_bytes())
314            .await
315            .map_err(|e| Error::tool(self.name(), format!("{}: {e}", path.display())))?;
316        // P5-11 (§2 modules 28/29, C10): the observer chain's `after_write`
317        // may hand back an annotation (a formatter's diff-back, LSP
318        // diagnostics, or both) — appended to the result text the model
319        // sees, never silently dropped. `None` (both modules off, the
320        // default) leaves this byte-identical to pre-P5-11 behavior.
321        let mut annotation = String::new();
322        if let Some(obs) = &ctx.write_observer {
323            if let Some(note) = obs.after_write(&path).await {
324                annotation = format!("\n\n{note}");
325            }
326        }
327        Ok(format!(
328            "Wrote {} bytes to {}{}",
329            a.content.len(),
330            rel(ctx, &path),
331            annotation
332        ))
333    }
334}
335
336// ---- edit -----------------------------------------------------------------
337
338/// Replace an exact substring in a file.
339pub struct EditFileTool;
340
341#[derive(Deserialize)]
342struct EditArgs {
343    path: String,
344    #[serde(default)]
345    old_string: String,
346    #[serde(default)]
347    new_string: String,
348    #[serde(default)]
349    replace_all: bool,
350    /// P4c (S1.2 `core.tools.edit_file.notebook_aware`): 0-based Jupyter
351    /// cell index. Presence (with `cell_op`) switches this call into the
352    /// notebook cell-surgery branch instead of exact-string replace.
353    #[serde(default)]
354    cell_index: Option<usize>,
355    /// P4c: `"replace"` | `"insert"` | `"delete"`.
356    #[serde(default)]
357    cell_op: Option<String>,
358    /// P4c: the cell's new source text (required for `replace`/`insert`).
359    #[serde(default)]
360    cell_source: Option<String>,
361    /// P4c: cell type for `insert` — `"code"` (default) or `"markdown"`.
362    #[serde(default)]
363    cell_type: Option<String>,
364}
365
366#[async_trait]
367impl Tool for EditFileTool {
368    fn name(&self) -> &str {
369        "edit_file"
370    }
371    fn description(&self) -> &str {
372        "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."
373    }
374    fn parameters(&self) -> Value {
375        json!({
376            "type": "object",
377            "properties": {
378                "path": {"type": "string"},
379                "old_string": {"type": "string", "description": "Exact text to replace."},
380                "new_string": {"type": "string", "description": "Replacement text."},
381                "replace_all": {"type": "boolean", "description": "Replace every occurrence instead of requiring uniqueness."},
382                "cell_index": {"type": "integer", "description": "0-based Jupyter cell index (notebook-aware mode only)."},
383                "cell_op": {"type": "string", "enum": ["replace", "insert", "delete"], "description": "Notebook cell operation (notebook-aware mode only)."},
384                "cell_source": {"type": "string", "description": "New cell source text (notebook-aware `replace`/`insert`)."},
385                "cell_type": {"type": "string", "enum": ["code", "markdown"], "description": "Cell type for notebook-aware `insert` (default `code`)."}
386            },
387            "required": ["path"],
388            "additionalProperties": false
389        })
390    }
391    async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<String> {
392        let a: EditArgs = parse_args(self.name(), args)?;
393        let path = ctx.resolve(&a.path);
394        ctx.check_write(&path)?;
395        // P5-9 (D-5 write-path interception seam): one capture point ahead
396        // of BOTH branches below (notebook cell-surgery and plain-text
397        // replace) — whichever this call takes, the pre-image is captured
398        // exactly once, before either mutates anything.
399        if let Some(obs) = &ctx.write_observer {
400            obs.before_write(&path).await;
401        }
402
403        // P4c (S1.2 `core.tools.edit_file.require_read_before_edit`, UNIQUE
404        // CC row): refuse unless `read_file` has already read this exact
405        // path this conversation. `false` (the default) never consults
406        // `ToolContext::was_read` at all — byte-identical to today.
407        if ctx.require_read_before_edit && !ctx.was_read(&path) {
408            return Err(Error::tool(
409                self.name(),
410                format!(
411                    "{} must be read with `read_file` before it can be edited this conversation",
412                    path.display()
413                ),
414            ));
415        }
416
417        // P4c (S1.2 `core.tools.edit_file.notebook_aware`): a Jupyter
418        // cell-surgery call is routed here BEFORE the exact-string-replace
419        // path — `false` (the default), or a `.ipynb` path with no
420        // `cell_op`, falls straight through unchanged.
421        if ctx.notebook_aware
422            && a.cell_op.is_some()
423            && path.extension().and_then(|e| e.to_str()) == Some(NOTEBOOK_EXTENSION)
424        {
425            let result = edit_notebook_cell(self.name(), ctx, &path, &a).await?;
426            let mut annotation = String::new();
427            if let Some(obs) = &ctx.write_observer {
428                if let Some(note) = obs.after_write(&path).await {
429                    annotation = format!("\n\n{note}");
430                }
431            }
432            return Ok(format!("{result}{annotation}"));
433        }
434
435        if a.old_string.is_empty() {
436            // An empty needle matches at every char boundary; with replace_all
437            // that would interleave new_string through the whole file.
438            return Err(Error::tool(self.name(), "old_string must not be empty"));
439        }
440        let original = tokio::fs::read_to_string(&path)
441            .await
442            .map_err(|e| Error::tool(self.name(), format!("{}: {e}", path.display())))?;
443        let count = original.matches(&a.old_string).count();
444        if count == 0 {
445            return Err(Error::tool(self.name(), "old_string not found in file"));
446        }
447        if count > 1 && !a.replace_all {
448            return Err(Error::tool(
449                self.name(),
450                format!("old_string occurs {count} times; pass replace_all or add more context"),
451            ));
452        }
453        let updated = if a.replace_all {
454            original.replace(&a.old_string, &a.new_string)
455        } else {
456            original.replacen(&a.old_string, &a.new_string, 1)
457        };
458        tokio::fs::write(&path, updated.as_bytes())
459            .await
460            .map_err(|e| Error::tool(self.name(), format!("{}: {e}", path.display())))?;
461        let mut result = format!(
462            "Replaced {} occurrence(s) in {}",
463            if a.replace_all { count } else { 1 },
464            rel(ctx, &path)
465        );
466        // P5-11: see `WriteFileTool::execute`'s matching comment above.
467        if let Some(obs) = &ctx.write_observer {
468            if let Some(note) = obs.after_write(&path).await {
469                result.push_str("\n\n");
470                result.push_str(&note);
471            }
472        }
473        if let Some(notice) = nested_instructions_notice(ctx, &path) {
474            result.push_str(&notice);
475        }
476        Ok(result)
477    }
478}
479
480/// P4c (S1.2 `core.tools.edit_file.notebook_aware`): Jupyter cell
481/// replace/insert/delete over the notebook's `cells` array. The notebook is
482/// parsed/re-serialized as generic JSON (`serde_json::Value`) rather than a
483/// typed nbformat model — the smallest form that satisfies "cell surgery",
484/// matching the catalog's S-sized classification for this row (not a full
485/// nbformat crate/schema).
486async fn edit_notebook_cell(
487    tool_name: &str,
488    ctx: &ToolContext,
489    path: &Path,
490    a: &EditArgs,
491) -> Result<String> {
492    let text = tokio::fs::read_to_string(path)
493        .await
494        .map_err(|e| Error::tool(tool_name, format!("{}: {e}", path.display())))?;
495    let mut doc: Value = serde_json::from_str(&text).map_err(|e| {
496        Error::tool(
497            tool_name,
498            format!("{}: not valid notebook JSON: {e}", path.display()),
499        )
500    })?;
501    let cells = doc
502        .get_mut("cells")
503        .and_then(|c| c.as_array_mut())
504        .ok_or_else(|| Error::tool(tool_name, format!("{}: no `cells` array", path.display())))?;
505    let index = a
506        .cell_index
507        .ok_or_else(|| Error::tool(tool_name, "cell_index is required for notebook cell edits"))?;
508    let op = a.cell_op.as_deref().unwrap_or("replace");
509    let summary = match op {
510        "delete" => {
511            if index >= cells.len() {
512                return Err(Error::tool(
513                    tool_name,
514                    format!("cell_index {index} out of range (0..{})", cells.len()),
515                ));
516            }
517            cells.remove(index);
518            format!("Deleted cell {index}")
519        }
520        "insert" => {
521            let source = a
522                .cell_source
523                .clone()
524                .ok_or_else(|| Error::tool(tool_name, "cell_source is required for insert"))?;
525            let cell_type = a.cell_type.as_deref().unwrap_or("code");
526            let new_cell = json!({
527                "cell_type": cell_type,
528                "metadata": {},
529                "source": [source],
530                "outputs": if cell_type == "code" { json!([]) } else { json!(null) },
531                "execution_count": json!(null),
532            });
533            if index > cells.len() {
534                return Err(Error::tool(
535                    tool_name,
536                    format!("cell_index {index} out of range (0..={})", cells.len()),
537                ));
538            }
539            cells.insert(index, new_cell);
540            format!("Inserted a {cell_type} cell at {index}")
541        }
542        "replace" => {
543            let source = a
544                .cell_source
545                .clone()
546                .ok_or_else(|| Error::tool(tool_name, "cell_source is required for replace"))?;
547            let len = cells.len();
548            let cell = cells.get_mut(index).ok_or_else(|| {
549                Error::tool(
550                    tool_name,
551                    format!("cell_index {index} out of range (0..{len})"),
552                )
553            })?;
554            cell["source"] = json!([source]);
555            format!("Replaced source of cell {index}")
556        }
557        other => {
558            return Err(Error::tool(
559                tool_name,
560                format!("unknown cell_op `{other}` (expected replace|insert|delete)"),
561            ))
562        }
563    };
564    let rendered =
565        serde_json::to_string_pretty(&doc).map_err(|e| Error::tool(tool_name, e.to_string()))?;
566    tokio::fs::write(path, rendered.as_bytes())
567        .await
568        .map_err(|e| Error::tool(tool_name, format!("{}: {e}", path.display())))?;
569    Ok(format!("{summary} in {}", rel(ctx, path)))
570}
571
572// ---- list -----------------------------------------------------------------
573
574/// List directory entries.
575pub struct ListDirTool;
576
577#[derive(Deserialize)]
578struct ListArgs {
579    #[serde(default)]
580    path: Option<String>,
581}
582
583#[async_trait]
584impl Tool for ListDirTool {
585    fn name(&self) -> &str {
586        "list_dir"
587    }
588    fn description(&self) -> &str {
589        "List the entries of a directory (defaults to the working directory). Directories are suffixed with `/`."
590    }
591    fn parameters(&self) -> Value {
592        json!({
593            "type": "object",
594            "properties": {
595                "path": {"type": "string", "description": "Directory to list. Defaults to the working directory."}
596            },
597            "additionalProperties": false
598        })
599    }
600    async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<String> {
601        let a: ListArgs = parse_args(self.name(), args)?;
602        let dir = match a.path {
603            Some(p) => ctx.resolve(&p),
604            None => ctx.cwd.clone(),
605        };
606        let mut rd = tokio::fs::read_dir(&dir)
607            .await
608            .map_err(|e| Error::tool(self.name(), format!("{}: {e}", dir.display())))?;
609        let mut entries = Vec::new();
610        while let Some(e) = rd
611            .next_entry()
612            .await
613            .map_err(|e| Error::tool(self.name(), e.to_string()))?
614        {
615            let name = e.file_name().to_string_lossy().into_owned();
616            let is_dir = e.file_type().await.map(|t| t.is_dir()).unwrap_or(false);
617            entries.push(if is_dir { format!("{name}/") } else { name });
618        }
619        entries.sort();
620        if entries.is_empty() {
621            Ok("(empty directory)".to_string())
622        } else {
623            Ok(entries.join("\n"))
624        }
625    }
626}
627
628// ---- glob -----------------------------------------------------------------
629
630/// Match files by glob pattern.
631pub struct GlobTool;
632
633#[derive(Deserialize)]
634struct GlobArgs {
635    pattern: String,
636}
637
638#[async_trait]
639impl Tool for GlobTool {
640    fn name(&self) -> &str {
641        "glob"
642    }
643    fn description(&self) -> &str {
644        "Find files matching a glob pattern (e.g. `src/**/*.rs`), relative to the working directory."
645    }
646    fn parameters(&self) -> Value {
647        json!({
648            "type": "object",
649            "properties": {
650                "pattern": {"type": "string", "description": "Glob pattern, e.g. **/*.rs"}
651            },
652            "required": ["pattern"],
653            "additionalProperties": false
654        })
655    }
656    async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<String> {
657        let a: GlobArgs = parse_args(self.name(), args)?;
658        let cwd = ctx.cwd.clone();
659        let full = if PathBuf::from(&a.pattern).is_absolute() {
660            a.pattern.clone()
661        } else {
662            cwd.join(&a.pattern).to_string_lossy().into_owned()
663        };
664        let cwd2 = cwd.clone();
665        let matches = tokio::task::spawn_blocking(move || {
666            let mut out = Vec::new();
667            if let Ok(paths) = glob::glob(&full) {
668                for p in paths.flatten() {
669                    let display = p
670                        .strip_prefix(&cwd2)
671                        .unwrap_or(&p)
672                        .to_string_lossy()
673                        .into_owned();
674                    out.push(display);
675                }
676            }
677            out
678        })
679        .await
680        .map_err(|e| Error::tool("glob", e.to_string()))?;
681        if matches.is_empty() {
682            Ok("(no matches)".to_string())
683        } else {
684            Ok(matches.join("\n"))
685        }
686    }
687}
688
689// ---- search ---------------------------------------------------------------
690
691/// Regex search file contents (respecting .gitignore).
692pub struct SearchTool;
693
694#[derive(Deserialize)]
695struct SearchArgs {
696    pattern: String,
697    #[serde(default)]
698    path: Option<String>,
699    #[serde(default)]
700    max_results: Option<usize>,
701}
702
703#[async_trait]
704impl Tool for SearchTool {
705    fn name(&self) -> &str {
706        "search"
707    }
708    fn description(&self) -> &str {
709        "Search file contents with a regular expression, respecting .gitignore. Returns `path:line: text` matches."
710    }
711    fn parameters(&self) -> Value {
712        json!({
713            "type": "object",
714            "properties": {
715                "pattern": {"type": "string", "description": "Regular expression to search for."},
716                "path": {"type": "string", "description": "Directory or file to search. Defaults to the working directory."},
717                "max_results": {"type": "integer", "description": "Cap on the number of matches (default 200)."}
718            },
719            "required": ["pattern"],
720            "additionalProperties": false
721        })
722    }
723    async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<String> {
724        let a: SearchArgs = parse_args(self.name(), args)?;
725        let re = regex::Regex::new(&a.pattern)
726            .map_err(|e| Error::tool(self.name(), format!("invalid regex: {e}")))?;
727        let root = match a.path {
728            Some(p) => ctx.resolve(&p),
729            None => ctx.cwd.clone(),
730        };
731        let cwd = ctx.cwd.clone();
732        let cap = a.max_results.unwrap_or(200);
733        let results = tokio::task::spawn_blocking(move || {
734            let mut out: Vec<String> = Vec::new();
735            let walker = ignore::WalkBuilder::new(&root).build();
736            'outer: for entry in walker.flatten() {
737                if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
738                    continue;
739                }
740                let path = entry.path();
741                let Ok(content) = std::fs::read_to_string(path) else {
742                    continue; // skip binary / unreadable
743                };
744                for (i, line) in content.lines().enumerate() {
745                    if re.is_match(line) {
746                        let rel = path.strip_prefix(&cwd).unwrap_or(path);
747                        out.push(format!("{}:{}: {}", rel.display(), i + 1, line.trim_end()));
748                        if out.len() >= cap {
749                            break 'outer;
750                        }
751                    }
752                }
753            }
754            out
755        })
756        .await
757        .map_err(|e| Error::tool("search", e.to_string()))?;
758        if results.is_empty() {
759            Ok("(no matches)".to_string())
760        } else {
761            Ok(results.join("\n"))
762        }
763    }
764}
765
766// ---- bash -----------------------------------------------------------------
767
768/// Run a shell command via `sh -c`.
769pub struct BashTool {
770    default_timeout_ms: u64,
771}
772
773/// Owns the process group created for one [`BashTool`] invocation.
774///
775/// The explicit calls clean up before we await pipe readers or reap the direct
776/// child. The `Drop` backstop also covers cancellation of the tool future: a
777/// cancelled request must not detach the shell's workers from Supercode.
778#[cfg(unix)]
779struct BashProcessTreeGuard(Option<u32>);
780
781#[cfg(windows)]
782struct BashProcessTreeGuard(Option<usize>);
783
784#[cfg(not(any(unix, windows)))]
785struct BashProcessTreeGuard;
786
787impl BashProcessTreeGuard {
788    #[cfg(unix)]
789    fn prepare() -> std::io::Result<Self> {
790        Ok(Self(None))
791    }
792
793    #[cfg(windows)]
794    fn prepare() -> std::io::Result<Self> {
795        use windows_sys::Win32::Foundation::CloseHandle;
796        use windows_sys::Win32::System::JobObjects::{
797            CreateJobObjectW, JobObjectExtendedLimitInformation, SetInformationJobObject,
798            JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
799        };
800
801        // A Job Object is Windows' process-tree ownership primitive. Closing
802        // this handle terminates every assigned descendant, including on an
803        // async future cancellation where no explicit timeout arm runs.
804        let job = unsafe { CreateJobObjectW(std::ptr::null(), std::ptr::null()) };
805        if job.is_null() {
806            return Err(std::io::Error::last_os_error());
807        }
808        let mut limits: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = unsafe { std::mem::zeroed() };
809        limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
810        let configured = unsafe {
811            SetInformationJobObject(
812                job,
813                JobObjectExtendedLimitInformation,
814                std::ptr::addr_of!(limits).cast(),
815                std::mem::size_of_val(&limits) as u32,
816            )
817        };
818        if configured == 0 {
819            let error = std::io::Error::last_os_error();
820            unsafe {
821                CloseHandle(job);
822            }
823            return Err(error);
824        }
825        Ok(Self(Some(job as usize)))
826    }
827
828    #[cfg(not(any(unix, windows)))]
829    fn prepare() -> std::io::Result<Self> {
830        Ok(Self)
831    }
832
833    fn configure_command(&self, command: &mut tokio::process::Command) {
834        #[cfg(unix)]
835        command.process_group(0);
836        #[cfg(windows)]
837        command.creation_flags(windows_sys::Win32::System::Threading::CREATE_SUSPENDED);
838    }
839
840    #[cfg(unix)]
841    fn attach_and_start(&mut self, child: &tokio::process::Child) -> std::io::Result<()> {
842        self.0 = child.id();
843        Ok(())
844    }
845
846    #[cfg(windows)]
847    fn attach_and_start(&mut self, child: &tokio::process::Child) -> std::io::Result<()> {
848        use windows_sys::Win32::System::JobObjects::AssignProcessToJobObject;
849
850        let job = self.0.ok_or_else(|| {
851            std::io::Error::new(
852                std::io::ErrorKind::BrokenPipe,
853                "command Job Object is closed",
854            )
855        })? as windows_sys::Win32::Foundation::HANDLE;
856        let process = child.raw_handle().ok_or_else(|| {
857            std::io::Error::new(
858                std::io::ErrorKind::BrokenPipe,
859                "suspended command has no process handle",
860            )
861        })?;
862        if unsafe { AssignProcessToJobObject(job, process.cast()) } == 0 {
863            return Err(std::io::Error::last_os_error());
864        }
865        Self::resume_primary_thread(child.id().ok_or_else(|| {
866            std::io::Error::new(
867                std::io::ErrorKind::BrokenPipe,
868                "suspended command has no process id",
869            )
870        })?)
871    }
872
873    #[cfg(windows)]
874    fn resume_primary_thread(process_id: u32) -> std::io::Result<()> {
875        use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE};
876        use windows_sys::Win32::System::Diagnostics::ToolHelp::{
877            CreateToolhelp32Snapshot, Thread32First, Thread32Next, TH32CS_SNAPTHREAD, THREADENTRY32,
878        };
879        use windows_sys::Win32::System::Threading::{
880            OpenThread, ResumeThread, THREAD_SUSPEND_RESUME,
881        };
882
883        let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0) };
884        if snapshot == INVALID_HANDLE_VALUE {
885            return Err(std::io::Error::last_os_error());
886        }
887        let result = (|| {
888            let mut entry: THREADENTRY32 = unsafe { std::mem::zeroed() };
889            entry.dwSize = std::mem::size_of::<THREADENTRY32>() as u32;
890            let mut has_entry = unsafe { Thread32First(snapshot, &mut entry) } != 0;
891            while has_entry {
892                if entry.th32OwnerProcessID == process_id {
893                    let thread =
894                        unsafe { OpenThread(THREAD_SUSPEND_RESUME, 0, entry.th32ThreadID) };
895                    if thread.is_null() {
896                        return Err(std::io::Error::last_os_error());
897                    }
898                    let resumed = unsafe { ResumeThread(thread) };
899                    unsafe {
900                        CloseHandle(thread);
901                    }
902                    if resumed == u32::MAX {
903                        return Err(std::io::Error::last_os_error());
904                    }
905                    return Ok(());
906                }
907                has_entry = unsafe { Thread32Next(snapshot, &mut entry) } != 0;
908            }
909            Err(std::io::Error::new(
910                std::io::ErrorKind::NotFound,
911                "suspended command's primary thread was not found",
912            ))
913        })();
914        unsafe {
915            CloseHandle(snapshot);
916        }
917        result
918    }
919
920    #[cfg(not(any(unix, windows)))]
921    fn attach_and_start(&mut self, _child: &tokio::process::Child) -> std::io::Result<()> {
922        Ok(())
923    }
924
925    fn kill(&mut self) {
926        #[cfg(unix)]
927        if let Some(pid) = self.0.take() {
928            crate::lsp::kill_process_group(pid);
929        }
930        #[cfg(windows)]
931        if let Some(job) = self.0.take() {
932            use windows_sys::Win32::Foundation::CloseHandle;
933            use windows_sys::Win32::System::JobObjects::TerminateJobObject;
934            let job = job as windows_sys::Win32::Foundation::HANDLE;
935            unsafe {
936                TerminateJobObject(job, 1);
937                CloseHandle(job);
938            }
939        }
940    }
941}
942
943impl Drop for BashProcessTreeGuard {
944    fn drop(&mut self) {
945        self.kill();
946    }
947}
948
949impl Default for BashTool {
950    fn default() -> Self {
951        BashTool {
952            default_timeout_ms: DEFAULT_BASH_TIMEOUT_MS,
953        }
954    }
955}
956
957#[derive(Deserialize)]
958struct BashArgs {
959    command: String,
960    #[serde(default)]
961    timeout_ms: Option<u64>,
962}
963
964#[async_trait]
965impl Tool for BashTool {
966    fn name(&self) -> &str {
967        "bash"
968    }
969    fn description(&self) -> &str {
970        "Execute a shell command via `sh -c` in the working directory and return its combined stdout/stderr and exit code."
971    }
972    fn parameters(&self) -> Value {
973        json!({
974            "type": "object",
975            "properties": {
976                "command": {"type": "string", "description": "Shell command to run."},
977                "timeout_ms": {"type": "integer", "description": "Timeout in milliseconds (default 120000)."}
978            },
979            "required": ["command"],
980            "additionalProperties": false
981        })
982    }
983    async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<String> {
984        let a: BashArgs = parse_args(self.name(), args)?;
985        // P4e (S3.1 `core.tools.bash.timeout_secs`, S14): a model-issued
986        // `timeout_ms` argument always wins (unchanged); absent that, a
987        // configured `ctx.bash_timeout_secs` REPLACES the built-in
988        // `self.default_timeout_ms` fallback instead of stacking with it.
989        // `ctx.bash_timeout_secs == None` (the default) makes this
990        // byte-identical to the pre-P4e single-source fallback.
991        let effective_default_ms = ctx
992            .bash_timeout_secs
993            .map(|s| s.saturating_mul(1000))
994            .unwrap_or(self.default_timeout_ms);
995        let timeout = Duration::from_millis(a.timeout_ms.unwrap_or(effective_default_ms));
996        let deadline = tokio::time::Instant::now() + timeout;
997
998        // P4c (S1.2 `core.shell_env_snapshot`)/P5-10 (`env_policy`):
999        // `build_sandboxed_sh` folds `ctx.shell_env` in via
1000        // `apply_sandbox_env_policy` (its own last step) — a no-op (byte-
1001        // identical spawn) when `ctx.shell_env` is `None` AND
1002        // `ctx.sandbox_env_policy` is `Inherit` (both defaults).
1003        let mut cmd = build_sandboxed_sh(&a.command, ctx)?;
1004        cmd.current_dir(&ctx.cwd)
1005            .stdin(std::process::Stdio::null())
1006            .stdout(std::process::Stdio::piped())
1007            .stderr(std::process::Stdio::piped())
1008            .kill_on_drop(true);
1009        // Own the tree before it can execute: Unix creates a new process
1010        // group at spawn; Windows starts suspended, enters a preconfigured
1011        // kill-on-close Job Object, and only then resumes its primary thread.
1012        let mut process_tree = BashProcessTreeGuard::prepare()
1013            .map_err(|error| Error::tool(self.name(), error.to_string()))?;
1014        process_tree.configure_command(&mut cmd);
1015        let mut child = cmd
1016            .spawn()
1017            .map_err(|error| Error::tool(self.name(), error.to_string()))?;
1018        process_tree
1019            .attach_and_start(&child)
1020            .map_err(|error| Error::tool(self.name(), error.to_string()))?;
1021        let mut stdout = child
1022            .stdout
1023            .take()
1024            .ok_or_else(|| Error::tool(self.name(), "spawned command has no stdout"))?;
1025        let mut stderr = child
1026            .stderr
1027            .take()
1028            .ok_or_else(|| Error::tool(self.name(), "spawned command has no stderr"))?;
1029        let mut stdout_task = tokio::spawn(async move {
1030            let mut bytes = Vec::new();
1031            let result = stdout.read_to_end(&mut bytes).await;
1032            (result, bytes)
1033        });
1034        let mut stderr_task = tokio::spawn(async move {
1035            let mut bytes = Vec::new();
1036            let result = stderr.read_to_end(&mut bytes).await;
1037            (result, bytes)
1038        });
1039
1040        let status = match tokio::time::timeout_at(deadline, child.wait()).await {
1041            Ok(Ok(status)) => status,
1042            Ok(Err(error)) => {
1043                process_tree.kill();
1044                let _ = child.start_kill();
1045                let _ = tokio::time::timeout(Duration::from_secs(1), child.wait()).await;
1046                stdout_task.abort();
1047                stderr_task.abort();
1048                return Err(Error::tool(self.name(), error.to_string()));
1049            }
1050            Err(_) => {
1051                process_tree.kill();
1052                let _ = child.start_kill();
1053                let _ = tokio::time::timeout(Duration::from_secs(1), child.wait()).await;
1054                stdout_task.abort();
1055                stderr_task.abort();
1056                return Err(Error::tool(
1057                    self.name(),
1058                    format!("command timed out after {timeout:?}"),
1059                ));
1060            }
1061        };
1062        // A shell can exit successfully after backgrounding a worker. Bash
1063        // is the bounded foreground tool; durable work belongs in the
1064        // background tool. Reap any remaining member before reading EOF.
1065        process_tree.kill();
1066        let pipe_output = tokio::time::timeout_at(deadline, async {
1067            let (stdout_result, stdout) = (&mut stdout_task)
1068                .await
1069                .map_err(|error| Error::tool(self.name(), error.to_string()))?;
1070            stdout_result.map_err(|error| Error::tool(self.name(), error.to_string()))?;
1071            let (stderr_result, stderr) = (&mut stderr_task)
1072                .await
1073                .map_err(|error| Error::tool(self.name(), error.to_string()))?;
1074            stderr_result.map_err(|error| Error::tool(self.name(), error.to_string()))?;
1075            Ok::<_, Error>((stdout, stderr))
1076        })
1077        .await;
1078        let (stdout, stderr) = match pipe_output {
1079            Ok(result) => result?,
1080            Err(_) => {
1081                stdout_task.abort();
1082                stderr_task.abort();
1083                return Err(Error::tool(
1084                    self.name(),
1085                    format!("command timed out after {timeout:?}"),
1086                ));
1087            }
1088        };
1089
1090        let mut buf = String::new();
1091        let stdout = String::from_utf8_lossy(&stdout);
1092        let stderr = String::from_utf8_lossy(&stderr);
1093        if !stdout.is_empty() {
1094            buf.push_str(&stdout);
1095        }
1096        if !stderr.is_empty() {
1097            if !buf.is_empty() && !buf.ends_with('\n') {
1098                buf.push('\n');
1099            }
1100            buf.push_str(&stderr);
1101        }
1102        let code = status.code().unwrap_or(-1);
1103        if buf.is_empty() {
1104            buf.push_str("(no output)");
1105        }
1106        Ok(format!("exit code: {code}\n{buf}"))
1107    }
1108}
1109
1110/// P5-10 (§2 module 12): the resolved, platform-agnostic decision
1111/// [`build_sandboxed_sh`]/[`build_sandboxed_interactive_sh`] act on —
1112/// computed ONCE by [`resolve_sandbox_plan`] so both callers (and
1113/// `crate::agent::Agent::background_exec`, via the same shared function)
1114/// apply the identical fs/net posture, never two independently-computed
1115/// ones that could drift.
1116struct SandboxPlan {
1117    /// Apply real OS fs confinement (seatbelt on macOS, Landlock on
1118    /// Linux) for this call.
1119    confine_fs: bool,
1120    /// `true` for `WorkspaceWrite` (writes allowed under `cwd` + temp),
1121    /// `false` for `ReadOnly` (no writes at all). Only consulted when
1122    /// `confine_fs` is `true`.
1123    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1124    fs_allow_writes: bool,
1125    /// Apply a real coarse network cut-off (Linux network-namespace
1126    /// isolation) for this call.
1127    #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1128    confine_net: bool,
1129}
1130
1131/// P5-10 (§2 module 12, cardinal rule "no silent-unsandboxed"): resolve
1132/// what [`build_sandboxed_sh`]/[`build_sandboxed_interactive_sh`] should
1133/// actually DO for one subprocess spawn — real availability probes
1134/// ([`crate::sandbox::landlock_available`]/[`crate::sandbox::
1135/// netns_available`], macOS's seatbelt treated as always-available,
1136/// matching its pre-P5-10 unconditional trigger) feed the PURE
1137/// `crate::sandbox::decide_fs`/`decide_net` functions, whose `Refuse`
1138/// outcome surfaces here as a real `Err` (the tool call fails — "refuse to
1139/// run the sandboxed subprocess") and whose `RunUnconfinedWithWarning`/
1140/// `GapWarn` outcomes print the loud one-time warning
1141/// (`crate::sandbox::warn_once`) and fall through to an UNCONFINED spawn
1142/// for that axis, rather than ever silently claiming confinement this
1143/// platform/kernel can't actually provide.
1144fn resolve_sandbox_plan(ctx: &ToolContext, subject: &str) -> Result<SandboxPlan> {
1145    use crate::sandbox::{decide_fs, decide_net, warn_once, FsDecision, NetDecision};
1146
1147    let fs_available = cfg!(target_os = "macos") || crate::sandbox::landlock_available();
1148    let approval = ctx.sandbox_approval_handler.as_deref();
1149    let fs_decision = decide_fs(
1150        ctx.sandbox,
1151        ctx.sandbox_os_enabled,
1152        fs_available,
1153        ctx.sandbox_escalation,
1154        approval,
1155        subject,
1156    );
1157    let confine_fs = match fs_decision {
1158        FsDecision::NotRequested => false,
1159        FsDecision::Confine => true,
1160        FsDecision::RunUnconfinedWithWarning { reason } => {
1161            warn_once(&reason);
1162            false
1163        }
1164        FsDecision::Refuse { reason } => return Err(Error::tool("sandbox", reason)),
1165    };
1166
1167    let network_enabled = ctx
1168        .network_policy
1169        .as_ref()
1170        .map(|p| p.enabled)
1171        .unwrap_or(false);
1172    let has_domain_rules = ctx
1173        .network_policy
1174        .as_ref()
1175        .map(|p| !p.allow_domains.is_empty() || !p.deny_domains.is_empty())
1176        .unwrap_or(false);
1177    let net_available = cfg!(target_os = "linux") && crate::sandbox::netns_available();
1178    let net_decision = decide_net(network_enabled, has_domain_rules, net_available);
1179    let confine_net = match net_decision {
1180        NetDecision::NotRequested => false,
1181        NetDecision::Confine => true,
1182        NetDecision::GapWarn { reason } => {
1183            warn_once(&reason);
1184            false
1185        }
1186    };
1187
1188    Ok(SandboxPlan {
1189        confine_fs,
1190        fs_allow_writes: ctx.sandbox == crate::tools::SandboxPolicy::WorkspaceWrite,
1191        confine_net,
1192    })
1193}
1194
1195/// P5-10: apply [`SandboxPlan::confine_net`]/`.confine_fs` to `cmd` via a
1196/// real `pre_exec` closure — Linux only (see
1197/// [`crate::sandbox::apply_linux_confinement`]'s own doc comment for the
1198/// full real-enforcement story: runs in the FORKED CHILD, never touches
1199/// supercode itself). `cwd`/the system temp dir are resolved through
1200/// [`crate::safe_path::resolve_real`] (not a bare `std::fs::canonicalize`)
1201/// — the same dual lexical+resolved discipline every other containment
1202/// check in this crate uses, so a symlink'd working directory grants the
1203/// REAL target, not its lexical location. A no-op on non-Linux platforms
1204/// (macOS's confinement is the separate `seatbelt_profile` wrapper below;
1205/// every other platform has no primitive at all, which is exactly why
1206/// `resolve_sandbox_plan` never sets `confine_fs`/`confine_net` there).
1207#[cfg(target_os = "linux")]
1208fn apply_linux_plan(cmd: &mut tokio::process::Command, ctx: &ToolContext, plan: &SandboxPlan) {
1209    if !plan.confine_fs && !plan.confine_net {
1210        return;
1211    }
1212    let cwd = crate::safe_path::resolve_real(&ctx.cwd).unwrap_or_else(|| ctx.cwd.clone());
1213    let tmp_dir = std::env::temp_dir();
1214    let tmp = crate::safe_path::resolve_real(&tmp_dir).unwrap_or(tmp_dir);
1215    crate::sandbox::apply_linux_confinement(
1216        cmd,
1217        plan.confine_fs,
1218        plan.fs_allow_writes,
1219        cwd,
1220        vec![tmp],
1221        plan.confine_net,
1222    );
1223}
1224
1225#[cfg(not(target_os = "linux"))]
1226fn apply_linux_plan(_cmd: &mut tokio::process::Command, _ctx: &ToolContext, _plan: &SandboxPlan) {}
1227
1228/// P5-10: env-policy pass — the LAST env-related step
1229/// [`BashTool::execute`]/`PersistentShellTool::execute`/`Agent::
1230/// background_exec` all apply, AFTER any `ctx.shell_env` snapshot has
1231/// already been folded in by the caller, so a `Filtered`/`None` policy
1232/// also strips a secret that arrived via the snapshot (not just the
1233/// process's own inherited environment) — computing the SAME effective
1234/// base independently here (rather than trying to introspect what a prior
1235/// `cmd.envs(...)` call already staged) and then `env_clear` + re-`envs`
1236/// the filtered result means call order never matters. `Inherit` (the
1237/// default) is a true no-op: `cmd` is never touched, so the spawn stays
1238/// byte-identical to pre-P5-10 behavior.
1239///
1240/// ALSO folds in `ctx.shell_env` (the `core.shell_env_snapshot` capture)
1241/// itself, for EVERY policy including `Inherit` — this is now the ONE place
1242/// that applies the snapshot, so callers must not separately call
1243/// `cmd.envs(ctx.shell_env...)` beforehand (that would let a secret from
1244/// the snapshot survive a `Filtered`/`None` policy by re-adding it after
1245/// this function's `env_clear()`, which is exactly the leak this
1246/// consolidation closes — see the git history for the P5-10 build's own
1247/// caught-in-review instance of that bug).
1248fn apply_sandbox_env_policy(cmd: &mut tokio::process::Command, ctx: &ToolContext) {
1249    if ctx.sandbox_env_policy == crate::sandbox::SandboxEnvPolicy::Inherit {
1250        // Byte-identical to pre-P5-10 behavior: don't touch the inherited
1251        // environment at all, just fold the snapshot on top if configured.
1252        if let Some(snapshot) = &ctx.shell_env {
1253            cmd.envs(snapshot.iter().map(|(k, v)| (k.as_str(), v.as_str())));
1254        }
1255        return;
1256    }
1257    let mut base: Vec<(String, String)> = std::env::vars().collect();
1258    if let Some(snapshot) = &ctx.shell_env {
1259        for (k, v) in snapshot.iter() {
1260            match base.iter_mut().find(|(bk, _)| bk == k) {
1261                Some(entry) => entry.1 = v.clone(),
1262                None => base.push((k.clone(), v.clone())),
1263            }
1264        }
1265    }
1266    let filtered = crate::sandbox::apply_env_policy(ctx.sandbox_env_policy, base);
1267    cmd.env_clear();
1268    cmd.envs(filtered);
1269}
1270
1271/// Build the `sh -c <command>` invocation, wrapped in real OS process
1272/// confinement when [`resolve_sandbox_plan`] says to (seatbelt on macOS,
1273/// Landlock + optional network-namespace isolation on Linux via
1274/// [`apply_linux_plan`]).
1275///
1276/// On macOS this uses `sandbox-exec` (seatbelt): `ReadOnly` denies all file
1277/// writes; `WorkspaceWrite` denies writes outside the working directory. On
1278/// Linux, Landlock gives the same real subprocess isolation (a `bash`
1279/// command cannot escape the policy via the kernel's own enforcement, not
1280/// just the file-tool confinement) — see `crate::sandbox`'s module doc
1281/// comment for the full fail-closed/escalation/gap-honesty story. Returns
1282/// `Err` when a confining tier was requested, this platform/kernel can't
1283/// provide it, and `escalation` says to refuse (the default) — "refuse to
1284/// run the sandboxed subprocess" rather than ever silently running
1285/// unconfined.
1286///
1287/// `pub(crate)` (P5-6, §2 module 4 `tools.background`, build brief "reuse
1288/// the bash tool's execution + sandbox path"): `crate::agent::Agent`'s
1289/// `background_exec` intrinsic calls this SAME function (re-exported via
1290/// `crate::tools::build_sandboxed_sh`) rather than reimplementing its own
1291/// spawn path, so a background command gets byte-identical sandboxing to a
1292/// foreground `bash` call — one enforcement point, not two that could
1293/// silently drift apart.
1294pub(crate) fn build_sandboxed_sh(
1295    command: &str,
1296    ctx: &ToolContext,
1297) -> Result<tokio::process::Command> {
1298    let plan = resolve_sandbox_plan(ctx, command)?;
1299    #[cfg(target_os = "macos")]
1300    {
1301        if plan.confine_fs {
1302            if let Some(profile) = seatbelt_profile(ctx) {
1303                let mut cmd = tokio::process::Command::new("sandbox-exec");
1304                cmd.arg("-p").arg(profile).arg("sh").arg("-c").arg(command);
1305                apply_sandbox_env_policy(&mut cmd, ctx);
1306                return Ok(cmd);
1307            }
1308        }
1309    }
1310    let mut cmd = tokio::process::Command::new("sh");
1311    cmd.arg("-c").arg(command);
1312    apply_linux_plan(&mut cmd, ctx, &plan);
1313    apply_sandbox_env_policy(&mut cmd, ctx);
1314    Ok(cmd)
1315}
1316
1317/// Like [`build_sandboxed_sh`] but for the *persistent* shell: an interactive
1318/// `sh` reading commands from its stdin (no `-c`). Without this the `shell`
1319/// tool would be an unsandboxed escape hatch around the policy that `bash`
1320/// honors.
1321fn build_sandboxed_interactive_sh(ctx: &ToolContext) -> Result<tokio::process::Command> {
1322    let plan = resolve_sandbox_plan(ctx, "<persistent shell>")?;
1323    #[cfg(target_os = "macos")]
1324    {
1325        if plan.confine_fs {
1326            if let Some(profile) = seatbelt_profile(ctx) {
1327                let mut cmd = tokio::process::Command::new("sandbox-exec");
1328                cmd.arg("-p").arg(profile).arg("sh");
1329                apply_sandbox_env_policy(&mut cmd, ctx);
1330                return Ok(cmd);
1331            }
1332        }
1333    }
1334    let mut cmd = tokio::process::Command::new("sh");
1335    apply_linux_plan(&mut cmd, ctx, &plan);
1336    apply_sandbox_env_policy(&mut cmd, ctx);
1337    Ok(cmd)
1338}
1339
1340/// A seatbelt profile string for the current sandbox policy, or `None` for
1341/// full access.
1342#[cfg(target_os = "macos")]
1343fn seatbelt_profile(ctx: &ToolContext) -> Option<String> {
1344    use crate::tools::SandboxPolicy;
1345    match ctx.sandbox {
1346        SandboxPolicy::DangerFullAccess => None,
1347        SandboxPolicy::ReadOnly => Some("(version 1)(allow default)(deny file-write*)".to_string()),
1348        SandboxPolicy::WorkspaceWrite => {
1349            // Allow writes only under the (real) working directory, plus the
1350            // usual harmless devices/temp. P5-10: reuses
1351            // `crate::safe_path::resolve_real` (the shared dual
1352            // lexical+resolved path primitive every other containment
1353            // check in this crate now goes through) instead of a bare
1354            // `std::fs::canonicalize` call, so a symlink'd `cwd` resolves
1355            // identically here and in the new Linux Landlock path.
1356            let real = crate::safe_path::resolve_real(&ctx.cwd).unwrap_or_else(|| ctx.cwd.clone());
1357            let dir = real.to_string_lossy().replace('"', "");
1358            Some(format!(
1359                "(version 1)(allow default)(deny file-write*)\
1360(allow file-write* (subpath \"{dir}\"))\
1361(allow file-write* (literal \"/dev/null\") (literal \"/dev/dtracehelper\") (literal \"/dev/tty\"))"
1362            ))
1363        }
1364    }
1365}
1366
1367// ---- apply_patch ----------------------------------------------------------
1368
1369/// Apply a Codex-style `apply_patch` envelope — Codex's primary edit
1370/// mechanism, richer than `edit_file`'s single string replace. Supports
1371/// `Add File`, `Delete File`, and `Update File` (with `-`/`+`/context hunks and
1372/// an optional `*** Move to:` rename) in one atomic-ish call.
1373pub struct ApplyPatchTool;
1374
1375#[derive(Deserialize)]
1376struct ApplyPatchArgs {
1377    /// The full `*** Begin Patch … *** End Patch` text.
1378    patch: String,
1379}
1380
1381/// One file operation parsed from a patch.
1382enum PatchOp {
1383    Add {
1384        path: String,
1385        body: String,
1386    },
1387    Delete {
1388        path: String,
1389    },
1390    Update {
1391        path: String,
1392        move_to: Option<String>,
1393        hunks: Vec<Hunk>,
1394    },
1395}
1396
1397/// A single update hunk: lines to match (context + removed) and the replacement
1398/// (context + added), in order. `anchor` is the optional text after the `@@`
1399/// header — it scopes where the hunk applies (and where a pure insertion goes).
1400#[derive(Default)]
1401struct Hunk {
1402    old: Vec<String>,
1403    new: Vec<String>,
1404    anchor: Option<String>,
1405}
1406
1407fn parse_patch(patch: &str) -> Result<Vec<PatchOp>> {
1408    let err = |m: &str| Error::tool("apply_patch", m.to_string());
1409    let lines: Vec<&str> = patch.lines().collect();
1410    let mut i = 0;
1411    // Skip to Begin Patch.
1412    while i < lines.len() && lines[i].trim() != "*** Begin Patch" {
1413        i += 1;
1414    }
1415    if i == lines.len() {
1416        return Err(err("missing '*** Begin Patch'"));
1417    }
1418    i += 1;
1419
1420    let mut ops = Vec::new();
1421    while i < lines.len() {
1422        let line = lines[i];
1423        let t = line.trim_end();
1424        if t == "*** End Patch" {
1425            return Ok(ops);
1426        } else if let Some(p) = t.strip_prefix("*** Add File: ") {
1427            i += 1;
1428            let mut body = Vec::new();
1429            while i < lines.len() && lines[i].starts_with('+') {
1430                body.push(&lines[i][1..]);
1431                i += 1;
1432            }
1433            ops.push(PatchOp::Add {
1434                path: p.to_string(),
1435                body: body.join("\n"),
1436            });
1437        } else if let Some(p) = t.strip_prefix("*** Delete File: ") {
1438            ops.push(PatchOp::Delete {
1439                path: p.to_string(),
1440            });
1441            i += 1;
1442        } else if let Some(p) = t.strip_prefix("*** Update File: ") {
1443            i += 1;
1444            let mut move_to = None;
1445            if i < lines.len() {
1446                if let Some(m) = lines[i].trim_end().strip_prefix("*** Move to: ") {
1447                    move_to = Some(m.to_string());
1448                    i += 1;
1449                }
1450            }
1451            let mut hunks = Vec::new();
1452            let mut cur = Hunk::default();
1453            let mut started = false;
1454            while i < lines.len() {
1455                let l = lines[i];
1456                let lt = l.trim_end();
1457                if lt.starts_with("*** ") {
1458                    break; // next section
1459                }
1460                if let Some(anchor) = lt.strip_prefix("@@") {
1461                    if started && (!cur.old.is_empty() || !cur.new.is_empty()) {
1462                        hunks.push(std::mem::take(&mut cur));
1463                    }
1464                    // The text after `@@` (e.g. `@@ def foo():`) anchors the hunk.
1465                    let anchor = anchor.trim();
1466                    cur.anchor = (!anchor.is_empty()).then(|| anchor.to_string());
1467                    started = true;
1468                    i += 1;
1469                    continue;
1470                }
1471                started = true;
1472                if let Some(rest) = l.strip_prefix('+') {
1473                    cur.new.push(rest.to_string());
1474                } else if let Some(rest) = l.strip_prefix('-') {
1475                    cur.old.push(rest.to_string());
1476                } else {
1477                    // context line (leading space, or bare)
1478                    let ctx = l.strip_prefix(' ').unwrap_or(l).to_string();
1479                    cur.old.push(ctx.clone());
1480                    cur.new.push(ctx);
1481                }
1482                i += 1;
1483            }
1484            if !cur.old.is_empty() || !cur.new.is_empty() {
1485                hunks.push(cur);
1486            }
1487            ops.push(PatchOp::Update {
1488                path: p.to_string(),
1489                move_to,
1490                hunks,
1491            });
1492        } else {
1493            // Stray line between sections — skip.
1494            i += 1;
1495        }
1496    }
1497    Err(err("missing '*** End Patch'"))
1498}
1499
1500/// P5-1 F4 (Fable-5 adversarial review): every file path an `apply_patch`
1501/// envelope's operations target — an `Add`/`Delete`/`Update` op's own
1502/// `path`, plus an `Update`'s `*** Move to:` destination when present.
1503/// `crate::agent`'s permissions gate uses this to check a patch's write
1504/// surface against `protected_paths` rules BEFORE the patch is applied —
1505/// module 13's protection previously only ever reached `read()`/`write()`
1506/// pseudo-tool calls, never an `apply_patch` envelope (whose args carry a
1507/// patch BODY, not a path, so the gate's ordinary `args.get("path")` lookup
1508/// never fires for it). Reuses [`parse_patch`] rather than re-deriving the
1509/// envelope grammar — one parser, one source of truth. `Err` propagates a
1510/// malformed envelope so the gate can fail closed on it too (never silently
1511/// skip the check just because the patch didn't parse).
1512pub(crate) fn patch_target_paths(patch: &str) -> Result<Vec<String>> {
1513    let ops = parse_patch(patch)?;
1514    let mut paths = Vec::with_capacity(ops.len());
1515    for op in ops {
1516        match op {
1517            PatchOp::Add { path, .. } | PatchOp::Delete { path } => paths.push(path),
1518            PatchOp::Update { path, move_to, .. } => {
1519                paths.push(path);
1520                if let Some(m) = move_to {
1521                    paths.push(m);
1522                }
1523            }
1524        }
1525    }
1526    Ok(paths)
1527}
1528
1529fn apply_update(original: &str, hunks: &[Hunk], tool: &str) -> Result<String> {
1530    let mut text = original.to_string();
1531    for h in hunks {
1532        // Resolve the `@@` anchor (if any) to a byte offset to search from, so
1533        // the hunk applies at the right place and an ambiguous old-block isn't
1534        // matched at the wrong (first) occurrence.
1535        let from = match &h.anchor {
1536            Some(a) => {
1537                let Some(pos) = text.find(a.as_str()) else {
1538                    return Err(Error::tool(tool, format!("@@ anchor not found: {a}")));
1539                };
1540                // Start just past the end of the anchor's line.
1541                text[pos..]
1542                    .find('\n')
1543                    .map(|nl| pos + nl + 1)
1544                    .unwrap_or(text.len())
1545            }
1546            None => 0,
1547        };
1548
1549        let new_block = h.new.join("\n");
1550
1551        if h.old.is_empty() {
1552            // Pure insertion. With an anchor, insert right after it; otherwise
1553            // append at EOF (the only sensible place with no location info).
1554            if h.anchor.is_some() {
1555                let needs_lead_nl = from > 0 && text.as_bytes()[from - 1] != b'\n';
1556                let payload = if needs_lead_nl {
1557                    format!("\n{new_block}\n")
1558                } else {
1559                    format!("{new_block}\n")
1560                };
1561                text.insert_str(from, &payload);
1562            } else {
1563                if !text.is_empty() && !text.ends_with('\n') {
1564                    text.push('\n');
1565                }
1566                text.push_str(&new_block);
1567            }
1568            continue;
1569        }
1570
1571        let old_block = h.old.join("\n");
1572        let region = &text[from..];
1573        let count = region.matches(&old_block).count();
1574        match count {
1575            0 => {
1576                return Err(Error::tool(
1577                    tool,
1578                    format!("hunk did not match file contents:\n{old_block}"),
1579                ))
1580            }
1581            1 => {
1582                let rel = region.find(&old_block).unwrap();
1583                let start = from + rel;
1584                text.replace_range(start..start + old_block.len(), &new_block);
1585            }
1586            _ => {
1587                return Err(Error::tool(
1588                    tool,
1589                    format!(
1590                        "hunk matches file contents {count} times; add more context lines or a more specific @@ anchor to disambiguate:\n{old_block}"
1591                    ),
1592                ))
1593            }
1594        }
1595    }
1596    Ok(text)
1597}
1598
1599#[async_trait]
1600impl Tool for ApplyPatchTool {
1601    fn name(&self) -> &str {
1602        "apply_patch"
1603    }
1604    fn description(&self) -> &str {
1605        "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."
1606    }
1607    fn parameters(&self) -> Value {
1608        json!({
1609            "type": "object",
1610            "properties": {
1611                "patch": {"type": "string", "description": "The full *** Begin Patch … *** End Patch text."}
1612            },
1613            "required": ["patch"],
1614            "additionalProperties": false
1615        })
1616    }
1617    async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<String> {
1618        let a: ApplyPatchArgs = parse_args(self.name(), args)?;
1619        let ops = parse_patch(&a.patch)?;
1620        let mut summary = Vec::new();
1621        // P5-11 (§2 modules 28/29, C10): `apply_patch` is multi-file, so
1622        // annotations from every op's `after_write` are collected here and
1623        // appended once at the end (rather than interleaved into `summary`)
1624        // — keeps the per-op summary lines a clean, stable format while
1625        // still surfacing every formatter diff-back / LSP diagnostics block
1626        // the model needs to see.
1627        let mut annotations: Vec<String> = Vec::new();
1628        for op in ops {
1629            match op {
1630                PatchOp::Add { path, body } => {
1631                    let full = ctx.resolve(&path);
1632                    ctx.check_write(&full)?;
1633                    // P5-9 (D-5 seam): capture BEFORE each op's own mutation
1634                    // — `apply_patch` is multi-file, so this fires once per
1635                    // path actually touched, not once for the whole call.
1636                    if let Some(obs) = &ctx.write_observer {
1637                        obs.before_write(&full).await;
1638                    }
1639                    if let Some(parent) = full.parent() {
1640                        tokio::fs::create_dir_all(parent).await.ok();
1641                    }
1642                    tokio::fs::write(&full, body.as_bytes())
1643                        .await
1644                        .map_err(|e| {
1645                            Error::tool(self.name(), format!("{}: {e}", full.display()))
1646                        })?;
1647                    if let Some(obs) = &ctx.write_observer {
1648                        if let Some(note) = obs.after_write(&full).await {
1649                            annotations.push(note);
1650                        }
1651                    }
1652                    summary.push(format!("A {}", rel(ctx, &full)));
1653                }
1654                PatchOp::Delete { path } => {
1655                    let full = ctx.resolve(&path);
1656                    ctx.check_write(&full)?;
1657                    if let Some(obs) = &ctx.write_observer {
1658                        obs.before_write(&full).await;
1659                    }
1660                    tokio::fs::remove_file(&full).await.map_err(|e| {
1661                        Error::tool(self.name(), format!("{}: {e}", full.display()))
1662                    })?;
1663                    if let Some(obs) = &ctx.write_observer {
1664                        // A deleted file has nothing to format/diagnose —
1665                        // still call the hook (some future observer might
1666                        // care about deletions) but a formatter/lsp
1667                        // observer's `after_write` is a no-op on a path
1668                        // that no longer exists, so this is never expected
1669                        // to produce an annotation in practice.
1670                        if let Some(note) = obs.after_write(&full).await {
1671                            annotations.push(note);
1672                        }
1673                    }
1674                    summary.push(format!("D {}", rel(ctx, &full)));
1675                }
1676                PatchOp::Update {
1677                    path,
1678                    move_to,
1679                    hunks,
1680                } => {
1681                    let full = ctx.resolve(&path);
1682                    let dest_for_check = move_to
1683                        .as_ref()
1684                        .map(|m| ctx.resolve(m))
1685                        .unwrap_or_else(|| full.clone());
1686                    ctx.check_write(&dest_for_check)?;
1687                    // Capture BOTH the source (`full` — read then possibly
1688                    // deleted on a move) and, when a move targets a
1689                    // DIFFERENT path, the destination's own pre-image too
1690                    // (it may already exist and be about to be overwritten).
1691                    if let Some(obs) = &ctx.write_observer {
1692                        obs.before_write(&full).await;
1693                        if dest_for_check != full {
1694                            obs.before_write(&dest_for_check).await;
1695                        }
1696                    }
1697                    let original = tokio::fs::read_to_string(&full).await.map_err(|e| {
1698                        Error::tool(self.name(), format!("{}: {e}", full.display()))
1699                    })?;
1700                    let updated = apply_update(&original, &hunks, self.name())?;
1701                    let dest = match &move_to {
1702                        Some(m) => ctx.resolve(m),
1703                        None => full.clone(),
1704                    };
1705                    if let Some(parent) = dest.parent() {
1706                        tokio::fs::create_dir_all(parent).await.ok();
1707                    }
1708                    tokio::fs::write(&dest, updated.as_bytes())
1709                        .await
1710                        .map_err(|e| {
1711                            Error::tool(self.name(), format!("{}: {e}", dest.display()))
1712                        })?;
1713                    if move_to.is_some() && dest != full {
1714                        tokio::fs::remove_file(&full).await.ok();
1715                        if let Some(obs) = &ctx.write_observer {
1716                            if let Some(note) = obs.after_write(&dest).await {
1717                                annotations.push(note);
1718                            }
1719                        }
1720                        summary.push(format!("M {} -> {}", rel(ctx, &full), rel(ctx, &dest)));
1721                    } else {
1722                        if let Some(obs) = &ctx.write_observer {
1723                            if let Some(note) = obs.after_write(&full).await {
1724                                annotations.push(note);
1725                            }
1726                        }
1727                        summary.push(format!("U {}", rel(ctx, &full)));
1728                    }
1729                }
1730            }
1731        }
1732        let annotation = if annotations.is_empty() {
1733            String::new()
1734        } else {
1735            format!("\n\n{}", annotations.join("\n\n"))
1736        };
1737        if summary.is_empty() {
1738            Ok("(empty patch)".to_string())
1739        } else {
1740            Ok(format!(
1741                "Applied patch:\n{}{annotation}",
1742                summary.join("\n")
1743            ))
1744        }
1745    }
1746}
1747
1748// ---- persistent shell -----------------------------------------------------
1749
1750use tokio::io::{AsyncReadExt, AsyncWriteExt};
1751use tokio::sync::Mutex as AsyncMutex;
1752
1753/// A long-lived shell whose state (working directory, environment variables,
1754/// shell functions) persists across calls — unlike the one-shot [`BashTool`].
1755/// Also supports `write_stdin` to feed raw input to the shell, for driving
1756/// interactive programs. This is the analog of Codex's persistent exec session.
1757///
1758/// Known limitation (documented, not fixed — see UX-29.md "Known
1759/// limitations"): if a REPL turn is hard-cancelled (`race_ctrl_c` in
1760/// `crates/cli/src/main.rs`) while `execute()` below is mid-`.await` waiting
1761/// for the completion sentinel, only that `.await` is dropped — the `child`
1762/// held in `ShellState` is untouched (by design: it must survive to serve
1763/// the NEXT call in this REPL session) and keeps running the in-flight
1764/// command to completion on its own schedule. The cancelled command's
1765/// eventual output (and sentinel line) still lands in the shared stdout
1766/// pipe, so the next `shell` call in the same session can either block
1767/// behind the stale command finishing or read a garbage prefix ahead of its
1768/// own sentinel. This is no worse than the pre-existing hard-kill behavior
1769/// this ticket replaced (which would have torn down the whole process), and
1770/// the on-disk session file itself is unaffected — only the persistent
1771/// shell's own child-process state can go stale.
1772pub struct PersistentShellTool {
1773    state: AsyncMutex<Option<ShellState>>,
1774    default_timeout_ms: u64,
1775}
1776
1777impl Default for PersistentShellTool {
1778    fn default() -> Self {
1779        PersistentShellTool {
1780            state: AsyncMutex::new(None),
1781            default_timeout_ms: DEFAULT_BASH_TIMEOUT_MS,
1782        }
1783    }
1784}
1785
1786struct ShellState {
1787    // Held to keep the shell process alive for the tool's lifetime; dropping it
1788    // would terminate the persistent shell.
1789    #[allow(dead_code)]
1790    child: tokio::process::Child,
1791    stdin: tokio::process::ChildStdin,
1792    stdout: tokio::io::BufReader<tokio::process::ChildStdout>,
1793}
1794
1795const SHELL_SENTINEL: &str = "__SC_SHELL_DONE__";
1796
1797use std::sync::atomic::{AtomicU64, Ordering};
1798
1799static SHELL_SENTINEL_SEQ: AtomicU64 = AtomicU64::new(0);
1800
1801/// Per-invocation completion sentinel: base string + unguessable hex token.
1802/// RandomState is seeded from OS entropy once per process; hashing
1803/// (pid, per-call counter, wall-clock nanos) through it yields a token a
1804/// user command cannot predict, with no new dependencies.
1805fn shell_sentinel() -> String {
1806    use std::hash::BuildHasher;
1807    let seq = SHELL_SENTINEL_SEQ.fetch_add(1, Ordering::Relaxed);
1808    let nanos = std::time::SystemTime::now()
1809        .duration_since(std::time::UNIX_EPOCH)
1810        .map(|d| d.as_nanos())
1811        .unwrap_or(0);
1812    let hash =
1813        std::collections::hash_map::RandomState::new().hash_one((std::process::id(), seq, nanos));
1814    format!("{SHELL_SENTINEL}_{hash:016x}{seq:04x}")
1815}
1816
1817#[derive(Deserialize)]
1818struct ShellArgs {
1819    #[serde(default)]
1820    command: Option<String>,
1821    #[serde(default)]
1822    write_stdin: Option<String>,
1823    #[serde(default)]
1824    timeout_ms: Option<u64>,
1825}
1826
1827impl PersistentShellTool {
1828    async fn ensure_started(
1829        &self,
1830        state: &mut Option<ShellState>,
1831        ctx: &ToolContext,
1832    ) -> Result<()> {
1833        if state.is_some() {
1834            return Ok(());
1835        }
1836        let mut child = build_sandboxed_interactive_sh(ctx)?
1837            .current_dir(&ctx.cwd)
1838            .stdin(std::process::Stdio::piped())
1839            .stdout(std::process::Stdio::piped())
1840            .stderr(std::process::Stdio::piped())
1841            // Don't leave the shell (or its sandbox-exec wrapper) running if the
1842            // tool is dropped without an explicit shutdown.
1843            .kill_on_drop(true)
1844            .spawn()
1845            .map_err(|e| Error::tool("shell", format!("spawn sh: {e}")))?;
1846        let stdin = child
1847            .stdin
1848            .take()
1849            .ok_or_else(|| Error::tool("shell", "no stdin"))?;
1850        let stdout = tokio::io::BufReader::new(
1851            child
1852                .stdout
1853                .take()
1854                .ok_or_else(|| Error::tool("shell", "no stdout"))?,
1855        );
1856        *state = Some(ShellState {
1857            child,
1858            stdin,
1859            stdout,
1860        });
1861        Ok(())
1862    }
1863}
1864
1865#[async_trait]
1866impl Tool for PersistentShellTool {
1867    fn name(&self) -> &str {
1868        "shell"
1869    }
1870    fn description(&self) -> &str {
1871        "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)."
1872    }
1873    fn parameters(&self) -> Value {
1874        json!({
1875            "type": "object",
1876            "properties": {
1877                "command": {"type": "string", "description": "Command to run in the persistent shell."},
1878                "write_stdin": {"type": "string", "description": "Raw text to write to the shell's stdin instead of running a command."},
1879                "timeout_ms": {"type": "integer", "description": "Timeout in milliseconds (default 120000)."}
1880            },
1881            "additionalProperties": false
1882        })
1883    }
1884    async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<String> {
1885        let a: ShellArgs = parse_args(self.name(), args)?;
1886        let timeout = Duration::from_millis(a.timeout_ms.unwrap_or(self.default_timeout_ms));
1887        let mut guard = self.state.lock().await;
1888        self.ensure_started(&mut guard, ctx).await?;
1889        let st = guard.as_mut().expect("started");
1890
1891        if let Some(input) = a.write_stdin {
1892            st.stdin
1893                .write_all(input.as_bytes())
1894                .await
1895                .map_err(|e| Error::tool(self.name(), format!("write_stdin: {e}")))?;
1896            st.stdin.flush().await.ok();
1897            // Best-effort: read whatever output arrives within a short window.
1898            let out = read_available(&mut st.stdout, Duration::from_millis(800)).await;
1899            return Ok(if out.is_empty() {
1900                "(no output)".into()
1901            } else {
1902                out
1903            });
1904        }
1905
1906        let command = a
1907            .command
1908            .ok_or_else(|| Error::tool(self.name(), "provide `command` or `write_stdin`"))?;
1909        // Wrap so the command's stderr merges into stdout, then emit a sentinel
1910        // line carrying the exit code. The sentinel is unguessable per-call so a
1911        // command that echoes the base string can't spoof completion.
1912        let sentinel = shell_sentinel();
1913        let wrapped = format!("{{ {command}\n}} 2>&1\nprintf '%s %d\\n' '{sentinel}' \"$?\"\n");
1914        st.stdin
1915            .write_all(wrapped.as_bytes())
1916            .await
1917            .map_err(|e| Error::tool(self.name(), format!("write: {e}")))?;
1918        st.stdin.flush().await.ok();
1919
1920        // Accumulate bytes until the sentinel line appears. Byte-based (not
1921        // line-based) so a flush that splits mid-line can't stall us.
1922        let mut acc = String::new();
1923        let mut code = -1;
1924        let read_fut = async {
1925            let mut chunk = [0u8; 4096];
1926            loop {
1927                let n = st.stdout.read(&mut chunk).await.unwrap_or(0);
1928                if n == 0 {
1929                    break; // EOF
1930                }
1931                acc.push_str(&String::from_utf8_lossy(&chunk[..n]));
1932                if let Some(pos) = acc.find(&sentinel) {
1933                    let after = &acc[pos + sentinel.len()..];
1934                    if let Some(nl) = after.find('\n') {
1935                        code = after[..nl].trim().parse().unwrap_or(-1);
1936                        acc.truncate(pos);
1937                        break;
1938                    }
1939                }
1940            }
1941        };
1942        if tokio::time::timeout(timeout, read_fut).await.is_err() {
1943            return Err(Error::tool(
1944                self.name(),
1945                format!("command timed out after {timeout:?}"),
1946            ));
1947        }
1948        let output = if acc.trim().is_empty() {
1949            "(no output)".to_string()
1950        } else {
1951            acc.trim_end().to_string()
1952        };
1953        Ok(format!("exit code: {code}\n{output}"))
1954    }
1955}
1956
1957/// Read whatever bytes are available on a reader within `window`, returning the
1958/// decoded text. Used by `write_stdin` where there is no completion sentinel.
1959async fn read_available<R: AsyncReadExt + Unpin>(reader: &mut R, window: Duration) -> String {
1960    let mut buf = Vec::new();
1961    let mut chunk = [0u8; 4096];
1962    loop {
1963        match tokio::time::timeout(window, reader.read(&mut chunk)).await {
1964            Ok(Ok(0)) => break, // EOF
1965            Ok(Ok(n)) => buf.extend_from_slice(&chunk[..n]),
1966            Ok(Err(_)) => break,
1967            Err(_) => break, // window elapsed
1968        }
1969    }
1970    String::from_utf8_lossy(&buf).into_owned()
1971}
1972
1973// ---- update_plan ----------------------------------------------------------
1974
1975/// A simple plan / task tracker. The model calls it to record or update a
1976/// checklist of steps (the analog of Codex `update_plan` / Claude's plan mode).
1977/// The plan is held in the tool so it persists across calls within a session.
1978pub struct UpdatePlanTool {
1979    plan: std::sync::Mutex<Vec<PlanStep>>,
1980}
1981
1982impl Default for UpdatePlanTool {
1983    fn default() -> Self {
1984        UpdatePlanTool {
1985            plan: std::sync::Mutex::new(Vec::new()),
1986        }
1987    }
1988}
1989
1990#[derive(Deserialize, Clone)]
1991struct PlanStep {
1992    step: String,
1993    #[serde(default = "default_status")]
1994    status: String,
1995}
1996fn default_status() -> String {
1997    "pending".to_string()
1998}
1999
2000#[derive(Deserialize)]
2001struct PlanArgs {
2002    plan: Vec<PlanStep>,
2003}
2004
2005impl UpdatePlanTool {
2006    /// The current plan as `(step, status)` pairs.
2007    pub fn current(&self) -> Vec<(String, String)> {
2008        self.plan
2009            .lock()
2010            .unwrap()
2011            .iter()
2012            .map(|s| (s.step.clone(), s.status.clone()))
2013            .collect()
2014    }
2015}
2016
2017#[async_trait]
2018impl Tool for UpdatePlanTool {
2019    fn name(&self) -> &str {
2020        "update_plan"
2021    }
2022    fn description(&self) -> &str {
2023        "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."
2024    }
2025    fn parameters(&self) -> Value {
2026        json!({
2027            "type": "object",
2028            "properties": {
2029                "plan": {
2030                    "type": "array",
2031                    "items": {
2032                        "type": "object",
2033                        "properties": {
2034                            "step": {"type": "string"},
2035                            "status": {"type": "string", "enum": ["pending", "in_progress", "completed"]}
2036                        },
2037                        "required": ["step"]
2038                    }
2039                }
2040            },
2041            "required": ["plan"],
2042            "additionalProperties": false
2043        })
2044    }
2045    async fn execute(&self, args: Value, _ctx: &ToolContext) -> Result<String> {
2046        let a: PlanArgs = parse_args(self.name(), args)?;
2047        *self.plan.lock().unwrap() = a.plan.clone();
2048        let rendered = a
2049            .plan
2050            .iter()
2051            .map(|s| {
2052                let mark = match s.status.as_str() {
2053                    "completed" => "[x]",
2054                    "in_progress" => "[~]",
2055                    _ => "[ ]",
2056                };
2057                format!("{mark} {}", s.step)
2058            })
2059            .collect::<Vec<_>>()
2060            .join("\n");
2061        Ok(if rendered.is_empty() {
2062            "(empty plan)".into()
2063        } else {
2064            format!("Plan updated:\n{rendered}")
2065        })
2066    }
2067}
2068
2069/// P4c-review (LOW follow-up): render a `reqwest::Error` together with its
2070/// full `source()` chain. `reqwest::Error`'s own `Display` for a redirect
2071/// blocked by [`network_checked_redirect_policy`] shows only "error
2072/// following redirect for url (...)" — the actually useful reason (this
2073/// crate's own "host `x` is denied by the active network policy" message)
2074/// lives one level down the `source()` chain and would otherwise be
2075/// silently dropped, leaving a vague tool error for the model to act on.
2076fn describe_reqwest_error(e: &reqwest::Error) -> String {
2077    let mut out = e.to_string();
2078    let mut source = std::error::Error::source(e);
2079    while let Some(s) = source {
2080        out.push_str(": ");
2081        out.push_str(&s.to_string());
2082        source = s.source();
2083    }
2084    out
2085}
2086
2087// ---- web_fetch / web_search (S2 module 5 `tools.web`) --------------------
2088
2089/// P4c (S2 module 5 `tools.web`, S4a "trivially addable... single tool
2090/// each, no loop changes"): fetch a URL and return its response body
2091/// (capped, text-decoded lossily). NOT registered by default — only reached
2092/// via `[capabilities.tools_web]` (module 5), off by default (§3.1). SECURITY
2093/// (S2.1 S17): honors `ToolContext::network_policy` when one is configured
2094/// (see `ToolContext::check_network`) — a caller (SDK embedder) that has set
2095/// up a sandbox/network policy on the context gets it enforced here too;
2096/// with no policy configured (today's honest default), the fetch is
2097/// unrestricted, same posture as every other network-capable path in this
2098/// crate today (C3).
2099pub struct WebFetchTool;
2100
2101/// Cap the fetched body at this many bytes before returning it to the model
2102/// — the same order of magnitude as `MAX_READ_BYTES`.
2103const MAX_FETCH_BYTES: usize = 200_000;
2104
2105#[derive(Deserialize)]
2106struct WebFetchArgs {
2107    url: String,
2108}
2109
2110#[async_trait]
2111impl Tool for WebFetchTool {
2112    fn name(&self) -> &str {
2113        "web_fetch"
2114    }
2115    fn description(&self) -> &str {
2116        "Fetch a URL over HTTP(S) and return its response body as text (truncated if large)."
2117    }
2118    fn parameters(&self) -> Value {
2119        json!({
2120            "type": "object",
2121            "properties": {
2122                "url": {"type": "string", "description": "The http:// or https:// URL to fetch."}
2123            },
2124            "required": ["url"],
2125            "additionalProperties": false
2126        })
2127    }
2128    async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<String> {
2129        let a: WebFetchArgs = parse_args(self.name(), args)?;
2130        ctx.check_network(&a.url)?;
2131        if !a.url.starts_with("http://") && !a.url.starts_with("https://") {
2132            return Err(Error::tool(self.name(), "url must be http:// or https://"));
2133        }
2134        // P4c-review (MEDIUM/LOW follow-up): re-run the same host check on
2135        // every redirect hop, not just this initial url — closes the SSRF
2136        // gap where a denied host reachable only via an allowed host's HTTP
2137        // redirect would otherwise slip past `check_network` above (see
2138        // `network_checked_redirect_policy`'s doc comment).
2139        let client = reqwest::Client::builder()
2140            .timeout(Duration::from_secs(30))
2141            .redirect(network_checked_redirect_policy(ctx.network_policy.clone()))
2142            .build()
2143            .map_err(|e| Error::tool(self.name(), e.to_string()))?;
2144        let resp = client.get(&a.url).send().await.map_err(|e| {
2145            Error::tool(
2146                self.name(),
2147                format!("fetch failed: {}", describe_reqwest_error(&e)),
2148            )
2149        })?;
2150        let status = resp.status();
2151        let body = resp
2152            .text()
2153            .await
2154            .map_err(|e| Error::tool(self.name(), format!("failed to read response body: {e}")))?;
2155        let mut end = MAX_FETCH_BYTES.min(body.len());
2156        while end > 0 && !body.is_char_boundary(end) {
2157            end -= 1;
2158        }
2159        let truncated = body.len() > MAX_FETCH_BYTES;
2160        let shown = &body[..end];
2161        Ok(if truncated {
2162            format!(
2163                "[web_fetch: HTTP {status}; body is {} bytes, showing first {end}]\n{shown}",
2164                body.len()
2165            )
2166        } else {
2167            format!("[web_fetch: HTTP {status}]\n{shown}")
2168        })
2169    }
2170}
2171
2172/// P4c (S2 module 5 `tools.web`): perform a web search. supercode bundles no
2173/// search provider of its own (S4a "single tool... no loop changes" scope —
2174/// not a search-engine implementation); a configured search endpoint
2175/// (`SUPERCODE_WEB_SEARCH_URL`, queried as `<url>?q=<query>`) is required,
2176/// same "bring your own credential/endpoint" posture `Config::base_url`
2177/// already has for the model provider itself. Absent that, the tool returns
2178/// a clear configuration error rather than silently no-op'ing.
2179pub struct WebSearchTool;
2180
2181/// Environment variable naming the search endpoint `WebSearchTool` queries.
2182pub const WEB_SEARCH_URL_ENV: &str = "SUPERCODE_WEB_SEARCH_URL";
2183
2184#[derive(Deserialize)]
2185struct WebSearchArgs {
2186    query: String,
2187}
2188
2189#[async_trait]
2190impl Tool for WebSearchTool {
2191    fn name(&self) -> &str {
2192        "web_search"
2193    }
2194    fn description(&self) -> &str {
2195        "Search the web and return matching results as text."
2196    }
2197    fn parameters(&self) -> Value {
2198        json!({
2199            "type": "object",
2200            "properties": {
2201                "query": {"type": "string", "description": "Search query."}
2202            },
2203            "required": ["query"],
2204            "additionalProperties": false
2205        })
2206    }
2207    async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<String> {
2208        let a: WebSearchArgs = parse_args(self.name(), args)?;
2209        let endpoint = std::env::var(WEB_SEARCH_URL_ENV).map_err(|_| {
2210            Error::tool(
2211                self.name(),
2212                format!(
2213                    "web_search requires a configured search endpoint; set {WEB_SEARCH_URL_ENV}"
2214                ),
2215            )
2216        })?;
2217        ctx.check_network(&endpoint)?;
2218        // P4c-review: same redirect-hop re-check as `WebFetchTool` — see
2219        // `network_checked_redirect_policy`'s doc comment.
2220        let client = reqwest::Client::builder()
2221            .timeout(Duration::from_secs(30))
2222            .redirect(network_checked_redirect_policy(ctx.network_policy.clone()))
2223            .build()
2224            .map_err(|e| Error::tool(self.name(), e.to_string()))?;
2225        let resp = client
2226            .get(&endpoint)
2227            .query(&[("q", &a.query)])
2228            .send()
2229            .await
2230            .map_err(|e| {
2231                Error::tool(
2232                    self.name(),
2233                    format!("search failed: {}", describe_reqwest_error(&e)),
2234                )
2235            })?;
2236        let status = resp.status();
2237        let body = resp
2238            .text()
2239            .await
2240            .map_err(|e| Error::tool(self.name(), format!("failed to read response body: {e}")))?;
2241        let mut end = MAX_FETCH_BYTES.min(body.len());
2242        while end > 0 && !body.is_char_boundary(end) {
2243            end -= 1;
2244        }
2245        Ok(format!("[web_search: HTTP {status}]\n{}", &body[..end]))
2246    }
2247}