feat(tools): coding-harness baseline primitives (#1205) (#1208)

This commit is contained in:
Steven Enamakel
2026-05-05 01:43:57 -07:00
committed by GitHub
parent 86740e2699
commit e945390d48
18 changed files with 2445 additions and 1 deletions
+1 -1
View File
@@ -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).
---
Generated
+2
View File
@@ -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",
+2
View File
@@ -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"
+172
View File
@@ -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
`<kind>\t<name>` 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/<category>/`, 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.
+4
View File
@@ -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};
+110
View File
@@ -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<ToolResult> {
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);
}
}
@@ -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<Vec<TodoItem>>,
}
impl TodoStore {
pub fn new() -> Self {
Self::default()
}
pub fn replace(&self, items: Vec<TodoItem>) {
*self.inner.lock() = items;
}
pub fn snapshot(&self) -> Vec<TodoItem> {
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<TodoStore> {
use once_cell::sync::OnceCell;
static STORE: OnceCell<Arc<TodoStore>> = OnceCell::new();
STORE.get_or_init(|| Arc::new(TodoStore::new())).clone()
}
pub struct TodoWriteTool {
store: Arc<TodoStore>,
}
impl TodoWriteTool {
pub fn new(store: Arc<TodoStore>) -> 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<ToolResult> {
let todos = args
.get("todos")
.ok_or_else(|| anyhow::anyhow!("Missing 'todos' parameter"))?;
let items: Vec<TodoItem> = 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);
}
}
@@ -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<SecurityPolicy>,
}
impl ApplyPatchTool {
pub fn new(security: Arc<SecurityPolicy>) -> 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<ToolResult> {
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<ParsedEdit> = 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<String, FileBuffer> = 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<String> = 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<String> {
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<SecurityPolicy> {
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"));
}
}
@@ -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<SecurityPolicy>,
}
impl EditFileTool {
pub fn new(security: Arc<SecurityPolicy>) -> 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<ToolResult> {
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<SecurityPolicy> {
Arc::new(SecurityPolicy {
autonomy: AutonomyLevel::Supervised,
workspace_dir: workspace,
..SecurityPolicy::default()
})
}
fn test_security_readonly(workspace: std::path::PathBuf) -> Arc<SecurityPolicy> {
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;
}
}
@@ -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<SecurityPolicy>,
}
impl GlobTool {
pub fn new(security: Arc<SecurityPolicy>) -> 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<ToolResult> {
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<String>, 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<String> = 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<SecurityPolicy> {
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;
}
}
+364
View File
@@ -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<SecurityPolicy>,
}
impl GrepTool {
pub fn new(security: Arc<SecurityPolicy>) -> 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<ToolResult> {
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, &regex, 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<Regex, regex::Error> {
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<String>, usize, bool) {
let mut matches: Vec<String> = 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<SecurityPolicy> {
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;
}
}
@@ -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<SecurityPolicy>,
}
impl ListFilesTool {
pub fn new(security: Arc<SecurityPolicy>) -> 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 \
`<kind>\t<name>` 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<ToolResult> {
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<SecurityPolicy> {
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;
}
}
@@ -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;
+2
View File
@@ -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;
@@ -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<SecurityPolicy>,
allowed_domains: Vec<String>,
max_bytes: usize,
timeout_secs: u64,
}
impl WebFetchTool {
pub fn new(
security: Arc<SecurityPolicy>,
allowed_domains: Vec<String>,
max_bytes: Option<usize>,
timeout_secs: Option<u64>,
) -> 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<ToolResult> {
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<SecurityPolicy> {
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);
}
}
+164
View File
@@ -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<ToolResult> {
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),
}
}
}
+2
View File
@@ -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;
+39
View File
@@ -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 `<workspace>/<curl.dest_subdir>`.
@@ -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
}