Skip to main content

langshell_tools/
lib.rs

1use std::{
2    fs,
3    path::{Component, Path, PathBuf},
4    sync::Arc,
5};
6
7use langshell_core::{
8    Capability, RegisteredTool, SideEffect, ToolCallContext, ToolError, ToolFuture, ToolRegistry,
9};
10use serde_json::{Value, json};
11
12#[derive(Debug, Clone)]
13pub struct FileMount {
14    pub virtual_path: String,
15    pub host_path: PathBuf,
16    pub writable: bool,
17}
18
19impl FileMount {
20    pub fn readonly(virtual_path: impl Into<String>, host_path: impl Into<PathBuf>) -> Self {
21        Self {
22            virtual_path: normalize_virtual_root(&virtual_path.into()),
23            host_path: host_path.into(),
24            writable: false,
25        }
26    }
27
28    pub fn readwrite(virtual_path: impl Into<String>, host_path: impl Into<PathBuf>) -> Self {
29        Self {
30            virtual_path: normalize_virtual_root(&virtual_path.into()),
31            host_path: host_path.into(),
32            writable: true,
33        }
34    }
35}
36
37#[derive(Debug, Clone, Default)]
38pub struct ToolConfig {
39    pub file_mounts: Vec<FileMount>,
40    pub http_allowlist: Vec<String>,
41}
42
43pub fn register_builtin_tools(
44    registry: &mut ToolRegistry,
45    config: ToolConfig,
46) -> Result<(), langshell_core::ErrorObject> {
47    if !config.file_mounts.is_empty() {
48        register_file_tools(registry, config.file_mounts)?;
49    }
50    if !config.http_allowlist.is_empty() {
51        register_http_tools(registry, config.http_allowlist)?;
52    }
53    register_discovery_tools(registry)?;
54    Ok(())
55}
56
57pub fn register_discovery_tools(
58    registry: &mut ToolRegistry,
59) -> Result<(), langshell_core::ErrorObject> {
60    let list_capability = Capability::new(
61        "list_tools",
62        "List capabilities registered in this session.",
63        SideEffect::None,
64    );
65    let describe_capability = Capability::new(
66        "describe_tool",
67        "Describe one registered capability by name.",
68        SideEffect::None,
69    );
70    let policy_capability = Capability::new(
71        "current_policy",
72        "Return the current sandbox policy summary.",
73        SideEffect::None,
74    );
75
76    let mut list_capabilities = registry.capabilities();
77    list_capabilities.push(list_capability.clone());
78    list_capabilities.push(describe_capability.clone());
79    list_capabilities.push(policy_capability.clone());
80    let list_tool = RegisteredTool::sync(list_capability, move |_| Ok(json!(list_capabilities)));
81    registry.register(list_tool)?;
82
83    let describe_capabilities = Arc::new(list_capabilities_for_describe(
84        registry,
85        &describe_capability,
86        &policy_capability,
87    ));
88    let describe_tool = RegisteredTool::sync(describe_capability, move |ctx| {
89        let name = first_string_arg(&ctx, "describe_tool")?;
90        describe_capabilities
91            .iter()
92            .find(|capability| capability.name == name)
93            .map(|capability| json!(capability))
94            .ok_or_else(|| {
95                ToolError::new(
96                    "UNKNOWN_TOOL",
97                    format!("Function {name} is not registered."),
98                )
99            })
100    });
101    registry.register(describe_tool)?;
102
103    let policy_capabilities = list_capabilities_for_policy(registry, &policy_capability);
104    let current_policy = RegisteredTool::sync(policy_capability, move |_| {
105        Ok(json!({
106            "default_permissions": "none",
107            "filesystem": "capability_only",
108            "network": "capability_only",
109            "subprocess": "denied",
110            "tools": policy_capabilities,
111        }))
112    });
113    registry.register(current_policy)?;
114    Ok(())
115}
116
117fn list_capabilities_for_describe(
118    registry: &ToolRegistry,
119    describe_capability: &Capability,
120    policy_capability: &Capability,
121) -> Vec<Capability> {
122    let mut capabilities = registry.capabilities();
123    capabilities.push(describe_capability.clone());
124    capabilities.push(policy_capability.clone());
125    capabilities
126}
127
128fn list_capabilities_for_policy(
129    registry: &ToolRegistry,
130    policy_capability: &Capability,
131) -> Vec<Capability> {
132    let mut capabilities = registry.capabilities();
133    capabilities.push(policy_capability.clone());
134    capabilities
135}
136
137pub fn register_file_tools(
138    registry: &mut ToolRegistry,
139    mounts: Vec<FileMount>,
140) -> Result<(), langshell_core::ErrorObject> {
141    let mounts = Arc::new(mounts);
142
143    let read_mounts = mounts.clone();
144    registry.register(RegisteredTool::sync(
145        Capability::new(
146            "read_text",
147            "Read UTF-8 text from an authorized virtual path.",
148            SideEffect::Read,
149        ),
150        move |ctx| {
151            let virtual_path = first_string_arg(&ctx, "read_text")?;
152            let resolved = resolve_virtual_path(&read_mounts, &virtual_path, false)?;
153            fs::read_to_string(&resolved)
154                .map(Value::String)
155                .map_err(|err| {
156                    ToolError::new("TOOL_ERROR", format!("read_text({virtual_path}): {err}"))
157                })
158        },
159    ))?;
160
161    let write_mounts = mounts.clone();
162    registry.register(RegisteredTool::sync(
163        Capability::new(
164            "write_text",
165            "Write UTF-8 text to an authorized writable virtual path.",
166            SideEffect::Write,
167        ),
168        move |ctx| {
169            let virtual_path = first_string_arg(&ctx, "write_text")?;
170            let text = ctx.args.get(1).and_then(Value::as_str).ok_or_else(|| {
171                ToolError::new("TYPE_ERROR", "write_text requires path and text arguments.")
172            })?;
173            let resolved = resolve_virtual_path(&write_mounts, &virtual_path, true)?;
174            if let Some(parent) = resolved.parent() {
175                fs::create_dir_all(parent).map_err(|err| {
176                    ToolError::new("TOOL_ERROR", format!("creating parent directory: {err}"))
177                })?;
178            }
179            fs::write(&resolved, text).map_err(|err| {
180                ToolError::new("TOOL_ERROR", format!("write_text({virtual_path}): {err}"))
181            })?;
182            Ok(json!({"path": virtual_path, "bytes": text.len()}))
183        },
184    ))?;
185
186    let list_mounts = mounts;
187    registry.register(RegisteredTool::sync(
188        Capability::new(
189            "list_dir",
190            "List direct children of an authorized virtual directory.",
191            SideEffect::Read,
192        ),
193        move |ctx| {
194            let virtual_path = first_string_arg(&ctx, "list_dir")?;
195            let resolved = resolve_virtual_path(&list_mounts, &virtual_path, false)?;
196            let mut entries = Vec::new();
197            for entry in fs::read_dir(&resolved).map_err(|err| {
198                ToolError::new("TOOL_ERROR", format!("list_dir({virtual_path}): {err}"))
199            })? {
200                let entry = entry.map_err(|err| {
201                    ToolError::new("TOOL_ERROR", format!("list_dir entry: {err}"))
202                })?;
203                entries.push(entry.file_name().to_string_lossy().to_string());
204            }
205            entries.sort();
206            Ok(json!(entries))
207        },
208    ))?;
209
210    Ok(())
211}
212
213pub fn register_http_tools(
214    registry: &mut ToolRegistry,
215    allowlist: Vec<String>,
216) -> Result<(), langshell_core::ErrorObject> {
217    let allowlist = Arc::new(allowlist);
218    let text_allowlist = allowlist.clone();
219    registry.register(RegisteredTool::asynchronous(
220        Capability::new(
221            "fetch_text",
222            "Fetch text from an allowlisted HTTP(S) URL.",
223            SideEffect::Network,
224        ),
225        move |ctx| {
226            let allowlist = text_allowlist.clone();
227            Box::pin(async move {
228                let url = first_string_arg(&ctx, "fetch_text")?;
229                ensure_url_allowed(&allowlist, &url)?;
230                Err(ToolError::new(
231                    "TOOL_ERROR",
232                    "fetch_text transport is not configured in this MVP build.",
233                ))
234            }) as ToolFuture
235        },
236    ))?;
237
238    let json_allowlist = allowlist;
239    registry.register(RegisteredTool::asynchronous(
240        Capability::new("fetch_json", "Fetch JSON from an allowlisted HTTP(S) URL.", SideEffect::Network),
241        move |ctx| {
242            let allowlist = json_allowlist.clone();
243            Box::pin(async move {
244                let url = first_string_arg(&ctx, "fetch_json")?;
245                ensure_url_allowed(&allowlist, &url)?;
246                Err(ToolError::new(
247                    "TOOL_ERROR",
248                    "fetch_json transport is not configured in this MVP build; register a host fetch_json capability.",
249                ))
250            }) as ToolFuture
251        },
252    ))?;
253
254    Ok(())
255}
256
257fn first_string_arg(ctx: &ToolCallContext, function: &str) -> Result<String, ToolError> {
258    ctx.args
259        .first()
260        .and_then(Value::as_str)
261        .map(ToOwned::to_owned)
262        .ok_or_else(|| {
263            ToolError::new(
264                "TYPE_ERROR",
265                format!("{function} requires a string first argument."),
266            )
267        })
268}
269
270fn normalize_virtual_root(path: &str) -> String {
271    let trimmed = path.trim_end_matches('/');
272    if trimmed.is_empty() {
273        "/".to_owned()
274    } else if trimmed.starts_with('/') {
275        trimmed.to_owned()
276    } else {
277        format!("/{trimmed}")
278    }
279}
280
281fn resolve_virtual_path(
282    mounts: &[FileMount],
283    virtual_path: &str,
284    write: bool,
285) -> Result<PathBuf, ToolError> {
286    if virtual_path.as_bytes().contains(&0) || !virtual_path.starts_with('/') {
287        return Err(ToolError::new(
288            "PERMISSION_DENIED",
289            format!("Path {virtual_path:?} is not an absolute virtual path."),
290        ));
291    }
292
293    let mount = mounts
294        .iter()
295        .filter(|mount| {
296            virtual_path == mount.virtual_path
297                || virtual_path.starts_with(&format!("{}/", mount.virtual_path))
298        })
299        .max_by_key(|mount| mount.virtual_path.len())
300        .ok_or_else(|| {
301            ToolError::new(
302                "PERMISSION_DENIED",
303                format!("No mount authorizes {virtual_path}."),
304            )
305        })?;
306
307    if write && !mount.writable {
308        return Err(ToolError::new(
309            "PERMISSION_DENIED",
310            format!("Mount {} is read-only.", mount.virtual_path),
311        ));
312    }
313
314    let suffix = virtual_path
315        .strip_prefix(&mount.virtual_path)
316        .unwrap_or(virtual_path)
317        .trim_start_matches('/');
318    let suffix_path = Path::new(suffix);
319    if suffix_path.components().any(|component| {
320        matches!(
321            component,
322            Component::ParentDir | Component::RootDir | Component::Prefix(_)
323        )
324    }) {
325        return Err(ToolError::new(
326            "PERMISSION_DENIED",
327            format!("Path traversal is not allowed: {virtual_path}."),
328        ));
329    }
330
331    let host_root = mount.host_path.canonicalize().map_err(|err| {
332        ToolError::new(
333            "PERMISSION_DENIED",
334            format!("Mount root is not accessible: {err}"),
335        )
336    })?;
337    let candidate = host_root.join(suffix_path);
338
339    if candidate.exists() {
340        let canonical = candidate.canonicalize().map_err(|err| {
341            ToolError::new(
342                "PERMISSION_DENIED",
343                format!("Path is not accessible: {err}"),
344            )
345        })?;
346        if !canonical.starts_with(&host_root) {
347            return Err(ToolError::new(
348                "PERMISSION_DENIED",
349                format!("Path escapes mount boundary: {virtual_path}."),
350            ));
351        }
352        Ok(canonical)
353    } else {
354        let parent = candidate.parent().unwrap_or(&host_root);
355        let canonical_parent = parent.canonicalize().map_err(|err| {
356            ToolError::new(
357                "PERMISSION_DENIED",
358                format!("Parent path is not accessible: {err}"),
359            )
360        })?;
361        if !canonical_parent.starts_with(&host_root) {
362            return Err(ToolError::new(
363                "PERMISSION_DENIED",
364                format!("Path escapes mount boundary: {virtual_path}."),
365            ));
366        }
367        Ok(candidate)
368    }
369}
370
371fn ensure_url_allowed(allowlist: &[String], url: &str) -> Result<(), ToolError> {
372    let Some(rest) = url
373        .strip_prefix("https://")
374        .or_else(|| url.strip_prefix("http://"))
375    else {
376        return Err(ToolError::new(
377            "PERMISSION_DENIED",
378            "Only http:// and https:// URLs are allowed.",
379        ));
380    };
381    let host = rest
382        .split('/')
383        .next()
384        .unwrap_or_default()
385        .split(':')
386        .next()
387        .unwrap_or_default();
388    if allowlist.iter().any(|allowed| allowed == host) {
389        Ok(())
390    } else {
391        Err(ToolError::new(
392            "PERMISSION_DENIED",
393            format!("Host {host} is not in the HTTP allowlist."),
394        ))
395    }
396}