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
57/// Register the read-only discovery tools (`list_tools`, `describe_tool`,
58/// `current_policy`).
59///
60/// **Ordering matters**: discovery tools capture a snapshot of the registry's
61/// current capabilities. Always call this *after* every other capability has
62/// been registered, otherwise the resulting `list_tools()` / `describe_tool()`
63/// output will not include capabilities registered later.
64pub fn register_discovery_tools(
65    registry: &mut ToolRegistry,
66) -> Result<(), langshell_core::ErrorObject> {
67    let list_capability = Capability::new(
68        "list_tools",
69        "List capabilities registered in this session.",
70        SideEffect::None,
71    )
72    .with_input_schema(no_args_schema())
73    .with_output_schema(json!({"type": "array", "items": capability_schema()}));
74    let describe_capability = Capability::new(
75        "describe_tool",
76        "Describe one registered capability by name.",
77        SideEffect::None,
78    )
79    .with_input_schema(single_string_arg_schema("name"))
80    .with_output_schema(capability_schema());
81    let policy_capability = Capability::new(
82        "current_policy",
83        "Return the current sandbox policy summary.",
84        SideEffect::None,
85    )
86    .with_input_schema(no_args_schema())
87    .with_output_schema(json!({"type": "object"}));
88
89    let mut list_capabilities = registry.capabilities();
90    list_capabilities.push(list_capability.clone());
91    list_capabilities.push(describe_capability.clone());
92    list_capabilities.push(policy_capability.clone());
93    let list_tool = RegisteredTool::sync(list_capability, move |_| Ok(json!(list_capabilities)));
94    registry.register(list_tool)?;
95
96    let describe_capabilities = Arc::new(list_capabilities_for_describe(
97        registry,
98        &describe_capability,
99        &policy_capability,
100    ));
101    let describe_tool = RegisteredTool::sync(describe_capability, move |ctx| {
102        let name = first_string_arg(&ctx, "describe_tool")?;
103        describe_capabilities
104            .iter()
105            .find(|capability| capability.name == name)
106            .map(|capability| json!(capability))
107            .ok_or_else(|| {
108                ToolError::new(
109                    "UNKNOWN_TOOL",
110                    format!("Function {name} is not registered."),
111                )
112            })
113    });
114    registry.register(describe_tool)?;
115
116    let policy_capabilities = list_capabilities_for_policy(registry, &policy_capability);
117    let current_policy = RegisteredTool::sync(policy_capability, move |_| {
118        Ok(json!({
119            "default_permissions": "none",
120            "filesystem": "capability_only",
121            "network": "capability_only",
122            "subprocess": "denied",
123            "tools": policy_capabilities,
124        }))
125    });
126    registry.register(current_policy)?;
127    Ok(())
128}
129
130fn list_capabilities_for_describe(
131    registry: &ToolRegistry,
132    describe_capability: &Capability,
133    policy_capability: &Capability,
134) -> Vec<Capability> {
135    let mut capabilities = registry.capabilities();
136    capabilities.push(describe_capability.clone());
137    capabilities.push(policy_capability.clone());
138    capabilities
139}
140
141fn list_capabilities_for_policy(
142    registry: &ToolRegistry,
143    policy_capability: &Capability,
144) -> Vec<Capability> {
145    let mut capabilities = registry.capabilities();
146    capabilities.push(policy_capability.clone());
147    capabilities
148}
149
150pub fn register_file_tools(
151    registry: &mut ToolRegistry,
152    mounts: Vec<FileMount>,
153) -> Result<(), langshell_core::ErrorObject> {
154    let mounts = Arc::new(mounts);
155
156    let read_mounts = mounts.clone();
157    registry.register(RegisteredTool::sync(
158        Capability::new(
159            "read_text",
160            "Read UTF-8 text from an authorized virtual path.",
161            SideEffect::Read,
162        )
163        .with_input_schema(single_string_arg_schema("path"))
164        .with_output_schema(json!({"type": "string"})),
165        move |ctx| {
166            let virtual_path = first_string_arg(&ctx, "read_text")?;
167            let resolved = resolve_virtual_path(&read_mounts, &virtual_path, false)?;
168            fs::read_to_string(&resolved)
169                .map(Value::String)
170                .map_err(|err| {
171                    ToolError::new("TOOL_ERROR", format!("read_text({virtual_path}): {err}"))
172                })
173        },
174    ))?;
175
176    let write_mounts = mounts.clone();
177    registry.register(RegisteredTool::sync(
178        Capability::new(
179            "write_text",
180            "Write UTF-8 text to an authorized writable virtual path.",
181            SideEffect::Write,
182        )
183        .with_input_schema(json!({
184            "type": "array",
185            "prefixItems": [
186                {"type": "string", "description": "Authorized virtual path."},
187                {"type": "string", "description": "UTF-8 text content."}
188            ],
189            "minItems": 2,
190            "maxItems": 2
191        }))
192        .with_output_schema(json!({
193            "type": "object",
194            "properties": {
195                "path": {"type": "string"},
196                "bytes": {"type": "integer", "minimum": 0}
197            },
198            "required": ["path", "bytes"]
199        })),
200        move |ctx| {
201            let virtual_path = first_string_arg(&ctx, "write_text")?;
202            let text = ctx.args.get(1).and_then(Value::as_str).ok_or_else(|| {
203                ToolError::new("TYPE_ERROR", "write_text requires path and text arguments.")
204            })?;
205            let resolved = resolve_virtual_path(&write_mounts, &virtual_path, true)?;
206            if let Some(parent) = resolved.parent() {
207                fs::create_dir_all(parent).map_err(|err| {
208                    ToolError::new("TOOL_ERROR", format!("creating parent directory: {err}"))
209                })?;
210            }
211            fs::write(&resolved, text).map_err(|err| {
212                ToolError::new("TOOL_ERROR", format!("write_text({virtual_path}): {err}"))
213            })?;
214            Ok(json!({"path": virtual_path, "bytes": text.len()}))
215        },
216    ))?;
217
218    let list_mounts = mounts;
219    registry.register(RegisteredTool::sync(
220        Capability::new(
221            "list_dir",
222            "List direct children of an authorized virtual directory.",
223            SideEffect::Read,
224        )
225        .with_input_schema(single_string_arg_schema("path"))
226        .with_output_schema(json!({"type": "array", "items": {"type": "string"}})),
227        move |ctx| {
228            let virtual_path = first_string_arg(&ctx, "list_dir")?;
229            let resolved = resolve_virtual_path(&list_mounts, &virtual_path, false)?;
230            let mut entries = Vec::new();
231            for entry in fs::read_dir(&resolved).map_err(|err| {
232                ToolError::new("TOOL_ERROR", format!("list_dir({virtual_path}): {err}"))
233            })? {
234                let entry = entry.map_err(|err| {
235                    ToolError::new("TOOL_ERROR", format!("list_dir entry: {err}"))
236                })?;
237                entries.push(entry.file_name().to_string_lossy().to_string());
238            }
239            entries.sort();
240            Ok(json!(entries))
241        },
242    ))?;
243
244    Ok(())
245}
246
247pub fn register_http_tools(
248    registry: &mut ToolRegistry,
249    allowlist: Vec<String>,
250) -> Result<(), langshell_core::ErrorObject> {
251    let allowlist: Arc<Vec<String>> = Arc::new(
252        allowlist
253            .into_iter()
254            .map(|host| host.to_lowercase())
255            .collect(),
256    );
257    let text_allowlist = allowlist.clone();
258    registry.register(RegisteredTool::asynchronous(
259        Capability::new(
260            "fetch_text",
261            "Fetch text from an allowlisted HTTP(S) URL.",
262            SideEffect::Network,
263        )
264        .with_input_schema(single_string_arg_schema("url"))
265        .with_output_schema(json!({"type": "string"})),
266        move |ctx| {
267            let allowlist = text_allowlist.clone();
268            Box::pin(async move {
269                let url = first_string_arg(&ctx, "fetch_text")?;
270                ensure_url_allowed(&allowlist, &url)?;
271                Err(ToolError::new(
272                    "TOOL_ERROR",
273                    "fetch_text transport is not configured in this build.",
274                ))
275            }) as ToolFuture
276        },
277    ))?;
278
279    let json_allowlist = allowlist;
280    registry.register(RegisteredTool::asynchronous(
281        Capability::new(
282            "fetch_json",
283            "Fetch JSON from an allowlisted HTTP(S) URL.",
284            SideEffect::Network,
285        )
286        .with_input_schema(single_string_arg_schema("url"))
287        .with_output_schema(json!({})),
288        move |ctx| {
289            let allowlist = json_allowlist.clone();
290            Box::pin(async move {
291                let url = first_string_arg(&ctx, "fetch_json")?;
292                ensure_url_allowed(&allowlist, &url)?;
293                Err(ToolError::new(
294                    "TOOL_ERROR",
295                    "fetch_json transport is not configured in this build; register a host fetch_json capability.",
296                ))
297            }) as ToolFuture
298        },
299    ))?;
300
301    Ok(())
302}
303
304fn no_args_schema() -> Value {
305    json!({"type": "array", "maxItems": 0})
306}
307
308fn single_string_arg_schema(name: &str) -> Value {
309    json!({
310        "type": "array",
311        "prefixItems": [{"type": "string", "description": name}],
312        "minItems": 1,
313        "maxItems": 1
314    })
315}
316
317fn capability_schema() -> Value {
318    json!({
319        "type": "object",
320        "properties": {
321            "name": {"type": "string"},
322            "description": {"type": "string"},
323            "input_schema": {"type": "object"},
324            "output_schema": {"type": "object"},
325            "side_effect": {"type": "string"}
326        },
327        "required": ["name", "description", "input_schema", "output_schema", "side_effect"]
328    })
329}
330
331fn first_string_arg(ctx: &ToolCallContext, function: &str) -> Result<String, ToolError> {
332    ctx.args
333        .first()
334        .and_then(Value::as_str)
335        .map(ToOwned::to_owned)
336        .ok_or_else(|| {
337            ToolError::new(
338                "TYPE_ERROR",
339                format!("{function} requires a string first argument."),
340            )
341        })
342}
343
344fn normalize_virtual_root(path: &str) -> String {
345    let trimmed = path.trim_end_matches('/');
346    if trimmed.is_empty() {
347        "/".to_owned()
348    } else if trimmed.starts_with('/') {
349        trimmed.to_owned()
350    } else {
351        format!("/{trimmed}")
352    }
353}
354
355fn resolve_virtual_path(
356    mounts: &[FileMount],
357    virtual_path: &str,
358    write: bool,
359) -> Result<PathBuf, ToolError> {
360    if virtual_path.as_bytes().contains(&0) || !virtual_path.starts_with('/') {
361        return Err(ToolError::new(
362            "PERMISSION_DENIED",
363            format!("Path {virtual_path:?} is not an absolute virtual path."),
364        ));
365    }
366
367    let mount = mounts
368        .iter()
369        .filter(|mount| {
370            virtual_path == mount.virtual_path
371                || virtual_path.starts_with(&format!("{}/", mount.virtual_path))
372        })
373        .max_by_key(|mount| mount.virtual_path.len())
374        .ok_or_else(|| {
375            ToolError::new(
376                "PERMISSION_DENIED",
377                format!("No mount authorizes {virtual_path}."),
378            )
379        })?;
380
381    if write && !mount.writable {
382        return Err(ToolError::new(
383            "PERMISSION_DENIED",
384            format!("Mount {} is read-only.", mount.virtual_path),
385        ));
386    }
387
388    let suffix = virtual_path
389        .strip_prefix(&mount.virtual_path)
390        .unwrap_or(virtual_path)
391        .trim_start_matches('/');
392    let suffix_path = Path::new(suffix);
393    if suffix_path.components().any(|component| {
394        matches!(
395            component,
396            Component::ParentDir | Component::RootDir | Component::Prefix(_)
397        )
398    }) {
399        return Err(ToolError::new(
400            "PERMISSION_DENIED",
401            format!("Path traversal is not allowed: {virtual_path}."),
402        ));
403    }
404
405    let host_root = mount.host_path.canonicalize().map_err(|err| {
406        ToolError::new(
407            "PERMISSION_DENIED",
408            format!("Mount root is not accessible: {err}"),
409        )
410    })?;
411    let candidate = host_root.join(suffix_path);
412
413    if candidate.exists() {
414        let canonical = candidate.canonicalize().map_err(|err| {
415            ToolError::new(
416                "PERMISSION_DENIED",
417                format!("Path is not accessible: {err}"),
418            )
419        })?;
420        if !canonical.starts_with(&host_root) {
421            return Err(ToolError::new(
422                "PERMISSION_DENIED",
423                format!("Path escapes mount boundary: {virtual_path}."),
424            ));
425        }
426        Ok(canonical)
427    } else {
428        let parent = candidate.parent().unwrap_or(&host_root);
429        let canonical_parent = parent.canonicalize().map_err(|err| {
430            ToolError::new(
431                "PERMISSION_DENIED",
432                format!("Parent path is not accessible: {err}"),
433            )
434        })?;
435        if !canonical_parent.starts_with(&host_root) {
436            return Err(ToolError::new(
437                "PERMISSION_DENIED",
438                format!("Path escapes mount boundary: {virtual_path}."),
439            ));
440        }
441        Ok(candidate)
442    }
443}
444
445fn ensure_url_allowed(allowlist: &[String], url: &str) -> Result<(), ToolError> {
446    let Some(rest) = url
447        .strip_prefix("https://")
448        .or_else(|| url.strip_prefix("http://"))
449    else {
450        return Err(ToolError::new(
451            "PERMISSION_DENIED",
452            "Only http:// and https:// URLs are allowed.",
453        ));
454    };
455    let host = rest
456        .split('/')
457        .next()
458        .unwrap_or_default()
459        .split(':')
460        .next()
461        .unwrap_or_default()
462        .to_lowercase();
463    if allowlist.iter().any(|allowed| allowed == &host) {
464        Ok(())
465    } else {
466        Err(ToolError::new(
467            "PERMISSION_DENIED",
468            format!("Host {host} is not in the HTTP allowlist."),
469        ))
470    }
471}