Skip to main content

localgpt_cli_tools/
lib.rs

1//! CLI-only tools: bash, read_file, write_file, edit_file.
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
7use 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
23/// Compile a tool filter from config (if present), then merge hardcoded defaults.
24fn 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
39/// Canonicalize configured allowed_directories into absolute paths.
40fn 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
58/// Create just the CLI-specific dangerous tools (bash, read_file, write_file, edit_file).
59///
60/// Use with `agent.extend_tools()` after `Agent::new()` to add these to an
61/// agent that already has safe tools.
62pub 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    // Build sandbox policy if enabled
67    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    // Compile per-tool filters with hardcoded defaults
89    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    // File tools get no hardcoded filters (path scoping handles security)
97    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
132// Bash Tool
133pub 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        // Check command against deny/allow filters
197        self.filter.check(command, "bash", "command")?;
198
199        // Best-effort protected file check for bash commands
200        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        // Use sandbox if policy is configured
229        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        // Fallback: run command directly without sandbox
241        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
279// Read File Tool
280pub 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        // Resolve symlinks and check path scoping
341        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        // Check credential directory access
357        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        // Handle offset and limit
372        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
393// Write File Tool
394pub 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        // Resolve symlinks and check path scoping
457        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        // Check credential directory access
473        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        // Check protected files
484        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        // Create parent directories if needed
507        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
521// Edit File Tool
522pub 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        // Resolve symlinks and check path scoping
597        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        // Check credential directory access
613        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        // Check protected files
624        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}