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}