1use std::path::{Path, PathBuf};
5use std::time::Duration;
6
7use async_trait::async_trait;
8use serde::de::DeserializeOwned;
9use serde::Deserialize;
10use serde_json::{json, Value};
11
12use crate::error::{Error, Result};
13use crate::tools::convert::{is_notebook_path, is_pdf_path, notebook_markdown, pdf_markdown};
14use crate::tools::{
15 image_mime_for, is_image_path, network_checked_redirect_policy, Tool, ToolContext,
16 MULTIMODAL_IMAGE_MARKER, NOTEBOOK_EXTENSION,
17};
18
19pub(crate) const MAX_READ_BYTES: usize = 400_000;
24const DEFAULT_BASH_TIMEOUT_MS: u64 = 120_000;
25
26pub(crate) fn parse_args<T: DeserializeOwned>(tool: &str, args: Value) -> Result<T> {
27 serde_json::from_value(args).map_err(|e| Error::InvalidArguments {
28 tool: tool.to_string(),
29 message: e.to_string(),
30 })
31}
32
33fn rel(ctx: &ToolContext, p: &Path) -> String {
34 p.strip_prefix(&ctx.cwd)
35 .unwrap_or(p)
36 .to_string_lossy()
37 .into_owned()
38}
39
40pub(crate) fn base64_encode(bytes: &[u8]) -> String {
45 const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
46 let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
47 for chunk in bytes.chunks(3) {
48 let b0 = chunk[0];
49 let b1 = chunk.get(1).copied();
50 let b2 = chunk.get(2).copied();
51 out.push(ALPHABET[(b0 >> 2) as usize] as char);
52 out.push(ALPHABET[(((b0 & 0x03) << 4) | (b1.unwrap_or(0) >> 4)) as usize] as char);
53 match b1 {
54 Some(b1) => {
55 out.push(ALPHABET[(((b1 & 0x0f) << 2) | (b2.unwrap_or(0) >> 6)) as usize] as char)
56 }
57 None => out.push('='),
58 }
59 match b2 {
60 Some(b2) => out.push(ALPHABET[(b2 & 0x3f) as usize] as char),
61 None => out.push('='),
62 }
63 }
64 out
65}
66
67fn pdf_tool_result(path: &Path, bytes: &[u8]) -> String {
71 pdf_markdown(
72 &path.file_name().unwrap_or_default().to_string_lossy(),
73 bytes,
74 )
75}
76
77fn notebook_tool_result(path: &Path, bytes: &[u8]) -> String {
79 notebook_markdown(
80 &path.file_name().unwrap_or_default().to_string_lossy(),
81 bytes,
82 )
83}
84
85fn image_tool_result(path: &Path, bytes: &[u8]) -> String {
89 let mime = image_mime_for(path);
90 let b64 = base64_encode(bytes);
91 format!("{MULTIMODAL_IMAGE_MARKER}data:{mime};base64,{b64}")
92}
93
94fn nested_instructions_notice(ctx: &ToolContext, touched: &Path) -> Option<String> {
103 if !ctx.nested_instructions {
104 return None;
105 }
106 let dir = if touched.is_dir() {
107 touched.to_path_buf()
108 } else {
109 touched.parent()?.to_path_buf()
110 };
111 if !crate::agent::import_target_is_contained(&dir, &ctx.cwd) {
112 return None;
113 }
114 let root = std::fs::canonicalize(&ctx.cwd).unwrap_or_else(|_| ctx.cwd.clone());
115 let real_dir = std::fs::canonicalize(&dir).ok()?;
116 if real_dir == root {
117 return None;
119 }
120 let mut found: Option<(PathBuf, String)> = None;
121 for name in ["CLAUDE.md", "AGENTS.md"] {
122 let candidate = dir.join(name);
123 if let Ok(content) = std::fs::read_to_string(&candidate) {
124 found = Some((candidate, content));
125 break;
126 }
127 }
128 let (candidate, content) = found?;
129 {
130 let mut seen = ctx.injected_instruction_dirs.lock().ok()?;
131 if !seen.insert(real_dir) {
132 return None;
134 }
135 }
136 let shown = rel(ctx, &candidate);
137 Some(format!(
138 "\n\n[nested instructions from {shown}]\n{}",
139 content.trim()
140 ))
141}
142
143fn path_rules_notice(ctx: &ToolContext, touched: &Path) -> Option<String> {
156 if ctx.path_rules.is_empty() {
157 return None;
158 }
159 let mut out = String::new();
160 for rule in ctx.path_rules.iter().filter(|r| r.is_scoped()) {
161 if !rule.matches(touched, &ctx.cwd) {
162 continue;
163 }
164 {
165 let mut seen = ctx.injected_rule_files.lock().ok()?;
166 if !seen.insert(rule.path.clone()) {
167 continue;
168 }
169 }
170 out.push_str("\n\n");
171 out.push_str(&rule.render());
172 }
173 (!out.is_empty()).then_some(out)
174}
175
176fn render_read_slice(ctx: &ToolContext, slice: &str, first_line: usize) -> String {
185 if !ctx.read_line_numbers {
186 return slice.to_string();
187 }
188 number_lines(slice, first_line)
189}
190
191pub(crate) fn number_lines(text: &str, first_line: usize) -> String {
193 if text.is_empty() {
194 return String::new();
195 }
196 let trailing_newline = text.ends_with('\n');
197 let mut out = String::with_capacity(text.len() + 8);
198 for (offset, line) in text.lines().enumerate() {
199 out.push_str(&format!("{:>6}\t{line}\n", first_line + offset));
200 }
201 if !trailing_newline {
202 out.pop();
203 }
204 out
205}
206
207pub struct ReadFileTool;
211
212#[derive(Deserialize)]
213struct ReadArgs {
214 path: String,
215 #[serde(default)]
216 offset: Option<usize>,
217 #[serde(default)]
218 limit: Option<usize>,
219}
220
221#[async_trait]
222impl Tool for ReadFileTool {
223 fn name(&self) -> &str {
224 "read_file"
225 }
226 fn description(&self) -> &str {
227 "Read the contents of a UTF-8 text file. Output may be line-numbered `cat -n` style (a right-aligned number and a tab before each line, numbered from `offset`); the numbers are the gutter, not file content. Large files are returned truncated from the start (with a notice stating the true size); pass `offset` (1-based start line) and/or `limit` (number of lines) to read further slices. PDFs come back as extracted text and Jupyter notebooks as their cells with outputs."
228 }
229 fn parameters(&self) -> Value {
230 json!({
231 "type": "object",
232 "properties": {
233 "path": {"type": "string", "description": "File path, absolute or relative to the working directory."},
234 "offset": {"type": "integer", "description": "1-based line to start at."},
235 "limit": {"type": "integer", "description": "Maximum number of lines to return."}
236 },
237 "required": ["path"],
238 "additionalProperties": false
239 })
240 }
241 async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<String> {
242 let a: ReadArgs = parse_args(self.name(), args)?;
243 let path = ctx.resolve(&a.path);
244 let bytes = tokio::fs::read(&path)
245 .await
246 .map_err(|e| Error::tool(self.name(), format!("{}: {e}", path.display())))?;
247 if ctx.multimodal_read && is_image_path(&path) {
255 ctx.mark_read_bytes(&path, &bytes);
256 return Ok(image_tool_result(&path, &bytes));
257 }
258 if ctx.multimodal_read && is_pdf_path(&path) {
265 ctx.mark_read_bytes(&path, &bytes);
266 return Ok(pdf_tool_result(&path, &bytes));
267 }
268 if ctx.multimodal_read && is_notebook_path(&path) {
269 ctx.mark_read_bytes(&path, &bytes);
270 return Ok(notebook_tool_result(&path, &bytes));
271 }
272 let text = String::from_utf8_lossy(&bytes);
273 let result = if a.offset.is_none() && a.limit.is_none() {
274 if bytes.len() > MAX_READ_BYTES {
275 let total = bytes.len();
276 let mut end = MAX_READ_BYTES.min(text.len());
278 while end > 0 && !text.is_char_boundary(end) {
279 end -= 1;
280 }
281 if let Some(nl) = text[..end].rfind('\n') {
284 end = nl + 1;
285 }
286 let shown = end;
287 let lines = text[..end].matches('\n').count();
288 let notice = format!(
289 "[read_file: file is {total} bytes; showing first {shown} bytes ({lines} lines). Pass offset/limit to read more.]\n"
290 );
291 notice + &render_read_slice(ctx, &text[..end], 1)
292 } else {
293 render_read_slice(ctx, &text, 1)
294 }
295 } else {
296 let start = a.offset.unwrap_or(1).saturating_sub(1);
297 let limit = a.limit.unwrap_or(usize::MAX);
298 let sliced: Vec<&str> = text.lines().skip(start).take(limit).collect();
299 render_read_slice(ctx, &sliced.join("\n"), start + 1)
300 };
301 ctx.mark_read_bytes(&path, &bytes);
307 let mut result = result;
308 if let Some(notice) = nested_instructions_notice(ctx, &path) {
310 result.push_str(¬ice);
311 }
312 if let Some(notice) = path_rules_notice(ctx, &path) {
314 result.push_str(¬ice);
315 }
316 Ok(result)
317 }
318}
319
320pub struct ViewImageTool;
329
330#[derive(Deserialize)]
331struct ViewImageArgs {
332 path: String,
333}
334
335#[async_trait]
336impl Tool for ViewImageTool {
337 fn name(&self) -> &str {
338 "view_image"
339 }
340 fn description(&self) -> &str {
341 "Read a local image file and return it as a model-visible image content block."
342 }
343 fn parameters(&self) -> Value {
344 json!({
345 "type": "object",
346 "properties": {
347 "path": {"type": "string", "description": "Image file path, absolute or relative to the working directory."}
348 },
349 "required": ["path"],
350 "additionalProperties": false
351 })
352 }
353 async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<String> {
354 let a: ViewImageArgs = parse_args(self.name(), args)?;
355 let path = ctx.resolve(&a.path);
356 if !is_image_path(&path) {
357 return Err(Error::tool(
358 self.name(),
359 format!(
360 "{} is not a recognized image file (expected one of: png, jpg, jpeg, gif, webp, bmp)",
361 path.display()
362 ),
363 ));
364 }
365 let bytes = tokio::fs::read(&path)
366 .await
367 .map_err(|e| Error::tool(self.name(), format!("{}: {e}", path.display())))?;
368 ctx.mark_read(&path);
369 Ok(image_tool_result(&path, &bytes))
370 }
371}
372
373pub struct WriteFileTool;
377
378#[derive(Deserialize)]
379struct WriteArgs {
380 path: String,
381 content: String,
382}
383
384#[async_trait]
385impl Tool for WriteFileTool {
386 fn name(&self) -> &str {
387 "write_file"
388 }
389 fn description(&self) -> &str {
390 "Create or overwrite a file with the given contents. Parent directories are created as needed."
391 }
392 fn parameters(&self) -> Value {
393 json!({
394 "type": "object",
395 "properties": {
396 "path": {"type": "string", "description": "File path to write."},
397 "content": {"type": "string", "description": "Full file contents."}
398 },
399 "required": ["path", "content"],
400 "additionalProperties": false
401 })
402 }
403 async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<String> {
404 let a: WriteArgs = parse_args(self.name(), args)?;
405 let path = ctx.resolve(&a.path);
406 ctx.check_write(&path)?;
407 if let Some(obs) = &ctx.write_observer {
411 obs.before_write(&path).await;
412 }
413 if let Some(parent) = path.parent() {
414 tokio::fs::create_dir_all(parent).await.ok();
415 }
416 tokio::fs::write(&path, a.content.as_bytes())
417 .await
418 .map_err(|e| Error::tool(self.name(), format!("{}: {e}", path.display())))?;
419 let mut annotation = String::new();
425 if let Some(obs) = &ctx.write_observer {
426 if let Some(note) = obs.after_write(&path).await {
427 annotation = format!("\n\n{note}");
428 }
429 }
430 Ok(format!(
431 "Wrote {} bytes to {}{}",
432 a.content.len(),
433 rel(ctx, &path),
434 annotation
435 ))
436 }
437}
438
439pub struct EditFileTool;
443
444#[derive(Deserialize)]
445struct EditArgs {
446 path: String,
447 #[serde(default)]
448 old_string: String,
449 #[serde(default)]
450 new_string: String,
451 #[serde(default)]
452 replace_all: bool,
453 #[serde(default)]
457 cell_index: Option<usize>,
458 #[serde(default)]
460 cell_op: Option<String>,
461 #[serde(default)]
463 cell_source: Option<String>,
464 #[serde(default)]
466 cell_type: Option<String>,
467}
468
469#[async_trait]
470impl Tool for EditFileTool {
471 fn name(&self) -> &str {
472 "edit_file"
473 }
474 fn description(&self) -> &str {
475 "Replace an exact substring in a file. By default `old_string` must occur exactly once; set `replace_all` to replace every occurrence. When notebook-aware editing is enabled, pass `cell_index`/`cell_op` (`replace`|`insert`|`delete`) instead to edit a Jupyter `.ipynb` cell."
476 }
477 fn parameters(&self) -> Value {
478 json!({
479 "type": "object",
480 "properties": {
481 "path": {"type": "string"},
482 "old_string": {"type": "string", "description": "Exact text to replace."},
483 "new_string": {"type": "string", "description": "Replacement text."},
484 "replace_all": {"type": "boolean", "description": "Replace every occurrence instead of requiring uniqueness."},
485 "cell_index": {"type": "integer", "description": "0-based Jupyter cell index (notebook-aware mode only)."},
486 "cell_op": {"type": "string", "enum": ["replace", "insert", "delete"], "description": "Notebook cell operation (notebook-aware mode only)."},
487 "cell_source": {"type": "string", "description": "New cell source text (notebook-aware `replace`/`insert`)."},
488 "cell_type": {"type": "string", "enum": ["code", "markdown"], "description": "Cell type for notebook-aware `insert` (default `code`)."}
489 },
490 "required": ["path"],
491 "additionalProperties": false
492 })
493 }
494 async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<String> {
495 let a: EditArgs = parse_args(self.name(), args)?;
496 let path = ctx.resolve(&a.path);
497 ctx.check_write(&path)?;
498 if let Some(obs) = &ctx.write_observer {
503 obs.before_write(&path).await;
504 }
505
506 if ctx.require_read_before_edit {
516 match ctx.read_state(&path) {
517 crate::tools::ReadState::Fresh => {}
518 crate::tools::ReadState::NeverRead => {
519 return Err(Error::tool(
520 self.name(),
521 format!(
522 "{} must be read with `read_file` before it can be edited this conversation",
523 path.display()
524 ),
525 ))
526 }
527 crate::tools::ReadState::Stale => {
528 return Err(Error::tool(
529 self.name(),
530 format!(
531 "{} has changed on disk since it was read; read it again before editing",
532 path.display()
533 ),
534 ))
535 }
536 }
537 }
538
539 if ctx.notebook_aware
544 && a.cell_op.is_some()
545 && path.extension().and_then(|e| e.to_str()) == Some(NOTEBOOK_EXTENSION)
546 {
547 let result = edit_notebook_cell(self.name(), ctx, &path, &a).await?;
548 ctx.mark_read(&path);
550 let mut annotation = String::new();
551 if let Some(obs) = &ctx.write_observer {
552 if let Some(note) = obs.after_write(&path).await {
553 annotation = format!("\n\n{note}");
554 }
555 }
556 return Ok(format!("{result}{annotation}"));
557 }
558
559 if a.old_string.is_empty() {
560 return Err(Error::tool(self.name(), "old_string must not be empty"));
563 }
564 let original = tokio::fs::read_to_string(&path)
565 .await
566 .map_err(|e| Error::tool(self.name(), format!("{}: {e}", path.display())))?;
567 let count = original.matches(&a.old_string).count();
568 if count == 0 {
569 return Err(Error::tool(self.name(), "old_string not found in file"));
570 }
571 if count > 1 && !a.replace_all {
572 return Err(Error::tool(
573 self.name(),
574 format!("old_string occurs {count} times; pass replace_all or add more context"),
575 ));
576 }
577 let updated = if a.replace_all {
578 original.replace(&a.old_string, &a.new_string)
579 } else {
580 original.replacen(&a.old_string, &a.new_string, 1)
581 };
582 tokio::fs::write(&path, updated.as_bytes())
583 .await
584 .map_err(|e| Error::tool(self.name(), format!("{}: {e}", path.display())))?;
585 ctx.mark_read_bytes(&path, updated.as_bytes());
593 let mut result = format!(
594 "Replaced {} occurrence(s) in {}",
595 if a.replace_all { count } else { 1 },
596 rel(ctx, &path)
597 );
598 if let Some(obs) = &ctx.write_observer {
600 if let Some(note) = obs.after_write(&path).await {
601 result.push_str("\n\n");
602 result.push_str(¬e);
603 }
604 }
605 if let Some(notice) = nested_instructions_notice(ctx, &path) {
606 result.push_str(¬ice);
607 }
608 if let Some(notice) = path_rules_notice(ctx, &path) {
610 result.push_str(¬ice);
611 }
612 Ok(result)
613 }
614}
615
616async fn edit_notebook_cell(
623 tool_name: &str,
624 ctx: &ToolContext,
625 path: &Path,
626 a: &EditArgs,
627) -> Result<String> {
628 let text = tokio::fs::read_to_string(path)
629 .await
630 .map_err(|e| Error::tool(tool_name, format!("{}: {e}", path.display())))?;
631 let mut doc: Value = serde_json::from_str(&text).map_err(|e| {
632 Error::tool(
633 tool_name,
634 format!("{}: not valid notebook JSON: {e}", path.display()),
635 )
636 })?;
637 let cells = doc
638 .get_mut("cells")
639 .and_then(|c| c.as_array_mut())
640 .ok_or_else(|| Error::tool(tool_name, format!("{}: no `cells` array", path.display())))?;
641 let index = a
642 .cell_index
643 .ok_or_else(|| Error::tool(tool_name, "cell_index is required for notebook cell edits"))?;
644 let op = a.cell_op.as_deref().unwrap_or("replace");
645 let summary = match op {
646 "delete" => {
647 if index >= cells.len() {
648 return Err(Error::tool(
649 tool_name,
650 format!("cell_index {index} out of range (0..{})", cells.len()),
651 ));
652 }
653 cells.remove(index);
654 format!("Deleted cell {index}")
655 }
656 "insert" => {
657 let source = a
658 .cell_source
659 .clone()
660 .ok_or_else(|| Error::tool(tool_name, "cell_source is required for insert"))?;
661 let cell_type = a.cell_type.as_deref().unwrap_or("code");
662 let new_cell = json!({
663 "cell_type": cell_type,
664 "metadata": {},
665 "source": [source],
666 "outputs": if cell_type == "code" { json!([]) } else { json!(null) },
667 "execution_count": json!(null),
668 });
669 if index > cells.len() {
670 return Err(Error::tool(
671 tool_name,
672 format!("cell_index {index} out of range (0..={})", cells.len()),
673 ));
674 }
675 cells.insert(index, new_cell);
676 format!("Inserted a {cell_type} cell at {index}")
677 }
678 "replace" => {
679 let source = a
680 .cell_source
681 .clone()
682 .ok_or_else(|| Error::tool(tool_name, "cell_source is required for replace"))?;
683 let len = cells.len();
684 let cell = cells.get_mut(index).ok_or_else(|| {
685 Error::tool(
686 tool_name,
687 format!("cell_index {index} out of range (0..{len})"),
688 )
689 })?;
690 cell["source"] = json!([source]);
691 format!("Replaced source of cell {index}")
692 }
693 other => {
694 return Err(Error::tool(
695 tool_name,
696 format!("unknown cell_op `{other}` (expected replace|insert|delete)"),
697 ))
698 }
699 };
700 let rendered =
701 serde_json::to_string_pretty(&doc).map_err(|e| Error::tool(tool_name, e.to_string()))?;
702 tokio::fs::write(path, rendered.as_bytes())
703 .await
704 .map_err(|e| Error::tool(tool_name, format!("{}: {e}", path.display())))?;
705 Ok(format!("{summary} in {}", rel(ctx, path)))
706}
707
708pub struct ListDirTool;
712
713#[derive(Deserialize)]
714struct ListArgs {
715 #[serde(default)]
716 path: Option<String>,
717}
718
719#[async_trait]
720impl Tool for ListDirTool {
721 fn name(&self) -> &str {
722 "list_dir"
723 }
724 fn description(&self) -> &str {
725 "List the entries of a directory (defaults to the working directory). Directories are suffixed with `/`."
726 }
727 fn parameters(&self) -> Value {
728 json!({
729 "type": "object",
730 "properties": {
731 "path": {"type": "string", "description": "Directory to list. Defaults to the working directory."}
732 },
733 "additionalProperties": false
734 })
735 }
736 async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<String> {
737 let a: ListArgs = parse_args(self.name(), args)?;
738 let dir = match a.path {
739 Some(p) => ctx.resolve(&p),
740 None => ctx.cwd.clone(),
741 };
742 let mut rd = tokio::fs::read_dir(&dir)
743 .await
744 .map_err(|e| Error::tool(self.name(), format!("{}: {e}", dir.display())))?;
745 let mut entries = Vec::new();
746 while let Some(e) = rd
747 .next_entry()
748 .await
749 .map_err(|e| Error::tool(self.name(), e.to_string()))?
750 {
751 let name = e.file_name().to_string_lossy().into_owned();
752 let is_dir = e.file_type().await.map(|t| t.is_dir()).unwrap_or(false);
753 entries.push(if is_dir { format!("{name}/") } else { name });
754 }
755 entries.sort();
756 if entries.is_empty() {
757 Ok("(empty directory)".to_string())
758 } else {
759 Ok(entries.join("\n"))
760 }
761 }
762}
763
764pub struct GlobTool;
768
769#[derive(Deserialize)]
770struct GlobArgs {
771 pattern: String,
772}
773
774#[async_trait]
775impl Tool for GlobTool {
776 fn name(&self) -> &str {
777 "glob"
778 }
779 fn description(&self) -> &str {
780 "Find files matching a glob pattern (e.g. `src/**/*.rs`), relative to the working directory."
781 }
782 fn parameters(&self) -> Value {
783 json!({
784 "type": "object",
785 "properties": {
786 "pattern": {"type": "string", "description": "Glob pattern, e.g. **/*.rs"}
787 },
788 "required": ["pattern"],
789 "additionalProperties": false
790 })
791 }
792 async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<String> {
793 let a: GlobArgs = parse_args(self.name(), args)?;
794 let cwd = ctx.cwd.clone();
795 let full = if PathBuf::from(&a.pattern).is_absolute() {
796 a.pattern.clone()
797 } else {
798 cwd.join(&a.pattern).to_string_lossy().into_owned()
799 };
800 let cwd2 = cwd.clone();
801 let matches = tokio::task::spawn_blocking(move || {
802 let mut out = Vec::new();
803 if let Ok(paths) = glob::glob(&full) {
804 for p in paths.flatten() {
805 let display = p
806 .strip_prefix(&cwd2)
807 .unwrap_or(&p)
808 .to_string_lossy()
809 .into_owned();
810 out.push(display);
811 }
812 }
813 out
814 })
815 .await
816 .map_err(|e| Error::tool("glob", e.to_string()))?;
817 if matches.is_empty() {
818 Ok("(no matches)".to_string())
819 } else {
820 Ok(matches.join("\n"))
821 }
822 }
823}
824
825pub struct SearchTool;
829
830#[derive(Deserialize)]
831struct SearchArgs {
832 pattern: String,
833 #[serde(default)]
834 path: Option<String>,
835 #[serde(default)]
836 max_results: Option<usize>,
837}
838
839#[async_trait]
840impl Tool for SearchTool {
841 fn name(&self) -> &str {
842 "search"
843 }
844 fn description(&self) -> &str {
845 "Search file contents with a regular expression, respecting .gitignore. Returns `path:line: text` matches."
846 }
847 fn parameters(&self) -> Value {
848 json!({
849 "type": "object",
850 "properties": {
851 "pattern": {"type": "string", "description": "Regular expression to search for."},
852 "path": {"type": "string", "description": "Directory or file to search. Defaults to the working directory."},
853 "max_results": {"type": "integer", "description": "Cap on the number of matches (default 200)."}
854 },
855 "required": ["pattern"],
856 "additionalProperties": false
857 })
858 }
859 async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<String> {
860 let a: SearchArgs = parse_args(self.name(), args)?;
861 let re = regex::Regex::new(&a.pattern)
862 .map_err(|e| Error::tool(self.name(), format!("invalid regex: {e}")))?;
863 let root = match a.path {
864 Some(p) => ctx.resolve(&p),
865 None => ctx.cwd.clone(),
866 };
867 let cwd = ctx.cwd.clone();
868 let cap = a.max_results.unwrap_or(200);
869 let results = tokio::task::spawn_blocking(move || {
870 let mut out: Vec<String> = Vec::new();
871 let walker = ignore::WalkBuilder::new(&root).build();
872 'outer: for entry in walker.flatten() {
873 if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
874 continue;
875 }
876 let path = entry.path();
877 let Ok(content) = std::fs::read_to_string(path) else {
878 continue; };
880 for (i, line) in content.lines().enumerate() {
881 if re.is_match(line) {
882 let rel = path.strip_prefix(&cwd).unwrap_or(path);
883 out.push(format!("{}:{}: {}", rel.display(), i + 1, line.trim_end()));
884 if out.len() >= cap {
885 break 'outer;
886 }
887 }
888 }
889 }
890 out
891 })
892 .await
893 .map_err(|e| Error::tool("search", e.to_string()))?;
894 if results.is_empty() {
895 Ok("(no matches)".to_string())
896 } else {
897 Ok(results.join("\n"))
898 }
899 }
900}
901
902pub struct BashTool {
906 default_timeout_ms: u64,
907}
908
909#[cfg(unix)]
915struct BashProcessTreeGuard(Option<u32>);
916
917#[cfg(windows)]
918struct BashProcessTreeGuard(Option<usize>);
919
920#[cfg(not(any(unix, windows)))]
921struct BashProcessTreeGuard;
922
923impl BashProcessTreeGuard {
924 #[cfg(unix)]
925 fn prepare() -> std::io::Result<Self> {
926 Ok(Self(None))
927 }
928
929 #[cfg(windows)]
930 fn prepare() -> std::io::Result<Self> {
931 use windows_sys::Win32::Foundation::CloseHandle;
932 use windows_sys::Win32::System::JobObjects::{
933 CreateJobObjectW, JobObjectExtendedLimitInformation, SetInformationJobObject,
934 JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
935 };
936
937 let job = unsafe { CreateJobObjectW(std::ptr::null(), std::ptr::null()) };
941 if job.is_null() {
942 return Err(std::io::Error::last_os_error());
943 }
944 let mut limits: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = unsafe { std::mem::zeroed() };
945 limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
946 let configured = unsafe {
947 SetInformationJobObject(
948 job,
949 JobObjectExtendedLimitInformation,
950 std::ptr::addr_of!(limits).cast(),
951 std::mem::size_of_val(&limits) as u32,
952 )
953 };
954 if configured == 0 {
955 let error = std::io::Error::last_os_error();
956 unsafe {
957 CloseHandle(job);
958 }
959 return Err(error);
960 }
961 Ok(Self(Some(job as usize)))
962 }
963
964 #[cfg(not(any(unix, windows)))]
965 fn prepare() -> std::io::Result<Self> {
966 Ok(Self)
967 }
968
969 fn configure_command(&self, command: &mut tokio::process::Command) {
970 #[cfg(unix)]
971 command.process_group(0);
972 #[cfg(windows)]
973 command.creation_flags(windows_sys::Win32::System::Threading::CREATE_SUSPENDED);
974 }
975
976 #[cfg(unix)]
977 fn attach_and_start(&mut self, child: &tokio::process::Child) -> std::io::Result<()> {
978 self.0 = child.id();
979 Ok(())
980 }
981
982 #[cfg(windows)]
983 fn attach_and_start(&mut self, child: &tokio::process::Child) -> std::io::Result<()> {
984 use windows_sys::Win32::System::JobObjects::AssignProcessToJobObject;
985
986 let job = self.0.ok_or_else(|| {
987 std::io::Error::new(
988 std::io::ErrorKind::BrokenPipe,
989 "command Job Object is closed",
990 )
991 })? as windows_sys::Win32::Foundation::HANDLE;
992 let process = child.raw_handle().ok_or_else(|| {
993 std::io::Error::new(
994 std::io::ErrorKind::BrokenPipe,
995 "suspended command has no process handle",
996 )
997 })?;
998 if unsafe { AssignProcessToJobObject(job, process.cast()) } == 0 {
999 return Err(std::io::Error::last_os_error());
1000 }
1001 Self::resume_primary_thread(child.id().ok_or_else(|| {
1002 std::io::Error::new(
1003 std::io::ErrorKind::BrokenPipe,
1004 "suspended command has no process id",
1005 )
1006 })?)
1007 }
1008
1009 #[cfg(windows)]
1010 fn resume_primary_thread(process_id: u32) -> std::io::Result<()> {
1011 use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE};
1012 use windows_sys::Win32::System::Diagnostics::ToolHelp::{
1013 CreateToolhelp32Snapshot, Thread32First, Thread32Next, TH32CS_SNAPTHREAD, THREADENTRY32,
1014 };
1015 use windows_sys::Win32::System::Threading::{
1016 OpenThread, ResumeThread, THREAD_SUSPEND_RESUME,
1017 };
1018
1019 let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0) };
1020 if snapshot == INVALID_HANDLE_VALUE {
1021 return Err(std::io::Error::last_os_error());
1022 }
1023 let result = (|| {
1024 let mut entry: THREADENTRY32 = unsafe { std::mem::zeroed() };
1025 entry.dwSize = std::mem::size_of::<THREADENTRY32>() as u32;
1026 let mut has_entry = unsafe { Thread32First(snapshot, &mut entry) } != 0;
1027 while has_entry {
1028 if entry.th32OwnerProcessID == process_id {
1029 let thread =
1030 unsafe { OpenThread(THREAD_SUSPEND_RESUME, 0, entry.th32ThreadID) };
1031 if thread.is_null() {
1032 return Err(std::io::Error::last_os_error());
1033 }
1034 let resumed = unsafe { ResumeThread(thread) };
1035 unsafe {
1036 CloseHandle(thread);
1037 }
1038 if resumed == u32::MAX {
1039 return Err(std::io::Error::last_os_error());
1040 }
1041 return Ok(());
1042 }
1043 has_entry = unsafe { Thread32Next(snapshot, &mut entry) } != 0;
1044 }
1045 Err(std::io::Error::new(
1046 std::io::ErrorKind::NotFound,
1047 "suspended command's primary thread was not found",
1048 ))
1049 })();
1050 unsafe {
1051 CloseHandle(snapshot);
1052 }
1053 result
1054 }
1055
1056 #[cfg(not(any(unix, windows)))]
1057 fn attach_and_start(&mut self, _child: &tokio::process::Child) -> std::io::Result<()> {
1058 Ok(())
1059 }
1060
1061 fn kill(&mut self) {
1062 #[cfg(unix)]
1063 if let Some(pid) = self.0.take() {
1064 crate::lsp::kill_process_group(pid);
1065 }
1066 #[cfg(windows)]
1067 if let Some(job) = self.0.take() {
1068 use windows_sys::Win32::Foundation::CloseHandle;
1069 use windows_sys::Win32::System::JobObjects::TerminateJobObject;
1070 let job = job as windows_sys::Win32::Foundation::HANDLE;
1071 unsafe {
1072 TerminateJobObject(job, 1);
1073 CloseHandle(job);
1074 }
1075 }
1076 }
1077}
1078
1079impl Drop for BashProcessTreeGuard {
1080 fn drop(&mut self) {
1081 self.kill();
1082 }
1083}
1084
1085impl Default for BashTool {
1086 fn default() -> Self {
1087 BashTool {
1088 default_timeout_ms: DEFAULT_BASH_TIMEOUT_MS,
1089 }
1090 }
1091}
1092
1093#[derive(Deserialize)]
1094struct BashArgs {
1095 command: String,
1096 #[serde(default)]
1097 timeout_ms: Option<u64>,
1098 #[serde(default)]
1104 with_escalated_permissions: Option<bool>,
1105 #[serde(default)]
1109 #[allow(dead_code)]
1112 justification: Option<String>,
1113}
1114
1115fn escalation_requested(tool: &str, requested: Option<bool>, ctx: &ToolContext) -> Result<bool> {
1134 if requested != Some(true) {
1135 return Ok(false);
1136 }
1137 if !ctx.permissions_engine_active() {
1138 return Err(Error::tool(
1139 tool,
1140 "with_escalated_permissions requires the permissions engine (capabilities.permissions.enabled) — there is no approval door to grant it, and an unadjudicated escalation would switch the sandbox off on the model's say-so",
1141 ));
1142 }
1143 Ok(true)
1144}
1145
1146fn looks_sandbox_denied(output: &str) -> bool {
1158 const MARKERS: &[&str] = &[
1159 "operation not permitted",
1160 "permission denied",
1161 "read-only file system",
1162 "sandbox-exec",
1163 "eperm",
1164 "could not resolve host",
1165 "temporary failure in name resolution",
1166 "network is unreachable",
1167 "connection refused",
1168 ];
1169 let lower = output.to_ascii_lowercase();
1170 MARKERS.iter().any(|m| lower.contains(m))
1171}
1172
1173fn escalation_hint(tool: &str) -> String {
1179 format!(
1180 "\n[{tool}: this command ran inside the sandbox and failed with an error that looks like a sandbox denial. If it genuinely has to run outside the sandbox, call {tool} again with `with_escalated_permissions: true` and a `justification` explaining why; the user is asked before the unsandboxed rerun happens.]"
1181 )
1182}
1183
1184pub(crate) fn bash_view_target(command: &str) -> Option<String> {
1195 if command
1196 .chars()
1197 .any(|c| matches!(c, '|' | '>' | '<' | ';' | '&' | '`' | '\n'))
1198 || command.contains("$(")
1199 {
1200 return None;
1201 }
1202 let tokens: Vec<&str> = command.split_whitespace().collect();
1203 let (first, rest) = tokens.split_first()?;
1204 let program = first.rsplit('/').next().unwrap_or(first);
1206 let (takes_pattern, value_flags): (bool, &[&str]) = match program {
1211 "cat" => (false, &[]),
1212 "head" | "tail" => (false, &["-n", "-c"]),
1213 "sed" => (true, &["-e", "-f", "-i"]),
1214 "grep" | "egrep" | "fgrep" => (true, &["-e", "-f", "-m", "-A", "-B", "-C"]),
1215 _ => return None,
1216 };
1217 if program == "sed" && !rest.iter().any(|t| *t == "-n" || *t == "--quiet") {
1218 return None;
1220 }
1221 let mut operands: Vec<String> = Vec::new();
1225 let mut skip_next = false;
1226 for token in rest {
1227 if skip_next {
1228 skip_next = false;
1229 continue;
1230 }
1231 if token.starts_with('-') {
1232 skip_next = value_flags.contains(token);
1233 continue;
1234 }
1235 let unquoted = token.trim_matches(|c| c == '\'' || c == '"');
1236 if unquoted.is_empty() {
1237 continue;
1238 }
1239 operands.push(unquoted.to_string());
1240 }
1241 if takes_pattern && !operands.is_empty() {
1242 operands.remove(0);
1243 }
1244 match operands.len() {
1245 1 => operands.pop(),
1246 _ => None,
1247 }
1248}
1249
1250#[async_trait]
1251impl Tool for BashTool {
1252 fn name(&self) -> &str {
1253 "bash"
1254 }
1255 fn description(&self) -> &str {
1256 "Execute a shell command via `sh -c` in the working directory and return its combined stdout/stderr and exit code."
1257 }
1258 fn parameters(&self) -> Value {
1259 json!({
1260 "type": "object",
1261 "properties": {
1262 "command": {"type": "string", "description": "Shell command to run."},
1263 "timeout_ms": {"type": "integer", "description": "Timeout in milliseconds (default 120000)."},
1264 "with_escalated_permissions": {"type": "boolean", "description": "Run this one command OUTSIDE the sandbox. Only use it after a sandboxed attempt failed for a sandbox reason; the user is asked first, and must supply a justification."},
1265 "justification": {"type": "string", "description": "Why this command needs to run outside the sandbox. Shown to the user with the approval request."}
1266 },
1267 "required": ["command"],
1268 "additionalProperties": false
1269 })
1270 }
1271 async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<String> {
1272 let a: BashArgs = parse_args(self.name(), args)?;
1273 let escalated = escalation_requested(self.name(), a.with_escalated_permissions, ctx)?;
1278 let effective_default_ms = ctx
1285 .bash_timeout_secs
1286 .map(|s| s.saturating_mul(1000))
1287 .unwrap_or(self.default_timeout_ms);
1288 let timeout = Duration::from_millis(a.timeout_ms.unwrap_or(effective_default_ms));
1289 let deadline = tokio::time::Instant::now() + timeout;
1290
1291 let mut cmd = if escalated {
1297 build_unsandboxed_sh(&a.command, ctx)
1298 } else {
1299 build_sandboxed_sh(&a.command, ctx)?
1300 };
1301 cmd.current_dir(&ctx.cwd)
1302 .stdin(std::process::Stdio::null())
1303 .stdout(std::process::Stdio::piped())
1304 .stderr(std::process::Stdio::piped())
1305 .kill_on_drop(true);
1306 let mut process_tree = BashProcessTreeGuard::prepare()
1310 .map_err(|error| Error::tool(self.name(), error.to_string()))?;
1311 process_tree.configure_command(&mut cmd);
1312 let mut child = cmd
1313 .spawn()
1314 .map_err(|error| Error::tool(self.name(), error.to_string()))?;
1315 process_tree
1316 .attach_and_start(&child)
1317 .map_err(|error| Error::tool(self.name(), error.to_string()))?;
1318 let mut stdout = child
1319 .stdout
1320 .take()
1321 .ok_or_else(|| Error::tool(self.name(), "spawned command has no stdout"))?;
1322 let mut stderr = child
1323 .stderr
1324 .take()
1325 .ok_or_else(|| Error::tool(self.name(), "spawned command has no stderr"))?;
1326 let mut stdout_task = tokio::spawn(async move {
1327 let mut bytes = Vec::new();
1328 let result = stdout.read_to_end(&mut bytes).await;
1329 (result, bytes)
1330 });
1331 let mut stderr_task = tokio::spawn(async move {
1332 let mut bytes = Vec::new();
1333 let result = stderr.read_to_end(&mut bytes).await;
1334 (result, bytes)
1335 });
1336
1337 let status = match tokio::time::timeout_at(deadline, child.wait()).await {
1338 Ok(Ok(status)) => status,
1339 Ok(Err(error)) => {
1340 process_tree.kill();
1341 let _ = child.start_kill();
1342 let _ = tokio::time::timeout(Duration::from_secs(1), child.wait()).await;
1343 stdout_task.abort();
1344 stderr_task.abort();
1345 return Err(Error::tool(self.name(), error.to_string()));
1346 }
1347 Err(_) => {
1348 process_tree.kill();
1349 let _ = child.start_kill();
1350 let _ = tokio::time::timeout(Duration::from_secs(1), child.wait()).await;
1351 stdout_task.abort();
1352 stderr_task.abort();
1353 return Err(Error::tool(
1354 self.name(),
1355 format!("command timed out after {timeout:?}"),
1356 ));
1357 }
1358 };
1359 process_tree.kill();
1363 let pipe_output = tokio::time::timeout_at(deadline, async {
1364 let (stdout_result, stdout) = (&mut stdout_task)
1365 .await
1366 .map_err(|error| Error::tool(self.name(), error.to_string()))?;
1367 stdout_result.map_err(|error| Error::tool(self.name(), error.to_string()))?;
1368 let (stderr_result, stderr) = (&mut stderr_task)
1369 .await
1370 .map_err(|error| Error::tool(self.name(), error.to_string()))?;
1371 stderr_result.map_err(|error| Error::tool(self.name(), error.to_string()))?;
1372 Ok::<_, Error>((stdout, stderr))
1373 })
1374 .await;
1375 let (stdout, stderr) = match pipe_output {
1376 Ok(result) => result?,
1377 Err(_) => {
1378 stdout_task.abort();
1379 stderr_task.abort();
1380 return Err(Error::tool(
1381 self.name(),
1382 format!("command timed out after {timeout:?}"),
1383 ));
1384 }
1385 };
1386
1387 let mut buf = String::new();
1388 let stdout = String::from_utf8_lossy(&stdout);
1389 let stderr = String::from_utf8_lossy(&stderr);
1390 if !stdout.is_empty() {
1391 buf.push_str(&stdout);
1392 }
1393 if !stderr.is_empty() {
1394 if !buf.is_empty() && !buf.ends_with('\n') {
1395 buf.push('\n');
1396 }
1397 buf.push_str(&stderr);
1398 }
1399 let code = status.code().unwrap_or(-1);
1400 if ctx.require_read_before_edit && code == 0 {
1406 if let Some(target) = bash_view_target(&a.command) {
1407 let resolved = ctx.resolve(&target);
1408 if resolved.is_file() {
1409 ctx.mark_read(&resolved);
1410 }
1411 }
1412 }
1413 if buf.is_empty() {
1414 buf.push_str("(no output)");
1415 }
1416 if !escalated && code != 0 && ctx.os_sandbox_active() && looks_sandbox_denied(&buf) {
1421 buf.push_str(&escalation_hint(self.name()));
1422 }
1423 Ok(format!("exit code: {code}\n{buf}"))
1424 }
1425}
1426
1427struct SandboxPlan {
1434 confine_fs: bool,
1437 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1441 fs_allow_writes: bool,
1442 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1445 confine_net: bool,
1446}
1447
1448fn resolve_sandbox_plan(ctx: &ToolContext, subject: &str) -> Result<SandboxPlan> {
1462 use crate::sandbox::{decide_fs, decide_net, warn_once, FsDecision, NetDecision};
1463
1464 let fs_available = cfg!(target_os = "macos") || crate::sandbox::landlock_available();
1465 let approval = ctx.sandbox_approval_handler.as_deref();
1466 let fs_decision = decide_fs(
1467 ctx.sandbox,
1468 ctx.sandbox_os_enabled,
1469 fs_available,
1470 ctx.sandbox_escalation,
1471 approval,
1472 subject,
1473 );
1474 let confine_fs = match fs_decision {
1475 FsDecision::NotRequested => false,
1476 FsDecision::Confine => true,
1477 FsDecision::RunUnconfinedWithWarning { reason } => {
1478 warn_once(&reason);
1479 false
1480 }
1481 FsDecision::Refuse { reason } => return Err(Error::tool("sandbox", reason)),
1482 };
1483
1484 let network_enabled = ctx
1485 .network_policy
1486 .as_ref()
1487 .map(|p| p.enabled)
1488 .unwrap_or(false);
1489 let has_domain_rules = ctx
1490 .network_policy
1491 .as_ref()
1492 .map(|p| !p.allow_domains.is_empty() || !p.deny_domains.is_empty())
1493 .unwrap_or(false);
1494 let net_available = cfg!(target_os = "macos")
1502 || (cfg!(target_os = "linux") && crate::sandbox::netns_available());
1503 let net_decision = decide_net(network_enabled, has_domain_rules, net_available);
1504 let confine_net = match net_decision {
1505 NetDecision::NotRequested => false,
1506 NetDecision::Confine => true,
1507 NetDecision::GapWarn { reason } => {
1508 warn_once(&reason);
1509 false
1510 }
1511 };
1512
1513 Ok(SandboxPlan {
1514 confine_fs,
1515 fs_allow_writes: ctx.sandbox == crate::tools::SandboxPolicy::WorkspaceWrite,
1516 confine_net,
1517 })
1518}
1519
1520#[cfg(target_os = "linux")]
1533fn apply_linux_plan(cmd: &mut tokio::process::Command, ctx: &ToolContext, plan: &SandboxPlan) {
1534 if !plan.confine_fs && !plan.confine_net {
1535 return;
1536 }
1537 let cwd = crate::safe_path::resolve_real(&ctx.cwd).unwrap_or_else(|| ctx.cwd.clone());
1538 let tmp_dir = std::env::temp_dir();
1539 let tmp = crate::safe_path::resolve_real(&tmp_dir).unwrap_or(tmp_dir);
1540 let mut extra = vec![tmp];
1543 for root in &ctx.extra_roots {
1544 extra.push(crate::safe_path::resolve_real(root).unwrap_or_else(|| root.clone()));
1545 }
1546 crate::sandbox::apply_linux_confinement(
1547 cmd,
1548 plan.confine_fs,
1549 plan.fs_allow_writes,
1550 cwd,
1551 extra,
1552 plan.confine_net,
1553 );
1554}
1555
1556#[cfg(not(target_os = "linux"))]
1557fn apply_linux_plan(_cmd: &mut tokio::process::Command, _ctx: &ToolContext, _plan: &SandboxPlan) {}
1558
1559fn apply_sandbox_env_policy(cmd: &mut tokio::process::Command, ctx: &ToolContext) {
1580 if ctx.sandbox_env_policy == crate::sandbox::SandboxEnvPolicy::Inherit {
1581 if let Some(snapshot) = &ctx.shell_env {
1584 cmd.envs(snapshot.iter().map(|(k, v)| (k.as_str(), v.as_str())));
1585 }
1586 return;
1587 }
1588 let mut base: Vec<(String, String)> = std::env::vars().collect();
1589 if let Some(snapshot) = &ctx.shell_env {
1590 for (k, v) in snapshot.iter() {
1591 match base.iter_mut().find(|(bk, _)| bk == k) {
1592 Some(entry) => entry.1 = v.clone(),
1593 None => base.push((k.clone(), v.clone())),
1594 }
1595 }
1596 }
1597 let filtered = crate::sandbox::apply_env_policy(ctx.sandbox_env_policy, base);
1598 cmd.env_clear();
1599 cmd.envs(filtered);
1600}
1601
1602pub(crate) fn build_unsandboxed_sh(command: &str, ctx: &ToolContext) -> tokio::process::Command {
1634 crate::sandbox::warn_once(&format!(
1635 "sandbox: running an APPROVED escalated command OUTSIDE the sandbox: {command}"
1636 ));
1637 let mut cmd = tokio::process::Command::new("sh");
1638 cmd.arg("-c").arg(command);
1639 apply_sandbox_env_policy(&mut cmd, ctx);
1640 cmd
1641}
1642
1643pub(crate) fn build_sandboxed_sh(
1644 command: &str,
1645 ctx: &ToolContext,
1646) -> Result<tokio::process::Command> {
1647 let plan = resolve_sandbox_plan(ctx, command)?;
1648 #[cfg(target_os = "macos")]
1649 {
1650 if plan.confine_fs || plan.confine_net {
1651 if let Some(profile) = seatbelt_profile(ctx, &plan) {
1652 let mut cmd = tokio::process::Command::new("sandbox-exec");
1653 cmd.arg("-p").arg(profile).arg("sh").arg("-c").arg(command);
1654 apply_sandbox_env_policy(&mut cmd, ctx);
1655 return Ok(cmd);
1656 }
1657 }
1658 }
1659 let mut cmd = tokio::process::Command::new("sh");
1660 cmd.arg("-c").arg(command);
1661 apply_linux_plan(&mut cmd, ctx, &plan);
1662 apply_sandbox_env_policy(&mut cmd, ctx);
1663 Ok(cmd)
1664}
1665
1666fn build_sandboxed_interactive_sh(ctx: &ToolContext) -> Result<tokio::process::Command> {
1671 let plan = resolve_sandbox_plan(ctx, "<persistent shell>")?;
1672 #[cfg(target_os = "macos")]
1673 {
1674 if plan.confine_fs || plan.confine_net {
1675 if let Some(profile) = seatbelt_profile(ctx, &plan) {
1676 let mut cmd = tokio::process::Command::new("sandbox-exec");
1677 cmd.arg("-p").arg(profile).arg("sh");
1678 apply_sandbox_env_policy(&mut cmd, ctx);
1679 return Ok(cmd);
1680 }
1681 }
1682 }
1683 let mut cmd = tokio::process::Command::new("sh");
1684 apply_linux_plan(&mut cmd, ctx, &plan);
1685 apply_sandbox_env_policy(&mut cmd, ctx);
1686 Ok(cmd)
1687}
1688
1689#[cfg(target_os = "macos")]
1702fn seatbelt_profile(ctx: &ToolContext, plan: &SandboxPlan) -> Option<String> {
1703 use crate::tools::SandboxPolicy;
1704 if !plan.confine_fs && !plan.confine_net {
1705 return None;
1706 }
1707 let net = if plan.confine_net {
1708 "(deny network*)"
1709 } else {
1710 ""
1711 };
1712 let fs = if !plan.confine_fs {
1713 String::new()
1714 } else {
1715 match ctx.sandbox {
1716 SandboxPolicy::DangerFullAccess => String::new(),
1717 SandboxPolicy::ReadOnly => "(deny file-write*)".to_string(),
1718 SandboxPolicy::WorkspaceWrite => {
1719 let mut out = "(deny file-write*)".to_string();
1728 for root in ctx.write_roots() {
1729 let real = crate::safe_path::resolve_real(&root).unwrap_or(root);
1730 let dir = real.to_string_lossy().replace('"', "");
1731 out.push_str(&format!("(allow file-write* (subpath \"{dir}\"))"));
1732 }
1733 out.push_str(
1734 "(allow file-write* (literal \"/dev/null\") \
1735 (literal \"/dev/dtracehelper\") (literal \"/dev/tty\"))",
1736 );
1737 out
1738 }
1739 }
1740 };
1741 if fs.is_empty() && net.is_empty() {
1742 return None;
1743 }
1744 Some(format!("(version 1)(allow default){fs}{net}"))
1745}
1746
1747pub struct ApplyPatchTool;
1754
1755#[derive(Deserialize)]
1756struct ApplyPatchArgs {
1757 patch: String,
1759}
1760
1761enum PatchOp {
1763 Add {
1764 path: String,
1765 body: String,
1766 },
1767 Delete {
1768 path: String,
1769 },
1770 Update {
1771 path: String,
1772 move_to: Option<String>,
1773 hunks: Vec<Hunk>,
1774 },
1775}
1776
1777#[derive(Default)]
1781struct Hunk {
1782 old: Vec<String>,
1783 new: Vec<String>,
1784 anchor: Option<String>,
1785}
1786
1787fn parse_patch(patch: &str) -> Result<Vec<PatchOp>> {
1788 let err = |m: &str| Error::tool("apply_patch", m.to_string());
1789 let lines: Vec<&str> = patch.lines().collect();
1790 let mut i = 0;
1791 while i < lines.len() && lines[i].trim() != "*** Begin Patch" {
1793 i += 1;
1794 }
1795 if i == lines.len() {
1796 return Err(err("missing '*** Begin Patch'"));
1797 }
1798 i += 1;
1799
1800 let mut ops = Vec::new();
1801 while i < lines.len() {
1802 let line = lines[i];
1803 let t = line.trim_end();
1804 if t == "*** End Patch" {
1805 return Ok(ops);
1806 } else if let Some(p) = t.strip_prefix("*** Add File: ") {
1807 i += 1;
1808 let mut body = Vec::new();
1809 while i < lines.len() && lines[i].starts_with('+') {
1810 body.push(&lines[i][1..]);
1811 i += 1;
1812 }
1813 ops.push(PatchOp::Add {
1814 path: p.to_string(),
1815 body: body.join("\n"),
1816 });
1817 } else if let Some(p) = t.strip_prefix("*** Delete File: ") {
1818 ops.push(PatchOp::Delete {
1819 path: p.to_string(),
1820 });
1821 i += 1;
1822 } else if let Some(p) = t.strip_prefix("*** Update File: ") {
1823 i += 1;
1824 let mut move_to = None;
1825 if i < lines.len() {
1826 if let Some(m) = lines[i].trim_end().strip_prefix("*** Move to: ") {
1827 move_to = Some(m.to_string());
1828 i += 1;
1829 }
1830 }
1831 let mut hunks = Vec::new();
1832 let mut cur = Hunk::default();
1833 let mut started = false;
1834 while i < lines.len() {
1835 let l = lines[i];
1836 let lt = l.trim_end();
1837 if lt.starts_with("*** ") {
1838 break; }
1840 if let Some(anchor) = lt.strip_prefix("@@") {
1841 if started && (!cur.old.is_empty() || !cur.new.is_empty()) {
1842 hunks.push(std::mem::take(&mut cur));
1843 }
1844 let anchor = anchor.trim();
1846 cur.anchor = (!anchor.is_empty()).then(|| anchor.to_string());
1847 started = true;
1848 i += 1;
1849 continue;
1850 }
1851 started = true;
1852 if let Some(rest) = l.strip_prefix('+') {
1853 cur.new.push(rest.to_string());
1854 } else if let Some(rest) = l.strip_prefix('-') {
1855 cur.old.push(rest.to_string());
1856 } else {
1857 let ctx = l.strip_prefix(' ').unwrap_or(l).to_string();
1859 cur.old.push(ctx.clone());
1860 cur.new.push(ctx);
1861 }
1862 i += 1;
1863 }
1864 if !cur.old.is_empty() || !cur.new.is_empty() {
1865 hunks.push(cur);
1866 }
1867 ops.push(PatchOp::Update {
1868 path: p.to_string(),
1869 move_to,
1870 hunks,
1871 });
1872 } else {
1873 i += 1;
1875 }
1876 }
1877 Err(err("missing '*** End Patch'"))
1878}
1879
1880pub(crate) fn patch_target_paths(patch: &str) -> Result<Vec<String>> {
1893 let ops = parse_patch(patch)?;
1894 let mut paths = Vec::with_capacity(ops.len());
1895 for op in ops {
1896 match op {
1897 PatchOp::Add { path, .. } | PatchOp::Delete { path } => paths.push(path),
1898 PatchOp::Update { path, move_to, .. } => {
1899 paths.push(path);
1900 if let Some(m) = move_to {
1901 paths.push(m);
1902 }
1903 }
1904 }
1905 }
1906 Ok(paths)
1907}
1908
1909fn apply_update(original: &str, hunks: &[Hunk], tool: &str) -> Result<String> {
1910 let mut text = original.to_string();
1911 for h in hunks {
1912 let from = match &h.anchor {
1916 Some(a) => {
1917 let Some(pos) = text.find(a.as_str()) else {
1918 return Err(Error::tool(tool, format!("@@ anchor not found: {a}")));
1919 };
1920 text[pos..]
1922 .find('\n')
1923 .map(|nl| pos + nl + 1)
1924 .unwrap_or(text.len())
1925 }
1926 None => 0,
1927 };
1928
1929 let new_block = h.new.join("\n");
1930
1931 if h.old.is_empty() {
1932 if h.anchor.is_some() {
1935 let needs_lead_nl = from > 0 && text.as_bytes()[from - 1] != b'\n';
1936 let payload = if needs_lead_nl {
1937 format!("\n{new_block}\n")
1938 } else {
1939 format!("{new_block}\n")
1940 };
1941 text.insert_str(from, &payload);
1942 } else {
1943 if !text.is_empty() && !text.ends_with('\n') {
1944 text.push('\n');
1945 }
1946 text.push_str(&new_block);
1947 }
1948 continue;
1949 }
1950
1951 let old_block = h.old.join("\n");
1952 let region = &text[from..];
1953 let count = region.matches(&old_block).count();
1954 match count {
1955 0 => {
1956 return Err(Error::tool(
1957 tool,
1958 format!("hunk did not match file contents:\n{old_block}"),
1959 ))
1960 }
1961 1 => {
1962 let rel = region.find(&old_block).unwrap();
1963 let start = from + rel;
1964 text.replace_range(start..start + old_block.len(), &new_block);
1965 }
1966 _ => {
1967 return Err(Error::tool(
1968 tool,
1969 format!(
1970 "hunk matches file contents {count} times; add more context lines or a more specific @@ anchor to disambiguate:\n{old_block}"
1971 ),
1972 ))
1973 }
1974 }
1975 }
1976 Ok(text)
1977}
1978
1979#[async_trait]
1980impl Tool for ApplyPatchTool {
1981 fn name(&self) -> &str {
1982 "apply_patch"
1983 }
1984 fn description(&self) -> &str {
1985 "Apply a patch in the apply_patch envelope format (*** Begin Patch / *** End Patch) with Add File, Delete File, and Update File operations. Update hunks use leading '+'/'-'/' ' on each line and may include '@@' context headers and an optional '*** Move to:' rename."
1986 }
1987 fn parameters(&self) -> Value {
1988 json!({
1989 "type": "object",
1990 "properties": {
1991 "patch": {"type": "string", "description": "The full *** Begin Patch … *** End Patch text."}
1992 },
1993 "required": ["patch"],
1994 "additionalProperties": false
1995 })
1996 }
1997 async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<String> {
1998 let a: ApplyPatchArgs = parse_args(self.name(), args)?;
1999 let ops = parse_patch(&a.patch)?;
2000 let mut summary = Vec::new();
2001 let mut annotations: Vec<String> = Vec::new();
2008 for op in ops {
2009 match op {
2010 PatchOp::Add { path, body } => {
2011 let full = ctx.resolve(&path);
2012 ctx.check_write(&full)?;
2013 if let Some(obs) = &ctx.write_observer {
2017 obs.before_write(&full).await;
2018 }
2019 if let Some(parent) = full.parent() {
2020 tokio::fs::create_dir_all(parent).await.ok();
2021 }
2022 tokio::fs::write(&full, body.as_bytes())
2023 .await
2024 .map_err(|e| {
2025 Error::tool(self.name(), format!("{}: {e}", full.display()))
2026 })?;
2027 if let Some(obs) = &ctx.write_observer {
2028 if let Some(note) = obs.after_write(&full).await {
2029 annotations.push(note);
2030 }
2031 }
2032 summary.push(format!("A {}", rel(ctx, &full)));
2033 }
2034 PatchOp::Delete { path } => {
2035 let full = ctx.resolve(&path);
2036 ctx.check_write(&full)?;
2037 if let Some(obs) = &ctx.write_observer {
2038 obs.before_write(&full).await;
2039 }
2040 tokio::fs::remove_file(&full).await.map_err(|e| {
2041 Error::tool(self.name(), format!("{}: {e}", full.display()))
2042 })?;
2043 if let Some(obs) = &ctx.write_observer {
2044 if let Some(note) = obs.after_write(&full).await {
2051 annotations.push(note);
2052 }
2053 }
2054 summary.push(format!("D {}", rel(ctx, &full)));
2055 }
2056 PatchOp::Update {
2057 path,
2058 move_to,
2059 hunks,
2060 } => {
2061 let full = ctx.resolve(&path);
2062 let dest_for_check = move_to
2063 .as_ref()
2064 .map(|m| ctx.resolve(m))
2065 .unwrap_or_else(|| full.clone());
2066 ctx.check_write(&dest_for_check)?;
2067 if let Some(obs) = &ctx.write_observer {
2072 obs.before_write(&full).await;
2073 if dest_for_check != full {
2074 obs.before_write(&dest_for_check).await;
2075 }
2076 }
2077 let original = tokio::fs::read_to_string(&full).await.map_err(|e| {
2078 Error::tool(self.name(), format!("{}: {e}", full.display()))
2079 })?;
2080 let updated = apply_update(&original, &hunks, self.name())?;
2081 let dest = match &move_to {
2082 Some(m) => ctx.resolve(m),
2083 None => full.clone(),
2084 };
2085 if let Some(parent) = dest.parent() {
2086 tokio::fs::create_dir_all(parent).await.ok();
2087 }
2088 tokio::fs::write(&dest, updated.as_bytes())
2089 .await
2090 .map_err(|e| {
2091 Error::tool(self.name(), format!("{}: {e}", dest.display()))
2092 })?;
2093 if move_to.is_some() && dest != full {
2094 tokio::fs::remove_file(&full).await.ok();
2095 if let Some(obs) = &ctx.write_observer {
2096 if let Some(note) = obs.after_write(&dest).await {
2097 annotations.push(note);
2098 }
2099 }
2100 summary.push(format!("M {} -> {}", rel(ctx, &full), rel(ctx, &dest)));
2101 } else {
2102 if let Some(obs) = &ctx.write_observer {
2103 if let Some(note) = obs.after_write(&full).await {
2104 annotations.push(note);
2105 }
2106 }
2107 summary.push(format!("U {}", rel(ctx, &full)));
2108 }
2109 }
2110 }
2111 }
2112 let annotation = if annotations.is_empty() {
2113 String::new()
2114 } else {
2115 format!("\n\n{}", annotations.join("\n\n"))
2116 };
2117 if summary.is_empty() {
2118 Ok("(empty patch)".to_string())
2119 } else {
2120 Ok(format!(
2121 "Applied patch:\n{}{annotation}",
2122 summary.join("\n")
2123 ))
2124 }
2125 }
2126}
2127
2128use tokio::io::{AsyncReadExt, AsyncWriteExt};
2131use tokio::sync::Mutex as AsyncMutex;
2132
2133pub struct PersistentShellTool {
2153 state: AsyncMutex<Option<ShellState>>,
2154 default_timeout_ms: u64,
2155}
2156
2157impl Default for PersistentShellTool {
2158 fn default() -> Self {
2159 PersistentShellTool {
2160 state: AsyncMutex::new(None),
2161 default_timeout_ms: DEFAULT_BASH_TIMEOUT_MS,
2162 }
2163 }
2164}
2165
2166struct ShellState {
2167 #[allow(dead_code)]
2170 child: tokio::process::Child,
2171 stdin: tokio::process::ChildStdin,
2172 stdout: tokio::io::BufReader<tokio::process::ChildStdout>,
2173}
2174
2175const SHELL_SENTINEL: &str = "__SC_SHELL_DONE__";
2176
2177use std::sync::atomic::{AtomicU64, Ordering};
2178
2179static SHELL_SENTINEL_SEQ: AtomicU64 = AtomicU64::new(0);
2180
2181fn shell_sentinel() -> String {
2186 use std::hash::BuildHasher;
2187 let seq = SHELL_SENTINEL_SEQ.fetch_add(1, Ordering::Relaxed);
2188 let nanos = std::time::SystemTime::now()
2189 .duration_since(std::time::UNIX_EPOCH)
2190 .map(|d| d.as_nanos())
2191 .unwrap_or(0);
2192 let hash =
2193 std::collections::hash_map::RandomState::new().hash_one((std::process::id(), seq, nanos));
2194 format!("{SHELL_SENTINEL}_{hash:016x}{seq:04x}")
2195}
2196
2197#[derive(Deserialize)]
2198struct ShellArgs {
2199 #[serde(default)]
2200 command: Option<String>,
2201 #[serde(default)]
2202 write_stdin: Option<String>,
2203 #[serde(default)]
2204 timeout_ms: Option<u64>,
2205}
2206
2207impl PersistentShellTool {
2208 async fn ensure_started(
2209 &self,
2210 state: &mut Option<ShellState>,
2211 ctx: &ToolContext,
2212 ) -> Result<()> {
2213 if state.is_some() {
2214 return Ok(());
2215 }
2216 let mut child = build_sandboxed_interactive_sh(ctx)?
2217 .current_dir(&ctx.cwd)
2218 .stdin(std::process::Stdio::piped())
2219 .stdout(std::process::Stdio::piped())
2220 .stderr(std::process::Stdio::piped())
2221 .kill_on_drop(true)
2224 .spawn()
2225 .map_err(|e| Error::tool("shell", format!("spawn sh: {e}")))?;
2226 let stdin = child
2227 .stdin
2228 .take()
2229 .ok_or_else(|| Error::tool("shell", "no stdin"))?;
2230 let stdout = tokio::io::BufReader::new(
2231 child
2232 .stdout
2233 .take()
2234 .ok_or_else(|| Error::tool("shell", "no stdout"))?,
2235 );
2236 *state = Some(ShellState {
2237 child,
2238 stdin,
2239 stdout,
2240 });
2241 Ok(())
2242 }
2243}
2244
2245#[async_trait]
2246impl Tool for PersistentShellTool {
2247 fn name(&self) -> &str {
2248 "shell"
2249 }
2250 fn description(&self) -> &str {
2251 "Run a command in a PERSISTENT shell whose working directory, environment, and shell functions survive across calls (unlike one-shot bash). Pass `command` to run to completion (returns exit code), or `write_stdin` to feed raw input to the shell (for interactive programs)."
2252 }
2253 fn parameters(&self) -> Value {
2254 json!({
2255 "type": "object",
2256 "properties": {
2257 "command": {"type": "string", "description": "Command to run in the persistent shell."},
2258 "write_stdin": {"type": "string", "description": "Raw text to write to the shell's stdin instead of running a command."},
2259 "timeout_ms": {"type": "integer", "description": "Timeout in milliseconds (default 120000)."}
2260 },
2261 "additionalProperties": false
2262 })
2263 }
2264 async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<String> {
2265 let a: ShellArgs = parse_args(self.name(), args)?;
2266 let timeout = Duration::from_millis(a.timeout_ms.unwrap_or(self.default_timeout_ms));
2267 let mut guard = self.state.lock().await;
2268 self.ensure_started(&mut guard, ctx).await?;
2269 let st = guard.as_mut().expect("started");
2270
2271 if let Some(input) = a.write_stdin {
2272 st.stdin
2273 .write_all(input.as_bytes())
2274 .await
2275 .map_err(|e| Error::tool(self.name(), format!("write_stdin: {e}")))?;
2276 st.stdin.flush().await.ok();
2277 let out = read_available(&mut st.stdout, Duration::from_millis(800)).await;
2279 return Ok(if out.is_empty() {
2280 "(no output)".into()
2281 } else {
2282 out
2283 });
2284 }
2285
2286 let command = a
2287 .command
2288 .ok_or_else(|| Error::tool(self.name(), "provide `command` or `write_stdin`"))?;
2289 let sentinel = shell_sentinel();
2293 let wrapped = format!("{{ {command}\n}} 2>&1\nprintf '%s %d\\n' '{sentinel}' \"$?\"\n");
2294 st.stdin
2295 .write_all(wrapped.as_bytes())
2296 .await
2297 .map_err(|e| Error::tool(self.name(), format!("write: {e}")))?;
2298 st.stdin.flush().await.ok();
2299
2300 let mut acc = String::new();
2303 let mut code = -1;
2304 let read_fut = async {
2305 let mut chunk = [0u8; 4096];
2306 loop {
2307 let n = st.stdout.read(&mut chunk).await.unwrap_or(0);
2308 if n == 0 {
2309 break; }
2311 acc.push_str(&String::from_utf8_lossy(&chunk[..n]));
2312 if let Some(pos) = acc.find(&sentinel) {
2313 let after = &acc[pos + sentinel.len()..];
2314 if let Some(nl) = after.find('\n') {
2315 code = after[..nl].trim().parse().unwrap_or(-1);
2316 acc.truncate(pos);
2317 break;
2318 }
2319 }
2320 }
2321 };
2322 if tokio::time::timeout(timeout, read_fut).await.is_err() {
2323 return Err(Error::tool(
2324 self.name(),
2325 format!("command timed out after {timeout:?}"),
2326 ));
2327 }
2328 let output = if acc.trim().is_empty() {
2329 "(no output)".to_string()
2330 } else {
2331 acc.trim_end().to_string()
2332 };
2333 Ok(format!("exit code: {code}\n{output}"))
2334 }
2335}
2336
2337async fn read_available<R: AsyncReadExt + Unpin>(reader: &mut R, window: Duration) -> String {
2340 let mut buf = Vec::new();
2341 let mut chunk = [0u8; 4096];
2342 loop {
2343 match tokio::time::timeout(window, reader.read(&mut chunk)).await {
2344 Ok(Ok(0)) => break, Ok(Ok(n)) => buf.extend_from_slice(&chunk[..n]),
2346 Ok(Err(_)) => break,
2347 Err(_) => break, }
2349 }
2350 String::from_utf8_lossy(&buf).into_owned()
2351}
2352
2353#[derive(Default)]
2364pub struct UpdatePlanTool;
2365
2366#[derive(Deserialize, Clone)]
2367struct PlanStep {
2368 step: String,
2369 #[serde(default = "default_status")]
2370 status: String,
2371}
2372fn default_status() -> String {
2373 "pending".to_string()
2374}
2375
2376#[derive(Deserialize)]
2377struct PlanArgs {
2378 plan: Vec<PlanStep>,
2379}
2380
2381#[async_trait]
2382impl Tool for UpdatePlanTool {
2383 fn name(&self) -> &str {
2384 "update_plan"
2385 }
2386 fn description(&self) -> &str {
2387 "Record or update the task plan: a checklist of steps with statuses (pending/in_progress/completed). Replaces the current plan. Use it to track multi-step work."
2388 }
2389 fn parameters(&self) -> Value {
2390 json!({
2391 "type": "object",
2392 "properties": {
2393 "plan": {
2394 "type": "array",
2395 "items": {
2396 "type": "object",
2397 "properties": {
2398 "step": {"type": "string"},
2399 "status": {"type": "string", "enum": ["pending", "in_progress", "completed"]}
2400 },
2401 "required": ["step"]
2402 }
2403 }
2404 },
2405 "required": ["plan"],
2406 "additionalProperties": false
2407 })
2408 }
2409 async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<String> {
2410 let a: PlanArgs = parse_args(self.name(), args)?;
2411 ctx.set_plan(
2412 a.plan
2413 .iter()
2414 .map(|s| crate::session_journal::PlanEntry {
2415 step: s.step.clone(),
2416 status: s.status.clone(),
2417 })
2418 .collect(),
2419 );
2420 let rendered = a
2421 .plan
2422 .iter()
2423 .map(|s| {
2424 let mark = match s.status.as_str() {
2425 "completed" => "[x]",
2426 "in_progress" => "[~]",
2427 _ => "[ ]",
2428 };
2429 format!("{mark} {}", s.step)
2430 })
2431 .collect::<Vec<_>>()
2432 .join("\n");
2433 Ok(if rendered.is_empty() {
2434 "(empty plan)".into()
2435 } else {
2436 format!("Plan updated:\n{rendered}")
2437 })
2438 }
2439}
2440
2441fn describe_reqwest_error(e: &reqwest::Error) -> String {
2449 let mut out = e.to_string();
2450 let mut source = std::error::Error::source(e);
2451 while let Some(s) = source {
2452 out.push_str(": ");
2453 out.push_str(&s.to_string());
2454 source = s.source();
2455 }
2456 out
2457}
2458
2459pub struct WebFetchTool;
2476
2477const MAX_FETCH_BYTES: usize = 200_000;
2480
2481pub(crate) const WEB_FETCH_TTL_SECS: u64 = 900;
2484
2485pub const WEB_CACHE_DIR_ENV: &str = "SUPERCODE_WEB_CACHE_DIR";
2488
2489pub(crate) fn web_cache_root() -> PathBuf {
2493 if let Some(dir) = std::env::var_os(WEB_CACHE_DIR_ENV) {
2494 return PathBuf::from(dir);
2495 }
2496 std::env::var_os("SUPERCODE_HOME")
2497 .map(PathBuf::from)
2498 .or_else(|| {
2499 std::env::var_os("HOME")
2500 .map(PathBuf::from)
2501 .map(|home| home.join(".supercode"))
2502 })
2503 .unwrap_or_else(|| std::env::temp_dir().join("supercode"))
2504 .join("web-cache")
2505}
2506
2507fn web_cache_path(url: &str) -> PathBuf {
2508 web_cache_root().join(format!("{}.md", blake3::hash(url.as_bytes()).to_hex()))
2509}
2510
2511fn unix_secs() -> u64 {
2512 std::time::SystemTime::now()
2513 .duration_since(std::time::UNIX_EPOCH)
2514 .map(|d| d.as_secs())
2515 .unwrap_or(0)
2516}
2517
2518pub(crate) fn web_cache_get(url: &str) -> Option<(String, u64)> {
2524 let text = std::fs::read_to_string(web_cache_path(url)).ok()?;
2525 let (header, body) = text.split_once('\n')?;
2526 let (stamp, cached_url) = header.split_once(' ')?;
2527 if cached_url != url {
2528 return None;
2529 }
2530 let stamped: u64 = stamp.parse().ok()?;
2531 let age = unix_secs().saturating_sub(stamped);
2532 if age > WEB_FETCH_TTL_SECS {
2533 return None;
2534 }
2535 Some((body.to_string(), age))
2536}
2537
2538pub(crate) fn web_cache_put(url: &str, body: &str) {
2541 let path = web_cache_path(url);
2542 if let Some(parent) = path.parent() {
2543 let _ = std::fs::create_dir_all(parent);
2544 }
2545 let _ = std::fs::write(path, format!("{} {url}\n{body}", unix_secs()));
2546}
2547
2548fn is_html_response(content_type: Option<&str>, body: &str) -> bool {
2550 if let Some(ct) = content_type {
2551 let ct = ct.to_ascii_lowercase();
2552 if ct.contains("html") {
2553 return true;
2554 }
2555 if ct.contains("json") || ct.contains("text/plain") || ct.contains("markdown") {
2556 return false;
2557 }
2558 }
2559 let head = body.trim_start();
2560 let head = &head[..head.len().min(512)].to_ascii_lowercase();
2561 head.starts_with("<!doctype html") || head.starts_with("<html") || head.contains("<body")
2562}
2563
2564fn cap_fetch_body(text: &str) -> (&str, bool) {
2566 let mut end = MAX_FETCH_BYTES.min(text.len());
2567 while end > 0 && !text.is_char_boundary(end) {
2568 end -= 1;
2569 }
2570 (&text[..end], text.len() > MAX_FETCH_BYTES)
2571}
2572
2573#[derive(Deserialize)]
2574struct WebFetchArgs {
2575 url: String,
2576}
2577
2578#[async_trait]
2579impl Tool for WebFetchTool {
2580 fn name(&self) -> &str {
2581 "web_fetch"
2582 }
2583 fn description(&self) -> &str {
2584 "Fetch a URL over HTTP(S) and return its content as markdown (HTML is converted; other content types are returned as text, truncated if large). Recent fetches of the same URL are served from a local cache."
2585 }
2586 fn parameters(&self) -> Value {
2587 json!({
2588 "type": "object",
2589 "properties": {
2590 "url": {"type": "string", "description": "The http:// or https:// URL to fetch."}
2591 },
2592 "required": ["url"],
2593 "additionalProperties": false
2594 })
2595 }
2596 async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<String> {
2597 let a: WebFetchArgs = parse_args(self.name(), args)?;
2598 ctx.check_network(&a.url)?;
2599 if !a.url.starts_with("http://") && !a.url.starts_with("https://") {
2600 return Err(Error::tool(self.name(), "url must be http:// or https://"));
2601 }
2602 if let Some((body, age)) = web_cache_get(&a.url) {
2606 return Ok(format!("[web_fetch: cached {age}s ago]\n{body}"));
2607 }
2608 let client = reqwest::Client::builder()
2614 .timeout(Duration::from_secs(30))
2615 .redirect(network_checked_redirect_policy(
2616 ctx.network_policy.clone(),
2617 ctx.permission_rules.clone(),
2618 ))
2619 .build()
2620 .map_err(|e| Error::tool(self.name(), e.to_string()))?;
2621 let resp = client.get(&a.url).send().await.map_err(|e| {
2622 Error::tool(
2623 self.name(),
2624 format!("fetch failed: {}", describe_reqwest_error(&e)),
2625 )
2626 })?;
2627 let status = resp.status();
2628 let content_type = resp
2629 .headers()
2630 .get(reqwest::header::CONTENT_TYPE)
2631 .and_then(|v| v.to_str().ok())
2632 .map(|v| v.to_string());
2633 let body = resp
2634 .text()
2635 .await
2636 .map_err(|e| Error::tool(self.name(), format!("failed to read response body: {e}")))?;
2637 let (rendered, converted) = if is_html_response(content_type.as_deref(), &body) {
2640 (crate::tools::convert::html_to_markdown(&body), true)
2641 } else {
2642 (body, false)
2643 };
2644 let (shown, truncated) = cap_fetch_body(&rendered);
2645 if status.is_success() {
2646 web_cache_put(&a.url, shown);
2647 }
2648 let form = if converted { "markdown" } else { "text" };
2649 Ok(if truncated {
2650 format!(
2651 "[web_fetch: HTTP {status}; {form}, {} bytes, showing first {}]\n{shown}",
2652 rendered.len(),
2653 shown.len()
2654 )
2655 } else {
2656 format!("[web_fetch: HTTP {status}; {form}]\n{shown}")
2657 })
2658 }
2659}
2660
2661pub struct WebSearchTool;
2673
2674pub const WEB_SEARCH_URL_ENV: &str = "SUPERCODE_WEB_SEARCH_URL";
2676
2677pub const DEFAULT_WEB_SEARCH_URL: &str = "https://html.duckduckgo.com/html/";
2679
2680pub(crate) fn web_search_endpoint() -> (String, bool) {
2683 match std::env::var(WEB_SEARCH_URL_ENV) {
2684 Ok(url) if !url.trim().is_empty() => (url, false),
2685 _ => (DEFAULT_WEB_SEARCH_URL.to_string(), true),
2686 }
2687}
2688
2689const WEB_SEARCH_USER_AGENT: &str = concat!("supercode/", env!("CARGO_PKG_VERSION"));
2691
2692#[derive(Deserialize)]
2693struct WebSearchArgs {
2694 query: String,
2695}
2696
2697#[async_trait]
2698impl Tool for WebSearchTool {
2699 fn name(&self) -> &str {
2700 "web_search"
2701 }
2702 fn description(&self) -> &str {
2703 "Search the web and return the top results as a numbered list of titles, URLs and snippets."
2704 }
2705 fn parameters(&self) -> Value {
2706 json!({
2707 "type": "object",
2708 "properties": {
2709 "query": {"type": "string", "description": "Search query."}
2710 },
2711 "required": ["query"],
2712 "additionalProperties": false
2713 })
2714 }
2715 async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<String> {
2716 let a: WebSearchArgs = parse_args(self.name(), args)?;
2717 let (endpoint, is_default) = web_search_endpoint();
2718 ctx.check_network(&endpoint)?;
2719 let client = reqwest::Client::builder()
2722 .timeout(Duration::from_secs(30))
2723 .redirect(network_checked_redirect_policy(
2724 ctx.network_policy.clone(),
2725 ctx.permission_rules.clone(),
2726 ))
2727 .build()
2728 .map_err(|e| Error::tool(self.name(), e.to_string()))?;
2729 let resp = client
2730 .get(&endpoint)
2731 .header(reqwest::header::USER_AGENT, WEB_SEARCH_USER_AGENT)
2732 .query(&[("q", &a.query)])
2733 .send()
2734 .await
2735 .map_err(|e| {
2736 let source = if is_default {
2737 format!(
2738 "the built-in search backend ({endpoint}) is unreachable: {}. Set {WEB_SEARCH_URL_ENV} to use a different search endpoint.",
2739 describe_reqwest_error(&e)
2740 )
2741 } else {
2742 format!(
2743 "the configured search endpoint ({endpoint}, from {WEB_SEARCH_URL_ENV}) is unreachable: {}",
2744 describe_reqwest_error(&e)
2745 )
2746 };
2747 Error::tool(self.name(), format!("search failed: {source}"))
2748 })?;
2749 let status = resp.status();
2750 let body = resp
2751 .text()
2752 .await
2753 .map_err(|e| Error::tool(self.name(), format!("failed to read response body: {e}")))?;
2754 let results = crate::tools::convert::parse_html_search_results(&body);
2755 if !results.is_empty() {
2756 return Ok(crate::tools::convert::render_search_results(
2757 &a.query, &results,
2758 ));
2759 }
2760 let rendered = if is_html_response(None, &body) {
2763 crate::tools::convert::html_to_markdown(&body)
2764 } else {
2765 body
2766 };
2767 let (shown, _) = cap_fetch_body(&rendered);
2768 Ok(format!(
2769 "[web_search: HTTP {status} from {endpoint}; no recognizable result list — the raw response follows]\n{shown}"
2770 ))
2771 }
2772}