Skip to main content

localgpt_cli_tools/
lib.rs

1//! CLI-only tools: bash, read_file, write_file, edit_file, browser.
2//!
3//! These tools are not included in `localgpt-core` because they have
4//! platform-specific dependencies (sandbox) and security implications
5//! that make them unsuitable for mobile.
6
7pub 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
25/// Compile a tool filter from config (if present), then merge hardcoded defaults.
26fn 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
41/// Canonicalize configured allowed_directories into absolute paths.
42fn 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
60/// Create just the CLI-specific dangerous tools (bash, read_file, write_file, edit_file).
61///
62/// Use with `agent.extend_tools()` after `Agent::new()` to add these to an
63/// agent that already has safe tools.
64pub 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    // Build sandbox policy if enabled
69    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    // Compile per-tool filters with hardcoded defaults
91    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    // File tools get no hardcoded filters (path scoping handles security)
99    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    // Conditionally add browser tool
134    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
143// Bash Tool
144pub 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        // Check command against deny/allow filters
208        self.filter.check(command, "bash", "command")?;
209
210        // Best-effort protected file check for bash commands
211        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        // Use sandbox if policy is configured
240        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        // Fallback: run command directly without sandbox
252        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
290// Read File Tool
291pub 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        // Resolve symlinks and check path scoping
352        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        // Check credential directory access
368        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        // Handle offset and limit
383        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
404// Write File Tool
405pub 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        // Resolve symlinks and check path scoping
468        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        // Check credential directory access
484        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        // Check protected files
495        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        // Create parent directories if needed
518        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
532// Edit File Tool
533pub 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        // Resolve symlinks and check path scoping
608        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        // Check credential directory access
624        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        // Check protected files
635        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}