langshell-tools 0.1.0

Built-in discovery, filesystem, and HTTP capability helpers for LangShell hosts.
Documentation
use std::{
    fs,
    path::{Component, Path, PathBuf},
    sync::Arc,
};

use langshell_core::{
    Capability, RegisteredTool, SideEffect, ToolCallContext, ToolError, ToolFuture, ToolRegistry,
};
use serde_json::{Value, json};

#[derive(Debug, Clone)]
pub struct FileMount {
    pub virtual_path: String,
    pub host_path: PathBuf,
    pub writable: bool,
}

impl FileMount {
    pub fn readonly(virtual_path: impl Into<String>, host_path: impl Into<PathBuf>) -> Self {
        Self {
            virtual_path: normalize_virtual_root(&virtual_path.into()),
            host_path: host_path.into(),
            writable: false,
        }
    }

    pub fn readwrite(virtual_path: impl Into<String>, host_path: impl Into<PathBuf>) -> Self {
        Self {
            virtual_path: normalize_virtual_root(&virtual_path.into()),
            host_path: host_path.into(),
            writable: true,
        }
    }
}

#[derive(Debug, Clone, Default)]
pub struct ToolConfig {
    pub file_mounts: Vec<FileMount>,
    pub http_allowlist: Vec<String>,
}

pub fn register_builtin_tools(
    registry: &mut ToolRegistry,
    config: ToolConfig,
) -> Result<(), langshell_core::ErrorObject> {
    if !config.file_mounts.is_empty() {
        register_file_tools(registry, config.file_mounts)?;
    }
    if !config.http_allowlist.is_empty() {
        register_http_tools(registry, config.http_allowlist)?;
    }
    register_discovery_tools(registry)?;
    Ok(())
}

pub fn register_discovery_tools(
    registry: &mut ToolRegistry,
) -> Result<(), langshell_core::ErrorObject> {
    let list_capability = Capability::new(
        "list_tools",
        "List capabilities registered in this session.",
        SideEffect::None,
    );
    let describe_capability = Capability::new(
        "describe_tool",
        "Describe one registered capability by name.",
        SideEffect::None,
    );
    let policy_capability = Capability::new(
        "current_policy",
        "Return the current sandbox policy summary.",
        SideEffect::None,
    );

    let mut list_capabilities = registry.capabilities();
    list_capabilities.push(list_capability.clone());
    list_capabilities.push(describe_capability.clone());
    list_capabilities.push(policy_capability.clone());
    let list_tool = RegisteredTool::sync(list_capability, move |_| Ok(json!(list_capabilities)));
    registry.register(list_tool)?;

    let describe_capabilities = Arc::new(list_capabilities_for_describe(
        registry,
        &describe_capability,
        &policy_capability,
    ));
    let describe_tool = RegisteredTool::sync(describe_capability, move |ctx| {
        let name = first_string_arg(&ctx, "describe_tool")?;
        describe_capabilities
            .iter()
            .find(|capability| capability.name == name)
            .map(|capability| json!(capability))
            .ok_or_else(|| {
                ToolError::new(
                    "UNKNOWN_TOOL",
                    format!("Function {name} is not registered."),
                )
            })
    });
    registry.register(describe_tool)?;

    let policy_capabilities = list_capabilities_for_policy(registry, &policy_capability);
    let current_policy = RegisteredTool::sync(policy_capability, move |_| {
        Ok(json!({
            "default_permissions": "none",
            "filesystem": "capability_only",
            "network": "capability_only",
            "subprocess": "denied",
            "tools": policy_capabilities,
        }))
    });
    registry.register(current_policy)?;
    Ok(())
}

fn list_capabilities_for_describe(
    registry: &ToolRegistry,
    describe_capability: &Capability,
    policy_capability: &Capability,
) -> Vec<Capability> {
    let mut capabilities = registry.capabilities();
    capabilities.push(describe_capability.clone());
    capabilities.push(policy_capability.clone());
    capabilities
}

fn list_capabilities_for_policy(
    registry: &ToolRegistry,
    policy_capability: &Capability,
) -> Vec<Capability> {
    let mut capabilities = registry.capabilities();
    capabilities.push(policy_capability.clone());
    capabilities
}

pub fn register_file_tools(
    registry: &mut ToolRegistry,
    mounts: Vec<FileMount>,
) -> Result<(), langshell_core::ErrorObject> {
    let mounts = Arc::new(mounts);

    let read_mounts = mounts.clone();
    registry.register(RegisteredTool::sync(
        Capability::new(
            "read_text",
            "Read UTF-8 text from an authorized virtual path.",
            SideEffect::Read,
        ),
        move |ctx| {
            let virtual_path = first_string_arg(&ctx, "read_text")?;
            let resolved = resolve_virtual_path(&read_mounts, &virtual_path, false)?;
            fs::read_to_string(&resolved)
                .map(Value::String)
                .map_err(|err| {
                    ToolError::new("TOOL_ERROR", format!("read_text({virtual_path}): {err}"))
                })
        },
    ))?;

    let write_mounts = mounts.clone();
    registry.register(RegisteredTool::sync(
        Capability::new(
            "write_text",
            "Write UTF-8 text to an authorized writable virtual path.",
            SideEffect::Write,
        ),
        move |ctx| {
            let virtual_path = first_string_arg(&ctx, "write_text")?;
            let text = ctx.args.get(1).and_then(Value::as_str).ok_or_else(|| {
                ToolError::new("TYPE_ERROR", "write_text requires path and text arguments.")
            })?;
            let resolved = resolve_virtual_path(&write_mounts, &virtual_path, true)?;
            if let Some(parent) = resolved.parent() {
                fs::create_dir_all(parent).map_err(|err| {
                    ToolError::new("TOOL_ERROR", format!("creating parent directory: {err}"))
                })?;
            }
            fs::write(&resolved, text).map_err(|err| {
                ToolError::new("TOOL_ERROR", format!("write_text({virtual_path}): {err}"))
            })?;
            Ok(json!({"path": virtual_path, "bytes": text.len()}))
        },
    ))?;

    let list_mounts = mounts;
    registry.register(RegisteredTool::sync(
        Capability::new(
            "list_dir",
            "List direct children of an authorized virtual directory.",
            SideEffect::Read,
        ),
        move |ctx| {
            let virtual_path = first_string_arg(&ctx, "list_dir")?;
            let resolved = resolve_virtual_path(&list_mounts, &virtual_path, false)?;
            let mut entries = Vec::new();
            for entry in fs::read_dir(&resolved).map_err(|err| {
                ToolError::new("TOOL_ERROR", format!("list_dir({virtual_path}): {err}"))
            })? {
                let entry = entry.map_err(|err| {
                    ToolError::new("TOOL_ERROR", format!("list_dir entry: {err}"))
                })?;
                entries.push(entry.file_name().to_string_lossy().to_string());
            }
            entries.sort();
            Ok(json!(entries))
        },
    ))?;

    Ok(())
}

pub fn register_http_tools(
    registry: &mut ToolRegistry,
    allowlist: Vec<String>,
) -> Result<(), langshell_core::ErrorObject> {
    let allowlist = Arc::new(allowlist);
    let text_allowlist = allowlist.clone();
    registry.register(RegisteredTool::asynchronous(
        Capability::new(
            "fetch_text",
            "Fetch text from an allowlisted HTTP(S) URL.",
            SideEffect::Network,
        ),
        move |ctx| {
            let allowlist = text_allowlist.clone();
            Box::pin(async move {
                let url = first_string_arg(&ctx, "fetch_text")?;
                ensure_url_allowed(&allowlist, &url)?;
                Err(ToolError::new(
                    "TOOL_ERROR",
                    "fetch_text transport is not configured in this MVP build.",
                ))
            }) as ToolFuture
        },
    ))?;

    let json_allowlist = allowlist;
    registry.register(RegisteredTool::asynchronous(
        Capability::new("fetch_json", "Fetch JSON from an allowlisted HTTP(S) URL.", SideEffect::Network),
        move |ctx| {
            let allowlist = json_allowlist.clone();
            Box::pin(async move {
                let url = first_string_arg(&ctx, "fetch_json")?;
                ensure_url_allowed(&allowlist, &url)?;
                Err(ToolError::new(
                    "TOOL_ERROR",
                    "fetch_json transport is not configured in this MVP build; register a host fetch_json capability.",
                ))
            }) as ToolFuture
        },
    ))?;

    Ok(())
}

fn first_string_arg(ctx: &ToolCallContext, function: &str) -> Result<String, ToolError> {
    ctx.args
        .first()
        .and_then(Value::as_str)
        .map(ToOwned::to_owned)
        .ok_or_else(|| {
            ToolError::new(
                "TYPE_ERROR",
                format!("{function} requires a string first argument."),
            )
        })
}

fn normalize_virtual_root(path: &str) -> String {
    let trimmed = path.trim_end_matches('/');
    if trimmed.is_empty() {
        "/".to_owned()
    } else if trimmed.starts_with('/') {
        trimmed.to_owned()
    } else {
        format!("/{trimmed}")
    }
}

fn resolve_virtual_path(
    mounts: &[FileMount],
    virtual_path: &str,
    write: bool,
) -> Result<PathBuf, ToolError> {
    if virtual_path.as_bytes().contains(&0) || !virtual_path.starts_with('/') {
        return Err(ToolError::new(
            "PERMISSION_DENIED",
            format!("Path {virtual_path:?} is not an absolute virtual path."),
        ));
    }

    let mount = mounts
        .iter()
        .filter(|mount| {
            virtual_path == mount.virtual_path
                || virtual_path.starts_with(&format!("{}/", mount.virtual_path))
        })
        .max_by_key(|mount| mount.virtual_path.len())
        .ok_or_else(|| {
            ToolError::new(
                "PERMISSION_DENIED",
                format!("No mount authorizes {virtual_path}."),
            )
        })?;

    if write && !mount.writable {
        return Err(ToolError::new(
            "PERMISSION_DENIED",
            format!("Mount {} is read-only.", mount.virtual_path),
        ));
    }

    let suffix = virtual_path
        .strip_prefix(&mount.virtual_path)
        .unwrap_or(virtual_path)
        .trim_start_matches('/');
    let suffix_path = Path::new(suffix);
    if suffix_path.components().any(|component| {
        matches!(
            component,
            Component::ParentDir | Component::RootDir | Component::Prefix(_)
        )
    }) {
        return Err(ToolError::new(
            "PERMISSION_DENIED",
            format!("Path traversal is not allowed: {virtual_path}."),
        ));
    }

    let host_root = mount.host_path.canonicalize().map_err(|err| {
        ToolError::new(
            "PERMISSION_DENIED",
            format!("Mount root is not accessible: {err}"),
        )
    })?;
    let candidate = host_root.join(suffix_path);

    if candidate.exists() {
        let canonical = candidate.canonicalize().map_err(|err| {
            ToolError::new(
                "PERMISSION_DENIED",
                format!("Path is not accessible: {err}"),
            )
        })?;
        if !canonical.starts_with(&host_root) {
            return Err(ToolError::new(
                "PERMISSION_DENIED",
                format!("Path escapes mount boundary: {virtual_path}."),
            ));
        }
        Ok(canonical)
    } else {
        let parent = candidate.parent().unwrap_or(&host_root);
        let canonical_parent = parent.canonicalize().map_err(|err| {
            ToolError::new(
                "PERMISSION_DENIED",
                format!("Parent path is not accessible: {err}"),
            )
        })?;
        if !canonical_parent.starts_with(&host_root) {
            return Err(ToolError::new(
                "PERMISSION_DENIED",
                format!("Path escapes mount boundary: {virtual_path}."),
            ));
        }
        Ok(candidate)
    }
}

fn ensure_url_allowed(allowlist: &[String], url: &str) -> Result<(), ToolError> {
    let Some(rest) = url
        .strip_prefix("https://")
        .or_else(|| url.strip_prefix("http://"))
    else {
        return Err(ToolError::new(
            "PERMISSION_DENIED",
            "Only http:// and https:// URLs are allowed.",
        ));
    };
    let host = rest
        .split('/')
        .next()
        .unwrap_or_default()
        .split(':')
        .next()
        .unwrap_or_default();
    if allowlist.iter().any(|allowed| allowed == host) {
        Ok(())
    } else {
        Err(ToolError::new(
            "PERMISSION_DENIED",
            format!("Host {host} is not in the HTTP allowlist."),
        ))
    }
}