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(
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}