diff --git a/CLAUDE.md b/CLAUDE.md index 4a058e8d9..970362bf0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,7 +2,7 @@ **AI assistant for communities — React + Tauri v2 desktop app with a Rust core (JSON-RPC / CLI).** -Narrative architecture: [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md). Frontend: [`docs/src/README.md`](docs/src/README.md). Tauri shell: [`docs/src-tauri/README.md`](docs/src-tauri/README.md). +Narrative architecture: [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md). Frontend: [`docs/src/README.md`](docs/src/README.md). Tauri shell: [`docs/src-tauri/README.md`](docs/src-tauri/README.md). Coding-harness tool surface: [`docs/CODING_HARNESS.md`](docs/CODING_HARNESS.md). --- diff --git a/Cargo.lock b/Cargo.lock index d8a94f9c8..e4b839316 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4528,6 +4528,7 @@ dependencies = [ "fs2", "futures", "futures-util", + "glob", "hex", "hmac 0.12.1", "hostname", @@ -4589,6 +4590,7 @@ dependencies = [ "uuid", "wacore", "wait-timeout", + "walkdir", "webpki-roots 1.0.6", "whatsapp-rust", "whatsapp-rust-tokio-transport", diff --git a/Cargo.toml b/Cargo.toml index 2ef4247f4..658dbeb02 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -89,6 +89,8 @@ dialoguer = { version = "0.12", features = ["fuzzy-select"] } dotenvy = "0.15" console = "0.16" regex = "1.10" +walkdir = "2" +glob = "0.3" unicode-segmentation = "1" unicode-width = "0.2" hostname = "0.4.2" diff --git a/docs/CODING_HARNESS.md b/docs/CODING_HARNESS.md new file mode 100644 index 000000000..0b1a23074 --- /dev/null +++ b/docs/CODING_HARNESS.md @@ -0,0 +1,172 @@ +# Coding-harness tool surface + +OpenHuman exposes a coherent baseline of code-focused tools to its +agents. This page is the canonical map: which tool the model should +reach for, what permissions it needs, and where the implementation +lives. + +It is intentionally a flat catalog — not a guide on how the agent +loop dispatches tools. For that, see +[`docs/ARCHITECTURE.md`](ARCHITECTURE.md) and +[`src/openhuman/tools/`](../src/openhuman/tools/). + +Tracking issue: [#1205](https://github.com/tinyhumansai/openhuman/issues/1205). + +## Surface at a glance + +| Category | Tool | Permission | Source | +| --- | --- | --- | --- | +| **Navigation** | `file_read` | ReadOnly | `tools/impl/filesystem/file_read.rs` | +| | `grep` | ReadOnly | `tools/impl/filesystem/grep.rs` | +| | `glob` | ReadOnly | `tools/impl/filesystem/glob_search.rs` | +| | `list` | ReadOnly | `tools/impl/filesystem/list_files.rs` | +| **Editing** | `file_write` | Write | `tools/impl/filesystem/file_write.rs` | +| | `edit` | Write | `tools/impl/filesystem/edit_file.rs` | +| | `apply_patch` | Write | `tools/impl/filesystem/apply_patch.rs` | +| **Execution** | `shell` | Execute | `tools/impl/system/shell.rs` | +| **Interaction** | `ask_clarification` (`question`) | None | `tools/impl/agent/ask_clarification.rs` | +| | `spawn_subagent` (`task`) | varies | `tools/impl/agent/spawn_subagent.rs` | +| | `todowrite` | None | `tools/impl/agent/todo_write.rs` | +| | `plan_exit` | None | `tools/impl/agent/plan_exit.rs` | +| **Web research** | `web_search` (`websearch`) | ReadOnly | `tools/impl/network/web_search.rs` | +| | `web_fetch` (`webfetch`) | ReadOnly | `tools/impl/network/web_fetch.rs` | +| | `http_request`, `curl` (richer HTTP) | ReadOnly | `tools/impl/network/` | +| **Code intel** | `lsp` *(capability-gated)* | ReadOnly | `tools/impl/system/lsp.rs` | + +Names in parentheses are the canonical coding-harness names from issue #1205. +The Rust struct and registration name match the column on the left; the +alias in parentheses is the conceptual role. + +## What each tool does + +### Navigation + +- **`file_read { path }`** — read a workspace-relative file (≤10 MB). + Path-sandboxed and symlink-escape blocked. +- **`grep { pattern, path?, max_matches?, case_insensitive? }`** — + regex search across files. Returns `path:line:text` lines, capped. + Skips `.git`, `node_modules`, `target`, `.next`, `dist`, `build`, + `.cache`. +- **`glob { pattern, max_results? }`** — list files matching a glob + (e.g. `src/**/*.rs`). Sorted newest-first. Same skip set as `grep`. +- **`list { path? }`** — non-recursive directory listing. Each line is + `\t` where kind is `dir`, `file`, or `link`. + +### Editing + +- **`file_write { path, content }`** — overwrite (or create) a file. +- **`edit { path, old_string, new_string, replace_all? }`** — exact + string-replace. By default `old_string` must be unique in the file + (so the model can't accidentally rewrite every occurrence); set + `replace_all` to override. +- **`apply_patch { edits[] }`** — atomic batch of `edit` operations + across one or more files. Validation runs over the whole batch + first; if any edit fails (path not allowed, non-unique match, file + too large, …) **no** files are written. + +### Execution + +- **`shell { command }`** — run a vetted shell command. Use this only + when the right primitive doesn't already exist (e.g. `grep` should + almost always replace `shell { command: "grep ..." }`). + +### Interaction & control flow + +- **`ask_clarification` (canonical `question`)** — pause the run and + ask the user a structured question. Resumes with the answer once + the user replies. +- **`spawn_subagent` (canonical `task`)** — delegate a focused unit of + work to a child agent. Returns a single text result. +- **`todowrite { todos[] }`** — replace the agent's lightweight todo + list. Each item is `{content, status}` where `status ∈ + {pending, in_progress, completed}`. Only one `in_progress` allowed. +- **`plan_exit { plan }`** — emit a `[plan_exit]` marker plus the + plan text, signaling that the plan-mode pass is done and the + harness should hand off to a build-mode pass. The plan→build + switch on the harness side is follow-up work; the marker is stable + today so prompts can be written against it. + +### Web research + +- **`web_search` (canonical `websearch`)** — backend-proxied search + via Parallel. Returns ranked excerpts. +- **`web_fetch` (canonical `webfetch`)** — single-purpose `GET` → + text body, capped. Reuses the same `allowed_domains` gate as + `http_request`. Reach for `web_fetch` when reading docs/READMEs; + reach for `http_request` only when you need methods, headers, or + a body. + +### Code intelligence + +- **`lsp { kind, language, file, line?, character?, symbol? }`** — + capability-gated. Registered only when `OPENHUMAN_LSP_ENABLED=1`. + Schema is stable; the language-server backend is a follow-up — the + current implementation returns a clear `not yet implemented` error + when called, so callers can feature-detect. + +## Permissions and modes + +Permissions live on the [`Tool` +trait](../src/openhuman/tools/traits.rs). Each tool returns one of: +`None`, `ReadOnly`, `Write`, `Execute`, `Dangerous`. Channels can set +a maximum permission level — anything above is rejected before +execution. + +Today's coding-harness mapping: + +| Permission | Tools | +| --- | --- | +| **None** | `todowrite`, `plan_exit`, `ask_clarification` | +| **ReadOnly** | `file_read`, `grep`, `glob`, `list`, `web_search`, `web_fetch`, `http_request`, `lsp` | +| **Write** | `file_write`, `edit`, `apply_patch` | +| **Execute** | `shell` | + +### Plan mode vs build mode + +`plan_exit` is the seam where a plan-mode pass hands off to a +build-mode pass. The marker (`[plan_exit]`) is stable; the harness +that consumes the marker and switches modes is a follow-up. Until +that lands, a single agent can still call `plan_exit` to log the +plan and then proceed to execution in the same pass. + +In a future plan-mode runner, the rules will be: + +- Plan mode allows only `None` and `ReadOnly` tools, plus + `plan_exit`. +- Build mode allows the full surface (subject to the channel cap). + +The permission machinery is already in place; only the mode-runner +wrapper is missing. + +## When to add a new tool + +If the question is "should this be a new tool or just `shell` with +a longer command?", prefer a new tool when: + +- The operation is one the model gets wrong from `shell` (e.g. + pattern-matching tasks where regex syntax differs across + platforms). +- The operation needs structured input/output the LLM can rely on + (e.g. `apply_patch`'s atomic semantics). +- The operation has security gates that are easier to enforce in + Rust than in shell quoting (e.g. `web_fetch`'s allowed-domains + gate). + +If the answer is yes, follow the existing pattern under +`src/openhuman/tools/impl//`, register in +`src/openhuman/tools/ops.rs`, and add unit tests in the same file. + +## Follow-up work + +These items from issue #1205 are explicitly out of scope for this +baseline PR: + +- Plan-mode and build-mode runners that consume `[plan_exit]`. +- Child-session-backed `task` execution with stable session ids. +- Real LSP backend behind the `lsp` tool. +- Richer permission model (per-tool channel allowlists, per-call + approval policies). +- Controller-registry exposure of the new agent-only tools to + JSON-RPC. Today they remain agent-only — the registry already + exposes `tools_web_search` (and friends) where the Tauri shell + needs them. diff --git a/src/openhuman/tools/impl/agent/mod.rs b/src/openhuman/tools/impl/agent/mod.rs index 928dc44ec..21972d22c 100644 --- a/src/openhuman/tools/impl/agent/mod.rs +++ b/src/openhuman/tools/impl/agent/mod.rs @@ -5,8 +5,10 @@ pub(crate) mod complete_onboarding; mod delegate; mod dispatch; pub(crate) mod onboarding_status; +mod plan_exit; mod skill_delegation; mod spawn_subagent; +mod todo_write; pub(crate) use dispatch::dispatch_subagent; @@ -15,5 +17,7 @@ pub use ask_clarification::AskClarificationTool; pub use check_onboarding_status::CheckOnboardingStatusTool; pub use complete_onboarding::CompleteOnboardingTool; pub use delegate::DelegateTool; +pub use plan_exit::{PlanExitTool, PLAN_EXIT_MARKER}; pub use skill_delegation::SkillDelegationTool; pub use spawn_subagent::SpawnSubagentTool; +pub use todo_write::{global_todo_store, TodoItem, TodoStatus, TodoStore, TodoWriteTool}; diff --git a/src/openhuman/tools/impl/agent/plan_exit.rs b/src/openhuman/tools/impl/agent/plan_exit.rs new file mode 100644 index 000000000..943746574 --- /dev/null +++ b/src/openhuman/tools/impl/agent/plan_exit.rs @@ -0,0 +1,110 @@ +//! `plan_exit` — signal the end of a plan-mode pass. +//! +//! Coding-harness baseline tool (issue #1205). When a plan-mode agent +//! is ready to hand off to an execution-mode agent, it calls +//! `plan_exit { plan }`. The tool returns a structured marker that the +//! agent harness can recognize to transition modes; absent a harness +//! that consumes the marker, callers can still read the rendered plan +//! out of the result. +//! +//! This is intentionally a thin primitive — the actual mode switch +//! lives outside the tool. The follow-up `plan` vs `build` mode work +//! (referenced in issue #1205) will wire the harness side. + +use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolResult}; +use async_trait::async_trait; +use serde_json::json; + +/// Stable marker the harness greps for to detect a plan→build hand-off. +pub const PLAN_EXIT_MARKER: &str = "[plan_exit]"; + +pub struct PlanExitTool; + +impl PlanExitTool { + pub fn new() -> Self { + Self + } +} + +impl Default for PlanExitTool { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl Tool for PlanExitTool { + fn name(&self) -> &str { + "plan_exit" + } + + fn description(&self) -> &str { + "Exit plan mode and hand off the plan to execution. Call this once \ + the plan is complete — the wrapped harness will switch to build \ + mode (when wired). The `plan` argument is the user-facing plan \ + text that downstream agents will execute against." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "plan": { + "type": "string", + "description": "Markdown-formatted plan text to hand off." + } + }, + "required": ["plan"] + }) + } + + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::None + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let plan = args + .get("plan") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing 'plan' parameter"))?; + let trimmed = plan.trim(); + if trimmed.is_empty() { + return Ok(ToolResult::error("`plan` must not be empty")); + } + Ok(ToolResult::success(format!( + "{PLAN_EXIT_MARKER}\n{trimmed}" + ))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn plan_exit_emits_marker() { + let tool = PlanExitTool::new(); + let result = tool + .execute(json!({ "plan": "1. Read X\n2. Edit Y" })) + .await + .unwrap(); + assert!(!result.is_error); + let output = result.output(); + assert!(output.starts_with(PLAN_EXIT_MARKER)); + assert!(output.contains("Read X")); + } + + #[tokio::test] + async fn plan_exit_rejects_empty() { + let tool = PlanExitTool::new(); + let result = tool.execute(json!({ "plan": " " })).await.unwrap(); + assert!(result.is_error); + } + + #[test] + fn plan_exit_metadata() { + let tool = PlanExitTool::new(); + assert_eq!(tool.name(), "plan_exit"); + assert_eq!(tool.permission_level(), PermissionLevel::None); + } +} diff --git a/src/openhuman/tools/impl/agent/todo_write.rs b/src/openhuman/tools/impl/agent/todo_write.rs new file mode 100644 index 000000000..4337b3757 --- /dev/null +++ b/src/openhuman/tools/impl/agent/todo_write.rs @@ -0,0 +1,221 @@ +//! `todowrite` — lightweight todo-list state for multi-step runs. +//! +//! Coding-harness baseline tool (issue #1205). Each call replaces the +//! current todo list. Items have a `status` of `pending`, `in_progress`, +//! or `completed`. The list is process-global (one shared registry per +//! core) — sufficient as a baseline; per-session scoping can come later +//! once `task` carries a stable session id. + +use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolResult}; +use async_trait::async_trait; +use parking_lot::Mutex; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use std::sync::Arc; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum TodoStatus { + Pending, + InProgress, + Completed, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TodoItem { + pub content: String, + pub status: TodoStatus, +} + +/// Process-global todo state. Replaced wholesale on every call. +#[derive(Default)] +pub struct TodoStore { + inner: Mutex>, +} + +impl TodoStore { + pub fn new() -> Self { + Self::default() + } + pub fn replace(&self, items: Vec) { + *self.inner.lock() = items; + } + pub fn snapshot(&self) -> Vec { + self.inner.lock().clone() + } +} + +/// Process-global todo store. Returning the same `Arc` across calls +/// keeps todo state alive across registry rebuilds (the agent loop +/// can request a fresh tool registry without losing the running +/// todo list). Per-session scoping is a follow-up. +pub fn global_todo_store() -> Arc { + use once_cell::sync::OnceCell; + static STORE: OnceCell> = OnceCell::new(); + STORE.get_or_init(|| Arc::new(TodoStore::new())).clone() +} + +pub struct TodoWriteTool { + store: Arc, +} + +impl TodoWriteTool { + pub fn new(store: Arc) -> Self { + Self { store } + } +} + +#[async_trait] +impl Tool for TodoWriteTool { + fn name(&self) -> &str { + "todowrite" + } + + fn description(&self) -> &str { + "Replace the current todo list. Each item: `{content, status}` where \ + `status` is `pending`, `in_progress`, or `completed`. Returns a rendered \ + summary of the new list." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "todos": { + "type": "array", + "items": { + "type": "object", + "properties": { + "content": { "type": "string" }, + "status": { + "type": "string", + "enum": ["pending", "in_progress", "completed"] + } + }, + "required": ["content", "status"] + } + } + }, + "required": ["todos"] + }) + } + + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::None + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let todos = args + .get("todos") + .ok_or_else(|| anyhow::anyhow!("Missing 'todos' parameter"))?; + let items: Vec = serde_json::from_value(todos.clone()) + .map_err(|e| anyhow::anyhow!("Invalid todos array: {e}"))?; + + if items.iter().any(|i| i.content.trim().is_empty()) { + return Ok(ToolResult::error("todo `content` must not be empty")); + } + + let in_progress_count = items + .iter() + .filter(|i| i.status == TodoStatus::InProgress) + .count(); + if in_progress_count > 1 { + return Ok(ToolResult::error(format!( + "Only one todo may be `in_progress` at a time (got {in_progress_count})" + ))); + } + + self.store.replace(items.clone()); + + let mut body = format!("Todo list updated ({} item(s)):", items.len()); + for item in &items { + let mark = match item.status { + TodoStatus::Completed => "[x]", + TodoStatus::InProgress => "[~]", + TodoStatus::Pending => "[ ]", + }; + body.push('\n'); + body.push_str(&format!("{mark} {}", item.content)); + } + Ok(ToolResult::success(body)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn todowrite_basic() { + let store = Arc::new(TodoStore::new()); + let tool = TodoWriteTool::new(store.clone()); + let result = tool + .execute(json!({ + "todos": [ + { "content": "do A", "status": "pending" }, + { "content": "do B", "status": "in_progress" }, + { "content": "do C", "status": "completed" } + ] + })) + .await + .unwrap(); + assert!(!result.is_error, "{}", result.output()); + let output = result.output(); + assert!(output.contains("[ ] do A")); + assert!(output.contains("[~] do B")); + assert!(output.contains("[x] do C")); + let snap = store.snapshot(); + assert_eq!(snap.len(), 3); + } + + #[tokio::test] + async fn todowrite_replaces_state() { + let store = Arc::new(TodoStore::new()); + let tool = TodoWriteTool::new(store.clone()); + tool.execute(json!({"todos": [{"content": "first", "status": "pending"}]})) + .await + .unwrap(); + tool.execute(json!({"todos": [{"content": "second", "status": "completed"}]})) + .await + .unwrap(); + let snap = store.snapshot(); + assert_eq!(snap.len(), 1); + assert_eq!(snap[0].content, "second"); + } + + #[tokio::test] + async fn todowrite_rejects_multiple_in_progress() { + let store = Arc::new(TodoStore::new()); + let tool = TodoWriteTool::new(store); + let result = tool + .execute(json!({ + "todos": [ + { "content": "A", "status": "in_progress" }, + { "content": "B", "status": "in_progress" } + ] + })) + .await + .unwrap(); + assert!(result.is_error); + assert!(result.output().contains("in_progress")); + } + + #[tokio::test] + async fn todowrite_rejects_empty_content() { + let store = Arc::new(TodoStore::new()); + let tool = TodoWriteTool::new(store); + let result = tool + .execute(json!({"todos": [{"content": " ", "status": "pending"}]})) + .await + .unwrap(); + assert!(result.is_error); + } + + #[tokio::test] + async fn todowrite_empty_list_is_allowed() { + let store = Arc::new(TodoStore::new()); + let tool = TodoWriteTool::new(store); + let result = tool.execute(json!({"todos": []})).await.unwrap(); + assert!(!result.is_error); + } +} diff --git a/src/openhuman/tools/impl/filesystem/apply_patch.rs b/src/openhuman/tools/impl/filesystem/apply_patch.rs new file mode 100644 index 000000000..fdfe9c2ba --- /dev/null +++ b/src/openhuman/tools/impl/filesystem/apply_patch.rs @@ -0,0 +1,409 @@ +//! `apply_patch` — atomic multi-edit across one or more files. +//! +//! Coding-harness baseline tool (issue #1205). Takes an array of +//! `{path, old_string, new_string}` edits and applies them atomically: +//! every edit is validated up front (path, exact-match, uniqueness) +//! before any file is written. If any edit fails validation, no files +//! are touched. + +use crate::openhuman::security::SecurityPolicy; +use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolResult}; +use async_trait::async_trait; +use serde_json::json; +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; + +const MAX_FILE_BYTES: u64 = 5 * 1024 * 1024; +const MAX_EDITS: usize = 50; + +pub struct ApplyPatchTool { + security: Arc, +} + +impl ApplyPatchTool { + pub fn new(security: Arc) -> Self { + Self { security } + } +} + +#[async_trait] +impl Tool for ApplyPatchTool { + fn name(&self) -> &str { + "apply_patch" + } + + fn description(&self) -> &str { + "Apply a batch of exact-string edits across one or more files atomically. \ + All edits are validated before any are written; validation failure rolls \ + back the whole batch. Each edit is `{path, old_string, new_string, replace_all?}`." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "edits": { + "type": "array", + "description": "Ordered list of edits.", + "items": { + "type": "object", + "properties": { + "path": { "type": "string" }, + "old_string": { "type": "string" }, + "new_string": { "type": "string" }, + "replace_all": { "type": "boolean", "default": false } + }, + "required": ["path", "old_string", "new_string"] + } + } + }, + "required": ["edits"] + }) + } + + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::Write + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let edits = args + .get("edits") + .and_then(|v| v.as_array()) + .ok_or_else(|| anyhow::anyhow!("Missing 'edits' array"))?; + if edits.is_empty() { + return Ok(ToolResult::error("`edits` array is empty")); + } + if edits.len() > MAX_EDITS { + return Ok(ToolResult::error(format!( + "Too many edits: {} (max {MAX_EDITS})", + edits.len() + ))); + } + + if !self.security.can_act() { + return Ok(ToolResult::error("Action blocked: autonomy is read-only")); + } + if self.security.is_rate_limited() { + return Ok(ToolResult::error( + "Rate limit exceeded: too many actions in the last hour", + )); + } + if !self.security.record_action() { + return Ok(ToolResult::error( + "Rate limit exceeded: action budget exhausted", + )); + } + + // Parse + group edits by file. + let mut parsed: Vec = Vec::with_capacity(edits.len()); + for (i, raw) in edits.iter().enumerate() { + let path = raw + .get("path") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("edit[{i}]: missing `path`"))?; + let old_string = raw + .get("old_string") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("edit[{i}]: missing `old_string`"))?; + let new_string = raw + .get("new_string") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("edit[{i}]: missing `new_string`"))?; + let replace_all = raw + .get("replace_all") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + if old_string.is_empty() { + return Ok(ToolResult::error(format!( + "edit[{i}]: `old_string` must not be empty" + ))); + } + if !self.security.is_path_allowed(path) { + return Ok(ToolResult::error(format!( + "edit[{i}]: path not allowed: {path}" + ))); + } + parsed.push(ParsedEdit { + index: i, + path: path.to_string(), + old_string: old_string.to_string(), + new_string: new_string.to_string(), + replace_all, + }); + } + + // Resolve paths + load file contents (once per file). Apply edits in + // memory; if any edit fails, return without writing. + let mut buffers: HashMap = HashMap::new(); + for edit in &parsed { + if !buffers.contains_key(&edit.path) { + let full = self.security.workspace_dir.join(&edit.path); + + // Symlink check must happen on the *unresolved* path — + // canonicalize resolves symlinks, so a check after that + // point would never see the link. + if let Ok(meta) = tokio::fs::symlink_metadata(&full).await { + if meta.file_type().is_symlink() { + return Ok(ToolResult::error(format!( + "edit[{}]: refusing to edit through symlink", + edit.index + ))); + } + } + + let resolved = match tokio::fs::canonicalize(&full).await { + Ok(p) => p, + Err(e) => { + return Ok(ToolResult::error(format!( + "edit[{}]: failed to resolve {}: {e}", + edit.index, edit.path + ))) + } + }; + if !self.security.is_resolved_path_allowed(&resolved) { + return Ok(ToolResult::error(format!( + "edit[{}]: resolved path escapes workspace", + edit.index + ))); + } + if let Ok(meta) = tokio::fs::metadata(&resolved).await { + if meta.len() > MAX_FILE_BYTES { + return Ok(ToolResult::error(format!( + "edit[{}]: file too large ({} bytes)", + edit.index, + meta.len() + ))); + } + } + let contents = match tokio::fs::read_to_string(&resolved).await { + Ok(c) => c, + Err(e) => { + return Ok(ToolResult::error(format!( + "edit[{}]: failed to read {}: {e}", + edit.index, edit.path + ))) + } + }; + buffers.insert( + edit.path.clone(), + FileBuffer { + resolved, + original: contents.clone(), + contents, + edit_count: 0, + }, + ); + } + + let buf = buffers.get_mut(&edit.path).unwrap(); + let count = buf.contents.matches(&edit.old_string).count(); + if count == 0 { + return Ok(ToolResult::error(format!( + "edit[{}]: `old_string` not found in {}", + edit.index, edit.path + ))); + } + if count > 1 && !edit.replace_all { + return Ok(ToolResult::error(format!( + "edit[{}]: `old_string` matches {count} times in {}; pass `replace_all`", + edit.index, edit.path + ))); + } + buf.contents = if edit.replace_all { + buf.contents.replace(&edit.old_string, &edit.new_string) + } else { + buf.contents.replacen(&edit.old_string, &edit.new_string, 1) + }; + buf.edit_count += count; + } + + // Best-effort atomic write across files. We cannot get true + // multi-file atomicity without filesystem-level transactions, + // but if the i-th write fails we attempt to restore originals + // for the i-1 already-written files from the in-memory snapshot. + let mut summary: Vec = Vec::new(); + let mut written: Vec<&FileBuffer> = Vec::new(); + for (path, buf) in &buffers { + if let Err(e) = tokio::fs::write(&buf.resolved, &buf.contents).await { + let restore_errors = restore_originals(&written).await; + let suffix = if restore_errors.is_empty() { + "; previously-written files restored from snapshot".to_string() + } else { + format!("; restore failed for: {}", restore_errors.join(", ")) + }; + return Ok(ToolResult::error(format!( + "Failed to write {path}: {e}{suffix}" + ))); + } + written.push(buf); + summary.push(format!("{path}: {} replacement(s)", buf.edit_count)); + } + summary.sort(); + Ok(ToolResult::success(format!( + "Applied {} edit(s) across {} file(s)\n{}", + parsed.len(), + buffers.len(), + summary.join("\n") + ))) + } +} + +async fn restore_originals(written: &[&FileBuffer]) -> Vec { + let mut errors = Vec::new(); + for buf in written { + if let Err(e) = tokio::fs::write(&buf.resolved, &buf.original).await { + errors.push(format!("{}: {e}", buf.resolved.display())); + } + } + errors +} + +struct ParsedEdit { + index: usize, + path: String, + old_string: String, + new_string: String, + replace_all: bool, +} + +struct FileBuffer { + resolved: PathBuf, + /// Snapshot of the file's contents as we first read them. + /// Used to restore on a partial-write failure. + original: String, + contents: String, + edit_count: usize, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::openhuman::security::{AutonomyLevel, SecurityPolicy}; + + fn test_security(workspace: std::path::PathBuf) -> Arc { + Arc::new(SecurityPolicy { + autonomy: AutonomyLevel::Supervised, + workspace_dir: workspace, + ..SecurityPolicy::default() + }) + } + + #[test] + fn apply_patch_name() { + let tool = ApplyPatchTool::new(test_security(std::env::temp_dir())); + assert_eq!(tool.name(), "apply_patch"); + } + + #[tokio::test] + async fn apply_patch_applies_multiple_edits() { + let dir = std::env::temp_dir().join("openhuman_test_patch_multi"); + let _ = tokio::fs::remove_dir_all(&dir).await; + tokio::fs::create_dir_all(&dir).await.unwrap(); + tokio::fs::write(dir.join("a.txt"), "alpha\nbravo") + .await + .unwrap(); + tokio::fs::write(dir.join("b.txt"), "one two") + .await + .unwrap(); + + let tool = ApplyPatchTool::new(test_security(dir.clone())); + let result = tool + .execute(json!({ + "edits": [ + { "path": "a.txt", "old_string": "alpha", "new_string": "ALPHA" }, + { "path": "b.txt", "old_string": "two", "new_string": "TWO" } + ] + })) + .await + .unwrap(); + assert!(!result.is_error, "{}", result.output()); + let a = tokio::fs::read_to_string(dir.join("a.txt")).await.unwrap(); + let b = tokio::fs::read_to_string(dir.join("b.txt")).await.unwrap(); + assert_eq!(a, "ALPHA\nbravo"); + assert_eq!(b, "one TWO"); + + let _ = tokio::fs::remove_dir_all(&dir).await; + } + + #[tokio::test] + async fn apply_patch_atomic_on_validation_failure() { + let dir = std::env::temp_dir().join("openhuman_test_patch_atomic"); + let _ = tokio::fs::remove_dir_all(&dir).await; + tokio::fs::create_dir_all(&dir).await.unwrap(); + tokio::fs::write(dir.join("a.txt"), "alpha").await.unwrap(); + tokio::fs::write(dir.join("b.txt"), "bravo").await.unwrap(); + + let tool = ApplyPatchTool::new(test_security(dir.clone())); + // Second edit will fail (no match) — first must NOT be applied. + let result = tool + .execute(json!({ + "edits": [ + { "path": "a.txt", "old_string": "alpha", "new_string": "ALPHA" }, + { "path": "b.txt", "old_string": "missing", "new_string": "x" } + ] + })) + .await + .unwrap(); + assert!(result.is_error); + let a = tokio::fs::read_to_string(dir.join("a.txt")).await.unwrap(); + assert_eq!(a, "alpha", "atomic: first edit must not be persisted"); + + let _ = tokio::fs::remove_dir_all(&dir).await; + } + + #[tokio::test] + async fn apply_patch_chained_edits_same_file() { + let dir = std::env::temp_dir().join("openhuman_test_patch_chain"); + let _ = tokio::fs::remove_dir_all(&dir).await; + tokio::fs::create_dir_all(&dir).await.unwrap(); + tokio::fs::write(dir.join("a.txt"), "one two three") + .await + .unwrap(); + + let tool = ApplyPatchTool::new(test_security(dir.clone())); + let result = tool + .execute(json!({ + "edits": [ + { "path": "a.txt", "old_string": "one", "new_string": "ONE" }, + { "path": "a.txt", "old_string": "two", "new_string": "TWO" } + ] + })) + .await + .unwrap(); + assert!(!result.is_error, "{}", result.output()); + let updated = tokio::fs::read_to_string(dir.join("a.txt")).await.unwrap(); + assert_eq!(updated, "ONE TWO three"); + + let _ = tokio::fs::remove_dir_all(&dir).await; + } + + #[tokio::test] + async fn apply_patch_rejects_empty_edits() { + let dir = std::env::temp_dir().join("openhuman_test_patch_empty"); + let _ = tokio::fs::remove_dir_all(&dir).await; + tokio::fs::create_dir_all(&dir).await.unwrap(); + + let tool = ApplyPatchTool::new(test_security(dir.clone())); + let result = tool.execute(json!({"edits": []})).await.unwrap(); + assert!(result.is_error); + + let _ = tokio::fs::remove_dir_all(&dir).await; + } + + #[tokio::test] + async fn apply_patch_rejects_traversal() { + let tool = ApplyPatchTool::new(test_security(std::env::temp_dir())); + let result = tool + .execute(json!({ + "edits": [ + { "path": "../etc/passwd", "old_string": "x", "new_string": "y" } + ] + })) + .await + .unwrap(); + assert!(result.is_error); + assert!(result.output().contains("not allowed")); + } +} diff --git a/src/openhuman/tools/impl/filesystem/edit_file.rs b/src/openhuman/tools/impl/filesystem/edit_file.rs new file mode 100644 index 000000000..51f951d80 --- /dev/null +++ b/src/openhuman/tools/impl/filesystem/edit_file.rs @@ -0,0 +1,329 @@ +//! `edit` — string-replace edit on a single file. +//! +//! Coding-harness baseline tool (issue #1205). Models the +//! Anthropic/Claude-Code `Edit` semantics: exact-match `old_string` → +//! `new_string` substitution. By default, `old_string` MUST match +//! exactly once in the file (so the model can't accidentally edit +//! every match). Set `replace_all` to override. + +use crate::openhuman::security::SecurityPolicy; +use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolResult}; +use async_trait::async_trait; +use serde_json::json; +use std::sync::Arc; + +const MAX_FILE_BYTES: u64 = 5 * 1024 * 1024; + +pub struct EditFileTool { + security: Arc, +} + +impl EditFileTool { + pub fn new(security: Arc) -> Self { + Self { security } + } +} + +#[async_trait] +impl Tool for EditFileTool { + fn name(&self) -> &str { + "edit" + } + + fn description(&self) -> &str { + "Edit a file by exact string replacement. By default `old_string` must \ + match exactly once. Set `replace_all` to true to replace every match." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "path": { "type": "string", "description": "Workspace-relative file path." }, + "old_string": { "type": "string", "description": "Text to find. Must match exactly." }, + "new_string": { "type": "string", "description": "Replacement text." }, + "replace_all": { + "type": "boolean", + "description": "If true, replace every occurrence (default false).", + "default": false + } + }, + "required": ["path", "old_string", "new_string"] + }) + } + + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::Write + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let path = args + .get("path") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing 'path' parameter"))?; + let old_string = args + .get("old_string") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing 'old_string' parameter"))?; + let new_string = args + .get("new_string") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing 'new_string' parameter"))?; + let replace_all = args + .get("replace_all") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + if old_string.is_empty() { + return Ok(ToolResult::error("`old_string` must not be empty")); + } + if old_string == new_string { + return Ok(ToolResult::error( + "`old_string` and `new_string` are identical — nothing to do", + )); + } + + if !self.security.can_act() { + return Ok(ToolResult::error("Action blocked: autonomy is read-only")); + } + if self.security.is_rate_limited() { + return Ok(ToolResult::error( + "Rate limit exceeded: too many actions in the last hour", + )); + } + if !self.security.is_path_allowed(path) { + return Ok(ToolResult::error(format!( + "Path not allowed by security policy: {path}" + ))); + } + if !self.security.record_action() { + return Ok(ToolResult::error( + "Rate limit exceeded: action budget exhausted", + )); + } + + let full = self.security.workspace_dir.join(path); + + // Symlink check must happen on the *unresolved* path — + // `canonicalize` resolves symlinks, so checking after that point + // would always see the link's final target. + if let Ok(meta) = tokio::fs::symlink_metadata(&full).await { + if meta.file_type().is_symlink() { + return Ok(ToolResult::error(format!( + "Refusing to edit through symlink: {}", + full.display() + ))); + } + } + + let resolved = match tokio::fs::canonicalize(&full).await { + Ok(p) => p, + Err(e) => return Ok(ToolResult::error(format!("Failed to resolve path: {e}"))), + }; + if !self.security.is_resolved_path_allowed(&resolved) { + return Ok(ToolResult::error(format!( + "Resolved path escapes workspace: {}", + resolved.display() + ))); + } + + if let Ok(meta) = tokio::fs::metadata(&resolved).await { + if meta.len() > MAX_FILE_BYTES { + return Ok(ToolResult::error(format!( + "File too large: {} bytes (limit: {MAX_FILE_BYTES} bytes)", + meta.len() + ))); + } + } + + let contents = match tokio::fs::read_to_string(&resolved).await { + Ok(c) => c, + Err(e) => return Ok(ToolResult::error(format!("Failed to read file: {e}"))), + }; + + let count = contents.matches(old_string).count(); + if count == 0 { + return Ok(ToolResult::error(format!( + "`old_string` not found in {path}" + ))); + } + if count > 1 && !replace_all { + return Ok(ToolResult::error(format!( + "`old_string` matches {count} times in {path}; pass `replace_all: true` or \ + expand `old_string` so it is unique" + ))); + } + + let updated = if replace_all { + contents.replace(old_string, new_string) + } else { + contents.replacen(old_string, new_string, 1) + }; + + match tokio::fs::write(&resolved, &updated).await { + Ok(()) => Ok(ToolResult::success(format!( + "Edited {path}: {count} replacement(s)" + ))), + Err(e) => Ok(ToolResult::error(format!("Failed to write file: {e}"))), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::openhuman::security::{AutonomyLevel, SecurityPolicy}; + + fn test_security(workspace: std::path::PathBuf) -> Arc { + Arc::new(SecurityPolicy { + autonomy: AutonomyLevel::Supervised, + workspace_dir: workspace, + ..SecurityPolicy::default() + }) + } + + fn test_security_readonly(workspace: std::path::PathBuf) -> Arc { + Arc::new(SecurityPolicy { + autonomy: AutonomyLevel::ReadOnly, + workspace_dir: workspace, + ..SecurityPolicy::default() + }) + } + + #[test] + fn edit_name() { + let tool = EditFileTool::new(test_security(std::env::temp_dir())); + assert_eq!(tool.name(), "edit"); + } + + #[tokio::test] + async fn edit_replaces_unique_match() { + let dir = std::env::temp_dir().join("openhuman_test_edit_unique"); + let _ = tokio::fs::remove_dir_all(&dir).await; + tokio::fs::create_dir_all(&dir).await.unwrap(); + tokio::fs::write(dir.join("f.txt"), "alpha bravo") + .await + .unwrap(); + + let tool = EditFileTool::new(test_security(dir.clone())); + let result = tool + .execute(json!({"path": "f.txt", "old_string": "bravo", "new_string": "charlie"})) + .await + .unwrap(); + assert!(!result.is_error, "{}", result.output()); + let updated = tokio::fs::read_to_string(dir.join("f.txt")).await.unwrap(); + assert_eq!(updated, "alpha charlie"); + + let _ = tokio::fs::remove_dir_all(&dir).await; + } + + #[tokio::test] + async fn edit_rejects_ambiguous_match() { + let dir = std::env::temp_dir().join("openhuman_test_edit_ambig"); + let _ = tokio::fs::remove_dir_all(&dir).await; + tokio::fs::create_dir_all(&dir).await.unwrap(); + tokio::fs::write(dir.join("f.txt"), "x x x").await.unwrap(); + + let tool = EditFileTool::new(test_security(dir.clone())); + let result = tool + .execute(json!({"path": "f.txt", "old_string": "x", "new_string": "y"})) + .await + .unwrap(); + assert!(result.is_error); + assert!(result.output().contains("matches 3 times")); + + let _ = tokio::fs::remove_dir_all(&dir).await; + } + + #[tokio::test] + async fn edit_replace_all() { + let dir = std::env::temp_dir().join("openhuman_test_edit_all"); + let _ = tokio::fs::remove_dir_all(&dir).await; + tokio::fs::create_dir_all(&dir).await.unwrap(); + tokio::fs::write(dir.join("f.txt"), "x x x").await.unwrap(); + + let tool = EditFileTool::new(test_security(dir.clone())); + let result = tool + .execute( + json!({"path": "f.txt", "old_string": "x", "new_string": "y", "replace_all": true}), + ) + .await + .unwrap(); + assert!(!result.is_error); + let updated = tokio::fs::read_to_string(dir.join("f.txt")).await.unwrap(); + assert_eq!(updated, "y y y"); + + let _ = tokio::fs::remove_dir_all(&dir).await; + } + + #[tokio::test] + async fn edit_no_match() { + let dir = std::env::temp_dir().join("openhuman_test_edit_nomatch"); + let _ = tokio::fs::remove_dir_all(&dir).await; + tokio::fs::create_dir_all(&dir).await.unwrap(); + tokio::fs::write(dir.join("f.txt"), "alpha").await.unwrap(); + + let tool = EditFileTool::new(test_security(dir.clone())); + let result = tool + .execute(json!({"path": "f.txt", "old_string": "zulu", "new_string": "x"})) + .await + .unwrap(); + assert!(result.is_error); + assert!(result.output().contains("not found")); + + let _ = tokio::fs::remove_dir_all(&dir).await; + } + + #[tokio::test] + async fn edit_blocks_readonly_mode() { + let dir = std::env::temp_dir().join("openhuman_test_edit_ro"); + let _ = tokio::fs::remove_dir_all(&dir).await; + tokio::fs::create_dir_all(&dir).await.unwrap(); + tokio::fs::write(dir.join("f.txt"), "abc").await.unwrap(); + + let tool = EditFileTool::new(test_security_readonly(dir.clone())); + let result = tool + .execute(json!({"path": "f.txt", "old_string": "abc", "new_string": "xyz"})) + .await + .unwrap(); + assert!(result.is_error); + assert!(result.output().contains("read-only")); + + let _ = tokio::fs::remove_dir_all(&dir).await; + } + + #[tokio::test] + async fn edit_rejects_empty_old_string() { + let dir = std::env::temp_dir().join("openhuman_test_edit_empty_old"); + let _ = tokio::fs::remove_dir_all(&dir).await; + tokio::fs::create_dir_all(&dir).await.unwrap(); + tokio::fs::write(dir.join("f.txt"), "abc").await.unwrap(); + + let tool = EditFileTool::new(test_security(dir.clone())); + let result = tool + .execute(json!({"path": "f.txt", "old_string": "", "new_string": "x"})) + .await + .unwrap(); + assert!(result.is_error); + + let _ = tokio::fs::remove_dir_all(&dir).await; + } + + #[tokio::test] + async fn edit_rejects_identical_strings() { + let dir = std::env::temp_dir().join("openhuman_test_edit_same"); + let _ = tokio::fs::remove_dir_all(&dir).await; + tokio::fs::create_dir_all(&dir).await.unwrap(); + tokio::fs::write(dir.join("f.txt"), "abc").await.unwrap(); + + let tool = EditFileTool::new(test_security(dir.clone())); + let result = tool + .execute(json!({"path": "f.txt", "old_string": "abc", "new_string": "abc"})) + .await + .unwrap(); + assert!(result.is_error); + assert!(result.output().contains("identical")); + + let _ = tokio::fs::remove_dir_all(&dir).await; + } +} diff --git a/src/openhuman/tools/impl/filesystem/glob_search.rs b/src/openhuman/tools/impl/filesystem/glob_search.rs new file mode 100644 index 000000000..ba8304bdc --- /dev/null +++ b/src/openhuman/tools/impl/filesystem/glob_search.rs @@ -0,0 +1,230 @@ +//! `glob` — find files by glob pattern. +//! +//! Coding-harness baseline tool (issue #1205): pure file discovery +//! by pattern (e.g. `src/**/*.rs`). Path traversal is blocked the same +//! way as `file_read`. + +use crate::openhuman::security::SecurityPolicy; +use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolResult}; +use async_trait::async_trait; +use glob::Pattern; +use serde_json::json; +use std::path::Path; +use std::sync::Arc; +use walkdir::WalkDir; + +const DEFAULT_MAX_RESULTS: usize = 500; + +pub struct GlobTool { + security: Arc, +} + +impl GlobTool { + pub fn new(security: Arc) -> Self { + Self { security } + } +} + +#[async_trait] +impl Tool for GlobTool { + fn name(&self) -> &str { + "glob" + } + + fn description(&self) -> &str { + "Find files matching a glob pattern (e.g. `src/**/*.rs`). Returns matching paths \ + relative to the workspace, sorted by modification time (newest first)." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "pattern": { + "type": "string", + "description": "Glob pattern, e.g. `**/*.rs` or `src/**/*.{ts,tsx}` (single brace expansion not supported — list patterns separately)." + }, + "max_results": { + "type": "integer", + "description": "Cap on returned paths (default 500).", + "minimum": 1 + } + }, + "required": ["pattern"] + }) + } + + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::ReadOnly + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let pattern_str = args + .get("pattern") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing 'pattern' parameter"))?; + let max_results = args + .get("max_results") + .and_then(|v| v.as_u64()) + .map(|n| (n as usize).max(1)) + .unwrap_or(DEFAULT_MAX_RESULTS); + + if self.security.is_rate_limited() { + return Ok(ToolResult::error( + "Rate limit exceeded: too many actions in the last hour", + )); + } + if !self.security.record_action() { + return Ok(ToolResult::error( + "Rate limit exceeded: action budget exhausted", + )); + } + + let pattern = match Pattern::new(pattern_str) { + Ok(p) => p, + Err(e) => return Ok(ToolResult::error(format!("Invalid glob pattern: {e}"))), + }; + + let workspace = self.security.workspace_dir.clone(); + let result = + tokio::task::spawn_blocking(move || collect_matches(&workspace, &pattern, max_results)) + .await + .map_err(|e| anyhow::anyhow!("scan task failed: {e}"))?; + + let (paths, truncated) = result; + let header = if truncated { + format!("{} match(es) (truncated at {max_results})", paths.len()) + } else { + format!("{} match(es)", paths.len()) + }; + + let mut body = String::with_capacity(paths.len() * 32 + header.len() + 1); + body.push_str(&header); + for p in paths { + body.push('\n'); + body.push_str(&p); + } + Ok(ToolResult::success(body)) + } +} + +fn collect_matches(workspace: &Path, pattern: &Pattern, max_results: usize) -> (Vec, bool) { + let mut hits: Vec<(std::time::SystemTime, String)> = Vec::new(); + + for entry in WalkDir::new(workspace) + .follow_links(false) + .into_iter() + .filter_entry(|e| !is_skipped(e.file_name().to_string_lossy().as_ref())) + .filter_map(|e| e.ok()) + { + if !entry.file_type().is_file() { + continue; + } + let rel = match entry.path().strip_prefix(workspace) { + Ok(p) => p, + Err(_) => continue, + }; + let rel_str = rel.to_string_lossy().replace('\\', "/"); + if !pattern.matches(&rel_str) { + continue; + } + let mtime = entry + .metadata() + .ok() + .and_then(|m| m.modified().ok()) + .unwrap_or(std::time::SystemTime::UNIX_EPOCH); + hits.push((mtime, rel_str)); + } + + // Newest first. + hits.sort_by(|a, b| b.0.cmp(&a.0)); + let truncated = hits.len() > max_results; + let paths: Vec = hits.into_iter().take(max_results).map(|(_, p)| p).collect(); + (paths, truncated) +} + +fn is_skipped(name: &str) -> bool { + matches!( + name, + ".git" | "node_modules" | "target" | ".next" | "dist" | "build" | ".cache" + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::openhuman::security::{AutonomyLevel, SecurityPolicy}; + + fn test_security(workspace: std::path::PathBuf) -> Arc { + Arc::new(SecurityPolicy { + autonomy: AutonomyLevel::Supervised, + workspace_dir: workspace, + ..SecurityPolicy::default() + }) + } + + #[test] + fn glob_name() { + let tool = GlobTool::new(test_security(std::env::temp_dir())); + assert_eq!(tool.name(), "glob"); + } + + #[tokio::test] + async fn glob_matches_extension() { + let dir = std::env::temp_dir().join("openhuman_test_glob_ext"); + let _ = tokio::fs::remove_dir_all(&dir).await; + tokio::fs::create_dir_all(dir.join("src/sub")) + .await + .unwrap(); + tokio::fs::write(dir.join("src/a.rs"), "// a") + .await + .unwrap(); + tokio::fs::write(dir.join("src/sub/b.rs"), "// b") + .await + .unwrap(); + tokio::fs::write(dir.join("src/c.txt"), "c").await.unwrap(); + + let tool = GlobTool::new(test_security(dir.clone())); + let result = tool.execute(json!({"pattern": "**/*.rs"})).await.unwrap(); + assert!(!result.is_error); + let output = result.output(); + assert!(output.contains("src/a.rs")); + assert!(output.contains("src/sub/b.rs")); + assert!(!output.contains("c.txt")); + + let _ = tokio::fs::remove_dir_all(&dir).await; + } + + #[tokio::test] + async fn glob_invalid_pattern() { + let dir = std::env::temp_dir().join("openhuman_test_glob_invalid"); + let _ = tokio::fs::remove_dir_all(&dir).await; + tokio::fs::create_dir_all(&dir).await.unwrap(); + let tool = GlobTool::new(test_security(dir.clone())); + let result = tool.execute(json!({"pattern": "**["})).await.unwrap(); + assert!(result.is_error); + assert!(result.output().contains("Invalid glob")); + let _ = tokio::fs::remove_dir_all(&dir).await; + } + + #[tokio::test] + async fn glob_skips_node_modules() { + let dir = std::env::temp_dir().join("openhuman_test_glob_skip"); + let _ = tokio::fs::remove_dir_all(&dir).await; + tokio::fs::create_dir_all(dir.join("node_modules")) + .await + .unwrap(); + tokio::fs::write(dir.join("node_modules/lib.js"), "") + .await + .unwrap(); + tokio::fs::write(dir.join("app.js"), "").await.unwrap(); + + let tool = GlobTool::new(test_security(dir.clone())); + let result = tool.execute(json!({"pattern": "**/*.js"})).await.unwrap(); + let output = result.output(); + assert!(output.contains("app.js")); + assert!(!output.contains("node_modules")); + + let _ = tokio::fs::remove_dir_all(&dir).await; + } +} diff --git a/src/openhuman/tools/impl/filesystem/grep.rs b/src/openhuman/tools/impl/filesystem/grep.rs new file mode 100644 index 000000000..002f3c8f0 --- /dev/null +++ b/src/openhuman/tools/impl/filesystem/grep.rs @@ -0,0 +1,364 @@ +//! `grep` — regex search across files in the workspace. +//! +//! Coding-harness baseline tool (issue #1205): a first-class +//! file-navigation primitive that lets the agent search for a regex +//! across the workspace without falling through to `shell`. Uses the +//! same path-sandboxing + rate-limiting as `file_read`. + +use crate::openhuman::security::SecurityPolicy; +use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolResult}; +use async_trait::async_trait; +use regex::Regex; +use serde_json::json; +use std::path::Path; +use std::sync::Arc; +use walkdir::WalkDir; + +const DEFAULT_MAX_MATCHES: usize = 200; +const MAX_LINE_BYTES: usize = 2_000; +const MAX_FILE_BYTES: u64 = 5 * 1024 * 1024; + +pub struct GrepTool { + security: Arc, +} + +impl GrepTool { + pub fn new(security: Arc) -> Self { + Self { security } + } +} + +#[async_trait] +impl Tool for GrepTool { + fn name(&self) -> &str { + "grep" + } + + fn description(&self) -> &str { + "Search file contents in the workspace with a regular expression. \ + Returns up to `max_matches` matches (default 200) as `path:line:text` lines." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "pattern": { + "type": "string", + "description": "Regular expression (Rust `regex` crate syntax)." + }, + "path": { + "type": "string", + "description": "Optional sub-path to restrict the search to (relative to workspace).", + "default": "." + }, + "max_matches": { + "type": "integer", + "description": "Cap on matches returned (default 200).", + "minimum": 1 + }, + "case_insensitive": { + "type": "boolean", + "description": "If true, compile the regex with the `i` flag.", + "default": false + } + }, + "required": ["pattern"] + }) + } + + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::ReadOnly + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let pattern = args + .get("pattern") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing 'pattern' parameter"))?; + let sub_path = args.get("path").and_then(|v| v.as_str()).unwrap_or("."); + let max_matches = args + .get("max_matches") + .and_then(|v| v.as_u64()) + .map(|n| (n as usize).max(1)) + .unwrap_or(DEFAULT_MAX_MATCHES); + let case_insensitive = args + .get("case_insensitive") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + if self.security.is_rate_limited() { + return Ok(ToolResult::error( + "Rate limit exceeded: too many actions in the last hour", + )); + } + if !self.security.is_path_allowed(sub_path) { + return Ok(ToolResult::error(format!( + "Path not allowed by security policy: {sub_path}" + ))); + } + if !self.security.record_action() { + return Ok(ToolResult::error( + "Rate limit exceeded: action budget exhausted", + )); + } + + let regex = match build_regex(pattern, case_insensitive) { + Ok(r) => r, + Err(e) => return Ok(ToolResult::error(format!("Invalid regex: {e}"))), + }; + + let root = self.security.workspace_dir.join(sub_path); + let resolved_root = match tokio::fs::canonicalize(&root).await { + Ok(p) => p, + Err(e) => return Ok(ToolResult::error(format!("Failed to resolve path: {e}"))), + }; + if !self.security.is_resolved_path_allowed(&resolved_root) { + return Ok(ToolResult::error(format!( + "Resolved path escapes workspace: {}", + resolved_root.display() + ))); + } + + let workspace = self.security.workspace_dir.clone(); + let result = tokio::task::spawn_blocking(move || { + scan_for_matches(&resolved_root, &workspace, ®ex, max_matches) + }) + .await + .map_err(|e| anyhow::anyhow!("scan task failed: {e}"))?; + + let (matches, scanned, truncated) = result; + let header = if truncated { + format!( + "{} match(es) (truncated at {max_matches}); scanned {scanned} file(s)", + matches.len() + ) + } else { + format!("{} match(es); scanned {scanned} file(s)", matches.len()) + }; + + let mut body = String::with_capacity(matches.len() * 80 + header.len() + 1); + body.push_str(&header); + for m in &matches { + body.push('\n'); + body.push_str(m); + } + Ok(ToolResult::success(body)) + } +} + +fn build_regex(pattern: &str, case_insensitive: bool) -> Result { + if case_insensitive { + regex::RegexBuilder::new(pattern) + .case_insensitive(true) + .build() + } else { + Regex::new(pattern) + } +} + +fn scan_for_matches( + root: &Path, + workspace: &Path, + regex: &Regex, + max_matches: usize, +) -> (Vec, usize, bool) { + let mut matches: Vec = Vec::new(); + let mut scanned = 0usize; + let mut truncated = false; + + 'outer: for entry in WalkDir::new(root) + .follow_links(false) + .into_iter() + .filter_entry(|e| !is_skipped(e.file_name().to_string_lossy().as_ref())) + .filter_map(|e| e.ok()) + { + if !entry.file_type().is_file() { + continue; + } + let path = entry.path(); + let Ok(meta) = entry.metadata() else { continue }; + if meta.len() > MAX_FILE_BYTES { + continue; + } + let Ok(contents) = std::fs::read_to_string(path) else { + continue; + }; + scanned += 1; + let rel = path.strip_prefix(workspace).unwrap_or(path); + for (lineno, line) in contents.lines().enumerate() { + if regex.is_match(line) { + let display_line = if line.len() > MAX_LINE_BYTES { + // Walk back to a UTF-8 char boundary; slicing `&str` at a + // non-boundary byte panics at runtime. + let mut cut = MAX_LINE_BYTES; + while cut > 0 && !line.is_char_boundary(cut) { + cut -= 1; + } + format!("{}…", &line[..cut]) + } else { + line.to_string() + }; + matches.push(format!("{}:{}:{}", rel.display(), lineno + 1, display_line)); + if matches.len() >= max_matches { + truncated = true; + break 'outer; + } + } + } + } + (matches, scanned, truncated) +} + +fn is_skipped(name: &str) -> bool { + matches!( + name, + ".git" | "node_modules" | "target" | ".next" | "dist" | "build" | ".cache" + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::openhuman::security::{AutonomyLevel, SecurityPolicy}; + + fn test_security(workspace: std::path::PathBuf) -> Arc { + Arc::new(SecurityPolicy { + autonomy: AutonomyLevel::Supervised, + workspace_dir: workspace, + ..SecurityPolicy::default() + }) + } + + #[test] + fn grep_name_and_schema() { + let tool = GrepTool::new(test_security(std::env::temp_dir())); + assert_eq!(tool.name(), "grep"); + let schema = tool.parameters_schema(); + assert!(schema["properties"]["pattern"].is_object()); + assert!(schema["required"] + .as_array() + .unwrap() + .contains(&json!("pattern"))); + } + + #[tokio::test] + async fn grep_finds_matches() { + let dir = std::env::temp_dir().join("openhuman_test_grep_finds"); + let _ = tokio::fs::remove_dir_all(&dir).await; + tokio::fs::create_dir_all(&dir).await.unwrap(); + tokio::fs::write(dir.join("a.txt"), "alpha\nbravo\ncharlie") + .await + .unwrap(); + tokio::fs::write(dir.join("b.txt"), "alpha2").await.unwrap(); + + let tool = GrepTool::new(test_security(dir.clone())); + let result = tool.execute(json!({"pattern": "^alpha"})).await.unwrap(); + assert!(!result.is_error); + let output = result.output(); + assert!(output.contains("a.txt:1:alpha")); + assert!(output.contains("b.txt:1:alpha2")); + assert!(!output.contains("bravo")); + + let _ = tokio::fs::remove_dir_all(&dir).await; + } + + #[tokio::test] + async fn grep_invalid_regex() { + let dir = std::env::temp_dir().join("openhuman_test_grep_invalid"); + let _ = tokio::fs::remove_dir_all(&dir).await; + tokio::fs::create_dir_all(&dir).await.unwrap(); + + let tool = GrepTool::new(test_security(dir.clone())); + let result = tool + .execute(json!({"pattern": "([unclosed"})) + .await + .unwrap(); + assert!(result.is_error); + assert!(result.output().contains("Invalid regex")); + + let _ = tokio::fs::remove_dir_all(&dir).await; + } + + #[tokio::test] + async fn grep_case_insensitive() { + let dir = std::env::temp_dir().join("openhuman_test_grep_ci"); + let _ = tokio::fs::remove_dir_all(&dir).await; + tokio::fs::create_dir_all(&dir).await.unwrap(); + tokio::fs::write(dir.join("c.txt"), "Hello World") + .await + .unwrap(); + + let tool = GrepTool::new(test_security(dir.clone())); + let result = tool + .execute(json!({"pattern": "hello", "case_insensitive": true})) + .await + .unwrap(); + assert!(!result.is_error); + assert!(result.output().contains("c.txt:1:Hello World")); + + let _ = tokio::fs::remove_dir_all(&dir).await; + } + + #[tokio::test] + async fn grep_blocks_path_traversal() { + let tool = GrepTool::new(test_security(std::env::temp_dir())); + let result = tool + .execute(json!({"pattern": ".", "path": "../.."})) + .await + .unwrap(); + assert!(result.is_error); + assert!(result.output().contains("not allowed")); + } + + #[tokio::test] + async fn grep_skips_node_modules_and_git() { + let dir = std::env::temp_dir().join("openhuman_test_grep_skip"); + let _ = tokio::fs::remove_dir_all(&dir).await; + tokio::fs::create_dir_all(dir.join("node_modules")) + .await + .unwrap(); + tokio::fs::create_dir_all(dir.join(".git")).await.unwrap(); + tokio::fs::write(dir.join("node_modules/x.txt"), "needle") + .await + .unwrap(); + tokio::fs::write(dir.join(".git/x.txt"), "needle") + .await + .unwrap(); + tokio::fs::write(dir.join("real.txt"), "needle") + .await + .unwrap(); + + let tool = GrepTool::new(test_security(dir.clone())); + let result = tool.execute(json!({"pattern": "needle"})).await.unwrap(); + assert!(!result.is_error); + let output = result.output(); + assert!(output.contains("real.txt")); + assert!(!output.contains("node_modules")); + assert!(!output.contains(".git")); + + let _ = tokio::fs::remove_dir_all(&dir).await; + } + + #[tokio::test] + async fn grep_respects_max_matches() { + let dir = std::env::temp_dir().join("openhuman_test_grep_max"); + let _ = tokio::fs::remove_dir_all(&dir).await; + tokio::fs::create_dir_all(&dir).await.unwrap(); + let mut text = String::new(); + for _ in 0..50 { + text.push_str("hit\n"); + } + tokio::fs::write(dir.join("many.txt"), text).await.unwrap(); + + let tool = GrepTool::new(test_security(dir.clone())); + let result = tool + .execute(json!({"pattern": "hit", "max_matches": 5})) + .await + .unwrap(); + assert!(!result.is_error); + assert!(result.output().contains("truncated")); + + let _ = tokio::fs::remove_dir_all(&dir).await; + } +} diff --git a/src/openhuman/tools/impl/filesystem/list_files.rs b/src/openhuman/tools/impl/filesystem/list_files.rs new file mode 100644 index 000000000..0b8567d18 --- /dev/null +++ b/src/openhuman/tools/impl/filesystem/list_files.rs @@ -0,0 +1,182 @@ +//! `list` — directory listing. +//! +//! Coding-harness baseline tool (issue #1205): non-recursive directory +//! listing keyed by a workspace-relative path. Distinguishes files, +//! directories, and symlinks. Path sandboxing matches `file_read`. + +use crate::openhuman::security::SecurityPolicy; +use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolResult}; +use async_trait::async_trait; +use serde_json::json; +use std::sync::Arc; + +const MAX_ENTRIES: usize = 1_000; + +pub struct ListFilesTool { + security: Arc, +} + +impl ListFilesTool { + pub fn new(security: Arc) -> Self { + Self { security } + } +} + +#[async_trait] +impl Tool for ListFilesTool { + fn name(&self) -> &str { + "list" + } + + fn description(&self) -> &str { + "List entries in a workspace directory (non-recursive). Each line is \ + `\t` where kind is `dir`, `file`, or `link`." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Directory path relative to the workspace (default `.`).", + "default": "." + } + } + }) + } + + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::ReadOnly + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let path = args.get("path").and_then(|v| v.as_str()).unwrap_or("."); + + if self.security.is_rate_limited() { + return Ok(ToolResult::error( + "Rate limit exceeded: too many actions in the last hour", + )); + } + if !self.security.is_path_allowed(path) { + return Ok(ToolResult::error(format!( + "Path not allowed by security policy: {path}" + ))); + } + if !self.security.record_action() { + return Ok(ToolResult::error( + "Rate limit exceeded: action budget exhausted", + )); + } + + let full = self.security.workspace_dir.join(path); + let resolved = match tokio::fs::canonicalize(&full).await { + Ok(p) => p, + Err(e) => return Ok(ToolResult::error(format!("Failed to resolve path: {e}"))), + }; + if !self.security.is_resolved_path_allowed(&resolved) { + return Ok(ToolResult::error(format!( + "Resolved path escapes workspace: {}", + resolved.display() + ))); + } + + let mut read = match tokio::fs::read_dir(&resolved).await { + Ok(r) => r, + Err(e) => return Ok(ToolResult::error(format!("Failed to read directory: {e}"))), + }; + + let mut entries: Vec<(String, String)> = Vec::new(); + loop { + match read.next_entry().await { + Ok(Some(entry)) => { + let name = entry.file_name().to_string_lossy().into_owned(); + let kind = match entry.file_type().await { + Ok(t) if t.is_symlink() => "link", + Ok(t) if t.is_dir() => "dir", + Ok(_) => "file", + Err(_) => "unknown", + }; + entries.push((kind.to_string(), name)); + if entries.len() >= MAX_ENTRIES { + break; + } + } + Ok(None) => break, + Err(e) => { + return Ok(ToolResult::error(format!( + "Failed to enumerate directory: {e}" + ))) + } + } + } + + entries.sort_by(|a, b| a.1.cmp(&b.1)); + + let mut body = format!("{} entr(ies) in {path}", entries.len()); + for (kind, name) in entries { + body.push('\n'); + body.push_str(&kind); + body.push('\t'); + body.push_str(&name); + } + Ok(ToolResult::success(body)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::openhuman::security::{AutonomyLevel, SecurityPolicy}; + + fn test_security(workspace: std::path::PathBuf) -> Arc { + Arc::new(SecurityPolicy { + autonomy: AutonomyLevel::Supervised, + workspace_dir: workspace, + ..SecurityPolicy::default() + }) + } + + #[test] + fn list_name() { + let tool = ListFilesTool::new(test_security(std::env::temp_dir())); + assert_eq!(tool.name(), "list"); + } + + #[tokio::test] + async fn list_lists_files_and_dirs() { + let dir = std::env::temp_dir().join("openhuman_test_list"); + let _ = tokio::fs::remove_dir_all(&dir).await; + tokio::fs::create_dir_all(dir.join("sub")).await.unwrap(); + tokio::fs::write(dir.join("a.txt"), "x").await.unwrap(); + + let tool = ListFilesTool::new(test_security(dir.clone())); + let result = tool.execute(json!({})).await.unwrap(); + assert!(!result.is_error); + let output = result.output(); + assert!(output.contains("file\ta.txt")); + assert!(output.contains("dir\tsub")); + + let _ = tokio::fs::remove_dir_all(&dir).await; + } + + #[tokio::test] + async fn list_blocks_path_traversal() { + let tool = ListFilesTool::new(test_security(std::env::temp_dir())); + let result = tool.execute(json!({"path": "../../etc"})).await.unwrap(); + assert!(result.is_error); + assert!(result.output().contains("not allowed")); + } + + #[tokio::test] + async fn list_missing_dir() { + let dir = std::env::temp_dir().join("openhuman_test_list_missing"); + let _ = tokio::fs::remove_dir_all(&dir).await; + tokio::fs::create_dir_all(&dir).await.unwrap(); + let tool = ListFilesTool::new(test_security(dir.clone())); + let result = tool.execute(json!({"path": "nope"})).await.unwrap(); + assert!(result.is_error); + assert!(result.output().contains("Failed to resolve")); + let _ = tokio::fs::remove_dir_all(&dir).await; + } +} diff --git a/src/openhuman/tools/impl/filesystem/mod.rs b/src/openhuman/tools/impl/filesystem/mod.rs index aa0fad292..aa8e607a5 100644 --- a/src/openhuman/tools/impl/filesystem/mod.rs +++ b/src/openhuman/tools/impl/filesystem/mod.rs @@ -1,16 +1,26 @@ +mod apply_patch; mod csv_export; +mod edit_file; mod file_read; mod file_write; mod git_operations; +mod glob_search; +mod grep; +mod list_files; mod read_diff; mod run_linter; mod run_tests; mod update_memory_md; +pub use apply_patch::ApplyPatchTool; pub use csv_export::CsvExportTool; +pub use edit_file::EditFileTool; pub use file_read::FileReadTool; pub use file_write::FileWriteTool; pub use git_operations::GitOperationsTool; +pub use glob_search::GlobTool; +pub use grep::GrepTool; +pub use list_files::ListFilesTool; pub use read_diff::ReadDiffTool; pub use run_linter::RunLinterTool; pub use run_tests::RunTestsTool; diff --git a/src/openhuman/tools/impl/network/mod.rs b/src/openhuman/tools/impl/network/mod.rs index 23d7fdd1d..67d2bb100 100644 --- a/src/openhuman/tools/impl/network/mod.rs +++ b/src/openhuman/tools/impl/network/mod.rs @@ -3,10 +3,12 @@ mod curl; mod gitbooks; mod http_request; mod url_guard; +mod web_fetch; mod web_search; pub use composio::{ComposioAction, ComposioTool}; pub use curl::CurlTool; pub use gitbooks::{GitbooksGetPageTool, GitbooksSearchTool}; pub use http_request::HttpRequestTool; +pub use web_fetch::WebFetchTool; pub use web_search::WebSearchTool; diff --git a/src/openhuman/tools/impl/network/web_fetch.rs b/src/openhuman/tools/impl/network/web_fetch.rs new file mode 100644 index 000000000..ad86757fb --- /dev/null +++ b/src/openhuman/tools/impl/network/web_fetch.rs @@ -0,0 +1,202 @@ +//! `web_fetch` — fetch a URL and return its text body. +//! +//! Coding-harness baseline tool (issue #1205). Distinct from +//! `http_request` (full method/header surface) and `curl` (writes to +//! disk). `web_fetch` is the single-purpose "GET and read" primitive +//! the agent reaches for when researching: returns the response body +//! as text, capped, with a tiny preamble (status + final URL). + +use super::url_guard::{normalize_allowed_domains, validate_url}; +use crate::openhuman::security::SecurityPolicy; +use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolResult}; +use async_trait::async_trait; +use serde_json::json; +use std::sync::Arc; +use std::time::Duration; + +const DEFAULT_MAX_BYTES: usize = 1_000_000; +const DEFAULT_TIMEOUT_SECS: u64 = 20; + +pub struct WebFetchTool { + security: Arc, + allowed_domains: Vec, + max_bytes: usize, + timeout_secs: u64, +} + +impl WebFetchTool { + pub fn new( + security: Arc, + allowed_domains: Vec, + max_bytes: Option, + timeout_secs: Option, + ) -> Self { + Self { + security, + allowed_domains: normalize_allowed_domains(allowed_domains), + max_bytes: max_bytes.unwrap_or(DEFAULT_MAX_BYTES), + timeout_secs: timeout_secs.unwrap_or(DEFAULT_TIMEOUT_SECS), + } + } +} + +#[async_trait] +impl Tool for WebFetchTool { + fn name(&self) -> &str { + "web_fetch" + } + + fn description(&self) -> &str { + "GET a URL and return its body as text (truncated). Use this for \ + reading docs / READMEs / spec pages. For richer HTTP semantics \ + (POST, custom headers, …) use `http_request`." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "url": { "type": "string", "description": "Absolute http(s) URL." }, + "max_bytes": { + "type": "integer", + "description": "Truncate body at this many bytes (default 1_000_000).", + "minimum": 1 + } + }, + "required": ["url"] + }) + } + + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::ReadOnly + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let raw_url = args + .get("url") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing 'url' parameter"))?; + let max_bytes = args + .get("max_bytes") + .and_then(|v| v.as_u64()) + .map(|n| (n as usize).max(1)) + .unwrap_or(self.max_bytes); + + if self.security.is_rate_limited() { + return Ok(ToolResult::error( + "Rate limit exceeded: too many actions in the last hour", + )); + } + if !self.security.record_action() { + return Ok(ToolResult::error( + "Rate limit exceeded: action budget exhausted", + )); + } + + let url = match validate_url(raw_url, &self.allowed_domains) { + Ok(u) => u, + Err(e) => return Ok(ToolResult::error(format!("URL rejected: {e}"))), + }; + + // Disable automatic redirect following: reqwest follows up to 10 + // redirects by default, and a redirect target may be on a host + // outside the allowed-domains list. We surface 3xx responses to + // the caller so they can decide whether to refetch the new URL. + let client = match reqwest::Client::builder() + .timeout(Duration::from_secs(self.timeout_secs)) + .redirect(reqwest::redirect::Policy::none()) + .build() + { + Ok(c) => c, + Err(e) => return Ok(ToolResult::error(format!("Failed to build client: {e}"))), + }; + + let resp = match client.get(&url).send().await { + Ok(r) => r, + Err(e) => return Ok(ToolResult::error(format!("Request failed: {e}"))), + }; + let status = resp.status(); + let final_url = resp.url().to_string(); + let location = resp + .headers() + .get(reqwest::header::LOCATION) + .and_then(|v| v.to_str().ok()) + .map(str::to_string); + let body = match resp.text().await { + Ok(b) => b, + Err(e) => return Ok(ToolResult::error(format!("Failed to read body: {e}"))), + }; + + if let Some(loc) = &location { + if status.is_redirection() { + return Ok(ToolResult::success(format!( + "status={} url={} location={loc}\n[redirect not followed — re-call web_fetch with the location URL if it's an allowed domain]", + status.as_u16(), + final_url + ))); + } + } + + let (snippet, truncated) = if body.len() > max_bytes { + let mut cut = max_bytes; + while cut > 0 && !body.is_char_boundary(cut) { + cut -= 1; + } + (&body[..cut], true) + } else { + (body.as_str(), false) + }; + + let suffix = if truncated { + format!("\n[truncated at {max_bytes} bytes]") + } else { + String::new() + }; + let header = format!("status={} url={}\n", status.as_u16(), final_url); + Ok(ToolResult::success(format!("{header}{snippet}{suffix}"))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::openhuman::security::{AutonomyLevel, SecurityPolicy}; + + fn test_security() -> Arc { + Arc::new(SecurityPolicy { + autonomy: AutonomyLevel::Supervised, + workspace_dir: std::env::temp_dir(), + ..SecurityPolicy::default() + }) + } + + #[test] + fn web_fetch_name_and_schema() { + let tool = WebFetchTool::new(test_security(), vec!["example.com".into()], None, None); + assert_eq!(tool.name(), "web_fetch"); + let schema = tool.parameters_schema(); + assert!(schema["properties"]["url"].is_object()); + assert!(schema["required"] + .as_array() + .unwrap() + .contains(&json!("url"))); + } + + #[tokio::test] + async fn web_fetch_rejects_disallowed_domain() { + let tool = WebFetchTool::new(test_security(), vec!["example.com".into()], None, None); + let result = tool + .execute(json!({ "url": "https://evil.test/path" })) + .await + .unwrap(); + assert!(result.is_error); + assert!(result.output().contains("URL rejected")); + } + + #[tokio::test] + async fn web_fetch_rejects_invalid_url() { + let tool = WebFetchTool::new(test_security(), vec!["example.com".into()], None, None); + let result = tool.execute(json!({ "url": "not-a-url" })).await.unwrap(); + assert!(result.is_error); + } +} diff --git a/src/openhuman/tools/impl/system/lsp.rs b/src/openhuman/tools/impl/system/lsp.rs new file mode 100644 index 000000000..1c8e421ba --- /dev/null +++ b/src/openhuman/tools/impl/system/lsp.rs @@ -0,0 +1,164 @@ +//! `lsp` — capability-gated LSP query stub. +//! +//! Coding-harness baseline tool (issue #1205). The full LSP integration +//! (spawning language servers, JSON-RPC bridge, completion / hover / +//! definition / references) is large enough to live in its own +//! follow-up. This tool exists today as the **agent-facing surface + +//! capability gate** so: +//! +//! 1. The schema is stable: prompts and downstream callers can be +//! written against `{ language, kind, file, line, character, symbol }` +//! without churn when the real backend lands. +//! 2. The gate is observable: with `OPENHUMAN_LSP_ENABLED=1` set the +//! tool registers; without it, it does not — so agents don't see a +//! method that will always fail. +//! 3. When enabled but no backend is wired, the tool returns a clear +//! "not yet implemented" error instead of silently misbehaving. + +use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolResult}; +use async_trait::async_trait; +use serde_json::json; + +/// Env var that gates LSP tool registration. +pub const LSP_ENABLED_ENV: &str = "OPENHUMAN_LSP_ENABLED"; + +/// Returns true when the LSP capability gate is on. Accepts `1`, `true`, +/// `yes` (case-insensitive). Anything else (including unset) is off. +pub fn lsp_capability_enabled() -> bool { + match std::env::var(LSP_ENABLED_ENV) { + Ok(v) => matches!( + v.trim().to_ascii_lowercase().as_str(), + "1" | "true" | "yes" | "on" + ), + Err(_) => false, + } +} + +pub struct LspTool; + +impl LspTool { + pub fn new() -> Self { + Self + } +} + +impl Default for LspTool { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl Tool for LspTool { + fn name(&self) -> &str { + "lsp" + } + + fn description(&self) -> &str { + "Query a Language Server for code intelligence (definition, references, \ + hover, completion). Capability-gated: only registered when \ + OPENHUMAN_LSP_ENABLED=1. The server-spawning backend is a follow-up \ + — calls today return a `not yet implemented` error so callers can \ + feature-detect." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": ["definition", "references", "hover", "completion"], + "description": "Which LSP query to run." + }, + "language": { + "type": "string", + "description": "Language id (e.g. `rust`, `typescript`, `python`)." + }, + "file": { "type": "string", "description": "Workspace-relative file path." }, + "line": { "type": "integer", "minimum": 0 }, + "character": { "type": "integer", "minimum": 0 }, + "symbol": { + "type": "string", + "description": "Optional symbol name (used by some kinds, e.g. references)." + } + }, + "required": ["kind", "language", "file"] + }) + } + + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::ReadOnly + } + + async fn execute(&self, _args: serde_json::Value) -> anyhow::Result { + Ok(ToolResult::error( + "lsp backend not yet implemented — capability gate is on but no language server is wired. Track this in the follow-up issue." + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use std::sync::Mutex; + + /// Serialize env-var mutation across tests in this module so they + /// don't race each other under Rust's default parallel runner. + static ENV_LOCK: Mutex<()> = Mutex::new(()); + + #[test] + fn lsp_name_and_schema() { + let tool = LspTool::new(); + assert_eq!(tool.name(), "lsp"); + let schema = tool.parameters_schema(); + let required = schema["required"].as_array().unwrap(); + assert!(required.contains(&json!("kind"))); + assert!(required.contains(&json!("language"))); + assert!(required.contains(&json!("file"))); + } + + #[tokio::test] + async fn lsp_returns_not_implemented_error() { + let tool = LspTool::new(); + let result = tool + .execute(json!({ + "kind": "definition", "language": "rust", "file": "src/main.rs", + "line": 0, "character": 0 + })) + .await + .unwrap(); + assert!(result.is_error); + assert!(result.output().contains("not yet implemented")); + } + + #[test] + fn lsp_capability_gate_off_by_default() { + let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let prev = std::env::var(LSP_ENABLED_ENV).ok(); + std::env::remove_var(LSP_ENABLED_ENV); + assert!(!lsp_capability_enabled()); + if let Some(v) = prev { + std::env::set_var(LSP_ENABLED_ENV, v); + } + } + + #[test] + fn lsp_capability_gate_accepts_truthy_values() { + let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let prev = std::env::var(LSP_ENABLED_ENV).ok(); + for v in ["1", "true", "TRUE", "yes", "on"] { + std::env::set_var(LSP_ENABLED_ENV, v); + assert!(lsp_capability_enabled(), "expected truthy for {v:?}"); + } + for v in ["0", "false", "no", "off", ""] { + std::env::set_var(LSP_ENABLED_ENV, v); + assert!(!lsp_capability_enabled(), "expected falsy for {v:?}"); + } + match prev { + Some(v) => std::env::set_var(LSP_ENABLED_ENV, v), + None => std::env::remove_var(LSP_ENABLED_ENV), + } + } +} diff --git a/src/openhuman/tools/impl/system/mod.rs b/src/openhuman/tools/impl/system/mod.rs index 56f9d8387..ffde5c104 100644 --- a/src/openhuman/tools/impl/system/mod.rs +++ b/src/openhuman/tools/impl/system/mod.rs @@ -1,5 +1,6 @@ mod current_time; mod insert_sql_record; +mod lsp; mod node_exec; mod npm_exec; mod proxy_config; @@ -11,6 +12,7 @@ mod workspace_state; pub use current_time::CurrentTimeTool; pub use insert_sql_record::InsertSqlRecordTool; +pub use lsp::{lsp_capability_enabled, LspTool, LSP_ENABLED_ENV}; pub use node_exec::NodeExecTool; pub use npm_exec::NpmExecTool; pub use proxy_config::ProxyConfigTool; diff --git a/src/openhuman/tools/ops.rs b/src/openhuman/tools/ops.rs index 7f16b2b07..66053bd4d 100644 --- a/src/openhuman/tools/ops.rs +++ b/src/openhuman/tools/ops.rs @@ -99,6 +99,14 @@ pub fn all_tools_with_runtime( shell, Box::new(FileReadTool::new(security.clone())), Box::new(FileWriteTool::new(security.clone())), + // Coding-harness baseline tools (issue #1205): file navigation + // + atomic editing primitives. Use these instead of falling + // through to `shell` for grep/find/sed work. + Box::new(GrepTool::new(security.clone())), + Box::new(GlobTool::new(security.clone())), + Box::new(ListFilesTool::new(security.clone())), + Box::new(EditFileTool::new(security.clone())), + Box::new(ApplyPatchTool::new(security.clone())), Box::new(CsvExportTool::new(security.clone())), // Sub-agent dispatch — lets the parent agent delegate focused // sub-tasks (research, code execution, API specialists, …) by @@ -107,6 +115,13 @@ pub fn all_tools_with_runtime( // returns a single text result. See // `agent::harness::subagent_runner` for the dispatch path. Box::new(SpawnSubagentTool::new()), + // Coding-harness control flow (issue #1205): a process-global + // todo registry the agent can rewrite end-to-end, plus the + // `plan_exit` marker that hands a plan-mode pass off to a + // build-mode pass. The plan→build mode switch itself is a + // follow-up; the tool emits a stable marker today. + Box::new(TodoWriteTool::new(global_todo_store())), + Box::new(PlanExitTool::new()), Box::new(CheckOnboardingStatusTool::new()), Box::new(CompleteOnboardingTool::new()), Box::new(CurrentTimeTool::new()), @@ -175,6 +190,17 @@ pub fn all_tools_with_runtime( http_config.timeout_secs, ))); + // Coding-harness baseline `web_fetch` (issue #1205) — single-purpose + // GET-and-read primitive that reuses the same allowed-domains gate + // as `http_request`. Use this for docs/READMEs; reach for + // `http_request` only when you need richer HTTP semantics. + tools.push(Box::new(WebFetchTool::new( + security.clone(), + http_config.allowed_domains.clone(), + Some(http_config.max_response_size), + Some(http_config.timeout_secs), + ))); + // curl — always registered. Shares `http_request.allowed_domains`, // adds streaming-to-disk with a hard byte ceiling. Writes land // under `/`. @@ -371,6 +397,19 @@ pub fn all_tools_with_runtime( ); } + // Coding-harness `lsp` tool (issue #1205) — capability-gated by the + // OPENHUMAN_LSP_ENABLED env var. The backend (real language-server + // bridge) is a follow-up; today the gate just controls visibility + // so agents don't see a method that always errors. + if crate::openhuman::tools::implementations::lsp_capability_enabled() { + tools.push(Box::new( + crate::openhuman::tools::implementations::LspTool::new(), + )); + tracing::debug!("[lsp] capability gate on — LspTool registered"); + } else { + tracing::debug!("[lsp] capability gate off (set OPENHUMAN_LSP_ENABLED=1 to register)"); + } + tools }