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