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::{
14 image_mime_for, is_image_path, network_checked_redirect_policy, Tool, ToolContext,
15 MULTIMODAL_IMAGE_MARKER, NOTEBOOK_EXTENSION,
16};
17
18pub(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
39fn 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
66fn 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
75fn 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 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 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
124pub 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 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 let mut end = MAX_READ_BYTES.min(text.len());
181 while end > 0 && !text.is_char_boundary(end) {
182 end -= 1;
183 }
184 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 ctx.mark_read(&path);
208 let mut result = result;
209 if let Some(notice) = nested_instructions_notice(ctx, &path) {
211 result.push_str(¬ice);
212 }
213 Ok(result)
214 }
215}
216
217pub 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
270pub 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 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 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
336pub 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 #[serde(default)]
354 cell_index: Option<usize>,
355 #[serde(default)]
357 cell_op: Option<String>,
358 #[serde(default)]
360 cell_source: Option<String>,
361 #[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 if let Some(obs) = &ctx.write_observer {
400 obs.before_write(&path).await;
401 }
402
403 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 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 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 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(¬e);
471 }
472 }
473 if let Some(notice) = nested_instructions_notice(ctx, &path) {
474 result.push_str(¬ice);
475 }
476 Ok(result)
477 }
478}
479
480async 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
572pub 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
628pub 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
689pub 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; };
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
766pub struct BashTool {
770 default_timeout_ms: u64,
771}
772
773#[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 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 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 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 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 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
1110struct SandboxPlan {
1117 confine_fs: bool,
1120 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1124 fs_allow_writes: bool,
1125 #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
1128 confine_net: bool,
1129}
1130
1131fn 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#[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
1228fn apply_sandbox_env_policy(cmd: &mut tokio::process::Command, ctx: &ToolContext) {
1249 if ctx.sandbox_env_policy == crate::sandbox::SandboxEnvPolicy::Inherit {
1250 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
1271pub(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
1317fn 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#[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 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
1367pub struct ApplyPatchTool;
1374
1375#[derive(Deserialize)]
1376struct ApplyPatchArgs {
1377 patch: String,
1379}
1380
1381enum 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#[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 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; }
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 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 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 i += 1;
1495 }
1496 }
1497 Err(err("missing '*** End Patch'"))
1498}
1499
1500pub(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 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 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 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 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 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 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 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
1748use tokio::io::{AsyncReadExt, AsyncWriteExt};
1751use tokio::sync::Mutex as AsyncMutex;
1752
1753pub 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 #[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
1801fn 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 .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 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 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 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; }
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
1957async 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, Ok(Ok(n)) => buf.extend_from_slice(&chunk[..n]),
1966 Ok(Err(_)) => break,
1967 Err(_) => break, }
1969 }
1970 String::from_utf8_lossy(&buf).into_owned()
1971}
1972
1973pub 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 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
2069fn 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
2087pub struct WebFetchTool;
2100
2101const 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 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
2172pub struct WebSearchTool;
2180
2181pub 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 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}