1pub mod browser;
8
9use anyhow::Result;
10use async_trait::async_trait;
11use serde_json::{Value, json};
12use std::fs;
13use std::path::PathBuf;
14use tracing::debug;
15
16use localgpt_core::agent::hardcoded_filters;
17use localgpt_core::agent::path_utils::{check_path_allowed, resolve_real_path};
18use localgpt_core::agent::providers::ToolSchema;
19use localgpt_core::agent::tool_filters::CompiledToolFilter;
20use localgpt_core::agent::tools::Tool;
21use localgpt_core::config::Config;
22use localgpt_core::security;
23use localgpt_sandbox::{self, SandboxPolicy};
24
25fn compile_filter_for(
27 config: &Config,
28 tool_name: &str,
29 hardcoded_subs: &[&str],
30 hardcoded_pats: &[&str],
31) -> Result<CompiledToolFilter> {
32 let base = config
33 .tools
34 .filters
35 .get(tool_name)
36 .map(CompiledToolFilter::compile)
37 .unwrap_or_else(|| Ok(CompiledToolFilter::permissive()))?;
38 base.merge_hardcoded(hardcoded_subs, hardcoded_pats)
39}
40
41fn resolve_allowed_directories(config: &Config) -> Vec<PathBuf> {
43 config
44 .security
45 .allowed_directories
46 .iter()
47 .filter_map(|d| {
48 let expanded = shellexpand::tilde(d).to_string();
49 match fs::canonicalize(&expanded) {
50 Ok(p) => Some(p),
51 Err(e) => {
52 tracing::warn!("Ignoring non-existent allowed_directory '{}': {}", d, e);
53 None
54 }
55 }
56 })
57 .collect()
58}
59
60pub fn create_cli_tools(config: &Config) -> Result<Vec<Box<dyn Tool>>> {
65 let workspace = config.workspace_path();
66 let state_dir = config.paths.state_dir.clone();
67
68 let sandbox_policy = if config.sandbox.enabled {
70 let caps = localgpt_sandbox::detect_capabilities();
71 let effective = caps.effective_level(&config.sandbox.level);
72 if effective > localgpt_sandbox::SandboxLevel::None {
73 Some(localgpt_sandbox::build_policy(
74 &config.sandbox,
75 &workspace,
76 effective,
77 ))
78 } else {
79 tracing::warn!(
80 "Sandbox enabled but no kernel support detected (level: {:?}). \
81 Commands will run without sandbox enforcement.",
82 caps.level
83 );
84 None
85 }
86 } else {
87 None
88 };
89
90 let bash_filter = compile_filter_for(
92 config,
93 "bash",
94 hardcoded_filters::BASH_DENY_SUBSTRINGS,
95 hardcoded_filters::BASH_DENY_PATTERNS,
96 )?;
97
98 let file_filter = compile_filter_for(config, "file", &[], &[])?;
100 let allowed_dirs = resolve_allowed_directories(config);
101 let strict_policy = config.security.strict_policy;
102
103 let mut tools: Vec<Box<dyn Tool>> = vec![
104 Box::new(BashTool::new(
105 config.tools.bash_timeout_ms,
106 state_dir.clone(),
107 sandbox_policy.clone(),
108 bash_filter,
109 strict_policy,
110 )),
111 Box::new(ReadFileTool::new(
112 sandbox_policy.clone(),
113 file_filter.clone(),
114 allowed_dirs.clone(),
115 state_dir.clone(),
116 )),
117 Box::new(WriteFileTool::new(
118 workspace.clone(),
119 state_dir.clone(),
120 sandbox_policy.clone(),
121 file_filter.clone(),
122 allowed_dirs.clone(),
123 )),
124 Box::new(EditFileTool::new(
125 workspace,
126 state_dir,
127 sandbox_policy,
128 file_filter,
129 allowed_dirs,
130 )),
131 ];
132
133 if config.tools.browser_enabled {
135 tools.push(Box::new(browser::BrowserTool::new(
136 config.tools.browser_port,
137 )));
138 }
139
140 Ok(tools)
141}
142
143pub struct BashTool {
145 default_timeout_ms: u64,
146 state_dir: PathBuf,
147 sandbox_policy: Option<SandboxPolicy>,
148 filter: CompiledToolFilter,
149 strict_policy: bool,
150}
151
152impl BashTool {
153 pub fn new(
154 default_timeout_ms: u64,
155 state_dir: PathBuf,
156 sandbox_policy: Option<SandboxPolicy>,
157 filter: CompiledToolFilter,
158 strict_policy: bool,
159 ) -> Self {
160 Self {
161 default_timeout_ms,
162 state_dir,
163 sandbox_policy,
164 filter,
165 strict_policy,
166 }
167 }
168}
169
170#[async_trait]
171impl Tool for BashTool {
172 fn name(&self) -> &str {
173 "bash"
174 }
175
176 fn schema(&self) -> ToolSchema {
177 ToolSchema {
178 name: "bash".to_string(),
179 description: "Execute a bash command and return the output".to_string(),
180 parameters: json!({
181 "type": "object",
182 "properties": {
183 "command": {
184 "type": "string",
185 "description": "The bash command to execute"
186 },
187 "timeout_ms": {
188 "type": "integer",
189 "description": format!("Optional timeout in milliseconds (default: {})", self.default_timeout_ms)
190 }
191 },
192 "required": ["command"]
193 }),
194 }
195 }
196
197 async fn execute(&self, arguments: &str) -> Result<String> {
198 let args: Value = serde_json::from_str(arguments)?;
199 let command = args["command"]
200 .as_str()
201 .ok_or_else(|| anyhow::anyhow!("Missing command"))?;
202
203 let timeout_ms = args["timeout_ms"]
204 .as_u64()
205 .unwrap_or(self.default_timeout_ms);
206
207 self.filter.check(command, "bash", "command")?;
209
210 let suspicious = security::check_bash_command(command);
212 if !suspicious.is_empty() {
213 let detail = format!(
214 "Bash command references protected files: {:?} (cmd: {})",
215 suspicious,
216 &command[..command.floor_char_boundary(command.len().min(200))]
217 );
218 let _ = security::append_audit_entry_with_detail(
219 &self.state_dir,
220 security::AuditAction::WriteBlocked,
221 "",
222 "tool:bash",
223 Some(&detail),
224 );
225 if self.strict_policy {
226 anyhow::bail!(
227 "Blocked: command references protected files: {:?}",
228 suspicious
229 );
230 }
231 tracing::warn!("Bash command may modify protected files: {:?}", suspicious);
232 }
233
234 debug!(
235 "Executing bash command (timeout: {}ms): {}",
236 timeout_ms, command
237 );
238
239 if let Some(ref policy) = self.sandbox_policy {
241 let (output, exit_code) =
242 localgpt_sandbox::run_sandboxed(command, policy, timeout_ms).await?;
243
244 if output.is_empty() {
245 return Ok(format!("Command completed with exit code: {}", exit_code));
246 }
247
248 return Ok(output);
249 }
250
251 let timeout_duration = std::time::Duration::from_millis(timeout_ms);
253 let output = tokio::time::timeout(
254 timeout_duration,
255 tokio::process::Command::new("bash")
256 .arg("-c")
257 .arg(command)
258 .output(),
259 )
260 .await
261 .map_err(|_| anyhow::anyhow!("Command timed out after {}ms", timeout_ms))??;
262
263 let stdout = String::from_utf8_lossy(&output.stdout);
264 let stderr = String::from_utf8_lossy(&output.stderr);
265
266 let mut result = String::new();
267
268 if !stdout.is_empty() {
269 result.push_str(&stdout);
270 }
271
272 if !stderr.is_empty() {
273 if !result.is_empty() {
274 result.push_str("\n\nSTDERR:\n");
275 }
276 result.push_str(&stderr);
277 }
278
279 if result.is_empty() {
280 result = format!(
281 "Command completed with exit code: {}",
282 output.status.code().unwrap_or(-1)
283 );
284 }
285
286 Ok(result)
287 }
288}
289
290pub struct ReadFileTool {
292 sandbox_policy: Option<SandboxPolicy>,
293 filter: CompiledToolFilter,
294 allowed_directories: Vec<PathBuf>,
295 state_dir: PathBuf,
296}
297
298impl ReadFileTool {
299 pub fn new(
300 sandbox_policy: Option<SandboxPolicy>,
301 filter: CompiledToolFilter,
302 allowed_directories: Vec<PathBuf>,
303 state_dir: PathBuf,
304 ) -> Self {
305 Self {
306 sandbox_policy,
307 filter,
308 allowed_directories,
309 state_dir,
310 }
311 }
312}
313
314#[async_trait]
315impl Tool for ReadFileTool {
316 fn name(&self) -> &str {
317 "read_file"
318 }
319
320 fn schema(&self) -> ToolSchema {
321 ToolSchema {
322 name: "read_file".to_string(),
323 description: "Read the contents of a file".to_string(),
324 parameters: json!({
325 "type": "object",
326 "properties": {
327 "path": {
328 "type": "string",
329 "description": "The path to the file to read"
330 },
331 "offset": {
332 "type": "integer",
333 "description": "Line number to start reading from (0-indexed)"
334 },
335 "limit": {
336 "type": "integer",
337 "description": "Maximum number of lines to read"
338 }
339 },
340 "required": ["path"]
341 }),
342 }
343 }
344
345 async fn execute(&self, arguments: &str) -> Result<String> {
346 let args: Value = serde_json::from_str(arguments)?;
347 let path = args["path"]
348 .as_str()
349 .ok_or_else(|| anyhow::anyhow!("Missing path"))?;
350
351 let real_path = resolve_real_path(path)?;
353 let real_path_str = real_path.to_string_lossy();
354 self.filter.check(&real_path_str, "read_file", "path")?;
355 if let Err(e) = check_path_allowed(&real_path, &self.allowed_directories) {
356 let detail = format!("read_file denied: {}", real_path.display());
357 let _ = security::append_audit_entry_with_detail(
358 &self.state_dir,
359 security::AuditAction::PathDenied,
360 "",
361 "tool:read_file",
362 Some(&detail),
363 );
364 return Err(e);
365 }
366
367 if let Some(ref policy) = self.sandbox_policy
369 && localgpt_sandbox::policy::is_path_denied(&real_path, policy)
370 {
371 anyhow::bail!(
372 "Cannot read file in denied directory: {}. \
373 This path is blocked by sandbox policy.",
374 real_path.display()
375 );
376 }
377
378 debug!("Reading file: {}", real_path.display());
379
380 let content = fs::read_to_string(&real_path)?;
381
382 let offset = args["offset"].as_u64().unwrap_or(0) as usize;
384 let limit = args["limit"].as_u64().map(|l| l as usize);
385
386 let lines: Vec<&str> = content.lines().collect();
387 let total_lines = lines.len();
388
389 let start = offset.min(total_lines);
390 let end = limit
391 .map(|l| (start + l).min(total_lines))
392 .unwrap_or(total_lines);
393
394 let selected: Vec<String> = lines[start..end]
395 .iter()
396 .enumerate()
397 .map(|(i, line)| format!("{:4}\t{}", start + i + 1, line))
398 .collect();
399
400 Ok(selected.join("\n"))
401 }
402}
403
404pub struct WriteFileTool {
406 workspace: PathBuf,
407 state_dir: PathBuf,
408 sandbox_policy: Option<SandboxPolicy>,
409 filter: CompiledToolFilter,
410 allowed_directories: Vec<PathBuf>,
411}
412
413impl WriteFileTool {
414 pub fn new(
415 workspace: PathBuf,
416 state_dir: PathBuf,
417 sandbox_policy: Option<SandboxPolicy>,
418 filter: CompiledToolFilter,
419 allowed_directories: Vec<PathBuf>,
420 ) -> Self {
421 Self {
422 workspace,
423 state_dir,
424 sandbox_policy,
425 filter,
426 allowed_directories,
427 }
428 }
429}
430
431#[async_trait]
432impl Tool for WriteFileTool {
433 fn name(&self) -> &str {
434 "write_file"
435 }
436
437 fn schema(&self) -> ToolSchema {
438 ToolSchema {
439 name: "write_file".to_string(),
440 description: "Write content to a file (creates or overwrites)".to_string(),
441 parameters: json!({
442 "type": "object",
443 "properties": {
444 "path": {
445 "type": "string",
446 "description": "The path to the file to write"
447 },
448 "content": {
449 "type": "string",
450 "description": "The content to write to the file"
451 }
452 },
453 "required": ["path", "content"]
454 }),
455 }
456 }
457
458 async fn execute(&self, arguments: &str) -> Result<String> {
459 let args: Value = serde_json::from_str(arguments)?;
460 let path = args["path"]
461 .as_str()
462 .ok_or_else(|| anyhow::anyhow!("Missing path"))?;
463 let content = args["content"]
464 .as_str()
465 .ok_or_else(|| anyhow::anyhow!("Missing content"))?;
466
467 let real_path = resolve_real_path(path)?;
469 let real_path_str = real_path.to_string_lossy();
470 self.filter.check(&real_path_str, "write_file", "path")?;
471 if let Err(e) = check_path_allowed(&real_path, &self.allowed_directories) {
472 let detail = format!("write_file denied: {}", real_path.display());
473 let _ = security::append_audit_entry_with_detail(
474 &self.state_dir,
475 security::AuditAction::PathDenied,
476 "",
477 "tool:write_file",
478 Some(&detail),
479 );
480 return Err(e);
481 }
482
483 if let Some(ref policy) = self.sandbox_policy
485 && localgpt_sandbox::policy::is_path_denied(&real_path, policy)
486 {
487 anyhow::bail!(
488 "Cannot write to denied directory: {}. \
489 This path is blocked by sandbox policy.",
490 real_path.display()
491 );
492 }
493
494 if security::is_path_protected(
496 &real_path.to_string_lossy(),
497 &self.workspace,
498 &self.state_dir,
499 ) {
500 let detail = format!("Agent attempted write to {}", real_path.display());
501 let _ = security::append_audit_entry_with_detail(
502 &self.state_dir,
503 security::AuditAction::WriteBlocked,
504 "",
505 "tool:write_file",
506 Some(&detail),
507 );
508 anyhow::bail!(
509 "Cannot write to protected file: {}. This file is managed by the security system. \
510 Use `localgpt md sign` to update the security policy.",
511 real_path.display()
512 );
513 }
514
515 debug!("Writing file: {}", real_path.display());
516
517 if let Some(parent) = real_path.parent() {
519 fs::create_dir_all(parent)?;
520 }
521
522 fs::write(&real_path, content)?;
523
524 Ok(format!(
525 "Successfully wrote {} bytes to {}",
526 content.len(),
527 real_path.display()
528 ))
529 }
530}
531
532pub struct EditFileTool {
534 workspace: PathBuf,
535 state_dir: PathBuf,
536 sandbox_policy: Option<SandboxPolicy>,
537 filter: CompiledToolFilter,
538 allowed_directories: Vec<PathBuf>,
539}
540
541impl EditFileTool {
542 pub fn new(
543 workspace: PathBuf,
544 state_dir: PathBuf,
545 sandbox_policy: Option<SandboxPolicy>,
546 filter: CompiledToolFilter,
547 allowed_directories: Vec<PathBuf>,
548 ) -> Self {
549 Self {
550 workspace,
551 state_dir,
552 sandbox_policy,
553 filter,
554 allowed_directories,
555 }
556 }
557}
558
559#[async_trait]
560impl Tool for EditFileTool {
561 fn name(&self) -> &str {
562 "edit_file"
563 }
564
565 fn schema(&self) -> ToolSchema {
566 ToolSchema {
567 name: "edit_file".to_string(),
568 description: "Edit a file by replacing old_string with new_string".to_string(),
569 parameters: json!({
570 "type": "object",
571 "properties": {
572 "path": {
573 "type": "string",
574 "description": "The path to the file to edit"
575 },
576 "old_string": {
577 "type": "string",
578 "description": "The text to replace"
579 },
580 "new_string": {
581 "type": "string",
582 "description": "The replacement text"
583 },
584 "replace_all": {
585 "type": "boolean",
586 "description": "Replace all occurrences (default: false)"
587 }
588 },
589 "required": ["path", "old_string", "new_string"]
590 }),
591 }
592 }
593
594 async fn execute(&self, arguments: &str) -> Result<String> {
595 let args: Value = serde_json::from_str(arguments)?;
596 let path = args["path"]
597 .as_str()
598 .ok_or_else(|| anyhow::anyhow!("Missing path"))?;
599 let old_string = args["old_string"]
600 .as_str()
601 .ok_or_else(|| anyhow::anyhow!("Missing old_string"))?;
602 let new_string = args["new_string"]
603 .as_str()
604 .ok_or_else(|| anyhow::anyhow!("Missing new_string"))?;
605 let replace_all = args["replace_all"].as_bool().unwrap_or(false);
606
607 let real_path = resolve_real_path(path)?;
609 let real_path_str = real_path.to_string_lossy();
610 self.filter.check(&real_path_str, "edit_file", "path")?;
611 if let Err(e) = check_path_allowed(&real_path, &self.allowed_directories) {
612 let detail = format!("edit_file denied: {}", real_path.display());
613 let _ = security::append_audit_entry_with_detail(
614 &self.state_dir,
615 security::AuditAction::PathDenied,
616 "",
617 "tool:edit_file",
618 Some(&detail),
619 );
620 return Err(e);
621 }
622
623 if let Some(ref policy) = self.sandbox_policy
625 && localgpt_sandbox::policy::is_path_denied(&real_path, policy)
626 {
627 anyhow::bail!(
628 "Cannot edit file in denied directory: {}. \
629 This path is blocked by sandbox policy.",
630 real_path.display()
631 );
632 }
633
634 if security::is_path_protected(
636 &real_path.to_string_lossy(),
637 &self.workspace,
638 &self.state_dir,
639 ) {
640 let detail = format!("Agent attempted edit to {}", real_path.display());
641 let _ = security::append_audit_entry_with_detail(
642 &self.state_dir,
643 security::AuditAction::WriteBlocked,
644 "",
645 "tool:edit_file",
646 Some(&detail),
647 );
648 anyhow::bail!(
649 "Cannot edit protected file: {}. This file is managed by the security system.",
650 real_path.display()
651 );
652 }
653
654 debug!("Editing file: {}", real_path.display());
655
656 let content = fs::read_to_string(&real_path)?;
657
658 let (new_content, count) = if replace_all {
659 let count = content.matches(old_string).count();
660 (content.replace(old_string, new_string), count)
661 } else if content.contains(old_string) {
662 (content.replacen(old_string, new_string, 1), 1)
663 } else {
664 return Err(anyhow::anyhow!("old_string not found in file"));
665 };
666
667 fs::write(&real_path, &new_content)?;
668
669 Ok(format!(
670 "Replaced {} occurrence(s) in {}",
671 count,
672 real_path.display()
673 ))
674 }
675}