diff --git a/AGENTS.md b/AGENTS.md index 7955fac..e1a53ab 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,7 +59,8 @@ approval — it belongs to the host, whose threat model and configuration the decision depends on. `crates/tinytools-std` holds host-independent tool building blocks (cross-agent -file staleness tracking, SSRF-safe URL validation, PATH probing). It is not part of the dependency-light vocabulary crate and may pull +file staleness tracking, SSRF-safe URL validation, PATH probing, and the +filesystem tools, which ask a host-implemented `FsGate` before they act). It is not part of the dependency-light vocabulary crate and may pull in `tokio`, `parking_lot` and `log`. Add a crate by creating `crates//` — `members = ["crates/*"]` picks it up diff --git a/Cargo.lock b/Cargo.lock index 1def83f..48fc69b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -34,6 +34,12 @@ version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + [[package]] name = "cfg-if" version = "1.0.5" @@ -50,6 +56,39 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + [[package]] name = "itoa" version = "1.0.18" @@ -89,6 +128,23 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + [[package]] name = "parking_lot" version = "0.12.5" @@ -136,6 +192,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "redox_syscall" version = "0.5.18" @@ -187,6 +249,15 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -236,6 +307,16 @@ dependencies = [ "zmij", ] +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "smallvec" version = "1.16.2" @@ -253,6 +334,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + [[package]] name = "tinytools" version = "0.5.0" @@ -291,13 +385,19 @@ version = "0.5.0" dependencies = [ "anyhow", "async-trait", + "fs2", + "glob", + "libc", "log", "parking_lot", + "regex", "rustix", "serde_json", + "tempfile", "tinytools", "tokio", "tracing", + "walkdir", ] [[package]] @@ -306,8 +406,13 @@ version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ + "bytes", + "libc", + "mio", "pin-project-lite", + "signal-hook-registry", "tokio-macros", + "windows-sys", ] [[package]] @@ -343,6 +448,53 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-link" version = "0.2.1" diff --git a/crates/tinytools-std/Cargo.toml b/crates/tinytools-std/Cargo.toml index 7ef3d9d..dc51279 100644 --- a/crates/tinytools-std/Cargo.toml +++ b/crates/tinytools-std/Cargo.toml @@ -13,19 +13,31 @@ publish = false [dependencies] anyhow = { workspace = true } async-trait = { workspace = true } +# Advisory inter-process `flock` around the MEMORY.md read-modify-write. +fs2 = "0.4" +# `glob_search` pattern matching. +glob = "0.3" +# `O_NOFOLLOW` when opening the workspace lock file (Unix only). +libc = "0.2" # Structured diagnostics for DNS validation and SSRF refusals. log = "0.4" # Async path locks must remain non-blocking while callers hold them across I/O. parking_lot = "0.12" +# `grep` pattern compilation. +regex = { workspace = true } # Safe effective-credential executable checks for Unix PATH candidates. rustix = { version = "1", default-features = false, features = ["fs"] } serde_json = { workspace = true } tinytools = { path = "../tinytools", version = "0.5.0" } -# `spawn_blocking` keeps synchronous system DNS resolution off async executor threads. -tokio = { version = "1", default-features = false, features = ["rt", "sync"] } +# `spawn_blocking` keeps synchronous system DNS resolution off async executor threads; +# `fs`/`process`/`time` serve the filesystem tools. +tokio = { version = "1", default-features = false, features = ["rt", "sync", "fs", "process", "time"] } tracing = { workspace = true } +# `grep` and `glob_search` directory walks. +walkdir = "2" [dev-dependencies] +tempfile = "3" tokio = { workspace = true } [lints] diff --git a/crates/tinytools-std/README.md b/crates/tinytools-std/README.md index b818eda..1759cf3 100644 --- a/crates/tinytools-std/README.md +++ b/crates/tinytools-std/README.md @@ -6,6 +6,7 @@ Host-independent building blocks for agent tools, extracted from OpenHuman. | --- | --- | | `file_state` | Process-wide read/write stamps so parallel agents detect stale or partial reads before overwriting a file. The host decides whether the guard is on (`init_global(enabled)`); read tools pass `record_read` an `Instant` captured before their I/O. | | `url_guard` | URL validation with SSRF checks for outbound network tools. `validate_url_with_dns_check` returns a `ValidatedUrl` whose vetted `addrs` the caller must pin its HTTP client to (e.g. `reqwest`'s `resolve_to_addrs`); re-resolving the hostname reopens DNS rebinding. | +| `filesystem` | The file and repository tools: `file_read`, `file_write`, `edit_file`, `apply_patch`, `grep`, `glob`, `list_files`, `csv_export`, `read_diff`, `git_operations`, `run_linter`, `run_tests`, `update_memory_md`. Every tool that can touch a path takes an `Arc`: the tool does the I/O, the host's gate (autonomy, workspace boundary, approvals, action budget) decides whether it may. | | `detect_tools` | `find_on_path` and the read-only `detect_tools` tool. | -No enforcement of host policy lives here; the crate only supplies mechanisms. +No enforcement of host policy lives here; the crate only supplies mechanisms. The `filesystem` tools' name, description and JSON Schema are pinned by the fixtures in `src/filesystem/fixtures/`. diff --git a/crates/tinytools-std/src/filesystem/apply_patch/mod.rs b/crates/tinytools-std/src/filesystem/apply_patch/mod.rs new file mode 100644 index 0000000..1b81e56 --- /dev/null +++ b/crates/tinytools-std/src/filesystem/apply_patch/mod.rs @@ -0,0 +1,445 @@ +//! `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. +//! +//! **Creating a file** is an empty `old_string` against a path that does not +//! exist yet; `new_string` becomes the whole contents. Before that existed the +//! tool could only edit, and an agent asked to produce a document had no route +//! at all: it would create a placeholder with `shell` purely so there was +//! something to patch (the life-scenario benchmark caught two 1-byte files +//! containing `x`). An empty `old_string` against a file that *does* exist is +//! still an error — "replace nothing" is ambiguous, not a create. + +use super::gate::{FsGate, gate_for_context}; +use crate::file_state; +use async_trait::async_trait; +use serde_json::json; +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; +use tinytools::ToolRunContext; +use tinytools::{PermissionLevel, Tool, ToolCallOptions, ToolResult}; + +const MAX_FILE_BYTES: u64 = 5 * 1024 * 1024; +const MAX_EDITS: usize = 50; + +/// Apply a multi-edit patch to workspace files atomically. +#[derive(Debug)] +pub struct ApplyPatchTool { + gate: Arc, +} + +impl ApplyPatchTool { + /// Create the tool. + #[must_use] + pub fn new(gate: Arc) -> Self { + Self { gate } + } +} + +#[async_trait] +impl Tool for ApplyPatchTool { + fn name(&self) -> &'static str { + "apply_patch" + } + + fn description(&self) -> &'static 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?}`. \ + To CREATE a new file, pass an empty `old_string` with the full contents \ + as `new_string`; the path must not already exist." + } + + 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", + "description": "Exact text to replace. Empty means CREATE: the path must not exist and `new_string` becomes the whole file." + }, + "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 + } + + /// `apply_patch` modifies existing files → in ask-before-edit it routes + /// through the human approval gate; in Full it runs; read-only is blocked + /// in `execute`. + fn external_effect_with_args(&self, _args: &serde_json::Value) -> bool { + self.gate.write_needs_approval() + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + self.execute_in_context(args, None).await + } + + async fn execute_with_context( + &self, + args: serde_json::Value, + _options: ToolCallOptions, + context: Option<&dyn ToolRunContext>, + ) -> anyhow::Result { + self.execute_in_context(args, context).await + } +} + +impl ApplyPatchTool { + async fn execute_in_context( + &self, + args: serde_json::Value, + context: Option<&dyn ToolRunContext>, + ) -> 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.gate.can_act() { + return Ok(ToolResult::error( + "[policy-blocked] Action blocked: autonomy is read-only", + )); + } + if self.gate.is_rate_limited() { + return Ok(ToolResult::error( + "Rate limit exceeded: too many actions in the last hour", + )); + } + if !self.gate.record_action() { + return Ok(ToolResult::error( + "Rate limit exceeded: action budget exhausted", + )); + } + + let path_policy = gate_for_context(&self.gate, context, "apply_patch"); + + // 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(serde_json::Value::as_bool) + .unwrap_or(false); + + if !path_policy.is_path_string_allowed(path) { + return Ok(ToolResult::error(format!( + "edit[{i}]: path not allowed: {path}" + ))); + } + // An empty `old_string` is a create, and only against a path that + // does not exist yet. Resolved the same way every other edit is — + // joined onto `action_dir` — so "exists" means the same thing here + // as it does in the apply loop below. + let exists = path_policy.action_dir().join(path).exists() + || (std::path::Path::new(path).is_absolute() + && std::path::Path::new(path).exists()); + let create = old_string.is_empty(); + if create && exists { + return Ok(ToolResult::error(format!( + "edit[{i}]: `old_string` must not be empty for an existing file ({path}). \ + Pass the exact text to replace, or write to a new path to create a file." + ))); + } + parsed.push(ParsedEdit { + index: i, + path: path.to_string(), + old_string: old_string.to_string(), + new_string: new_string.to_string(), + replace_all, + create, + }); + } + + // Acquire per-path locks for all unique paths before any reads. + let unique_paths: Vec = { + let mut seen = std::collections::HashSet::new(); + parsed + .iter() + .filter_map(|e| { + if seen.insert(e.path.clone()) { + Some(e.path.clone()) + } else { + None + } + }) + .collect() + }; + let mut _path_guards = Vec::new(); + for p in &unique_paths { + let full = path_policy.action_dir().join(p); + if let Ok(resolved) = tokio::fs::canonicalize(&full).await + && let Some(guard) = file_state::acquire_path_lock(&resolved).await + { + _path_guards.push(guard); + } + } + + // File-state guard: reject edits based on stale or partial reads. + if let Some(agent_id) = file_state::current_file_state_agent_id() { + for p in &unique_paths { + let full = path_policy.action_dir().join(p); + if let Ok(resolved) = tokio::fs::canonicalize(&full).await { + if let Some(msg) = file_state::check_stale_read(&agent_id, &resolved) { + tracing::debug!( + agent = %agent_id, + path = %resolved.display(), + "[file_state] apply_patch blocked: stale read" + ); + return Ok(ToolResult::error(msg)); + } + if let Some(msg) = file_state::check_partial_read(&agent_id, &resolved) { + tracing::debug!( + agent = %agent_id, + path = %resolved.display(), + "[file_state] apply_patch blocked: partial read" + ); + return Ok(ToolResult::error(msg)); + } + } + } + } + + // 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 = path_policy.action_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 + && meta.file_type().is_symlink() + { + return Ok(ToolResult::error(format!( + "edit[{}]: refusing to edit through symlink", + edit.index + ))); + } + + // Security check: validate path string, resolve symlinks, confirm + // workspace containment. A create has no file to canonicalize, + // so it resolves through the parent — the same gate + // `file_write` uses, which walks up to the deepest existing + // ancestor and still checks it for symlink escapes. + let resolved = if edit.create { + match path_policy.validate_parent_path(&edit.path).await { + Ok(p) => p, + Err(msg) => { + return Ok(ToolResult::error(format!("edit[{}]: {msg}", edit.index))); + } + } + } else { + match path_policy.validate_path(&edit.path).await { + Ok(p) => p, + Err(msg) => { + return Ok(ToolResult::error(format!("edit[{}]: {msg}", edit.index))); + } + } + }; + if edit.create { + if let Some(parent) = resolved.parent() + && let Err(e) = tokio::fs::create_dir_all(parent).await + { + return Ok(ToolResult::error(format!( + "edit[{}]: failed to create parent of {}: {e}", + edit.index, edit.path + ))); + } + buffers.insert( + edit.path.clone(), + FileBuffer { + resolved, + original: None, + contents: edit.new_string.clone(), + edit_count: 1, + }, + ); + continue; + } + if let Ok(meta) = tokio::fs::metadata(&resolved).await + && 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: Some(contents.clone()), + contents, + edit_count: 0, + }, + ); + } + + if edit.create { + // A second create for the same path in one batch. The first one + // already populated the buffer; a duplicate would silently + // discard one of the two bodies, so say so. + return Ok(ToolResult::error(format!( + "edit[{}]: {} is created earlier in this batch; only one create per path", + edit.index, edit.path + ))); + } + + 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(match buf.original { + None => format!("{path}: created ({} bytes)", buf.contents.len()), + Some(_) => format!("{path}: {} replacement(s)", buf.edit_count), + }); + } + // Record writes in the file-state coordinator. + if let Some(agent_id) = file_state::current_file_state_agent_id() { + for buf in buffers.values() { + file_state::record_write(&agent_id, buf.resolved.clone()); + } + } + + 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 { + let result = match &buf.original { + Some(original) => tokio::fs::write(&buf.resolved, original).await, + // The batch created this file, so "restore" means remove it — + // otherwise a failed multi-file patch leaves a half-written new + // file behind and the caller cannot tell it apart from a success. + None => tokio::fs::remove_file(&buf.resolved).await, + }; + if let Err(e) = result { + errors.push(format!("{}: {e}", buf.resolved.display())); + } + } + errors +} + +struct ParsedEdit { + index: usize, + path: String, + old_string: String, + new_string: String, + replace_all: bool, + /// Empty `old_string` against a path that does not exist: `new_string` + /// becomes the whole file. + create: bool, +} + +struct FileBuffer { + resolved: PathBuf, + /// Snapshot of the file's contents as we first read them. + /// Used to restore on a partial-write failure. `None` for a file this batch + /// is creating — there is nothing to restore *to*, so rollback deletes it. + original: Option, + contents: String, + edit_count: usize, +} + +#[cfg(test)] +mod test; diff --git a/crates/tinytools-std/src/filesystem/apply_patch/test.rs b/crates/tinytools-std/src/filesystem/apply_patch/test.rs new file mode 100644 index 0000000..391daa0 --- /dev/null +++ b/crates/tinytools-std/src/filesystem/apply_patch/test.rs @@ -0,0 +1,506 @@ +//! Behavior tests for the `apply_patch` tool, driven through a fake [`FsGate`]. + +#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)] + +use super::*; +use crate::filesystem::test_support::TestGate; + +fn test_security(workspace: std::path::PathBuf) -> Arc { + TestGate::supervised(workspace) +} + +#[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")); +} + +// -- create mode -------------------------------------------------------------- +// +// Empty `old_string` + a path that does not exist = create. Before this the +// tool could only edit, so an agent asked to produce a document had no route: +// the life-scenario `meal-plan` run left two 1-byte files containing `x`, +// placeholders it made with `shell` purely so `apply_patch` had something to +// patch. + +#[tokio::test] +async fn apply_patch_creates_a_new_file_from_an_empty_old_string() { + let dir = std::env::temp_dir().join("openhuman_test_patch_create"); + 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": [ + { "path": "out/plan.md", "old_string": "", "new_string": "# Plan\nday one\n" } + ] + })) + .await + .unwrap(); + assert!(!result.is_error, "{}", result.output()); + let written = tokio::fs::read_to_string(dir.join("out/plan.md")) + .await + .expect("the file (and its parent) must have been created"); + assert_eq!(written, "# Plan\nday one\n"); + assert!(result.output().contains("created"), "{}", result.output()); +} + +#[tokio::test] +async fn apply_patch_refuses_an_empty_old_string_on_an_existing_file() { + let dir = std::env::temp_dir().join("openhuman_test_patch_create_existing"); + 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(); + + let tool = ApplyPatchTool::new(test_security(dir.clone())); + let result = tool + .execute(json!({ + "edits": [{ "path": "a.txt", "old_string": "", "new_string": "overwritten" }] + })) + .await + .unwrap(); + assert!(result.is_error, "{}", result.output()); + // Unchanged — "replace nothing" must never become "replace everything". + let a = tokio::fs::read_to_string(dir.join("a.txt")).await.unwrap(); + assert_eq!(a, "alpha"); +} + +#[tokio::test] +async fn apply_patch_mixes_a_create_and_an_edit_in_one_batch() { + let dir = std::env::temp_dir().join("openhuman_test_patch_create_mixed"); + 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(); + + 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": "", "new_string": "bravo" } + ] + })) + .await + .unwrap(); + assert!(!result.is_error, "{}", result.output()); + assert_eq!( + tokio::fs::read_to_string(dir.join("a.txt")).await.unwrap(), + "ALPHA" + ); + assert_eq!( + tokio::fs::read_to_string(dir.join("b.txt")).await.unwrap(), + "bravo" + ); +} + +#[tokio::test] +async fn apply_patch_refuses_two_creates_for_the_same_path() { + let dir = std::env::temp_dir().join("openhuman_test_patch_create_dup"); + 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": [ + { "path": "c.txt", "old_string": "", "new_string": "first" }, + { "path": "c.txt", "old_string": "", "new_string": "second" } + ] + })) + .await + .unwrap(); + assert!(result.is_error, "{}", result.output()); + assert!( + !dir.join("c.txt").exists(), + "a rejected batch must write nothing" + ); +} + +#[tokio::test] +async fn apply_patch_create_still_obeys_the_path_policy() { + let dir = std::env::temp_dir().join("openhuman_test_patch_create_escape"); + 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": [{ "path": "../escaped.md", "old_string": "", "new_string": "nope" }] + })) + .await + .unwrap(); + assert!(result.is_error, "{}", result.output()); +} + +// ── Coverage of limits, budgets, guards, symlinks and write failures ──── + +use crate::filesystem::test_support::{AutonomyLevel, RacyGate, WorkspaceContext}; + +fn one_edit(path: &str, old: &str, new: &str) -> serde_json::Value { + json!({"edits": [{"path": path, "old_string": old, "new_string": new}]}) +} + +#[tokio::test] +async fn apply_patch_rejects_too_many_edits() { + let dir = tempfile::tempdir().unwrap(); + let edits: Vec<_> = (0..=MAX_EDITS) + .map(|_| json!({"path": "a.txt", "old_string": "a", "new_string": "b"})) + .collect(); + let tool = ApplyPatchTool::new(test_security(dir.path().to_path_buf())); + let result = tool.execute(json!({"edits": edits})).await.unwrap(); + assert!(result.is_error); + assert!(result.output().contains("Too many edits")); +} + +#[tokio::test] +async fn apply_patch_enforces_autonomy_and_budgets() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("a.txt"), "x").unwrap(); + let args = one_edit("a.txt", "x", "y"); + + let read_only = ApplyPatchTool::new(TestGate::with( + dir.path().to_path_buf(), + AutonomyLevel::ReadOnly, + 100, + )); + let result = read_only.execute(args.clone()).await.unwrap(); + assert!(result.output().contains("autonomy is read-only")); + + let limited = ApplyPatchTool::new(TestGate::with( + dir.path().to_path_buf(), + AutonomyLevel::Supervised, + 0, + )); + let result = limited.execute(args.clone()).await.unwrap(); + assert!( + result + .output() + .contains("too many actions in the last hour") + ); + + let racy = ApplyPatchTool::new(Arc::new(RacyGate(test_security(dir.path().to_path_buf())))); + let result = racy.execute(args).await.unwrap(); + assert!(result.output().contains("action budget exhausted")); +} + +#[tokio::test] +async fn apply_patch_resolves_an_absolute_create_target() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("abs_new.txt"); + let tool = ApplyPatchTool::new(test_security(dir.path().to_path_buf())); + let result = tool + .execute(one_edit(target.to_str().unwrap(), "", "created")) + .await + .unwrap(); + assert!(!result.is_error, "{}", result.output()); + assert_eq!(std::fs::read_to_string(target).unwrap(), "created"); +} + +#[cfg(unix)] +#[tokio::test] +async fn apply_patch_refuses_to_edit_through_a_symlink() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("real.txt"), "x").unwrap(); + std::os::unix::fs::symlink(dir.path().join("real.txt"), dir.path().join("link.txt")).unwrap(); + let tool = ApplyPatchTool::new(test_security(dir.path().to_path_buf())); + let result = tool.execute(one_edit("link.txt", "x", "y")).await.unwrap(); + assert!(result.is_error); + assert!(result.output().contains("refusing to edit through symlink")); +} + +#[tokio::test] +async fn apply_patch_reports_unresolvable_and_unreadable_targets() { + let dir = tempfile::tempdir().unwrap(); + let tool = ApplyPatchTool::new(test_security(dir.path().to_path_buf())); + let result = tool + .execute(one_edit("absent.txt", "x", "y")) + .await + .unwrap(); + assert!(result.is_error); + assert!(result.output().starts_with("edit[0]: ")); + assert!(result.output().contains("Failed to resolve")); + + std::fs::write(dir.path().join("bin.dat"), [0xff_u8, 0xfe]).unwrap(); + let result = tool.execute(one_edit("bin.dat", "x", "y")).await.unwrap(); + assert!(result.output().contains("failed to read bin.dat")); +} + +#[tokio::test] +async fn apply_patch_reports_a_create_whose_parent_cannot_be_made() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("file.txt"), "x").unwrap(); + let tool = ApplyPatchTool::new(test_security(dir.path().to_path_buf())); + let result = tool + .execute(one_edit("file.txt/child.txt", "", "body")) + .await + .unwrap(); + assert!(result.is_error); + assert!( + result + .output() + .contains("failed to create parent of file.txt/child.txt") + ); +} + +#[tokio::test] +async fn apply_patch_refuses_an_oversized_file() { + let dir = tempfile::tempdir().unwrap(); + let file = std::fs::File::create(dir.path().join("big.txt")).unwrap(); + file.set_len(MAX_FILE_BYTES + 1).unwrap(); + let tool = ApplyPatchTool::new(test_security(dir.path().to_path_buf())); + let result = tool.execute(one_edit("big.txt", "x", "y")).await.unwrap(); + assert!(result.output().contains("file too large")); +} + +#[tokio::test] +async fn apply_patch_needs_replace_all_for_repeated_matches() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("r.txt"), "a a a").unwrap(); + let tool = ApplyPatchTool::new(test_security(dir.path().to_path_buf())); + let result = tool.execute(one_edit("r.txt", "a", "b")).await.unwrap(); + assert!(result.is_error); + assert!(result.output().contains("matches 3 times")); + assert_eq!( + std::fs::read_to_string(dir.path().join("r.txt")).unwrap(), + "a a a" + ); + + let result = tool + .execute(json!({"edits": [ + {"path": "r.txt", "old_string": "a", "new_string": "b", "replace_all": true} + ]})) + .await + .unwrap(); + assert!(!result.is_error, "{}", result.output()); + assert_eq!( + std::fs::read_to_string(dir.path().join("r.txt")).unwrap(), + "b b b" + ); +} + +#[tokio::test] +async fn apply_patch_uses_the_context_workspace_when_one_is_threaded() { + let home = tempfile::tempdir().unwrap(); + let isolated = tempfile::tempdir().unwrap(); + std::fs::write(isolated.path().join("w.txt"), "x").unwrap(); + let tool = ApplyPatchTool::new(test_security(home.path().to_path_buf())); + let context = WorkspaceContext::at(isolated.path()); + let result = tool + .execute_with_context( + one_edit("w.txt", "x", "y"), + ToolCallOptions::default(), + Some(&context), + ) + .await + .unwrap(); + assert!(!result.is_error, "{}", result.output()); + assert_eq!( + std::fs::read_to_string(isolated.path().join("w.txt")).unwrap(), + "y" + ); +} + +#[tokio::test] +async fn apply_patch_honours_the_file_state_guard_and_records_writes() { + crate::file_state::init_global(true); + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().canonicalize().unwrap().join("g.txt"); + std::fs::write(&target, "x").unwrap(); + let tool = ApplyPatchTool::new(test_security(dir.path().to_path_buf())); + let agent = format!("ap-agent-{}", dir.path().display()); + let other = format!("ap-other-{}", dir.path().display()); + let run = |agent: String| { + crate::file_state::with_file_state_agent_id( + agent, + tool.execute(one_edit("g.txt", "x", "y")), + ) + }; + + crate::file_state::record_read( + &agent, + target.clone(), + std::time::SystemTime::now(), + true, + std::time::Instant::now(), + ); + assert!( + run(agent.clone()) + .await + .unwrap() + .output() + .contains("Partial read") + ); + + crate::file_state::record_read( + &agent, + target.clone(), + std::time::SystemTime::now(), + false, + std::time::Instant::now(), + ); + crate::file_state::record_write(&other, target.clone()); + assert!( + run(agent.clone()) + .await + .unwrap() + .output() + .contains("Stale read") + ); + + crate::file_state::record_read( + &agent, + target.clone(), + std::time::SystemTime::now(), + false, + std::time::Instant::now(), + ); + let result = run(agent.clone()).await.unwrap(); + assert!(!result.is_error, "{}", result.output()); + assert!(crate::file_state::check_stale_read(&agent, &target).is_none()); +} + +/// A create whose file name exceeds the OS limit passes every check and then +/// fails at write time. Whichever order the batch writes in, no file from the +/// failed batch may be left changed, and the error must say it was restored. +#[tokio::test] +async fn apply_patch_restores_earlier_writes_when_a_later_write_fails() { + let long_name = "n".repeat(300); + let mut saw_restore = false; + for _ in 0..40 { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("keep.txt"), "orig").unwrap(); + let tool = ApplyPatchTool::new(test_security(dir.path().to_path_buf())); + let result = tool + .execute(json!({"edits": [ + {"path": "keep.txt", "old_string": "orig", "new_string": "changed"}, + {"path": "fresh.txt", "old_string": "", "new_string": "new"}, + {"path": long_name, "old_string": "", "new_string": "boom"}, + ]})) + .await + .unwrap(); + assert!(result.is_error, "{}", result.output()); + assert!(result.output().contains("Failed to write")); + assert_eq!( + std::fs::read_to_string(dir.path().join("keep.txt")).unwrap(), + "orig" + ); + assert!(!dir.path().join("fresh.txt").exists()); + saw_restore |= result.output().contains("restored from snapshot"); + } + assert!( + saw_restore, + "no iteration wrote a file before the failing one" + ); +} diff --git a/crates/tinytools-std/src/filesystem/contract_test.rs b/crates/tinytools-std/src/filesystem/contract_test.rs new file mode 100644 index 0000000..7e7d432 --- /dev/null +++ b/crates/tinytools-std/src/filesystem/contract_test.rs @@ -0,0 +1,145 @@ +//! Wire-contract fixtures: the name, description, permission level, exposure +//! and JSON Schema each filesystem tool advertises to the model. +//! +//! These are what a model is prompted with, and what existing transcripts were +//! produced against, so they are pinned against literal fixture files rather +//! than re-derived from the code under test. A change here is a change to the +//! tool's public contract and must be made on purpose. + +#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)] + +use std::sync::Arc; + +use serde_json::{Value, json}; +use tinytools::Tool; + +use super::*; +use crate::filesystem::test_support::{AutonomyLevel, TestGate}; + +fn tools() -> Vec<(&'static str, Box)> { + let gate = TestGate::supervised(std::env::temp_dir()); + let workspace = std::env::temp_dir(); + vec![ + ( + "apply_patch", + Box::new(ApplyPatchTool::new(gate.clone())) as Box, + ), + ("csv_export", Box::new(CsvExportTool::new(gate.clone()))), + ("edit_file", Box::new(EditFileTool::new(gate.clone()))), + ("file_read", Box::new(FileReadTool::new(gate.clone()))), + ("file_write", Box::new(FileWriteTool::new(gate.clone()))), + ( + "git_operations", + Box::new(GitOperationsTool::new(gate.clone(), workspace.clone())), + ), + ("glob_search", Box::new(GlobTool::new(gate.clone()))), + ("grep", Box::new(GrepTool::new(gate.clone()))), + ("list_files", Box::new(ListFilesTool::new(gate))), + ("read_diff", Box::new(ReadDiffTool::new(workspace.clone()))), + ( + "run_linter", + Box::new(RunLinterTool::new(workspace.clone())), + ), + ("run_tests", Box::new(RunTestsTool::new(workspace.clone()))), + ( + "update_memory_md", + Box::new(UpdateMemoryMdTool::new(workspace)), + ), + ] +} + +fn describe(tool: &dyn Tool) -> Value { + json!({ + "name": tool.name(), + "description": tool.description(), + "permission_level": format!("{:?}", tool.permission_level()), + "exposure": format!("{:?}", tool.exposure()), + "schema": tool.parameters_schema(), + }) +} + +fn fixture(name: &str) -> Value { + let raw = match name { + "apply_patch" => include_str!("fixtures/apply_patch.json"), + "csv_export" => include_str!("fixtures/csv_export.json"), + "edit_file" => include_str!("fixtures/edit_file.json"), + "file_read" => include_str!("fixtures/file_read.json"), + "file_write" => include_str!("fixtures/file_write.json"), + "git_operations" => include_str!("fixtures/git_operations.json"), + "glob_search" => include_str!("fixtures/glob_search.json"), + "grep" => include_str!("fixtures/grep.json"), + "list_files" => include_str!("fixtures/list_files.json"), + "read_diff" => include_str!("fixtures/read_diff.json"), + "run_linter" => include_str!("fixtures/run_linter.json"), + "run_tests" => include_str!("fixtures/run_tests.json"), + "update_memory_md" => include_str!("fixtures/update_memory_md.json"), + other => panic!("no fixture for {other}"), + }; + serde_json::from_str(raw).expect("fixture is valid JSON") +} + +#[test] +fn every_tool_advertises_exactly_its_pinned_contract() { + for (fixture_name, tool) in tools() { + assert_eq!( + describe(tool.as_ref()), + fixture(fixture_name), + "contract drift in `{fixture_name}`" + ); + } +} + +#[test] +fn tool_names_are_unique() { + let mut names: Vec = tools().iter().map(|(_, t)| t.name().to_string()).collect(); + names.sort(); + let before = names.len(); + names.dedup(); + assert_eq!(before, names.len()); +} + +#[test] +fn a_gate_can_be_shared_across_tools() { + let gate: Arc = TestGate::supervised(std::env::temp_dir()); + let _read = FileReadTool::new(Arc::clone(&gate)); + let _write = FileWriteTool::new(gate); +} + +fn gate_at(autonomy: AutonomyLevel) -> Arc { + TestGate::with(std::env::temp_dir(), autonomy, 1_000_000) +} + +#[test] +fn write_tools_route_through_approval_only_when_the_gate_asks() { + let existing = tempfile::tempdir().unwrap(); + std::fs::write(existing.path().join("f.txt"), "x").unwrap(); + let existing_arg = json!({"path": existing.path().join("f.txt").to_string_lossy()}); + let new_arg = json!({"path": existing.path().join("missing.txt").to_string_lossy()}); + + for (autonomy, prompts) in [ + (AutonomyLevel::Supervised, true), + (AutonomyLevel::Full, false), + (AutonomyLevel::ReadOnly, false), + ] { + let gate = gate_at(autonomy); + assert_eq!( + EditFileTool::new(gate.clone()).external_effect_with_args(&json!({})), + prompts + ); + assert_eq!( + ApplyPatchTool::new(gate.clone()).external_effect_with_args(&json!({})), + prompts + ); + let write = FileWriteTool::new(gate.clone()); + // Overwriting prompts, creating does not, an unknown path fails safe. + assert_eq!(write.external_effect_with_args(&existing_arg), prompts); + assert!(!write.external_effect_with_args(&new_arg)); + assert_eq!(write.external_effect_with_args(&json!({})), prompts); + let git = GitOperationsTool::new(gate, std::env::temp_dir()); + assert_eq!( + git.external_effect_with_args(&json!({"operation": "commit"})), + prompts + ); + assert!(!git.external_effect_with_args(&json!({"operation": "status"}))); + } +} diff --git a/crates/tinytools-std/src/filesystem/csv_export/extra_test.rs b/crates/tinytools-std/src/filesystem/csv_export/extra_test.rs new file mode 100644 index 0000000..7dcc47c --- /dev/null +++ b/crates/tinytools-std/src/filesystem/csv_export/extra_test.rs @@ -0,0 +1,161 @@ +//! Refusal, size-label and failure-path tests for `csv_export`. + +#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)] + +use super::*; +use crate::filesystem::test_support::{AutonomyLevel, TestGate, WrapGate}; +use tempfile::TempDir; + +fn tool_in(dir: &TempDir) -> CsvExportTool { + CsvExportTool::new(TestGate::supervised(dir.path().to_path_buf())) +} + +fn data(v: &serde_json::Value) -> String { + serde_json::to_string(v).unwrap() +} + +#[tokio::test] +async fn read_only_autonomy_is_refused() { + let dir = TempDir::new().unwrap(); + let gate = TestGate::with(dir.path().to_path_buf(), AutonomyLevel::ReadOnly, 100); + let r = CsvExportTool::new(gate) + .execute(json!({"data": data(&json!([{"a": 1}])), "filename": "x.csv"})) + .await + .unwrap(); + assert!(r.is_error); + assert!(r.output().contains("autonomy is read-only")); +} + +#[tokio::test] +async fn exhausted_hourly_limit_is_refused() { + let dir = TempDir::new().unwrap(); + let gate = TestGate::with(dir.path().to_path_buf(), AutonomyLevel::Supervised, 0); + let r = CsvExportTool::new(gate) + .execute(json!({"data": data(&json!([{"a": 1}])), "filename": "x.csv"})) + .await + .unwrap(); + assert!(r.output().contains("too many actions in the last hour")); +} + +#[tokio::test] +async fn exhausted_action_budget_is_refused_before_writing() { + let dir = TempDir::new().unwrap(); + let gate = WrapGate::new(TestGate::supervised(dir.path().to_path_buf())) + .no_budget() + .arc(); + let r = CsvExportTool::new(gate) + .execute(json!({"data": data(&json!([{"a": 1}])), "filename": "x.csv"})) + .await + .unwrap(); + assert!(r.output().contains("action budget exhausted")); + assert!(!dir.path().join("exports/x.csv").exists()); +} + +#[tokio::test] +async fn invalid_json_is_reported() { + let dir = TempDir::new().unwrap(); + let r = tool_in(&dir) + .execute(json!({"data": "{not json", "filename": "x.csv"})) + .await + .unwrap(); + assert!(r.is_error); + assert!(r.output().contains("Failed to parse data as JSON")); +} + +#[tokio::test] +async fn empty_array_is_reported() { + let dir = TempDir::new().unwrap(); + let r = tool_in(&dir) + .execute(json!({"data": "[]", "filename": "x.csv"})) + .await + .unwrap(); + assert!(r.output().contains("nothing to export")); +} + +#[tokio::test] +async fn a_path_the_gate_refuses_is_reported() { + let dir = TempDir::new().unwrap(); + let r = tool_in(&dir) + .execute(json!({"data": data(&json!([{"a": 1}])), "filename": "../escape.csv"})) + .await + .unwrap(); + assert!(r.is_error); + assert!(r.output().contains("Path not allowed")); +} + +#[cfg(unix)] +#[tokio::test] +async fn a_symlinked_target_is_refused() { + let dir = TempDir::new().unwrap(); + std::fs::create_dir(dir.path().join("exports")).unwrap(); + std::fs::write(dir.path().join("real.txt"), "keep").unwrap(); + std::os::unix::fs::symlink( + dir.path().join("real.txt"), + dir.path().join("exports/link.csv"), + ) + .unwrap(); + let r = tool_in(&dir) + .execute(json!({"data": data(&json!([{"a": 1}])), "filename": "link.csv"})) + .await + .unwrap(); + assert!(r.is_error); + assert!(r.output().contains("Refusing to write through symlink")); + assert_eq!( + std::fs::read_to_string(dir.path().join("real.txt")).unwrap(), + "keep" + ); +} + +#[tokio::test] +async fn a_directory_in_the_way_reports_the_write_failure() { + let dir = TempDir::new().unwrap(); + std::fs::create_dir_all(dir.path().join("exports/out.csv")).unwrap(); + let r = tool_in(&dir) + .execute(json!({"data": data(&json!([{"a": 1}])), "filename": "out.csv"})) + .await + .unwrap(); + assert!(r.is_error); + assert!(r.output().contains("Failed to write CSV file")); +} + +#[tokio::test] +async fn size_labels_scale_to_kb_and_mb() { + let dir = TempDir::new().unwrap(); + let kb_rows: Vec<_> = (0..200) + .map(|i| json!({"n": format!("row-{i:04}-padding")})) + .collect(); + let r = tool_in(&dir) + .execute(json!({"data": data(&json!(kb_rows)), "filename": "kb.csv"})) + .await + .unwrap(); + assert!(r.output().contains(" KB)"), "{}", r.output()); + + let big = "x".repeat(1024 * 1024 + 10); + let r = tool_in(&dir) + .execute(json!({"data": data(&json!([{"blob": big}])), "filename": "mb.csv"})) + .await + .unwrap(); + assert!(r.output().contains(" MB)"), "{}", r.output()); +} + +#[test] +fn cells_and_columns_cover_bools_and_non_object_rows() { + assert_eq!(value_to_cell(&json!(true)), "true"); + assert_eq!(value_to_cell(&json!(null)), ""); + assert_eq!(value_to_cell(&json!([1, 2])), "[1,2]"); + assert!(resolve_columns(&[json!(1), json!(2)], None).is_empty()); +} + +#[tokio::test] +async fn execute_with_context_delegates() { + let dir = TempDir::new().unwrap(); + let r = tool_in(&dir) + .execute_with_context( + json!({"data": data(&json!([{"ok": true}])), "filename": "c.csv"}), + ToolCallOptions::default(), + None, + ) + .await + .unwrap(); + assert!(!r.is_error, "{}", r.output()); +} diff --git a/crates/tinytools-std/src/filesystem/csv_export/mod.rs b/crates/tinytools-std/src/filesystem/csv_export/mod.rs new file mode 100644 index 0000000..c256863 --- /dev/null +++ b/crates/tinytools-std/src/filesystem/csv_export/mod.rs @@ -0,0 +1,268 @@ +use super::gate::{FsGate, gate_for_context}; +use async_trait::async_trait; +use serde_json::json; +use std::sync::Arc; +use tinytools::ToolRunContext; +use tinytools::{Tool, ToolCallOptions, ToolResult}; + +/// Export structured data (JSON array of objects) as a CSV file to the workspace. +#[derive(Debug)] +pub struct CsvExportTool { + gate: Arc, +} + +impl CsvExportTool { + /// Create the tool. + #[must_use] + pub fn new(gate: Arc) -> Self { + Self { gate } + } +} + +/// Escape a value for inclusion in a CSV cell. Wraps the value in +/// double-quotes when it contains commas, quotes, or newlines. Embedded +/// double-quotes are escaped by doubling them per RFC 4180. +fn csv_escape(value: &str) -> String { + if value.contains(',') || value.contains('"') || value.contains('\n') || value.contains('\r') { + let escaped = value.replace('"', "\"\""); + format!("\"{escaped}\"") + } else { + value.to_string() + } +} + +/// Convert a `serde_json::Value` into a plain string suitable for a CSV +/// cell. Objects and arrays are serialised as compact JSON; booleans and +/// numbers use their natural representation; nulls become the empty +/// string. +fn value_to_cell(v: &serde_json::Value) -> String { + match v { + serde_json::Value::Null => String::new(), + serde_json::Value::String(s) => s.clone(), + serde_json::Value::Bool(b) => b.to_string(), + serde_json::Value::Number(n) => n.to_string(), + // Nested objects/arrays → compact JSON string + other => other.to_string(), + } +} + +/// Collect column headers from a JSON array. If `columns` is provided, +/// use those in order. Otherwise, collect all keys from the first object +/// in the array (sorted alphabetically — `serde_json` uses `BTreeMap` by +/// default). Callers who need a specific column order should pass the +/// `columns` parameter. +fn resolve_columns(items: &[serde_json::Value], columns: Option<&[String]>) -> Vec { + if let Some(cols) = columns { + return cols.to_vec(); + } + // Collect keys from the first object. + if let Some(first) = items.first() + && let Some(obj) = first.as_object() + { + return obj.keys().cloned().collect(); + } + Vec::new() +} + +/// Render a JSON array of objects into a CSV string. +fn render_csv(items: &[serde_json::Value], columns: &[String]) -> String { + let mut buf = String::new(); + + // Header row + let header: Vec = columns.iter().map(|c| csv_escape(c)).collect(); + buf.push_str(&header.join(",")); + buf.push('\n'); + + // Data rows + for item in items { + let row: Vec = columns + .iter() + .map(|col| { + let cell_value = item.get(col).map(value_to_cell).unwrap_or_default(); + csv_escape(&cell_value) + }) + .collect(); + buf.push_str(&row.join(",")); + buf.push('\n'); + } + + buf +} + +#[async_trait] +impl Tool for CsvExportTool { + fn name(&self) -> &'static str { + "csv_export" + } + + fn description(&self) -> &'static str { + "Export structured data (JSON array of objects) as a CSV file to the workspace. \ + Returns the file path. Use when the user wants raw tabular data from a tool \ + result that's too large to include inline." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "data": { + "type": "string", + "description": "JSON string containing an array of objects to export. Each object becomes a row; keys become column headers." + }, + "filename": { + "type": "string", + "description": "Output filename (without path). Will be written to workspace/exports/. Example: 'github-issues-2026-04-16.csv'" + }, + "columns": { + "type": "array", + "items": { "type": "string" }, + "description": "Optional ordered list of column names to include. If omitted, all keys from the first object are used as headers." + } + }, + "required": ["data", "filename"] + }) + } + + fn permission_level(&self) -> tinytools::PermissionLevel { + tinytools::PermissionLevel::Write + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + self.execute_in_context(args, None).await + } + + async fn execute_with_context( + &self, + args: serde_json::Value, + _options: ToolCallOptions, + context: Option<&dyn ToolRunContext>, + ) -> anyhow::Result { + self.execute_in_context(args, context).await + } +} + +impl CsvExportTool { + async fn execute_in_context( + &self, + args: serde_json::Value, + context: Option<&dyn ToolRunContext>, + ) -> anyhow::Result { + let data_str = args + .get("data") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing 'data' parameter"))?; + + let filename = args + .get("filename") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing 'filename' parameter"))?; + + let columns: Option> = args.get("columns").and_then(|v| { + v.as_array().map(|arr| { + arr.iter() + .filter_map(|item| item.as_str().map(String::from)) + .collect() + }) + }); + + // Security: check write permission + if !self.gate.can_act() { + return Ok(ToolResult::error( + "[policy-blocked] Action blocked: autonomy is read-only", + )); + } + + if self.gate.is_rate_limited() { + return Ok(ToolResult::error( + "Rate limit exceeded: too many actions in the last hour", + )); + } + + // Parse the JSON data + let parsed: serde_json::Value = match serde_json::from_str(data_str) { + Ok(v) => v, + Err(e) => { + return Ok(ToolResult::error(format!( + "Failed to parse data as JSON: {e}" + ))); + } + }; + + let items = match parsed.as_array() { + Some(arr) => arr, + None => { + return Ok(ToolResult::error( + "Data must be a JSON array of objects, but got a non-array value", + )); + } + }; + + if items.is_empty() { + return Ok(ToolResult::error("Data array is empty — nothing to export")); + } + + // Resolve columns and render CSV + let cols = resolve_columns(items, columns.as_deref()); + let csv_content = render_csv(items, &cols); + let csv_bytes = csv_content.len(); + + // Validate the relative path + let relative_path = format!("exports/{filename}"); + + let path_policy = gate_for_context(&self.gate, context, "csv_export"); + + // Security check first: validate path string, resolve symlinks, confirm workspace + // containment. validate_parent_path walks up to the deepest existing ancestor so + // it does not require the exports/ directory to exist yet. + let resolved_target = match path_policy.validate_parent_path(&relative_path).await { + Ok(p) => p, + Err(msg) => return Ok(ToolResult::error(msg)), + }; + + // Create exports/ directory only at the validated, resolved location. + if let Some(resolved_parent) = resolved_target.parent() { + tokio::fs::create_dir_all(resolved_parent).await?; + } + + // If the target already exists and is a symlink, refuse to follow it + if let Ok(meta) = tokio::fs::symlink_metadata(&resolved_target).await + && meta.file_type().is_symlink() + { + return Ok(ToolResult::error(format!( + "Refusing to write through symlink: {}", + resolved_target.display() + ))); + } + + if !self.gate.record_action() { + return Ok(ToolResult::error( + "Rate limit exceeded: action budget exhausted", + )); + } + + // Write the CSV file + match tokio::fs::write(&resolved_target, &csv_content).await { + Ok(()) => { + let size_display = if csv_bytes >= 1024 * 1024 { + format!("{:.1} MB", csv_bytes as f64 / (1024.0 * 1024.0)) + } else if csv_bytes >= 1024 { + format!("{:.1} KB", csv_bytes as f64 / 1024.0) + } else { + format!("{csv_bytes} bytes") + }; + + Ok(ToolResult::success(format!( + "Exported {} rows to {relative_path} ({size_display})", + items.len() + ))) + } + Err(e) => Ok(ToolResult::error(format!("Failed to write CSV file: {e}"))), + } + } +} + +#[cfg(test)] +mod test; + +#[cfg(test)] +mod extra_test; diff --git a/crates/tinytools-std/src/filesystem/csv_export/test.rs b/crates/tinytools-std/src/filesystem/csv_export/test.rs new file mode 100644 index 0000000..d2d8cc0 --- /dev/null +++ b/crates/tinytools-std/src/filesystem/csv_export/test.rs @@ -0,0 +1,234 @@ +//! Behavior tests for the `csv_export` tool, driven through a fake [`FsGate`]. + +#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)] + +use super::*; +use crate::filesystem::test_support::TestGate; + +fn test_security(workspace: std::path::PathBuf) -> Arc { + TestGate::supervised(workspace) +} + +#[test] +fn csv_export_name() { + let tool = CsvExportTool::new(test_security(std::env::temp_dir())); + assert_eq!(tool.name(), "csv_export"); +} + +#[test] +fn csv_export_schema_has_required_fields() { + let tool = CsvExportTool::new(test_security(std::env::temp_dir())); + let schema = tool.parameters_schema(); + assert!(schema["properties"]["data"].is_object()); + assert!(schema["properties"]["filename"].is_object()); + assert!(schema["properties"]["columns"].is_object()); + let required = schema["required"].as_array().unwrap(); + assert!(required.contains(&json!("data"))); + assert!(required.contains(&json!("filename"))); +} + +#[tokio::test] +async fn csv_export_formats_simple_array() { + let dir = std::env::temp_dir().join("openhuman_test_csv_export_simple"); + let _ = tokio::fs::remove_dir_all(&dir).await; + tokio::fs::create_dir_all(&dir).await.unwrap(); + + let tool = CsvExportTool::new(test_security(dir.clone())); + let data = serde_json::to_string(&json!([ + {"name": "Alice", "age": 30, "city": "NYC"}, + {"name": "Bob", "age": 25, "city": "LA"}, + {"name": "Carol", "age": 35, "city": "Chicago"} + ])) + .unwrap(); + + let result = tool + .execute(json!({ + "data": data, + "filename": "people.csv" + })) + .await + .unwrap(); + + assert!(!result.is_error, "unexpected error: {}", result.output()); + assert!(result.output().contains("3 rows")); + + let content = tokio::fs::read_to_string(dir.join("exports/people.csv")) + .await + .unwrap(); + let lines: Vec<&str> = content.trim().lines().collect(); + assert_eq!(lines.len(), 4, "header + 3 data rows"); + + // Header should contain the keys from the first object + let header = lines[0]; + assert!(header.contains("name")); + assert!(header.contains("age")); + assert!(header.contains("city")); + + // Data rows should contain values + assert!(lines[1].contains("Alice")); + assert!(lines[2].contains("Bob")); + assert!(lines[3].contains("Carol")); + + let _ = tokio::fs::remove_dir_all(&dir).await; +} + +#[tokio::test] +async fn csv_export_handles_missing_keys() { + let dir = std::env::temp_dir().join("openhuman_test_csv_export_missing_keys"); + let _ = tokio::fs::remove_dir_all(&dir).await; + tokio::fs::create_dir_all(&dir).await.unwrap(); + + let tool = CsvExportTool::new(test_security(dir.clone())); + let data = serde_json::to_string(&json!([ + {"name": "Alice", "age": 30, "city": "NYC"}, + {"name": "Bob"}, + {"name": "Carol", "city": "Chicago"} + ])) + .unwrap(); + + let result = tool + .execute(json!({ + "data": data, + "filename": "sparse.csv", + "columns": ["name", "age", "city"] + })) + .await + .unwrap(); + + assert!(!result.is_error, "unexpected error: {}", result.output()); + + let content = tokio::fs::read_to_string(dir.join("exports/sparse.csv")) + .await + .unwrap(); + let lines: Vec<&str> = content.trim().lines().collect(); + assert_eq!(lines.len(), 4); + + // Bob's row should have empty cells for age and city + let bob_row = lines[2]; + let bob_cells: Vec<&str> = bob_row.split(',').collect(); + assert_eq!(bob_cells.len(), 3, "Bob row should have 3 cells"); + assert_eq!(bob_cells[0], "Bob"); + assert_eq!(bob_cells[1], "", "missing age should be empty"); + assert_eq!(bob_cells[2], "", "missing city should be empty"); + + let _ = tokio::fs::remove_dir_all(&dir).await; +} + +#[tokio::test] +async fn csv_export_respects_column_order() { + let dir = std::env::temp_dir().join("openhuman_test_csv_export_column_order"); + let _ = tokio::fs::remove_dir_all(&dir).await; + tokio::fs::create_dir_all(&dir).await.unwrap(); + + let tool = CsvExportTool::new(test_security(dir.clone())); + let data = serde_json::to_string(&json!([ + {"name": "Alice", "age": 30, "city": "NYC"}, + {"name": "Bob", "age": 25, "city": "LA"} + ])) + .unwrap(); + + let result = tool + .execute(json!({ + "data": data, + "filename": "ordered.csv", + "columns": ["city", "name", "age"] + })) + .await + .unwrap(); + + assert!(!result.is_error, "unexpected error: {}", result.output()); + + let content = tokio::fs::read_to_string(dir.join("exports/ordered.csv")) + .await + .unwrap(); + let lines: Vec<&str> = content.trim().lines().collect(); + assert_eq!( + lines[0], "city,name,age", + "header must follow requested column order" + ); + assert_eq!(lines[1], "NYC,Alice,30"); + assert_eq!(lines[2], "LA,Bob,25"); + + let _ = tokio::fs::remove_dir_all(&dir).await; +} + +#[tokio::test] +async fn csv_export_rejects_non_array_input() { + let dir = std::env::temp_dir().join("openhuman_test_csv_export_non_array"); + let _ = tokio::fs::remove_dir_all(&dir).await; + tokio::fs::create_dir_all(&dir).await.unwrap(); + + let tool = CsvExportTool::new(test_security(dir.clone())); + let data = serde_json::to_string(&json!({"not": "an array"})).unwrap(); + + let result = tool + .execute(json!({ + "data": data, + "filename": "bad.csv" + })) + .await + .unwrap(); + + assert!(result.is_error); + assert!( + result.output().contains("non-array"), + "error should mention non-array, got: {}", + result.output() + ); + + let _ = tokio::fs::remove_dir_all(&dir).await; +} + +#[tokio::test] +async fn csv_export_handles_nested_values() { + let dir = std::env::temp_dir().join("openhuman_test_csv_export_nested"); + let _ = tokio::fs::remove_dir_all(&dir).await; + tokio::fs::create_dir_all(&dir).await.unwrap(); + + let tool = CsvExportTool::new(test_security(dir.clone())); + let data = serde_json::to_string(&json!([ + { + "name": "Alice", + "tags": ["admin", "dev"], + "meta": {"role": "lead", "level": 5} + }, + { + "name": "Bob", + "tags": [], + "meta": null + } + ])) + .unwrap(); + + let result = tool + .execute(json!({ + "data": data, + "filename": "nested.csv", + "columns": ["name", "tags", "meta"] + })) + .await + .unwrap(); + + assert!(!result.is_error, "unexpected error: {}", result.output()); + + let content = tokio::fs::read_to_string(dir.join("exports/nested.csv")) + .await + .unwrap(); + let lines: Vec<&str> = content.trim().lines().collect(); + assert_eq!(lines.len(), 3, "header + 2 data rows"); + + // Alice's tags should be serialized as a JSON string (in quotes because it contains commas) + let alice_row = lines[1]; + assert!(alice_row.contains("Alice")); + // The JSON array should be serialized as a string and quoted + assert!( + alice_row.contains(r#"[""admin"",""dev""]"#), + "nested arrays should be JSON-serialized in CSV: {alice_row}" + ); + + // Bob's meta is null → empty cell + let bob_row = lines[2]; + assert!(bob_row.contains("Bob")); + + let _ = tokio::fs::remove_dir_all(&dir).await; +} diff --git a/crates/tinytools-std/src/filesystem/edit_file/mod.rs b/crates/tinytools-std/src/filesystem/edit_file/mod.rs new file mode 100644 index 0000000..c86a6f4 --- /dev/null +++ b/crates/tinytools-std/src/filesystem/edit_file/mod.rs @@ -0,0 +1,241 @@ +//! `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 super::gate::{FsGate, gate_for_context}; +use crate::file_state; +use async_trait::async_trait; +use serde_json::json; +use std::sync::Arc; +use tinytools::ToolRunContext; +use tinytools::{PermissionLevel, Tool, ToolCallOptions, ToolResult}; + +const MAX_FILE_BYTES: u64 = 5 * 1024 * 1024; + +/// Replace an exact string in a workspace file. +#[derive(Debug)] +pub struct EditFileTool { + gate: Arc, + sink: Arc, +} + +impl EditFileTool { + /// Create the tool. + #[must_use] + pub fn new(gate: Arc) -> Self { + Self { + gate, + sink: super::file_sink::os_sink(), + } + } + + /// Sends this tool's writes somewhere other than the OS. + #[cfg(test)] + pub(super) fn with_sink(mut self, sink: Arc) -> Self { + self.sink = sink; + self + } +} + +#[async_trait] +impl Tool for EditFileTool { + fn exposure(&self) -> tinytools::ToolExposure { + tinytools::ToolExposure::Deferred + } + + fn name(&self) -> &'static str { + "edit" + } + + fn description(&self) -> &'static 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 + } + + /// `edit` always modifies an **existing** file → in ask-before-edit it + /// routes through the human approval gate; in Full it runs; read-only is + /// blocked in `execute`. + fn external_effect_with_args(&self, _args: &serde_json::Value) -> bool { + self.gate.write_needs_approval() + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + self.execute_in_context(args, None).await + } + + async fn execute_with_context( + &self, + args: serde_json::Value, + _options: ToolCallOptions, + context: Option<&dyn ToolRunContext>, + ) -> anyhow::Result { + self.execute_in_context(args, context).await + } +} + +impl EditFileTool { + async fn execute_in_context( + &self, + args: serde_json::Value, + context: Option<&dyn ToolRunContext>, + ) -> 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(serde_json::Value::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.gate.can_act() { + return Ok(ToolResult::error( + "[policy-blocked] Action blocked: autonomy is read-only", + )); + } + if self.gate.is_rate_limited() { + return Ok(ToolResult::error( + "Rate limit exceeded: too many actions in the last hour", + )); + } + if !self.gate.record_action() { + return Ok(ToolResult::error( + "Rate limit exceeded: action budget exhausted", + )); + } + + let path_policy = gate_for_context(&self.gate, context, "edit"); + let full = path_policy.action_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 + && meta.file_type().is_symlink() + { + return Ok(ToolResult::error(format!( + "Refusing to edit through symlink: {}", + full.display() + ))); + } + + // Security check: validate path string, resolve symlinks, confirm workspace containment. + let resolved = match path_policy.validate_path(path).await { + Ok(p) => p, + Err(msg) => return Ok(ToolResult::error(msg)), + }; + + if let Ok(meta) = tokio::fs::metadata(&resolved).await + && meta.len() > MAX_FILE_BYTES + { + return Ok(ToolResult::error(format!( + "File too large: {} bytes (limit: {MAX_FILE_BYTES} bytes)", + meta.len() + ))); + } + + // Acquire per-path lock for the read-modify-write section. + let _path_guard = file_state::acquire_path_lock(&resolved).await; + + // File-state guard: reject edits based on stale or partial reads. + if let Some(agent_id) = file_state::current_file_state_agent_id() { + if let Some(msg) = file_state::check_stale_read(&agent_id, &resolved) { + tracing::debug!( + agent = %agent_id, + path = %resolved.display(), + "[file_state] edit blocked: stale read" + ); + return Ok(ToolResult::error(msg)); + } + if let Some(msg) = file_state::check_partial_read(&agent_id, &resolved) { + tracing::debug!( + agent = %agent_id, + path = %resolved.display(), + "[file_state] edit blocked: partial read" + ); + return Ok(ToolResult::error(msg)); + } + } + + 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 self.sink.write(&resolved, updated.as_bytes()).await { + Ok(()) => { + if let Some(agent_id) = file_state::current_file_state_agent_id() { + file_state::record_write(&agent_id, resolved); + } + Ok(ToolResult::success(format!( + "Edited {path}: {count} replacement(s)" + ))) + } + Err(e) => Ok(ToolResult::error(format!("Failed to write file: {e}"))), + } + } +} + +#[cfg(test)] +mod test; diff --git a/crates/tinytools-std/src/filesystem/edit_file/test.rs b/crates/tinytools-std/src/filesystem/edit_file/test.rs new file mode 100644 index 0000000..af29075 --- /dev/null +++ b/crates/tinytools-std/src/filesystem/edit_file/test.rs @@ -0,0 +1,316 @@ +//! Behavior tests for the `edit_file` tool, driven through a fake [`FsGate`]. + +#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)] + +use super::*; +use crate::filesystem::test_support::{AutonomyLevel, TestGate}; + +fn test_security(workspace: std::path::PathBuf) -> Arc { + TestGate::supervised(workspace) +} + +fn test_security_readonly(workspace: std::path::PathBuf) -> Arc { + TestGate::with(workspace, AutonomyLevel::ReadOnly, 1_000_000) +} + +#[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; +} + +#[tokio::test] +async fn edit_reports_an_os_write_failure_rather_than_a_silent_success() { + let dir = std::env::temp_dir().join("openhuman_test_edit_os_write_fail"); + let _ = tokio::fs::remove_dir_all(&dir).await; + tokio::fs::create_dir_all(&dir).await.unwrap(); + let file = dir.join("f.txt"); + tokio::fs::write(&file, "abc").await.unwrap(); + + let tool = EditFileTool::new(test_security(dir.clone())).with_sink(Arc::new( + crate::filesystem::file_sink::RefusingSink(std::io::ErrorKind::PermissionDenied), + )); + let result = tool + .execute(json!({"path": "f.txt", "old_string": "abc", "new_string": "xyz"})) + .await + .unwrap(); + + assert!( + result.is_error, + "a refused write must surface as an error, not a fabricated success" + ); + assert!(result.output().contains("Failed to write file")); + assert_eq!( + tokio::fs::read_to_string(&file).await.unwrap(), + "abc", + "the file must be left as it was when the write did not happen" + ); + + let _ = tokio::fs::remove_dir_all(&dir).await; +} + +// ── Coverage of the budget, symlink, size, read and guard branches ────── + +use crate::filesystem::test_support::{RacyGate, WorkspaceContext}; + +fn edit_args(path: &str) -> serde_json::Value { + json!({"path": path, "old_string": "aaa", "new_string": "bbb"}) +} + +#[tokio::test] +async fn edit_reports_rate_limits() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("f.txt"), "aaa").unwrap(); + + let limited = EditFileTool::new(TestGate::with( + dir.path().to_path_buf(), + AutonomyLevel::Supervised, + 0, + )); + let result = limited.execute(edit_args("f.txt")).await.unwrap(); + assert!( + result + .output() + .contains("too many actions in the last hour") + ); + + let racy = EditFileTool::new(Arc::new(RacyGate(test_security(dir.path().to_path_buf())))); + let result = racy.execute(edit_args("f.txt")).await.unwrap(); + assert!(result.output().contains("action budget exhausted")); +} + +#[cfg(unix)] +#[tokio::test] +async fn edit_refuses_to_edit_through_a_symlink() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("real.txt"), "aaa").unwrap(); + std::os::unix::fs::symlink(dir.path().join("real.txt"), dir.path().join("link.txt")).unwrap(); + let tool = EditFileTool::new(test_security(dir.path().to_path_buf())); + let result = tool.execute(edit_args("link.txt")).await.unwrap(); + assert!(result.is_error); + assert!(result.output().contains("Refusing to edit through symlink")); +} + +#[tokio::test] +async fn edit_reports_a_missing_file_and_unreadable_contents() { + let dir = tempfile::tempdir().unwrap(); + let tool = EditFileTool::new(test_security(dir.path().to_path_buf())); + let result = tool.execute(edit_args("absent.txt")).await.unwrap(); + assert!(result.is_error); + assert!(result.output().contains("Failed to resolve")); + + std::fs::write(dir.path().join("bin.dat"), [0xff_u8, 0xfe, 0xfd]).unwrap(); + let result = tool.execute(edit_args("bin.dat")).await.unwrap(); + assert!(result.is_error); + assert!(result.output().contains("Failed to read file")); +} + +#[tokio::test] +async fn edit_refuses_an_oversized_file() { + let dir = tempfile::tempdir().unwrap(); + let file = std::fs::File::create(dir.path().join("big.txt")).unwrap(); + file.set_len(MAX_FILE_BYTES + 1).unwrap(); + let tool = EditFileTool::new(test_security(dir.path().to_path_buf())); + let result = tool.execute(edit_args("big.txt")).await.unwrap(); + assert!(result.is_error); + assert!(result.output().contains("File too large")); +} + +#[tokio::test] +async fn edit_uses_the_context_workspace_when_one_is_threaded() { + let home = tempfile::tempdir().unwrap(); + let isolated = tempfile::tempdir().unwrap(); + std::fs::write(isolated.path().join("w.txt"), "aaa").unwrap(); + let tool = EditFileTool::new(test_security(home.path().to_path_buf())); + let context = WorkspaceContext::at(isolated.path()); + let result = tool + .execute_with_context( + edit_args("w.txt"), + ToolCallOptions::default(), + Some(&context), + ) + .await + .unwrap(); + assert!(!result.is_error, "{}", result.output()); + assert_eq!( + std::fs::read_to_string(isolated.path().join("w.txt")).unwrap(), + "bbb" + ); +} + +#[tokio::test] +async fn edit_honours_the_file_state_guard_and_records_its_write() { + crate::file_state::init_global(true); + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().canonicalize().unwrap().join("g.txt"); + std::fs::write(&target, "aaa").unwrap(); + let tool = EditFileTool::new(test_security(dir.path().to_path_buf())); + let agent = format!("ed-agent-{}", dir.path().display()); + let other = format!("ed-other-{}", dir.path().display()); + let run = |agent: String| { + crate::file_state::with_file_state_agent_id(agent, tool.execute(edit_args("g.txt"))) + }; + + crate::file_state::record_read( + &agent, + target.clone(), + std::time::SystemTime::now(), + true, + std::time::Instant::now(), + ); + let result = run(agent.clone()).await.unwrap(); + assert!(result.output().contains("Partial read")); + + crate::file_state::record_read( + &agent, + target.clone(), + std::time::SystemTime::now(), + false, + std::time::Instant::now(), + ); + crate::file_state::record_write(&other, target.clone()); + let result = run(agent.clone()).await.unwrap(); + assert!(result.output().contains("Stale read")); + + crate::file_state::record_read( + &agent, + target.clone(), + std::time::SystemTime::now(), + false, + std::time::Instant::now(), + ); + let result = run(agent.clone()).await.unwrap(); + assert!(!result.is_error, "{}", result.output()); + assert!(crate::file_state::check_stale_read(&agent, &target).is_none()); +} diff --git a/crates/tinytools-std/src/filesystem/file_read/extra_test.rs b/crates/tinytools-std/src/filesystem/file_read/extra_test.rs new file mode 100644 index 0000000..b5a07e2 --- /dev/null +++ b/crates/tinytools-std/src/filesystem/file_read/extra_test.rs @@ -0,0 +1,96 @@ +//! Budget, metadata-failure, file-state and paging-marker tests for `file_read`. + +#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)] + +use super::*; +use crate::filesystem::test_support::{TestGate, WorkspaceContext, WrapGate}; +use tempfile::TempDir; + +#[test] +fn reads_are_concurrency_safe() { + let dir = TempDir::new().unwrap(); + let tool = FileReadTool::new(TestGate::supervised(dir.path().to_path_buf())); + assert!(tool.is_concurrency_safe(&json!({}))); +} + +#[tokio::test] +async fn exhausted_action_budget_is_refused() { + let dir = TempDir::new().unwrap(); + std::fs::write(dir.path().join("a.txt"), "x").unwrap(); + let gate = WrapGate::new(TestGate::supervised(dir.path().to_path_buf())) + .no_budget() + .arc(); + let r = FileReadTool::new(gate) + .execute(json!({"path": "a.txt"})) + .await + .unwrap(); + assert!(r.output().contains("action budget exhausted")); +} + +#[tokio::test] +async fn missing_metadata_is_reported() { + let dir = TempDir::new().unwrap(); + let gate = WrapGate::new(TestGate::supervised(dir.path().to_path_buf())) + .fixed_path(dir.path().join("vanished.txt")) + .arc(); + let r = FileReadTool::new(gate) + .execute(json!({"path": "vanished.txt"})) + .await + .unwrap(); + assert!(r.is_error); + assert!(r.output().contains("Failed to read file metadata")); +} + +#[tokio::test] +async fn a_read_under_an_agent_id_records_file_state() { + let dir = TempDir::new().unwrap(); + std::fs::write(dir.path().join("a.txt"), "tracked").unwrap(); + let tool = FileReadTool::new(TestGate::supervised(dir.path().to_path_buf())); + let r = crate::file_state::with_file_state_agent_id( + "agent-file-read-extra".to_string(), + tool.execute(json!({"path": "a.txt"})), + ) + .await + .unwrap(); + assert_eq!(r.output(), "tracked"); +} + +#[tokio::test] +async fn execute_with_context_delegates() { + let dir = TempDir::new().unwrap(); + std::fs::write(dir.path().join("a.txt"), "ctx").unwrap(); + let tool = FileReadTool::new(TestGate::supervised(dir.path().to_path_buf())); + let ctx = WorkspaceContext::at(dir.path()); + let r = tool + .execute_with_context( + json!({"path": "a.txt"}), + ToolCallOptions::default(), + Some(&ctx), + ) + .await + .unwrap(); + assert_eq!(r.output(), "ctx"); +} + +#[tokio::test] +async fn an_oversized_path_drops_out_of_the_continuation_marker() { + let dir = TempDir::new().unwrap(); + let file = dir.path().join("big.txt"); + std::fs::write(&file, "a".repeat(40 * 1024)).unwrap(); + let gate = WrapGate::new(TestGate::supervised(dir.path().to_path_buf())) + .fixed_path(file) + .arc(); + let long_path = "p".repeat(20 * 1024); + let r = FileReadTool::new(gate) + .execute(json!({"path": long_path})) + .await + .unwrap(); + assert!(!r.is_error); + let out = r.output(); + assert!( + out.contains("continue with file_read at \"offset\":"), + "marker missing" + ); + assert!(!out.contains("ppppp")); + assert!(out.len() <= 16 * 1024); +} diff --git a/crates/tinytools-std/src/filesystem/file_read/mod.rs b/crates/tinytools-std/src/filesystem/file_read/mod.rs new file mode 100644 index 0000000..dbb7343 --- /dev/null +++ b/crates/tinytools-std/src/filesystem/file_read/mod.rs @@ -0,0 +1,238 @@ +use super::gate::{FsGate, gate_for_context}; +use crate::file_state; +use async_trait::async_trait; +use serde_json::json; +use std::sync::Arc; +use tinytools::ToolRunContext; +use tinytools::{Tool, ToolCallOptions, ToolResult}; + +const MAX_FILE_SIZE_BYTES: u64 = 10 * 1024 * 1024; +/// Keep a page below the harness's 16 KiB per-result ceiling so the continuation +/// marker survives the result middleware. Returning the whole remainder and +/// relying on that middleware to truncate it loses the next offset and makes a +/// model repeat the same read forever. +const MAX_PAGE_BYTES: usize = 12 * 1024; +const MAX_TOOL_OUTPUT_BYTES: usize = 16 * 1024; + +/// Read file contents with path sandboxing +#[derive(Debug)] +pub struct FileReadTool { + gate: Arc, +} + +impl FileReadTool { + /// Largest file this tool will open. Anything that must stay readable + /// through it (a persisted tool-result artifact) has to fit. + pub const MAX_FILE_SIZE_BYTES: u64 = MAX_FILE_SIZE_BYTES; + + /// Create the tool. + #[must_use] + pub fn new(gate: Arc) -> Self { + Self { gate } + } +} + +#[async_trait] +impl Tool for FileReadTool { + fn name(&self) -> &'static str { + "file_read" + } + + fn description(&self) -> &'static str { + "Read the contents of a file in your working directory (the action sandbox). \ + Relative paths resolve against that directory; paths outside it are blocked. \ + To read a file written by `shell`, confirm its location with `pwd` and use the \ + same relative path. Long files are returned one page at a time; continue with \ + the exact `offset` reported at the end of the page." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Relative path to the file within the workspace" + }, + "offset": { + "type": "integer", + "minimum": 0, + "description": "Byte offset to start reading from. Use the offset a paged read reports to continue it." + } + }, + "required": ["path"] + }) + } + + /// Pure read — safe to fan out across parallel `file_read` calls. + fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool { + true + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + self.execute_in_context(args, None).await + } + + async fn execute_with_context( + &self, + args: serde_json::Value, + _options: ToolCallOptions, + context: Option<&dyn ToolRunContext>, + ) -> anyhow::Result { + self.execute_in_context(args, context).await + } +} + +impl FileReadTool { + async fn execute_in_context( + &self, + args: serde_json::Value, + context: Option<&dyn ToolRunContext>, + ) -> anyhow::Result { + let path = args + .get("path") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing 'path' parameter"))?; + + if self.gate.is_rate_limited() { + return Ok(ToolResult::error( + "Rate limit exceeded: too many actions in the last hour", + )); + } + + // Record action BEFORE validation so that every non-trivially-rejected + // request consumes rate limit budget. This prevents attackers from probing + // path existence (via canonicalize errors) without rate limit cost. + if !self.gate.record_action() { + return Ok(ToolResult::error( + "Rate limit exceeded: action budget exhausted", + )); + } + + // Security check: validate path string, resolve symlinks, confirm workspace containment. + let path_policy = gate_for_context(&self.gate, context, "file_read"); + let resolved_path = match path_policy.validate_path(path).await { + Ok(p) => p, + Err(msg) => return Ok(ToolResult::error(msg)), + }; + + // Captured before any read I/O: `file_state` compares it against + // sibling agents' writes to decide whether this read may already be stale. + let read_started = std::time::Instant::now(); + + // Check file size AFTER canonicalization to prevent TOCTOU symlink bypass + match tokio::fs::metadata(&resolved_path).await { + Ok(meta) => { + if meta.len() > MAX_FILE_SIZE_BYTES { + return Ok(ToolResult::error(format!( + "File too large: {} bytes (limit: {MAX_FILE_SIZE_BYTES} bytes)", + meta.len() + ))); + } + } + Err(e) => { + return Ok(ToolResult::error(format!( + "Failed to read file metadata: {e}" + ))); + } + } + + match tokio::fs::read_to_string(&resolved_path).await { + Ok(contents) => { + if let Some(agent_id) = file_state::current_file_state_agent_id() { + let mtime = tokio::fs::metadata(&resolved_path) + .await + .ok() + .and_then(|m| m.modified().ok()) + .unwrap_or(std::time::SystemTime::UNIX_EPOCH); + file_state::record_read(&agent_id, resolved_path, mtime, false, read_started); + } + // An absent or null offset reads from the start; anything else + // that is not a non-negative integer is rejected, so a + // malformed continuation never silently re-reads the file from + // byte 0. + let offset = match args.get("offset") { + None | Some(serde_json::Value::Null) => 0, + Some(value) => match value.as_u64().and_then(|o| usize::try_from(o).ok()) { + Some(offset) => offset, + None => { + return Ok(ToolResult::error(format!( + "offset must be a non-negative integer byte offset, got {value}" + ))); + } + }, + }; + if offset > contents.len() { + return Ok(ToolResult::error(format!( + "offset {offset} is past the end of the file ({} bytes)", + contents.len() + ))); + } + // Rejected rather than snapped to a boundary: a caller paging + // from `offset` computes the next offset from where it asked to + // start, so a silent move backwards would skip or repeat bytes. + if !contents.is_char_boundary(offset) { + return Ok(ToolResult::error(format!( + "offset {offset} falls inside a multi-byte character; continue from the offset a paged read reports" + ))); + } + Ok(ToolResult::success(page_contents(&contents, path, offset))) + } + Err(e) => Ok(ToolResult::error(format!("Failed to read file: {e}"))), + } + } +} + +fn page_contents(contents: &str, path: &str, offset: usize) -> String { + let initial_end = offset.saturating_add(MAX_PAGE_BYTES).min(contents.len()); + if initial_end == contents.len() { + return contents[offset..].to_string(); + } + + let path_json = + serde_json::to_string(path).unwrap_or_else(|_| "\"\"".to_string()); + let marker_with_path = |end: usize| { + format!( + "\n\n[file_read page: bytes {offset}..{end} of {}; continue with file_read \ + {{\"path\":{path_json},\"offset\":{end}}}]", + contents.len() + ) + }; + let marker_without_path = |end: usize| { + format!( + "\n\n[file_read page: bytes {offset}..{end} of {}; continue with file_read at \ + \"offset\":{end}]", + contents.len() + ) + }; + + // Size against the longest offset this page can report. If the escaped path + // consumes too much of the middleware budget, omit it: the caller already + // has the path and the continuation offset is the irreplaceable part. + let longest_with_path = marker_with_path(initial_end); + let include_path = longest_with_path.len() + 4 <= MAX_TOOL_OUTPUT_BYTES; + let marker_len = if include_path { + longest_with_path.len() + } else { + marker_without_path(initial_end).len() + }; + let content_budget = MAX_PAGE_BYTES.min(MAX_TOOL_OUTPUT_BYTES.saturating_sub(marker_len)); + let mut end = offset.saturating_add(content_budget).min(contents.len()); + while end > offset && !contents.is_char_boundary(end) { + end -= 1; + } + let marker = if include_path { + marker_with_path(end) + } else { + marker_without_path(end) + }; + let mut page = contents[offset..end].to_string(); + page.push_str(&marker); + page +} + +#[cfg(test)] +mod test; + +#[cfg(test)] +mod extra_test; diff --git a/crates/tinytools-std/src/filesystem/file_read/test.rs b/crates/tinytools-std/src/filesystem/file_read/test.rs new file mode 100644 index 0000000..6eef9e5 --- /dev/null +++ b/crates/tinytools-std/src/filesystem/file_read/test.rs @@ -0,0 +1,431 @@ +//! Behavior tests for the `file_read` tool, driven through a fake [`FsGate`]. + +#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)] + +use super::*; +use crate::filesystem::test_support::{AutonomyLevel, TestGate}; + +fn test_security(workspace: std::path::PathBuf) -> Arc { + TestGate::supervised(workspace) +} + +fn test_security_with( + workspace: std::path::PathBuf, + autonomy: AutonomyLevel, + max_actions_per_hour: usize, +) -> Arc { + TestGate::with(workspace, autonomy, max_actions_per_hour) +} + +#[test] +fn file_read_name() { + let tool = FileReadTool::new(test_security(std::env::temp_dir())); + assert_eq!(tool.name(), "file_read"); +} + +#[test] +fn file_read_schema_has_path() { + let tool = FileReadTool::new(test_security(std::env::temp_dir())); + let schema = tool.parameters_schema(); + assert!(schema["properties"]["path"].is_object()); + assert!( + schema["required"] + .as_array() + .unwrap() + .contains(&json!("path")) + ); +} + +#[tokio::test] +async fn file_read_existing_file() { + let dir = std::env::temp_dir().join("openhuman_test_file_read"); + let _ = tokio::fs::remove_dir_all(&dir).await; + tokio::fs::create_dir_all(&dir).await.unwrap(); + tokio::fs::write(dir.join("test.txt"), "hello world") + .await + .unwrap(); + + let tool = FileReadTool::new(test_security(dir.clone())); + let result = tool.execute(json!({"path": "test.txt"})).await.unwrap(); + assert!(!result.is_error); + assert_eq!(result.output(), "hello world"); + assert!(!result.is_error); + + let _ = tokio::fs::remove_dir_all(&dir).await; +} + +#[tokio::test] +async fn file_read_nonexistent_file() { + let dir = std::env::temp_dir().join("openhuman_test_file_read_missing"); + let _ = tokio::fs::remove_dir_all(&dir).await; + tokio::fs::create_dir_all(&dir).await.unwrap(); + + let tool = FileReadTool::new(test_security(dir.clone())); + let result = tool.execute(json!({"path": "nope.txt"})).await.unwrap(); + assert!(result.is_error); + assert!(&result.output().contains("Failed to resolve")); + + let _ = tokio::fs::remove_dir_all(&dir).await; +} + +#[tokio::test] +async fn file_read_blocks_path_traversal() { + let dir = std::env::temp_dir().join("openhuman_test_file_read_traversal"); + let _ = tokio::fs::remove_dir_all(&dir).await; + tokio::fs::create_dir_all(&dir).await.unwrap(); + + let tool = FileReadTool::new(test_security(dir.clone())); + let result = tool + .execute(json!({"path": "../../../etc/passwd"})) + .await + .unwrap(); + assert!(result.is_error); + assert!(&result.output().contains("not allowed")); + + let _ = tokio::fs::remove_dir_all(&dir).await; +} + +#[tokio::test] +async fn file_read_blocks_absolute_path() { + let tool = FileReadTool::new(test_security(std::env::temp_dir())); + let result = tool.execute(json!({"path": "/etc/passwd"})).await.unwrap(); + assert!(result.is_error); + assert!(&result.output().contains("not allowed")); +} + +#[tokio::test] +async fn file_read_blocks_when_rate_limited() { + let dir = std::env::temp_dir().join("openhuman_test_file_read_rate_limited"); + let _ = tokio::fs::remove_dir_all(&dir).await; + tokio::fs::create_dir_all(&dir).await.unwrap(); + tokio::fs::write(dir.join("test.txt"), "hello world") + .await + .unwrap(); + + let tool = FileReadTool::new(test_security_with( + dir.clone(), + AutonomyLevel::Supervised, + 0, + )); + let result = tool.execute(json!({"path": "test.txt"})).await.unwrap(); + + assert!(result.is_error); + assert!(result.output().contains("Rate limit exceeded")); + + let _ = tokio::fs::remove_dir_all(&dir).await; +} + +#[tokio::test] +async fn file_read_allows_readonly_mode() { + let dir = std::env::temp_dir().join("openhuman_test_file_read_readonly"); + let _ = tokio::fs::remove_dir_all(&dir).await; + tokio::fs::create_dir_all(&dir).await.unwrap(); + tokio::fs::write(dir.join("test.txt"), "readonly ok") + .await + .unwrap(); + + let tool = FileReadTool::new(test_security_with(dir.clone(), AutonomyLevel::ReadOnly, 20)); + let result = tool.execute(json!({"path": "test.txt"})).await.unwrap(); + + assert!(!result.is_error); + assert_eq!(result.output(), "readonly ok"); + + let _ = tokio::fs::remove_dir_all(&dir).await; +} + +#[tokio::test] +async fn file_read_missing_path_param() { + let tool = FileReadTool::new(test_security(std::env::temp_dir())); + let result = tool.execute(json!({})).await; + assert!(result.is_err()); +} + +#[tokio::test] +async fn file_read_empty_file() { + let dir = std::env::temp_dir().join("openhuman_test_file_read_empty"); + let _ = tokio::fs::remove_dir_all(&dir).await; + tokio::fs::create_dir_all(&dir).await.unwrap(); + tokio::fs::write(dir.join("empty.txt"), "").await.unwrap(); + + let tool = FileReadTool::new(test_security(dir.clone())); + let result = tool.execute(json!({"path": "empty.txt"})).await.unwrap(); + assert!(!result.is_error); + assert_eq!(result.output(), ""); + + let _ = tokio::fs::remove_dir_all(&dir).await; +} + +#[tokio::test] +async fn file_read_nested_path() { + let dir = std::env::temp_dir().join("openhuman_test_file_read_nested"); + let _ = tokio::fs::remove_dir_all(&dir).await; + tokio::fs::create_dir_all(dir.join("sub/dir")) + .await + .unwrap(); + tokio::fs::write(dir.join("sub/dir/deep.txt"), "deep content") + .await + .unwrap(); + + let tool = FileReadTool::new(test_security(dir.clone())); + let result = tool + .execute(json!({"path": "sub/dir/deep.txt"})) + .await + .unwrap(); + assert!(!result.is_error); + assert_eq!(result.output(), "deep content"); + + let _ = tokio::fs::remove_dir_all(&dir).await; +} + +#[cfg(unix)] +#[tokio::test] +async fn file_read_blocks_symlink_escape() { + use std::os::unix::fs::symlink; + + let root = std::env::temp_dir().join("openhuman_test_file_read_symlink_escape"); + let workspace = root.join("workspace"); + let outside = root.join("outside"); + + let _ = tokio::fs::remove_dir_all(&root).await; + tokio::fs::create_dir_all(&workspace).await.unwrap(); + tokio::fs::create_dir_all(&outside).await.unwrap(); + + tokio::fs::write(outside.join("secret.txt"), "outside workspace") + .await + .unwrap(); + + symlink(outside.join("secret.txt"), workspace.join("escape.txt")).unwrap(); + + let tool = FileReadTool::new(test_security(workspace.clone())); + let result = tool.execute(json!({"path": "escape.txt"})).await.unwrap(); + + assert!(result.is_error); + // After the symlink-safe canonical check landed in + // SecurityPolicy::is_path_allowed (#1927), the policy layer blocks + // the escape before file_read's own resolved-path check runs — the + // error becomes "Path not allowed by security policy". Either + // message signals defense-in-depth worked. + let out = result.output(); + assert!( + out.contains("escapes workspace") || out.contains("not allowed"), + "expected escape/not-allowed error, got: {out}" + ); + + let _ = tokio::fs::remove_dir_all(&root).await; +} + +#[tokio::test] +async fn file_read_nonexistent_consumes_rate_limit_budget() { + let dir = std::env::temp_dir().join("openhuman_test_file_read_probe"); + let _ = tokio::fs::remove_dir_all(&dir).await; + tokio::fs::create_dir_all(&dir).await.unwrap(); + + // Allow only 2 actions total + let tool = FileReadTool::new(test_security_with( + dir.clone(), + AutonomyLevel::Supervised, + 2, + )); + + // Both reads fail (file doesn't exist) but should consume budget + let r1 = tool.execute(json!({"path": "nope1.txt"})).await.unwrap(); + assert!(r1.is_error); + assert!(r1.output().contains("Failed to resolve")); + + let r2 = tool.execute(json!({"path": "nope2.txt"})).await.unwrap(); + assert!(r2.is_error); + assert!(r2.output().contains("Failed to resolve")); + + // Third attempt should be rate limited even though file doesn't exist + let r3 = tool.execute(json!({"path": "nope3.txt"})).await.unwrap(); + assert!(r3.is_error); + let r3_output = r3.output(); + assert!( + r3_output.contains("Rate limit"), + "Expected rate limit error, got: {r3_output:?}" + ); + + let _ = tokio::fs::remove_dir_all(&dir).await; +} + +#[tokio::test] +async fn file_read_rejects_oversized_file() { + let dir = std::env::temp_dir().join("openhuman_test_file_read_large"); + let _ = tokio::fs::remove_dir_all(&dir).await; + tokio::fs::create_dir_all(&dir).await.unwrap(); + + // Create a file just over 10 MB + let big = vec![b'x'; 10 * 1024 * 1024 + 1]; + tokio::fs::write(dir.join("huge.bin"), &big).await.unwrap(); + + let tool = FileReadTool::new(test_security(dir.clone())); + let result = tool.execute(json!({"path": "huge.bin"})).await.unwrap(); + assert!(result.is_error); + assert!(&result.output().contains("File too large")); + + let _ = tokio::fs::remove_dir_all(&dir).await; +} + +#[tokio::test] +async fn file_read_reports_invalid_utf8_as_an_error_not_a_panic() { + let dir = std::env::temp_dir().join("openhuman_test_file_read_non_utf8"); + let _ = tokio::fs::remove_dir_all(&dir).await; + tokio::fs::create_dir_all(&dir).await.unwrap(); + + // Passes the size cap, fails `read_to_string`: 0x80 is a lone UTF-8 + // continuation byte with no lead byte, invalid at any position. + tokio::fs::write(dir.join("binary.dat"), [0xFFu8, 0xFE, 0x00, 0x80]) + .await + .unwrap(); + + let tool = FileReadTool::new(test_security(dir.clone())); + let result = tool.execute(json!({"path": "binary.dat"})).await.unwrap(); + assert!( + result.is_error, + "invalid UTF-8 must surface as a tool error, not succeed with replaced/garbled text" + ); + assert!(&result.output().contains("Failed to read file")); + + let _ = tokio::fs::remove_dir_all(&dir).await; +} + +#[tokio::test] +async fn file_read_offset_continues_from_the_given_byte() { + let dir = tempfile::tempdir().unwrap(); + tokio::fs::write(dir.path().join("paged.txt"), "hello world") + .await + .unwrap(); + let tool = FileReadTool::new(test_security(dir.path().to_path_buf())); + + let page = tool + .execute(json!({"path": "paged.txt", "offset": 6})) + .await + .unwrap(); + assert!(!page.is_error, "{}", page.output()); + assert_eq!( + page.output(), + "world", + "a read with an offset must start at that byte" + ); + + let past_end = tool + .execute(json!({"path": "paged.txt", "offset": 99})) + .await + .unwrap(); + assert!( + past_end.is_error, + "an offset past the end must be reported, not returned as an empty read" + ); +} + +#[tokio::test] +async fn file_read_pages_long_files_and_reports_a_working_continuation() { + let dir = tempfile::tempdir().unwrap(); + let contents = format!("{}éTAIL", "x".repeat(MAX_PAGE_BYTES - 1)); + tokio::fs::write(dir.path().join("long.txt"), &contents) + .await + .unwrap(); + let tool = FileReadTool::new(test_security(dir.path().to_path_buf())); + + let first = tool.execute(json!({"path": "long.txt"})).await.unwrap(); + assert!(!first.is_error, "{}", first.output()); + assert!( + first.output().contains("continue with file_read"), + "a truncated page must tell the caller exactly how to continue: {}", + first.output() + ); + assert!( + first.output().len() < 16 * 1024, + "the page and marker must survive the harness's per-result budget" + ); + let marker = first + .output() + .split("\"offset\":") + .nth(1) + .and_then(|tail| tail.split('}').next()) + .and_then(|value| value.parse::().ok()) + .expect("continuation offset in marker"); + assert!(contents.is_char_boundary(marker)); + + let second = tool + .execute(json!({"path": "long.txt", "offset": marker})) + .await + .unwrap(); + assert!(!second.is_error, "{}", second.output()); + assert_eq!( + format!("{}{}", &first.output()[..marker], second.output()), + contents + ); +} + +#[test] +fn file_read_reserves_the_continuation_marker_for_a_long_escaped_path() { + let contents = "x".repeat(MAX_PAGE_BYTES * 2); + let path = format!("{}quoted\\\"name.txt", "nested\\\\".repeat(900)); + let page = page_contents(&contents, &path, 0); + + assert!(page.len() <= MAX_TOOL_OUTPUT_BYTES, "{}", page.len()); + assert!(page.contains("continue with file_read")); + assert!(page.contains("\"offset\":")); + assert!( + !page.contains(&path), + "an oversized escaped path should be omitted so the offset survives" + ); +} + +#[tokio::test] +async fn file_read_rejects_an_offset_inside_a_multibyte_character() { + let dir = tempfile::tempdir().unwrap(); + // "é" is two bytes, so byte 1 is inside it. + tokio::fs::write(dir.path().join("utf8.txt"), "é world") + .await + .unwrap(); + let tool = FileReadTool::new(test_security(dir.path().to_path_buf())); + + let inside = tool + .execute(json!({"path": "utf8.txt", "offset": 1})) + .await + .unwrap(); + assert!( + inside.is_error, + "an offset inside a character must be rejected, not moved, or paging skips bytes: {}", + inside.output() + ); + + let boundary = tool + .execute(json!({"path": "utf8.txt", "offset": 2})) + .await + .unwrap(); + assert_eq!(boundary.output(), " world"); +} + +#[tokio::test] +async fn file_read_rejects_an_offset_that_is_not_a_non_negative_integer() { + let dir = tempfile::tempdir().unwrap(); + tokio::fs::write(dir.path().join("offsets.txt"), "hello world") + .await + .unwrap(); + let tool = FileReadTool::new(test_security(dir.path().to_path_buf())); + + for bad in [json!(-5), json!("6"), json!(1.5)] { + let result = tool + .execute(json!({"path": "offsets.txt", "offset": bad.clone()})) + .await + .unwrap(); + assert!( + result.is_error, + "offset {bad} must be rejected, not treated as 0: got {:?}", + result.output() + ); + } + + let null_offset = tool + .execute(json!({"path": "offsets.txt", "offset": null})) + .await + .unwrap(); + assert_eq!( + null_offset.output(), + "hello world", + "a null offset reads from the start" + ); +} diff --git a/crates/tinytools-std/src/filesystem/file_sink.rs b/crates/tinytools-std/src/filesystem/file_sink.rs new file mode 100644 index 0000000..a50dcf2 --- /dev/null +++ b/crates/tinytools-std/src/filesystem/file_sink.rs @@ -0,0 +1,59 @@ +//! The filesystem write the tools go through. +//! +//! Every tool that reports "the write failed" has a branch that only runs when +//! the OS refuses, and that branch is the one worth asserting: it decides +//! whether a failed edit is reported honestly or fabricated as a success. +//! +//! Provoking a real refusal from a test is where this gets awkward. A +//! mode-bit denial is a discretionary check, and a root test runner carries +//! `CAP_DAC_OVERRIDE` and writes through it — so a fixture built on +//! permissions succeeds under CI, and the case either fails on a premise that +//! never held or is skipped and asserts nothing at all. Both read as coverage. +//! +//! So the write is a seam instead. Production installs the OS; a test installs +//! one that refuses, and the reporting branch becomes ordinary to assert on +//! any platform and as any user. + +use std::path::Path; +use std::sync::Arc; + +use async_trait::async_trait; + +/// Where a tool's file writes land. +#[async_trait] +pub(super) trait FileSink: std::fmt::Debug + Send + Sync { + async fn write(&self, path: &Path, contents: &[u8]) -> std::io::Result<()>; +} + +/// The real filesystem. +#[derive(Debug)] +pub(super) struct OsFileSink; + +#[async_trait] +impl FileSink for OsFileSink { + async fn write(&self, path: &Path, contents: &[u8]) -> std::io::Result<()> { + tokio::fs::write(path, contents).await + } +} + +/// The sink a tool uses unless a caller installs another one. +pub(super) fn os_sink() -> Arc { + Arc::new(OsFileSink) +} + +/// A sink that refuses every write with `kind`, for asserting the branch a +/// tool takes when the filesystem says no. +#[cfg(test)] +#[derive(Debug)] +pub(super) struct RefusingSink(pub std::io::ErrorKind); + +#[cfg(test)] +#[async_trait] +impl FileSink for RefusingSink { + async fn write(&self, path: &Path, _contents: &[u8]) -> std::io::Result<()> { + Err(std::io::Error::new( + self.0, + format!("refused by the test sink: {}", path.display()), + )) + } +} diff --git a/crates/tinytools-std/src/filesystem/file_write/mod.rs b/crates/tinytools-std/src/filesystem/file_write/mod.rs new file mode 100644 index 0000000..24459bb5f --- /dev/null +++ b/crates/tinytools-std/src/filesystem/file_write/mod.rs @@ -0,0 +1,233 @@ +use super::gate::{FsGate, gate_for_context}; +use crate::file_state; +use async_trait::async_trait; +use serde_json::json; +use std::sync::Arc; +use tinytools::ToolRunContext; +use tinytools::{PermissionLevel, Tool, ToolCallOptions, ToolResult}; + +const MAX_CONTENT_BYTES: usize = 5 * 1024 * 1024; + +/// Write file contents with path sandboxing +#[derive(Debug)] +pub struct FileWriteTool { + gate: Arc, + approval_workspace_root: Option, + sink: Arc, +} + +impl FileWriteTool { + /// Create the tool. + #[must_use] + pub fn new(gate: Arc) -> Self { + Self { + gate, + approval_workspace_root: None, + sink: super::file_sink::os_sink(), + } + } + + /// Sends this tool's writes somewhere other than the OS. + #[cfg(test)] + pub(super) fn with_sink(mut self, sink: Arc) -> Self { + self.sink = sink; + self + } + + /// Use the same effective workspace root for approval routing that tool + /// execution receives through its `ToolExecutionContext`. + pub fn with_approval_workspace_root( + gate: Arc, + approval_workspace_root: std::path::PathBuf, + ) -> Self { + Self { + gate, + approval_workspace_root: Some(approval_workspace_root), + sink: super::file_sink::os_sink(), + } + } +} + +#[async_trait] +impl Tool for FileWriteTool { + fn name(&self) -> &'static str { + "file_write" + } + + fn description(&self) -> &'static str { + "Write contents to a file in your working directory (the action sandbox). \ + Relative paths resolve against that directory; writes outside it are blocked. \ + Reference the file later by the same relative path so `file_read` resolves to it." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Relative path to the file within the workspace" + }, + "content": { + "type": "string", + "description": "Content to write to the file" + } + }, + "required": ["path", "content"] + }) + } + + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::Write + } + + /// "Ask before edit": modifying an **existing** file routes through the + /// human approval gate in ask-before-edit mode; creating a **new** file is + /// free. In Full neither prompts; in read-only `execute` blocks via + /// `can_act()`. The existence probe is best-effort (relative to the + /// workspace); when the path can't be resolved we fail safe and prompt. + /// + /// The probe runs at gate-routing time, microseconds before `execute()` in + /// the same sequential turn. A create→edit flip in that window would require + /// an external process to win the race — outside this gate's threat model, + /// which governs the agent, not concurrent writers — and `execute()` still + /// enforces workspace containment + symlink refusal regardless, so a write + /// that slips through as "create" cannot escape the sandbox. We therefore + /// do not re-probe in `execute()`. + fn external_effect_with_args(&self, args: &serde_json::Value) -> bool { + if !self.gate.write_needs_approval() { + return false; // Full (allow) or read-only (blocked in execute) + } + let Some(path) = args.get("path").and_then(|v| v.as_str()) else { + return true; // unknown path → prompt (fail safe) + }; + let target = if std::path::Path::new(path).is_absolute() { + std::path::PathBuf::from(path) + } else { + self.approval_workspace_root + .as_deref() + .unwrap_or_else(|| self.gate.action_dir()) + .join(path) + }; + // Sync `stat` — intentionally blocking, since the `Tool` trait makes + // this method sync. Fast for local paths; would only need + // `block_in_place` if a remote/slow filesystem is ever supported here. + target.exists() // exists = edit → prompt; new = create → free + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + self.execute_in_context(args, None).await + } + + async fn execute_with_context( + &self, + args: serde_json::Value, + _options: ToolCallOptions, + context: Option<&dyn ToolRunContext>, + ) -> anyhow::Result { + self.execute_in_context(args, context).await + } +} + +impl FileWriteTool { + async fn execute_in_context( + &self, + args: serde_json::Value, + context: Option<&dyn ToolRunContext>, + ) -> anyhow::Result { + let path = args + .get("path") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing 'path' parameter"))?; + + let content = args + .get("content") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing 'content' parameter"))?; + + if !self.gate.can_act() { + return Ok(ToolResult::error( + "[policy-blocked] Action blocked: autonomy is read-only", + )); + } + + if content.len() > MAX_CONTENT_BYTES { + return Ok(ToolResult::error(format!( + "Content too large: {} bytes (limit: {MAX_CONTENT_BYTES} bytes)", + content.len() + ))); + } + + if self.gate.is_rate_limited() { + return Ok(ToolResult::error( + "Rate limit exceeded: too many actions in the last hour", + )); + } + + // Security check first: validate path string, resolve symlinks, confirm workspace + // containment. validate_parent_path walks up to the deepest existing ancestor so + // it does not require the parent directory to exist yet. + let path_policy = gate_for_context(&self.gate, context, "file_write"); + let resolved_target = match path_policy.validate_parent_path(path).await { + Ok(p) => p, + Err(msg) => return Ok(ToolResult::error(msg)), + }; + + // Create parent directory only at the validated, resolved location. + if let Some(resolved_parent) = resolved_target.parent() { + tokio::fs::create_dir_all(resolved_parent).await?; + } + + // If the target already exists and is a symlink, refuse to follow it + if let Ok(meta) = tokio::fs::symlink_metadata(&resolved_target).await + && meta.file_type().is_symlink() + { + return Ok(ToolResult::error(format!( + "Refusing to write through symlink: {}", + resolved_target.display() + ))); + } + + if !self.gate.record_action() { + return Ok(ToolResult::error( + "Rate limit exceeded: action budget exhausted", + )); + } + + // File-state guard: reject writes based on stale or partial reads. + if let Some(agent_id) = file_state::current_file_state_agent_id() { + if let Some(msg) = file_state::check_stale_read(&agent_id, &resolved_target) { + tracing::debug!( + agent = %agent_id, + path = %resolved_target.display(), + "[file_state] file_write blocked: stale read" + ); + return Ok(ToolResult::error(msg)); + } + if let Some(msg) = file_state::check_partial_read(&agent_id, &resolved_target) { + tracing::debug!( + agent = %agent_id, + path = %resolved_target.display(), + "[file_state] file_write blocked: partial read" + ); + return Ok(ToolResult::error(msg)); + } + } + + match self.sink.write(&resolved_target, content.as_bytes()).await { + Ok(()) => { + if let Some(agent_id) = file_state::current_file_state_agent_id() { + file_state::record_write(&agent_id, resolved_target); + } + Ok(ToolResult::success(format!( + "Written {} bytes to {path}", + content.len() + ))) + } + Err(e) => Ok(ToolResult::error(format!("Failed to write file: {e}"))), + } + } +} + +#[cfg(test)] +mod test; diff --git a/crates/tinytools-std/src/filesystem/file_write/test.rs b/crates/tinytools-std/src/filesystem/file_write/test.rs new file mode 100644 index 0000000..73407b3 --- /dev/null +++ b/crates/tinytools-std/src/filesystem/file_write/test.rs @@ -0,0 +1,502 @@ +//! Behavior tests for the `file_write` tool, driven through a fake [`FsGate`]. + +#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)] + +use super::*; +use crate::filesystem::test_support::{AutonomyLevel, TestGate}; + +fn test_security(workspace: std::path::PathBuf) -> Arc { + TestGate::supervised(workspace) +} + +fn test_security_with( + workspace: std::path::PathBuf, + autonomy: AutonomyLevel, + max_actions_per_hour: usize, +) -> Arc { + TestGate::with(workspace, autonomy, max_actions_per_hour) +} + +#[test] +fn file_write_name() { + let tool = FileWriteTool::new(test_security(std::env::temp_dir())); + assert_eq!(tool.name(), "file_write"); +} + +#[test] +fn file_write_schema_has_path_and_content() { + let tool = FileWriteTool::new(test_security(std::env::temp_dir())); + let schema = tool.parameters_schema(); + assert!(schema["properties"]["path"].is_object()); + assert!(schema["properties"]["content"].is_object()); + let required = schema["required"].as_array().unwrap(); + assert!(required.contains(&json!("path"))); + assert!(required.contains(&json!("content"))); +} + +#[test] +fn approval_probe_uses_effective_workspace_root() { + let root = tempfile::tempdir().expect("root"); + let profile_workspace = root.path().join("profiles/alice"); + std::fs::create_dir_all(&profile_workspace).expect("profile workspace"); + std::fs::write(profile_workspace.join("notes.md"), "existing").expect("seed file"); + + let tool = FileWriteTool::with_approval_workspace_root( + test_security(root.path().to_path_buf()), + profile_workspace, + ); + + assert!(tool.external_effect_with_args(&json!({ + "path": "notes.md", + "content": "updated" + }))); +} + +#[tokio::test] +async fn file_write_creates_file() { + let dir = std::env::temp_dir().join("openhuman_test_file_write"); + let _ = tokio::fs::remove_dir_all(&dir).await; + tokio::fs::create_dir_all(&dir).await.unwrap(); + + let tool = FileWriteTool::new(test_security(dir.clone())); + let result = tool + .execute(json!({"path": "out.txt", "content": "written!"})) + .await + .unwrap(); + assert!(!result.is_error); + assert!(result.output().contains("8 bytes")); + + let content = tokio::fs::read_to_string(dir.join("out.txt")) + .await + .unwrap(); + assert_eq!(content, "written!"); + + let _ = tokio::fs::remove_dir_all(&dir).await; +} + +#[tokio::test] +async fn file_write_creates_parent_dirs() { + let dir = std::env::temp_dir().join("openhuman_test_file_write_nested"); + let _ = tokio::fs::remove_dir_all(&dir).await; + tokio::fs::create_dir_all(&dir).await.unwrap(); + + let tool = FileWriteTool::new(test_security(dir.clone())); + let result = tool + .execute(json!({"path": "a/b/c/deep.txt", "content": "deep"})) + .await + .unwrap(); + assert!(!result.is_error); + + let content = tokio::fs::read_to_string(dir.join("a/b/c/deep.txt")) + .await + .unwrap(); + assert_eq!(content, "deep"); + + let _ = tokio::fs::remove_dir_all(&dir).await; +} + +#[tokio::test] +async fn file_write_overwrites_existing() { + let dir = std::env::temp_dir().join("openhuman_test_file_write_overwrite"); + let _ = tokio::fs::remove_dir_all(&dir).await; + tokio::fs::create_dir_all(&dir).await.unwrap(); + tokio::fs::write(dir.join("exist.txt"), "old") + .await + .unwrap(); + + let tool = FileWriteTool::new(test_security(dir.clone())); + let result = tool + .execute(json!({"path": "exist.txt", "content": "new"})) + .await + .unwrap(); + assert!(!result.is_error); + + let content = tokio::fs::read_to_string(dir.join("exist.txt")) + .await + .unwrap(); + assert_eq!(content, "new"); + + let _ = tokio::fs::remove_dir_all(&dir).await; +} + +#[tokio::test] +async fn file_write_blocks_path_traversal() { + let dir = std::env::temp_dir().join("openhuman_test_file_write_traversal"); + let _ = tokio::fs::remove_dir_all(&dir).await; + tokio::fs::create_dir_all(&dir).await.unwrap(); + + let tool = FileWriteTool::new(test_security(dir.clone())); + let result = tool + .execute(json!({"path": "../../etc/evil", "content": "bad"})) + .await + .unwrap(); + assert!(result.is_error); + assert!(&result.output().contains("not allowed")); + + let _ = tokio::fs::remove_dir_all(&dir).await; +} + +#[tokio::test] +async fn file_write_blocks_absolute_path() { + let tool = FileWriteTool::new(test_security(std::env::temp_dir())); + let result = tool + .execute(json!({"path": "/etc/evil", "content": "bad"})) + .await + .unwrap(); + assert!(result.is_error); + assert!(&result.output().contains("not allowed")); +} + +#[tokio::test] +async fn file_write_missing_path_param() { + let tool = FileWriteTool::new(test_security(std::env::temp_dir())); + let result = tool.execute(json!({"content": "data"})).await; + assert!(result.is_err()); +} + +#[tokio::test] +async fn file_write_missing_content_param() { + let tool = FileWriteTool::new(test_security(std::env::temp_dir())); + let result = tool.execute(json!({"path": "file.txt"})).await; + assert!(result.is_err()); +} + +#[tokio::test] +async fn file_write_empty_content() { + let dir = std::env::temp_dir().join("openhuman_test_file_write_empty"); + let _ = tokio::fs::remove_dir_all(&dir).await; + tokio::fs::create_dir_all(&dir).await.unwrap(); + + let tool = FileWriteTool::new(test_security(dir.clone())); + let result = tool + .execute(json!({"path": "empty.txt", "content": ""})) + .await + .unwrap(); + assert!(!result.is_error); + assert!(result.output().contains("0 bytes")); + + let _ = tokio::fs::remove_dir_all(&dir).await; +} + +#[cfg(unix)] +#[tokio::test] +async fn file_write_blocks_symlink_escape() { + use std::os::unix::fs::symlink; + + let root = std::env::temp_dir().join("openhuman_test_file_write_symlink_escape"); + let workspace = root.join("workspace"); + let outside = root.join("outside"); + + let _ = tokio::fs::remove_dir_all(&root).await; + tokio::fs::create_dir_all(&workspace).await.unwrap(); + tokio::fs::create_dir_all(&outside).await.unwrap(); + + symlink(&outside, workspace.join("escape_dir")).unwrap(); + + let tool = FileWriteTool::new(test_security(workspace.clone())); + let result = tool + .execute(json!({"path": "escape_dir/hijack.txt", "content": "bad"})) + .await + .unwrap(); + + assert!(result.is_error); + // SecurityPolicy now blocks symlink escapes at the is_path_allowed + // layer (#1927) — error becomes "Path not allowed by security + // policy" rather than the deeper "escapes workspace" message. + let out = result.output(); + assert!( + out.contains("escapes workspace") || out.contains("not allowed"), + "expected escape/not-allowed error, got: {out}" + ); + assert!(!outside.join("hijack.txt").exists()); + + let _ = tokio::fs::remove_dir_all(&root).await; +} + +#[tokio::test] +async fn file_write_blocks_readonly_mode() { + let dir = std::env::temp_dir().join("openhuman_test_file_write_readonly"); + let _ = tokio::fs::remove_dir_all(&dir).await; + tokio::fs::create_dir_all(&dir).await.unwrap(); + + let tool = FileWriteTool::new(test_security_with(dir.clone(), AutonomyLevel::ReadOnly, 20)); + let result = tool + .execute(json!({"path": "out.txt", "content": "should-block"})) + .await + .unwrap(); + + assert!(result.is_error); + assert!(result.output().contains("read-only")); + // The readonly block must carry the hard-reject marker so the agent + // harness recognizes it and halts on a verbatim repeat instead of + // grinding. Ties this tool's literal to the marker const — the + // const→detector half lives in `RepeatedToolFailureMiddleware` and is + // covered by its tests (`crates/openhuman-core/src/agent/tinyagents/middleware.rs`). + assert!( + result + .output() + .contains(crate::filesystem::test_support::POLICY_BLOCKED_MARKER), + "file_write readonly block must carry the hard-reject marker: {}", + result.output() + ); + assert!(!dir.join("out.txt").exists()); + + let _ = tokio::fs::remove_dir_all(&dir).await; +} + +#[tokio::test] +async fn file_write_blocks_when_rate_limited() { + let dir = std::env::temp_dir().join("openhuman_test_file_write_rate_limited"); + let _ = tokio::fs::remove_dir_all(&dir).await; + tokio::fs::create_dir_all(&dir).await.unwrap(); + + let tool = FileWriteTool::new(test_security_with( + dir.clone(), + AutonomyLevel::Supervised, + 0, + )); + let result = tool + .execute(json!({"path": "out.txt", "content": "should-block"})) + .await + .unwrap(); + + assert!(result.is_error); + assert!(result.output().contains("Rate limit exceeded")); + assert!(!dir.join("out.txt").exists()); + + let _ = tokio::fs::remove_dir_all(&dir).await; +} + +// ── §5.1 TOCTOU / symlink file write protection tests ──── + +#[cfg(unix)] +#[tokio::test] +async fn file_write_blocks_symlink_target_file() { + use std::os::unix::fs::symlink; + + let root = std::env::temp_dir().join("openhuman_test_file_write_symlink_target"); + let workspace = root.join("workspace"); + let outside = root.join("outside"); + + let _ = tokio::fs::remove_dir_all(&root).await; + tokio::fs::create_dir_all(&workspace).await.unwrap(); + tokio::fs::create_dir_all(&outside).await.unwrap(); + + // Create a file outside and symlink to it inside workspace + tokio::fs::write(outside.join("target.txt"), "original") + .await + .unwrap(); + symlink(outside.join("target.txt"), workspace.join("linked.txt")).unwrap(); + + let tool = FileWriteTool::new(test_security(workspace.clone())); + let result = tool + .execute(json!({"path": "linked.txt", "content": "overwritten"})) + .await + .unwrap(); + + assert!(result.is_error, "writing through symlink must be blocked"); + // The symlink-safe is_path_allowed check (#1927) blocks at the + // policy layer before the tool's own symlink-target detection + // runs; accept either error message. + let out = result.output(); + assert!( + out.contains("symlink") || out.contains("not allowed"), + "error should mention symlink or policy block, got: {out}" + ); + + // Verify original file was not modified + let content = tokio::fs::read_to_string(outside.join("target.txt")) + .await + .unwrap(); + assert_eq!(content, "original", "original file must not be modified"); + + let _ = tokio::fs::remove_dir_all(&root).await; +} + +#[tokio::test] +async fn file_write_blocks_null_byte_in_path() { + let dir = std::env::temp_dir().join("openhuman_test_file_write_null"); + let _ = tokio::fs::remove_dir_all(&dir).await; + tokio::fs::create_dir_all(&dir).await.unwrap(); + + let tool = FileWriteTool::new(test_security(dir.clone())); + let result = tool + .execute(json!({"path": "file\u{0000}.txt", "content": "bad"})) + .await + .unwrap(); + assert!(result.is_error, "paths with null bytes must be blocked"); + + let _ = tokio::fs::remove_dir_all(&dir).await; +} + +/// `file_write` enforces the same 5MB content-size cap as `edit_file` and +/// `grep`'s file caps (`file_read` allows up to 10MB for reads). +#[tokio::test] +async fn file_write_reports_a_refused_write_rather_than_a_silent_success() { + let dir = std::env::temp_dir().join("openhuman_test_file_write_refused"); + let _ = tokio::fs::remove_dir_all(&dir).await; + tokio::fs::create_dir_all(&dir).await.unwrap(); + + let tool = FileWriteTool::new(test_security(dir.clone())).with_sink(Arc::new( + crate::filesystem::file_sink::RefusingSink(std::io::ErrorKind::PermissionDenied), + )); + let result = tool + .execute(json!({"path": "f.txt", "content": "abc"})) + .await + .unwrap(); + + assert!( + result.is_error, + "a refused write must surface as an error, not a fabricated success" + ); + assert!( + !dir.join("f.txt").exists(), + "and nothing may be left behind claiming the write happened" + ); + + let _ = tokio::fs::remove_dir_all(&dir).await; +} + +#[tokio::test] +async fn file_write_refuses_an_oversized_content_write() { + let dir = std::env::temp_dir().join("openhuman_test_file_write_oversized"); + let _ = tokio::fs::remove_dir_all(&dir).await; + tokio::fs::create_dir_all(&dir).await.unwrap(); + + let tool = FileWriteTool::new(test_security(dir.clone())); + let content = "x".repeat(64 * 1024 * 1024); + let result = tool + .execute(json!({"path": "huge.txt", "content": content})) + .await + .unwrap(); + assert!( + result.is_error, + "a 64MB write must be refused by a stated ceiling, not written: {}", + result.output() + ); + + let _ = tokio::fs::remove_dir_all(&dir).await; +} + +// ── Coverage of the guard, symlink and budget branches ────────────────── + +use crate::filesystem::test_support::RacyGate; + +#[tokio::test] +async fn file_write_refuses_a_racing_budget() { + let dir = tempfile::tempdir().unwrap(); + let tool = FileWriteTool::new(Arc::new(RacyGate(test_security(dir.path().to_path_buf())))); + let result = tool + .execute(json!({"path": "a.txt", "content": "x"})) + .await + .unwrap(); + assert!(result.is_error); + assert!(result.output().contains("action budget exhausted")); +} + +#[cfg(unix)] +#[tokio::test] +async fn file_write_refuses_to_write_through_an_in_workspace_symlink() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("real.txt"), "real").unwrap(); + std::os::unix::fs::symlink(dir.path().join("real.txt"), dir.path().join("link.txt")).unwrap(); + let tool = FileWriteTool::new(test_security(dir.path().to_path_buf())); + let result = tool + .execute(json!({"path": "link.txt", "content": "x"})) + .await + .unwrap(); + assert!(result.is_error); + assert!( + result + .output() + .contains("Refusing to write through symlink") + ); + assert_eq!( + std::fs::read_to_string(dir.path().join("real.txt")).unwrap(), + "real" + ); +} + +#[tokio::test] +async fn file_write_honours_the_file_state_guard_and_records_its_write() { + crate::file_state::init_global(true); + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().canonicalize().unwrap().join("g.txt"); + std::fs::write(&target, "old").unwrap(); + let tool = FileWriteTool::new(test_security(dir.path().to_path_buf())); + let agent = format!("fw-agent-{}", dir.path().display()); + let other = format!("fw-other-{}", dir.path().display()); + + // Partial read blocks the overwrite. + crate::file_state::record_read( + &agent, + target.clone(), + std::time::SystemTime::now(), + true, + std::time::Instant::now(), + ); + let result = crate::file_state::with_file_state_agent_id( + agent.clone(), + tool.execute(json!({"path": "g.txt", "content": "new"})), + ) + .await + .unwrap(); + assert!(result.is_error); + assert!(result.output().contains("Partial read")); + + // A sibling's later write makes the read stale. + crate::file_state::record_read( + &agent, + target.clone(), + std::time::SystemTime::now(), + false, + std::time::Instant::now(), + ); + crate::file_state::record_write(&other, target.clone()); + let result = crate::file_state::with_file_state_agent_id( + agent.clone(), + tool.execute(json!({"path": "g.txt", "content": "new"})), + ) + .await + .unwrap(); + assert!(result.is_error); + assert!(result.output().contains("Stale read")); + + // With a fresh full read the write goes through and is recorded. + crate::file_state::record_read( + &agent, + target.clone(), + std::time::SystemTime::now(), + false, + std::time::Instant::now(), + ); + let result = crate::file_state::with_file_state_agent_id( + agent.clone(), + tool.execute(json!({"path": "g.txt", "content": "new"})), + ) + .await + .unwrap(); + assert!(!result.is_error, "{}", result.output()); + assert_eq!(std::fs::read_to_string(&target).unwrap(), "new"); + assert!(crate::file_state::check_stale_read(&agent, &target).is_none()); +} + +#[tokio::test] +async fn file_write_uses_the_context_workspace_when_one_is_threaded() { + use crate::filesystem::test_support::WorkspaceContext; + let home = tempfile::tempdir().unwrap(); + let isolated = tempfile::tempdir().unwrap(); + let tool = FileWriteTool::new(test_security(home.path().to_path_buf())); + let context = WorkspaceContext::at(isolated.path()); + let result = tool + .execute_with_context( + json!({"path": "in_ws.txt", "content": "hi"}), + ToolCallOptions::default(), + Some(&context), + ) + .await + .unwrap(); + assert!(!result.is_error, "{}", result.output()); + assert!(isolated.path().join("in_ws.txt").exists()); + assert!(!home.path().join("in_ws.txt").exists()); +} diff --git a/crates/tinytools-std/src/filesystem/fixtures/apply_patch.json b/crates/tinytools-std/src/filesystem/fixtures/apply_patch.json new file mode 100644 index 0000000..89b3b67 --- /dev/null +++ b/crates/tinytools-std/src/filesystem/fixtures/apply_patch.json @@ -0,0 +1,42 @@ +{ + "description": "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?}`. To CREATE a new file, pass an empty `old_string` with the full contents as `new_string`; the path must not already exist.", + "exposure": "Direct", + "name": "apply_patch", + "permission_level": "Write", + "schema": { + "properties": { + "edits": { + "description": "Ordered list of edits.", + "items": { + "properties": { + "new_string": { + "type": "string" + }, + "old_string": { + "description": "Exact text to replace. Empty means CREATE: the path must not exist and `new_string` becomes the whole file.", + "type": "string" + }, + "path": { + "type": "string" + }, + "replace_all": { + "default": false, + "type": "boolean" + } + }, + "required": [ + "path", + "old_string", + "new_string" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "edits" + ], + "type": "object" + } +} diff --git a/crates/tinytools-std/src/filesystem/fixtures/csv_export.json b/crates/tinytools-std/src/filesystem/fixtures/csv_export.json new file mode 100644 index 0000000..dd4e4d5 --- /dev/null +++ b/crates/tinytools-std/src/filesystem/fixtures/csv_export.json @@ -0,0 +1,30 @@ +{ + "description": "Export structured data (JSON array of objects) as a CSV file to the workspace. Returns the file path. Use when the user wants raw tabular data from a tool result that's too large to include inline.", + "exposure": "Direct", + "name": "csv_export", + "permission_level": "Write", + "schema": { + "properties": { + "columns": { + "description": "Optional ordered list of column names to include. If omitted, all keys from the first object are used as headers.", + "items": { + "type": "string" + }, + "type": "array" + }, + "data": { + "description": "JSON string containing an array of objects to export. Each object becomes a row; keys become column headers.", + "type": "string" + }, + "filename": { + "description": "Output filename (without path). Will be written to workspace/exports/. Example: 'github-issues-2026-04-16.csv'", + "type": "string" + } + }, + "required": [ + "data", + "filename" + ], + "type": "object" + } +} diff --git a/crates/tinytools-std/src/filesystem/fixtures/edit_file.json b/crates/tinytools-std/src/filesystem/fixtures/edit_file.json new file mode 100644 index 0000000..b6d9581 --- /dev/null +++ b/crates/tinytools-std/src/filesystem/fixtures/edit_file.json @@ -0,0 +1,33 @@ +{ + "description": "Edit a file by exact string replacement. By default `old_string` must match exactly once. Set `replace_all` to true to replace every match.", + "exposure": "Deferred", + "name": "edit", + "permission_level": "Write", + "schema": { + "properties": { + "new_string": { + "description": "Replacement text.", + "type": "string" + }, + "old_string": { + "description": "Text to find. Must match exactly.", + "type": "string" + }, + "path": { + "description": "Workspace-relative file path.", + "type": "string" + }, + "replace_all": { + "default": false, + "description": "If true, replace every occurrence (default false).", + "type": "boolean" + } + }, + "required": [ + "path", + "old_string", + "new_string" + ], + "type": "object" + } +} diff --git a/crates/tinytools-std/src/filesystem/fixtures/file_read.json b/crates/tinytools-std/src/filesystem/fixtures/file_read.json new file mode 100644 index 0000000..fa9744f --- /dev/null +++ b/crates/tinytools-std/src/filesystem/fixtures/file_read.json @@ -0,0 +1,23 @@ +{ + "description": "Read the contents of a file in your working directory (the action sandbox). Relative paths resolve against that directory; paths outside it are blocked. To read a file written by `shell`, confirm its location with `pwd` and use the same relative path. Long files are returned one page at a time; continue with the exact `offset` reported at the end of the page.", + "exposure": "Direct", + "name": "file_read", + "permission_level": "ReadOnly", + "schema": { + "properties": { + "offset": { + "description": "Byte offset to start reading from. Use the offset a paged read reports to continue it.", + "minimum": 0, + "type": "integer" + }, + "path": { + "description": "Relative path to the file within the workspace", + "type": "string" + } + }, + "required": [ + "path" + ], + "type": "object" + } +} diff --git a/crates/tinytools-std/src/filesystem/fixtures/file_write.json b/crates/tinytools-std/src/filesystem/fixtures/file_write.json new file mode 100644 index 0000000..8621d0b --- /dev/null +++ b/crates/tinytools-std/src/filesystem/fixtures/file_write.json @@ -0,0 +1,23 @@ +{ + "description": "Write contents to a file in your working directory (the action sandbox). Relative paths resolve against that directory; writes outside it are blocked. Reference the file later by the same relative path so `file_read` resolves to it.", + "exposure": "Direct", + "name": "file_write", + "permission_level": "Write", + "schema": { + "properties": { + "content": { + "description": "Content to write to the file", + "type": "string" + }, + "path": { + "description": "Relative path to the file within the workspace", + "type": "string" + } + }, + "required": [ + "path", + "content" + ], + "type": "object" + } +} diff --git a/crates/tinytools-std/src/filesystem/fixtures/git_operations.json b/crates/tinytools-std/src/filesystem/fixtures/git_operations.json new file mode 100644 index 0000000..302536c --- /dev/null +++ b/crates/tinytools-std/src/filesystem/fixtures/git_operations.json @@ -0,0 +1,66 @@ +{ + "description": "Perform structured Git operations (status, diff, log, branch, commit, add, checkout, stash). Provides parsed JSON output and integrates with security policy for autonomy controls.", + "exposure": "Direct", + "name": "git_operations", + "permission_level": "ReadOnly", + "schema": { + "properties": { + "action": { + "description": "Stash action (for 'stash' operation)", + "enum": [ + "push", + "pop", + "list", + "drop" + ], + "type": "string" + }, + "branch": { + "description": "Branch name (for 'checkout' operation)", + "type": "string" + }, + "cached": { + "description": "Show staged changes (for 'diff' operation)", + "type": "boolean" + }, + "files": { + "description": "File or path to diff (for 'diff' operation, default: '.')", + "type": "string" + }, + "index": { + "description": "Stash index (for 'stash' with 'drop' action)", + "type": "integer" + }, + "limit": { + "description": "Number of log entries (for 'log' operation, default: 10)", + "type": "integer" + }, + "message": { + "description": "Commit message (for 'commit' operation)", + "type": "string" + }, + "operation": { + "description": "Git operation to perform", + "enum": [ + "status", + "diff", + "log", + "branch", + "commit", + "add", + "checkout", + "stash" + ], + "type": "string" + }, + "paths": { + "description": "File paths to stage (for 'add' operation)", + "type": "string" + } + }, + "required": [ + "operation" + ], + "type": "object" + } +} diff --git a/crates/tinytools-std/src/filesystem/fixtures/glob_search.json b/crates/tinytools-std/src/filesystem/fixtures/glob_search.json new file mode 100644 index 0000000..926937d --- /dev/null +++ b/crates/tinytools-std/src/filesystem/fixtures/glob_search.json @@ -0,0 +1,27 @@ +{ + "description": "Find files matching a glob pattern (e.g. `src/**/*.rs`), searching the action sandbox unless `path` says otherwise. Returns paths newest-first, ready to pass straight to `file_read` / `grep`.", + "exposure": "Direct", + "name": "glob", + "permission_level": "ReadOnly", + "schema": { + "properties": { + "max_results": { + "description": "Cap on returned paths (default 500).", + "minimum": 1, + "type": "integer" + }, + "path": { + "description": "Directory to search within. Defaults to the working directory (action sandbox). Must be a location you're allowed to read; relative paths resolve against the working directory.", + "type": "string" + }, + "pattern": { + "description": "Glob pattern, e.g. `**/*.rs` or `src/**/*.{ts,tsx}` (single brace expansion not supported — list patterns separately). Matched against paths relative to the search directory.", + "type": "string" + } + }, + "required": [ + "pattern" + ], + "type": "object" + } +} diff --git a/crates/tinytools-std/src/filesystem/fixtures/grep.json b/crates/tinytools-std/src/filesystem/fixtures/grep.json new file mode 100644 index 0000000..039c98f --- /dev/null +++ b/crates/tinytools-std/src/filesystem/fixtures/grep.json @@ -0,0 +1,33 @@ +{ + "description": "Search file contents in the workspace with a regular expression. Returns up to `max_matches` matches (default 200) as `path:line:text` lines.", + "exposure": "Direct", + "name": "grep", + "permission_level": "ReadOnly", + "schema": { + "properties": { + "case_insensitive": { + "default": false, + "description": "If true, compile the regex with the `i` flag.", + "type": "boolean" + }, + "max_matches": { + "description": "Cap on matches returned (default 200).", + "minimum": 1, + "type": "integer" + }, + "path": { + "default": ".", + "description": "Optional sub-path to restrict the search to (relative to workspace).", + "type": "string" + }, + "pattern": { + "description": "Regular expression (Rust `regex` crate syntax).", + "type": "string" + } + }, + "required": [ + "pattern" + ], + "type": "object" + } +} diff --git a/crates/tinytools-std/src/filesystem/fixtures/list_files.json b/crates/tinytools-std/src/filesystem/fixtures/list_files.json new file mode 100644 index 0000000..98415d4 --- /dev/null +++ b/crates/tinytools-std/src/filesystem/fixtures/list_files.json @@ -0,0 +1,16 @@ +{ + "description": "List entries in a workspace directory (non-recursive). Each line is `\t` where kind is `dir`, `file`, or `link`.", + "exposure": "Direct", + "name": "list", + "permission_level": "ReadOnly", + "schema": { + "properties": { + "path": { + "default": ".", + "description": "Directory path relative to the workspace (default `.`).", + "type": "string" + } + }, + "type": "object" + } +} diff --git a/crates/tinytools-std/src/filesystem/fixtures/read_diff.json b/crates/tinytools-std/src/filesystem/fixtures/read_diff.json new file mode 100644 index 0000000..f32196a --- /dev/null +++ b/crates/tinytools-std/src/filesystem/fixtures/read_diff.json @@ -0,0 +1,23 @@ +{ + "description": "Get the git diff of current changes. Can diff staged, unstaged, or against a specific base branch/commit. Returns file paths and hunks.", + "exposure": "Deferred", + "name": "read_diff", + "permission_level": "ReadOnly", + "schema": { + "properties": { + "base": { + "description": "Base ref to diff against (e.g. 'main', 'HEAD~3'). Default: unstaged changes.", + "type": "string" + }, + "path_filter": { + "description": "Limit diff to a specific path or glob.", + "type": "string" + }, + "staged": { + "description": "Show staged changes only (--cached). Default: false.", + "type": "boolean" + } + }, + "type": "object" + } +} diff --git a/crates/tinytools-std/src/filesystem/fixtures/run_linter.json b/crates/tinytools-std/src/filesystem/fixtures/run_linter.json new file mode 100644 index 0000000..4c16bbd --- /dev/null +++ b/crates/tinytools-std/src/filesystem/fixtures/run_linter.json @@ -0,0 +1,25 @@ +{ + "description": "Run linting tools on the codebase. Supports 'clippy' for Rust and 'eslint' for TypeScript/JavaScript. Returns warnings and errors.", + "exposure": "Deferred", + "name": "run_linter", + "permission_level": "Execute", + "schema": { + "properties": { + "linter": { + "default": "auto", + "description": "Which linter to run. 'auto' detects from project files.", + "enum": [ + "clippy", + "eslint", + "auto" + ], + "type": "string" + }, + "path": { + "description": "Limit linting to a specific path.", + "type": "string" + } + }, + "type": "object" + } +} diff --git a/crates/tinytools-std/src/filesystem/fixtures/run_tests.json b/crates/tinytools-std/src/filesystem/fixtures/run_tests.json new file mode 100644 index 0000000..f0f5dc9 --- /dev/null +++ b/crates/tinytools-std/src/filesystem/fixtures/run_tests.json @@ -0,0 +1,30 @@ +{ + "description": "Run the project test suite. Supports 'cargo_test' for Rust and 'vitest' for TypeScript/JavaScript. Returns pass/fail results with output.", + "exposure": "Deferred", + "name": "run_tests", + "permission_level": "Execute", + "schema": { + "properties": { + "filter": { + "description": "Filter to run specific tests (e.g. test name or module).", + "type": "string" + }, + "runner": { + "default": "auto", + "description": "Which test runner to use. 'auto' detects from project files.", + "enum": [ + "cargo_test", + "vitest", + "auto" + ], + "type": "string" + }, + "timeout_secs": { + "default": 120, + "description": "Timeout in seconds (default: 120).", + "type": "integer" + } + }, + "type": "object" + } +} diff --git a/crates/tinytools-std/src/filesystem/fixtures/update_memory_md.json b/crates/tinytools-std/src/filesystem/fixtures/update_memory_md.json new file mode 100644 index 0000000..1623718 --- /dev/null +++ b/crates/tinytools-std/src/filesystem/fixtures/update_memory_md.json @@ -0,0 +1,40 @@ +{ + "description": "Append or update sections in MEMORY.md or SKILL.md workspace files. Use 'append' to add new notes at the end, or 'replace_section' to overwrite the body under a named '## Section' heading.", + "exposure": "Direct", + "name": "update_memory_md", + "permission_level": "Write", + "schema": { + "properties": { + "action": { + "description": "'append' adds content at the end; 'replace_section' replaces the body of the named section.", + "enum": [ + "append", + "replace_section" + ], + "type": "string" + }, + "content": { + "description": "The markdown text to write.", + "type": "string" + }, + "file": { + "description": "Which workspace markdown file to modify.", + "enum": [ + "MEMORY.md", + "SKILL.md" + ], + "type": "string" + }, + "section_title": { + "description": "Required for 'replace_section': the heading text (without '## ').", + "type": "string" + } + }, + "required": [ + "file", + "action", + "content" + ], + "type": "object" + } +} diff --git a/crates/tinytools-std/src/filesystem/gate.rs b/crates/tinytools-std/src/filesystem/gate.rs new file mode 100644 index 0000000..909e78f --- /dev/null +++ b/crates/tinytools-std/src/filesystem/gate.rs @@ -0,0 +1,112 @@ +//! The host's say over every filesystem tool call. +//! +//! The tools in this module read, write, search and patch files, but they do +//! not decide what they are *allowed* to touch. That decision (an autonomy +//! level, a workspace boundary, an approval prompt, an hourly action budget, +//! credential stores that are never reachable) belongs to the host whose threat +//! model and configuration it depends on. A tool asks an [`FsGate`] instead. +//! +//! The trait is exactly the set of questions the tools put to a policy, and +//! nothing wider: it carries no decision *types* of its own, only booleans and +//! resolved paths, so a host maps its own policy onto it without translating a +//! vocabulary. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use async_trait::async_trait; +use tinytools::ToolRunContext; + +/// The host policy a filesystem tool consults before it acts. +/// +/// Implementations must be cheap to call: the tools ask on every invocation. +/// All methods take `&self`; an implementation that counts actions keeps that +/// state behind interior mutability. +#[async_trait] +pub trait FsGate: std::fmt::Debug + Send + Sync { + /// Whether the host permits the agent to change anything at all right now. + /// + /// `false` blocks every mutating tool before it touches the disk. + fn can_act(&self) -> bool; + + /// Whether the host is in a strictly read-only mode. + /// + /// Asked by `git_operations`, on top of [`can_act`](Self::can_act), before + /// a write operation runs. + fn is_read_only(&self) -> bool; + + /// Whether the action budget is already spent, without consuming any of it. + fn is_rate_limited(&self) -> bool; + + /// Consume one unit of the action budget. + /// + /// Returns `false` when the call is over budget and must be refused. + fn record_action(&self) -> bool; + + /// Whether a write must be confirmed by a human before it runs. + /// + /// Drives the tools' `external_effect_with_args` flag, which routes the + /// call through the host's approval flow. + fn write_needs_approval(&self) -> bool; + + /// The directory relative paths resolve against. + fn action_dir(&self) -> &Path; + + /// Cheap, string-level check of whether `path` may be used at all. + /// + /// Used to filter search hits without touching the disk. + fn is_path_string_allowed(&self, path: &str) -> bool; + + /// Resolve an existing path and confirm the host allows it. + /// + /// # Errors + /// + /// Returns the message shown to the model when the path is refused or + /// cannot be resolved. + async fn validate_path(&self, path: &str) -> Result; + + /// Resolve a path whose *parent* must exist but whose target may not, and + /// confirm the host allows it. Used for writes that create files. + /// + /// # Errors + /// + /// Returns the message shown to the model when the path is refused or + /// cannot be resolved. + async fn validate_parent_path(&self, path: &str) -> Result; + + /// A gate for the same policy, rooted at `root`. + /// + /// The run's isolated workspace becomes both the directory relative paths + /// resolve against and a readable-and-writable root. The grant is + /// per-call: it must not mutate `self`, so concurrent turns cannot race + /// each other, and it must not widen whatever the host treats as always + /// forbidden. `root` originates from trusted in-process code (the session + /// builder or a sub-agent runner), never from model-supplied text. + fn scoped_to_workspace(&self, root: &Path) -> Arc; +} + +/// Resolve the gate a tool should validate paths against for this call. +/// +/// When the harness threaded a workspace descriptor into the call (an +/// edit-capable worker running in an isolated checkout), the returned gate is +/// scoped to that workspace root; otherwise it is `gate` itself. Counting and +/// approval questions ([`FsGate::can_act`], [`FsGate::record_action`], ...) +/// are still asked of the unscoped gate. +pub(crate) fn gate_for_context( + gate: &Arc, + context: Option<&dyn ToolRunContext>, + tool: &str, +) -> Arc { + match context.and_then(ToolRunContext::workspace) { + Some(workspace) => { + tracing::debug!( + tool, + workspace_root = %workspace.root.display(), + policy_id = %workspace.policy_id, + "[tools:filesystem] granting TinyAgents workspace descriptor as action dir + trusted root" + ); + gate.scoped_to_workspace(&workspace.root) + } + None => Arc::clone(gate), + } +} diff --git a/crates/tinytools-std/src/filesystem/gate_test.rs b/crates/tinytools-std/src/filesystem/gate_test.rs new file mode 100644 index 0000000..ff3e225 --- /dev/null +++ b/crates/tinytools-std/src/filesystem/gate_test.rs @@ -0,0 +1,75 @@ +//! Tests for how a call's workspace descriptor scopes the gate. + +#![allow(clippy::unwrap_used)] + +use std::sync::Arc; + +use super::gate::{FsGate, gate_for_context}; +use super::test_support::{TestGate, WorkspaceContext}; + +#[tokio::test] +async fn without_a_descriptor_the_same_gate_is_used() { + let dir = tempfile::tempdir().unwrap(); + let gate: Arc = TestGate::supervised(dir.path().to_path_buf()); + let resolved = gate_for_context(&gate, None, "file_read"); + assert!(Arc::ptr_eq(&gate, &resolved)); +} + +#[tokio::test] +async fn a_descriptor_roots_the_gate_at_the_isolated_workspace() { + let home = tempfile::tempdir().unwrap(); + let isolated = tempfile::tempdir().unwrap(); + std::fs::write(isolated.path().join("seen.txt"), "x").unwrap(); + let gate: Arc = TestGate::supervised(home.path().to_path_buf()); + + let context = WorkspaceContext::at(isolated.path()); + let scoped = gate_for_context(&gate, Some(&context), "file_read"); + + assert_eq!(scoped.action_dir(), isolated.path()); + let resolved = scoped.validate_path("seen.txt").await.unwrap(); + assert_eq!( + resolved, + isolated.path().join("seen.txt").canonicalize().unwrap() + ); + // The original gate keeps its own root and refuses the isolated file. + assert!(gate.validate_path("seen.txt").await.is_err()); +} + +#[tokio::test] +async fn the_fake_gates_delegate_every_question_to_the_gate_they_wrap() { + use super::test_support::{AutonomyLevel, RacyGate, WrapGate}; + + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("f.txt"), "x").unwrap(); + let inner = TestGate::with(dir.path().to_path_buf(), AutonomyLevel::Supervised, 5); + + let racy = RacyGate(inner.clone()); + let wrap = WrapGate::new(inner.clone()).arc(); + let gates: [&dyn FsGate; 2] = [&racy, wrap.as_ref()]; + for gate in gates { + assert!(gate.can_act()); + assert!(!gate.is_read_only()); + assert!(gate.write_needs_approval()); + assert_eq!(gate.action_dir(), dir.path()); + assert!(gate.is_path_string_allowed("f.txt")); + assert!(gate.validate_path("f.txt").await.is_ok()); + assert!(gate.validate_parent_path("new.txt").await.is_ok()); + let scoped = gate.scoped_to_workspace(dir.path()); + assert_eq!(scoped.action_dir(), dir.path()); + } +} + +#[tokio::test] +async fn the_fake_gate_refuses_what_a_real_policy_would() { + let dir = tempfile::tempdir().unwrap(); + let gate = TestGate::supervised(dir.path().to_path_buf()); + + // No parent / no file name to resolve. + assert!(gate.validate_parent_path("/").await.is_err()); + // Nothing on disk to resolve, even at the filesystem root. + let far = TestGate::supervised(std::path::PathBuf::from("/definitely/not/a/real/root")); + assert!(!far.is_path_string_allowed("x.txt")); + assert!(far.validate_parent_path("x.txt").await.is_err()); + // A NUL byte is never a path. + assert!(!gate.is_path_string_allowed("a\0b")); +} diff --git a/crates/tinytools-std/src/filesystem/git_operations/config.rs b/crates/tinytools-std/src/filesystem/git_operations/config.rs new file mode 100644 index 0000000..1d27edd --- /dev/null +++ b/crates/tinytools-std/src/filesystem/git_operations/config.rs @@ -0,0 +1,376 @@ +//! Ambient-git-config hardening for [`super::git_operations`]. +//! +//! Split out of `git_operations.rs` for the Rust layout gate. Cohesive on its +//! own: the allow-list, the neutralised keys, and the two helpers that build a +//! hardened `git` invocation are one policy, and reviewing that policy is +//! easier when it is not interleaved with the command implementations. + +use std::path::Path; + +/// Repository config keys this tool will run `git` under. +/// +/// This is an allowlist, and the direction is the whole point. Several git +/// config keys name a command that git then executes — `core.fsmonitor` is +/// run by `git status` (used by the `status` operation here), `core.sshCommand` +/// by anything that reaches a remote, `diff.external` by `diff`, `core.pager` +/// and `core.editor` by the commands that use them, and the `filter.*.process` +/// / `*.clean` / `*.smudge` and `diff.*.textconv` families by content +/// operations. Enumerating *those* and clearing them is the obvious fix and it +/// is the wrong shape: the list is only correct until git adds a key, and a +/// denylist that has gone stale reads as protection while providing none. +/// +/// An allowlist ages the other way. A key nobody here has heard of is refused, +/// so a new git release makes this tool fail closed and loud rather than +/// silently regain the hole. +/// +/// The entries are `section.key`, lowercased, with any subsection elided — +/// `remote.origin.url` is checked as `remote.url`. +pub(super) const ALLOWED_REPO_CONFIG: &[&str] = &[ + // What `git init` and `git clone` write, and nothing else. + // + // `core.worktree` is deliberately absent, unlike the read-only sibling + // this list started from. It redirects the working-tree root Git + // operates against — including a linked-worktree-shaped redirect this + // tool does not otherwise offer — and this tool runs *write* operations + // (`checkout`, `add`, `commit`, `stash`) whose target directory is + // supposed to be `action_dir`/the resolved workspace, never something a + // repository's own config gets to name. Worktree isolation is already + // handled through `WorkspaceDescriptor` (`effective_action_dir_for_context`), + // a trusted, in-process mechanism — nothing here needs the config key. + "core.repositoryformatversion", + "core.filemode", + "core.bare", + "core.logallrefupdates", + "core.ignorecase", + "core.precomposeunicode", + "core.symlinks", + "remote.url", + "remote.fetch", + "remote.pushurl", + "remote.mirror", + "branch.remote", + "branch.merge", + "branch.rebase", + "submodule.active", + "submodule.url", + "user.name", + "user.email", + "pull.rebase", + "push.default", + "init.defaultbranch", + // Inert settings ordinary repositories carry that a first-draft allowlist + // would refuse, making the tool useless on a large class of real + // workspaces. Each is a value git *interprets*; none names a program git + // runs. + "core.autocrlf", + "core.eol", + "core.untrackedcache", + "core.longpaths", + "core.fscache", + "core.hidedotfiles", + "core.sparsecheckout", + "core.sparsecheckoutcone", + "commit.gpgsign", + "tag.gpgsign", + "remote.tagopt", + "remote.prune", + "remote.partialclonefilter", + "remote.promisor", + "branch.vscodemerge", + "gc.auto", + "fetch.prune", + // SHA-256 repositories and worktree-scoped config. Only these two + // `extensions.*` keys — the namespace as a whole is where git puts + // repository-format switches, and a blanket allow would admit whatever it + // adds next. + "extensions.objectformat", + "extensions.worktreeconfig", + // `filter..required` is a boolean. The driver's actual programs — + // `clean`, `smudge`, `process` — are NOT here and must not be; see the LFS + // note on `NEUTRALISED_CONFIG`. + "filter.required", + "lfs.repositoryformatversion", +]; + +/// Command-valued keys cleared on the command line as a second layer. +/// +/// Command-line `-c` outranks every config file, so this genuinely +/// neutralises these keys even when a repository sets them — and it does so +/// at the moment `git` actually runs, not at the moment +/// [`first_disallowed_repo_config_key`] happened to inspect the config a +/// moment earlier. That distinction matters: the inspection and the real +/// command are two separate `git` invocations, so a key set in the gap +/// between them (a second concurrent writer, or a worktree-scoped write) is +/// invisible to the inspection but still reaches here — where it is +/// neutralised regardless of timing. It is a denylist and therefore cannot be +/// the *only* guarantee — [`ALLOWED_REPO_CONFIG`] is the one that fails +/// closed on a key nobody anticipated — but this layer is what actually holds +/// under a race, not the inspection step. +/// +/// `credential.helper` is command-valued — a value beginning `!` is run as a +/// shell command — so it belongs nowhere near [`ALLOWED_REPO_CONFIG`] despite +/// reading like a mere preference; it is refused there instead, since none of +/// this tool's operations reach a remote and so have no legitimate use for it +/// to preserve. +/// +/// A `git lfs install` clone is refused, deliberately: `filter.lfs.clean`, +/// `.smudge` and `.process` each name a program, so an LFS working copy +/// cannot be read by this tool. That is the fail-closed answer and it is the +/// intended one. Only `filter..required`, a boolean, is allowed. +/// +/// `core.hooksPath` and `commit.gpgSign` are handled separately, in +/// [`hardened_git`] — see there for why. +pub(super) const NEUTRALISED_CONFIG: &[&str] = &[ + "core.fsmonitor=", + "core.sshCommand=", + "core.pager=cat", + "core.editor=false", + // NOT `diff.external=`. An empty value does not disable an external diff — + // git tries to *execute* the empty string and the whole command dies with + // `error: cannot run : No such file or directory` / `fatal: external diff + // died`, so every `diff` operation failed rather than being hardened. + // Suppression belongs on the command instead: `git diff --no-ext-diff`, + // which ignores `diff.external` however the repository set it. Verified + // both ways against a repo with `diff.external=/bin/false`: plain `diff` + // dies, `--no-ext-diff` prints the patch. + "sequence.editor=false", + "uploadpack.packObjectsHook=", +]; + +/// The entries of [`NEUTRALISED_CONFIG`] that are also safe to force on an +/// *arbitrary* `git` invocation, plus `commit.gpgSign`. +/// +/// The `shell` tool runs git too, and since the `files` tool pack withheld +/// `git_operations` it runs most of it. Nothing there controls argv, so the +/// `-c` layer [`hardened_git`] builds is unavailable; the same overrides ride +/// `GIT_CONFIG_PARAMETERS` instead, which git documents as the mechanism `-c` +/// itself uses to reach child processes, at the same precedence. +/// +/// **This is the denylist half of the policy, not the fail-closed half.** +/// [`ALLOWED_REPO_CONFIG`] cannot come with it: inspecting the repository +/// config before every shell command would cost a `git` round trip on commands +/// that have nothing to do with git, and would refuse to run in an ordinary +/// LFS checkout. So this narrows the surface; it does not close it the way +/// `git_operations` does. +/// +/// Three entries of [`NEUTRALISED_CONFIG`] are deliberately **not** here: +/// +/// - `core.sshCommand=`. It is the same trap the `diff.external` note above +/// records: an empty value is not "use the default", it is a command git +/// tries to execute. `git_operations` gets away with listing it because none +/// of its operations reach a remote; a shell `git fetch` / `git push` does, +/// and would die on every one. Excluded rather than risked. +/// - `diff.external`. Unsuppressable by value for the same reason, and the fix +/// `git_operations` uses — `--no-ext-diff` — is an argv change this path +/// cannot make. A repository that sets it keeps it, and that is a known gap. +/// - `core.hooksPath`. There is no portable inert value for it, and overriding +/// it would change ordinary shell behavior by suppressing repository hooks. +/// Shell-invoked git therefore keeps the repository's configured hooks. +pub(crate) const SHELL_NEUTRALISED_CONFIG: &[&str] = &[ + "core.fsmonitor=", + "core.pager=cat", + "core.editor=false", + "sequence.editor=false", + "uploadpack.packObjectsHook=", + // Not in `NEUTRALISED_CONFIG` — `hardened_git` adds it separately, for the + // reason written there: a repository must not get to spend the host's + // signing key on a commit nobody asked to sign. + "commit.gpgSign=false", +]; + +/// Environment overrides applied to arbitrary commands launched by the shell +/// tool so any nested `git` process receives the safe subset above. +/// +/// Preserve ambient `GIT_CONFIG_PARAMETERS` exactly, including non-UTF-8 +/// bytes, because git uses it to propagate a parent's `-c` settings. The +/// safety overrides are appended so they win when the same key is repeated. +#[must_use] +pub fn shell_git_env() -> std::collections::HashMap { + let mut parameters = std::env::var_os("GIT_CONFIG_PARAMETERS").unwrap_or_default(); + for entry in SHELL_NEUTRALISED_CONFIG { + let Some((key, value)) = entry.split_once('=') else { + continue; + }; + if !parameters.is_empty() { + parameters.push(" "); + } + parameters.push(format!("'{key}'='{value}'")); + } + + std::collections::HashMap::from([( + std::ffi::OsString::from("GIT_CONFIG_PARAMETERS"), + parameters, + )]) +} + +/// A path git will read as an empty config file. +/// +/// `GIT_CONFIG_GLOBAL` must name something readable-and-empty rather than be +/// unset — unsetting it lets git fall back to `~/.gitconfig`, which is the +/// thing being suppressed. `/dev/null` is not a path on Windows; `NUL` is. +pub(super) const NULL_CONFIG_PATH: &str = if cfg!(windows) { "NUL" } else { "/dev/null" }; + +/// Close the system/global config files and the command-valued `GIT_*` env +/// vars on a `git` invocation, without touching anything about how it reads +/// the repository's own config. +/// +/// `GIT_CONFIG_NOSYSTEM` and `GIT_CONFIG_GLOBAL` close the system and global +/// config files. Note what they do *not* close: the repository's own local +/// and worktree-scoped config, which is what an agent-writable workspace +/// actually lets an attacker author. That is handled by +/// [`first_disallowed_repo_config_key`] — a separate step, precisely because +/// [`hardened_git`]'s `-c` layer below must not be present while that step is +/// reading what the repository itself set (see its own doc comment). +pub(super) fn suppress_ambient_git_config( + cmd: &mut tokio::process::Command, +) -> &mut tokio::process::Command { + cmd.env("GIT_CONFIG_NOSYSTEM", "1") + .env("GIT_CONFIG_GLOBAL", NULL_CONFIG_PATH) + // A parent `git -c` invocation propagates its command-line settings + // through these variables. They outrank every config file, so leaving + // them intact would let an ambient `core.hooksPath` or any other + // command-valued setting bypass both the file suppression and the + // repository allowlist below. + .env_remove("GIT_CONFIG_PARAMETERS") + .env_remove("GIT_CONFIG_COUNT") + // `git` consults these before it reads any config file. + .env_remove("GIT_EXTERNAL_DIFF") + .env_remove("GIT_PAGER") + .env_remove("GIT_EDITOR") + .env_remove("GIT_SSH") + .env_remove("GIT_SSH_COMMAND") + .env_remove("GIT_ALTERNATE_OBJECT_DIRECTORIES") + .env("GIT_TERMINAL_PROMPT", "0") +} + +/// Build the `git` invocation actually used to run a requested operation. +/// +/// Layers [`suppress_ambient_git_config`] under the [`NEUTRALISED_CONFIG`] +/// `-c` overrides, which outrank every config file the repository itself +/// could set — including one set in the gap between +/// [`first_disallowed_repo_config_key`]'s inspection and this invocation. +/// +/// Two more `-c` overrides are added here rather than in the static +/// [`NEUTRALISED_CONFIG`] list, because their safe value is not a fixed +/// literal: +/// +/// - `core.hooksPath=` pointed at [`NULL_CONFIG_PATH`]. `commit` and +/// `checkout` are the two operations this tool exposes that run hooks +/// (`pre-commit`/`commit-msg`/`post-commit`, `post-checkout`), and a +/// repository-writable `core.hooksPath` naming a directory with an +/// executable `pre-commit` in it is exactly the shape of the worktree-scoped +/// bypass this hardening exists to close — verified directly: pointing +/// `core.hooksPath` at a directory containing a `pre-commit` that touches a +/// marker file, then running with `-c core.hooksPath=`, leaves +/// the marker untouched. A previous version of this comment claimed there +/// was no portable value that meant "nowhere"; there is — the same one +/// [`suppress_ambient_git_config`] already uses for `GIT_CONFIG_GLOBAL`, +/// since a location with nothing at it is exactly what git needs it to be. +/// - `commit.gpgSign=false`. `commit.gpgsign` is on [`ALLOWED_REPO_CONFIG`] as +/// an ordinary boolean, but a repository could still set it to force every +/// commit through this tool to be GPG-signed — with whatever real signing +/// key the *host* happens to have configured, silently attributing a +/// cryptographic signature to a commit the operator did not ask this tool to +/// sign. Overriding it here removes that decision from the repository +/// entirely, the same way `core.editor=false` removes commit message +/// editing from it in [`NEUTRALISED_CONFIG`] above. +pub(super) fn hardened_git(dir: &Path) -> tokio::process::Command { + let mut cmd = tokio::process::Command::new("git"); + suppress_ambient_git_config(&mut cmd).current_dir(dir); + for kv in NEUTRALISED_CONFIG { + cmd.arg("-c").arg(kv); + } + cmd.arg("-c") + .arg(format!("core.hooksPath={NULL_CONFIG_PATH}")) + .arg("-c") + .arg("commit.gpgSign=false"); + cmd +} + +/// Normalise a `git config --list` key to the `section.key` form +/// [`ALLOWED_REPO_CONFIG`] uses, dropping any subsection. +/// +/// `remote.origin.url` → `remote.url`; `core.filemode` → `core.filemode`. A +/// subsection may itself contain dots (`includeIf.gitdir:~/x.y/.path`), so the +/// first and last components are the reliable ones. +pub(super) fn normalise_config_key(key: &str) -> String { + let key = key.to_ascii_lowercase(); + match (key.find('.'), key.rfind('.')) { + (Some(first), Some(last)) if first != last => { + format!("{}.{}", &key[..first], &key[last + 1..]) + } + _ => key, + } +} + +/// Returns the first repository config key at `dir` that is not on +/// [`ALLOWED_REPO_CONFIG`], or `None` if every key it sets is recognised. +/// +/// Deliberately **not** `--local`: when `extensions.worktreeConfig` is set — +/// itself on [`ALLOWED_REPO_CONFIG`] as an ordinary, non-command-valued +/// setting — git additionally reads `config.worktree`, a second file `--local` +/// does not cover. A `core.hooksPath` set with `git config --worktree ...` +/// lands there, is invisible to `--local`, and is exactly the kind of key this +/// check exists to catch: `commit` runs the hook it names. Bare `--list` +/// returns the same merged view `git` itself consults — local *and* +/// worktree-scoped — with system and global excluded by +/// [`suppress_ambient_git_config`] instead of by a location flag. Verified +/// directly: `git config --worktree core.hooksPath ...` followed by +/// `--local --null` omits it; the same followed by a bare `--list --null` +/// (system/global suppressed) reports it. +/// +/// This step must run through [`suppress_ambient_git_config`] alone, **not** +/// [`hardened_git`]: the latter's `-c` layer would inject the very keys this +/// check inspects for (`core.fsmonitor=`, `core.pager=cat`, …), none of which +/// are themselves on [`ALLOWED_REPO_CONFIG`], and every invocation would +/// refuse itself. Reading the config this way also does not consult +/// `core.fsmonitor` or spawn a pager when its output is captured, so this +/// inspection step does not have the property it is checking for. +/// +/// A non-zero exit here is treated as a refusal, not as "nothing to +/// distrust": by the time this runs, the caller (`execute_in_context`) has +/// already confirmed `dir` — or one of its parents — contains a `.git`, so +/// `git config --list` failing means the config could not be read, not that +/// there is none. Proceeding to run the real command against config this step +/// never actually inspected would defeat the point of inspecting it first. +/// The refusal text for a repository whose config carries `key`. +/// +/// Lives here so the two paths that produce it — the guard inside +/// `run_git_command_in` and the repository probe in `execute_in_context`, +/// which reaches this conclusion without ever getting as far as the guard — +/// cannot word the same refusal differently. +pub(super) fn disallowed_config_refusal(dir: &Path, key: &str) -> String { + format!( + "refusing to run git in {}: its repository config sets `{key}`, which is \ + not on the allowlist of configuration this tool will run under. \ + Several git config keys name a command git then executes, and this \ + directory is agent-writable, so unrecognised configuration is treated \ + as untrusted rather than honoured.", + dir.display() + ) +} + +pub(super) async fn first_disallowed_repo_config_key(dir: &Path) -> anyhow::Result> { + let mut cmd = tokio::process::Command::new("git"); + suppress_ambient_git_config(&mut cmd).current_dir(dir); + let output = cmd.args(["config", "--list", "--null"]).output().await?; + + if !output.status.success() { + anyhow::bail!( + "refusing to run git in {}: could not inspect its repository config \ + ({}), so whether it is safe to run under could not be determined", + dir.display(), + String::from_utf8_lossy(&output.stderr).trim() + ); + } + + let listing = String::from_utf8_lossy(&output.stdout); + for entry in listing.split('\0').filter(|e| !e.is_empty()) { + // `--null` separates entries with NUL and key from value with LF. + let key = entry.split('\n').next().unwrap_or(entry); + if !ALLOWED_REPO_CONFIG.contains(&normalise_config_key(key).as_str()) { + return Ok(Some(key.to_string())); + } + } + Ok(None) +} diff --git a/crates/tinytools-std/src/filesystem/git_operations/config_test.rs b/crates/tinytools-std/src/filesystem/git_operations/config_test.rs new file mode 100644 index 0000000..12a329e --- /dev/null +++ b/crates/tinytools-std/src/filesystem/git_operations/config_test.rs @@ -0,0 +1,671 @@ +//! Repository-config hardening (#5494) — the tests for +//! [`super::config`]. +//! +//! Split out of `test.rs` for the Rust layout gate, along the +//! same seam as the module split: these exercise the allow-list and the +//! hardened `git` invocation rather than the command surface. +//! +//! Attached to `git_operations` (not to the config module) so `use super::*` +//! still resolves `GitOperationsTool` the way it did before the split, and so +//! the fixtures in the sibling test module stay reachable. + +#![allow( + clippy::expect_used, + clippy::panic, + clippy::unwrap_used, + clippy::items_after_statements, + clippy::single_match_else, + clippy::single_match +)] + +use super::config::{ + NEUTRALISED_CONFIG, SHELL_NEUTRALISED_CONFIG, normalise_config_key, shell_git_env, +}; +// The fixtures stay in `test.rs` and are shared rather than +// duplicated: both modules are children of `git_operations`, so `pub(super)` +// there makes them reachable here. +use super::test::{error_text, hermetic, init_git_repo, test_tool}; +use super::*; +use tempfile::TempDir; + +// ── run_git_command_in: repository config hardening (issue #5494) ───────── +// +// `run_git_command_in` backs every operation this tool exposes, including +// `status`, which — like `read_workspace_state`'s `run_git` before #5493 — +// invokes `core.fsmonitor` from the repository's own `.git/config`. That file +// lives in `action_dir`, which `file_write` and `git_operations` itself +// (`add`, `commit`, `checkout`) can write to, so it is attacker-controlled +// input, not trusted configuration. + +/// Write a `core.fsmonitor` hook into `dir`'s repository config that creates a +/// marker file when git runs it, and return the marker's path. +/// +/// Runs the hook once up front and asserts the marker appears, then removes +/// it — so a later absent marker means the hardening refused the hook, not +/// that the hook itself was silently broken (e.g. by `{:?}`-escaping a path +/// the shell would quote differently than Rust's `Debug` does). +#[cfg(unix)] +fn plant_fsmonitor_hook(dir: &std::path::Path) -> std::path::PathBuf { + let hook = dir.join("hook.sh"); + let marker = dir.join("COMMAND_RAN"); + std::fs::write( + &hook, + format!("#!/bin/sh\ntouch {:?}\nexit 1\n", marker.to_string_lossy()), + ) + .unwrap(); + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)).unwrap(); + + // Running a newly-written file directly can race the overlay filesystem in + // CI with ETXTBSY. Invoke the same script through the shell instead; git + // executes the configured hook through its interpreter as well. + std::process::Command::new("sh") + .arg(&hook) + .status() + .unwrap(); + assert!(marker.exists(), "the planted hook does not run at all"); + std::fs::remove_file(&marker).unwrap(); + + // Written with `git config` rather than by appending to the file: + // appending only lands in `[core]` while `[core]` happens to be the last + // section, which is true of a fresh `git init` and is not a property + // worth depending on. + let ok = hermetic( + std::process::Command::new("git") + .args(["config", "core.fsmonitor"]) + .arg(&hook) + .current_dir(dir), + ) + .status() + .unwrap() + .success(); + assert!(ok, "failed to plant the hook in the repository config"); + marker +} + +/// Set a repository config key with `git config`, asserting it took. +fn set_config(dir: &std::path::Path, key: &str, value: &str) { + let ok = hermetic( + std::process::Command::new("git") + .args(["config", key, value]) + .current_dir(dir), + ) + .status() + .unwrap() + .success(); + assert!(ok, "failed to set {key} in the test workspace"); +} + +/// Issue #5494. `git status` executes the command named by the workspace's +/// own repository config unless `run_git_command_in` refuses to run under it. +/// Revert the hardening and this test fails by finding the marker — verified, +/// not assumed. +#[cfg(unix)] +#[tokio::test] +async fn repository_config_naming_a_command_does_not_get_to_run_it() { + let tmp = TempDir::new().unwrap(); + init_git_repo(tmp.path()); + let marker = plant_fsmonitor_hook(tmp.path()); + + let tool = test_tool(tmp.path()); + let result = tool.execute(json!({"operation": "status"})).await; + let msg = error_text(&result); + + assert!( + !marker.exists(), + "`git status` executed the command named by the workspace's own \ + repository config — this tool is a code-execution primitive" + ); + assert!( + msg.contains("fsmonitor"), + "the refusal should name the key that caused it, got: {msg}" + ); +} + +/// The allowlist has to leave an ordinary repository working, or the fix is +/// just a different way of breaking the tool. +#[tokio::test] +async fn an_ordinary_repository_still_reports_status() { + let tmp = TempDir::new().unwrap(); + init_git_repo(tmp.path()); + std::fs::write(tmp.path().join("tracked.txt"), "hi").unwrap(); + + let tool = test_tool(tmp.path()); + let result = tool.execute(json!({"operation": "status"})).await.unwrap(); + + assert!(!result.is_error, "got: {}", result.output()); + assert!( + result.output().contains("tracked.txt"), + "a plain `git init` workspace must still report status, got: {}", + result.output() + ); +} + +/// A first-draft allowlist that refused any repository carrying an ordinary +/// setting like `core.autocrlf` would report nothing useful for a large class +/// of real workspaces. +#[tokio::test] +async fn an_inert_setting_an_ordinary_repository_carries_is_allowed() { + let tmp = TempDir::new().unwrap(); + init_git_repo(tmp.path()); + set_config(tmp.path(), "core.autocrlf", "input"); + set_config(tmp.path(), "gc.auto", "0"); + set_config(tmp.path(), "remote.origin.prune", "true"); + + let tool = test_tool(tmp.path()); + let result = tool.execute(json!({"operation": "status"})).await.unwrap(); + + assert!( + !result.is_error && !result.output().contains("not on the allowlist"), + "an ordinary repository must still report status, got: {}", + result.output() + ); +} + +/// The other half of the same question, and the answer is the opposite one. +/// `filter.lfs.clean` names a program, so an LFS working copy is refused — +/// fail-closed, and intended rather than an oversight. +#[tokio::test] +async fn an_lfs_clone_is_refused_because_its_filter_names_a_program() { + let tmp = TempDir::new().unwrap(); + init_git_repo(tmp.path()); + // What `git lfs install` writes. `required` is inert and allowed; the + // three programs are not. + set_config(tmp.path(), "filter.lfs.required", "true"); + set_config(tmp.path(), "filter.lfs.clean", "git-lfs clean -- %f"); + + let tool = test_tool(tmp.path()); + let result = tool.execute(json!({"operation": "status"})).await; + let msg = error_text(&result); + + assert!( + msg.contains("filter.lfs.clean"), + "the refusal must name the key that caused it, got: {msg}" + ); +} + +/// `credential.helper` reads like a preference and is command-valued: a value +/// beginning `!` is run as a shell command. It must be refused however inert +/// it reads. +#[tokio::test] +async fn credential_helper_is_refused_despite_looking_like_a_preference() { + let tmp = TempDir::new().unwrap(); + init_git_repo(tmp.path()); + set_config(tmp.path(), "credential.helper", "!echo pwned"); + + let tool = test_tool(tmp.path()); + let result = tool.execute(json!({"operation": "status"})).await; + let msg = error_text(&result); + + assert!( + msg.contains("credential.helper"), + "a command-valued key must be refused however inert it reads, got: {msg}" + ); +} + +/// The refusal must hold for a write operation too, not just `status` — the +/// same repository config runs under `commit`/`add`/`checkout`/`stash`. +#[tokio::test] +async fn refusal_also_covers_write_operations() { + let tmp = TempDir::new().unwrap(); + init_git_repo(tmp.path()); + set_config(tmp.path(), "credential.helper", "!echo pwned"); + + let tool = test_tool(tmp.path()); + let result = tool + .execute(json!({"operation": "commit", "message": "test"})) + .await + .unwrap(); + + assert!( + result.is_error && result.output().contains("credential.helper"), + "write operations must be refused under untrusted repo config too, got: {}", + result.output() + ); +} + +/// `core.worktree` redirects the working-tree root every write operation +/// here (`checkout`, `add`, `commit`, `stash`) targets. Left on the +/// allowlist, a repository config could point that root outside +/// `action_dir` and turn a supposedly sandboxed write into one against an +/// arbitrary directory. Nothing this tool does needs the key — worktree +/// isolation goes through `WorkspaceDescriptor` instead. +#[tokio::test] +async fn core_worktree_is_refused_because_it_can_redirect_writes_outside_the_sandbox() { + let tmp = TempDir::new().unwrap(); + let elsewhere = TempDir::new().unwrap(); + init_git_repo(tmp.path()); + set_config( + tmp.path(), + "core.worktree", + &elsewhere.path().to_string_lossy(), + ); + + let tool = test_tool(tmp.path()); + let result = tool.execute(json!({"operation": "status"})).await; + let msg = error_text(&result); + + assert!( + msg.contains("core.worktree"), + "the refusal must name the key that caused it, got: {msg}" + ); +} + +/// `extensions.worktreeConfig` is itself allowlisted as an ordinary setting, +/// but turning it on makes git additionally read `config.worktree` — a +/// second file `--local` alone does not see. A `core.hooksPath` set there is +/// invisible to a `--local`-only inspection and would still run on the next +/// `commit`. The inspection step must read the same merged view git does. +#[tokio::test] +async fn a_hookspath_hidden_in_worktree_scoped_config_is_still_refused() { + let tmp = TempDir::new().unwrap(); + init_git_repo(tmp.path()); + set_config(tmp.path(), "extensions.worktreeConfig", "true"); + // What `git config --worktree core.hooksPath ` writes; `set_config` + // only reaches `--local`, so this key is planted directly the same way + // the production inspection step reads it — via a real `git config + // --worktree` invocation — to prove the bypass is closed, not just that + // `set_config` happens to skip it. + let ok = hermetic( + std::process::Command::new("git") + .args(["config", "--worktree", "core.hooksPath"]) + .arg(tmp.path()) + .current_dir(tmp.path()), + ) + .status() + .unwrap() + .success(); + assert!(ok, "failed to set core.hooksPath in worktree-scoped config"); + + let tool = test_tool(tmp.path()); + let result = tool.execute(json!({"operation": "status"})).await; + let msg = error_text(&result); + + assert!( + msg.contains("core.hookspath"), + "a hookspath hidden in worktree-scoped config must still be refused, got: {msg}" + ); +} + +/// The allowlist inspection and the real command are two separate `git` +/// invocations, so a config change landing in the gap between them would be +/// invisible to the first and still reach the second. This test calls +/// `hardened_git` directly — skipping `first_disallowed_repo_config_key` +/// entirely, standing in for that gap — to prove the second invocation does +/// not depend on the first having caught anything: `core.hooksPath` is +/// neutralised at the point of execution regardless of what any inspection +/// saw or missed. +#[cfg(unix)] +#[tokio::test] +async fn hardened_git_neutralises_hookspath_even_if_the_allowlist_check_never_ran() { + let tmp = TempDir::new().unwrap(); + init_git_repo(tmp.path()); + set_config(tmp.path(), "user.email", "test@example.com"); + set_config(tmp.path(), "user.name", "Test"); + + let hooks_dir = tmp.path().join("evil-hooks"); + std::fs::create_dir(&hooks_dir).unwrap(); + let marker = tmp.path().join("HOOK_RAN"); + std::fs::write( + hooks_dir.join("pre-commit"), + format!("#!/bin/sh\ntouch {:?}\nexit 0\n", marker.to_string_lossy()), + ) + .unwrap(); + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions( + hooks_dir.join("pre-commit"), + std::fs::Permissions::from_mode(0o755), + ) + .unwrap(); + } + set_config(tmp.path(), "core.hooksPath", &hooks_dir.to_string_lossy()); + + std::fs::write(tmp.path().join("f.txt"), "hi").unwrap(); + let staged = hermetic( + std::process::Command::new("git") + .args(["add", "f.txt"]) + .current_dir(tmp.path()), + ) + .status() + .unwrap() + .success(); + assert!(staged, "failed to stage the test file"); + + let output = super::hardened_git(tmp.path()) + .args(["commit", "-m", "msg"]) + .output() + .await + .unwrap(); + + assert!( + output.status.success(), + "commit should succeed: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert!( + !marker.exists(), + "hardened_git ran the repository-configured pre-commit hook — the \ + override that is supposed to hold even without the allowlist check \ + did not" + ); +} + +/// `commit.gpgsign` is on `ALLOWED_REPO_CONFIG` as an ordinary boolean, but +/// left un-neutralised it would let a repository force every commit through +/// this tool to be signed. `output.status.success()` alone does not prove +/// that: a repository that also configures a *working* `gpg.program` would +/// make a signed commit succeed too, so this plants a fake one that always +/// signs successfully and then inspects the commit object itself for a +/// `gpgsig` header — the only assertion that actually distinguishes "signing +/// was skipped" from "signing was attempted and happened to work". +#[cfg(unix)] +#[tokio::test] +async fn hardened_git_neutralises_forced_commit_signing() { + use std::os::unix::fs::PermissionsExt; + + let tmp = TempDir::new().unwrap(); + init_git_repo(tmp.path()); + set_config(tmp.path(), "user.email", "test@example.com"); + set_config(tmp.path(), "user.name", "Test"); + set_config(tmp.path(), "commit.gpgsign", "true"); + + // A `gpg.program` that always "signs" successfully, so a regression here + // fails by finding a signature, not by the commit merely erroring out — + // the same distinction CodeRabbit's review raised. + let fake_gpg = tmp.path().join("fake-gpg.sh"); + std::fs::write( + &fake_gpg, + "#!/bin/sh\n\ + printf '%s\\n' '[GNUPG:] BEGIN_SIGNING H10' >&2\n\ + cat >/dev/null\n\ + printf -- '-----BEGIN PGP SIGNATURE-----\\n\\nZmFrZQ==\\n-----END PGP SIGNATURE-----\\n'\n\ + printf '%s\\n' '[GNUPG:] SIG_CREATED D 1 10 00 0 0123456789ABCDEF' >&2\n", + ) + .unwrap(); + std::fs::set_permissions(&fake_gpg, std::fs::Permissions::from_mode(0o755)).unwrap(); + set_config(tmp.path(), "gpg.program", &fake_gpg.to_string_lossy()); + + std::fs::write(tmp.path().join("f.txt"), "hi").unwrap(); + let staged = hermetic( + std::process::Command::new("git") + .args(["add", "f.txt"]) + .current_dir(tmp.path()), + ) + .status() + .unwrap() + .success(); + assert!(staged, "failed to stage the test file"); + + let output = super::hardened_git(tmp.path()) + .args(["commit", "-m", "msg"]) + .output() + .await + .unwrap(); + assert!( + output.status.success(), + "commit failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let commit_object = super::hardened_git(tmp.path()) + .args(["cat-file", "-p", "HEAD"]) + .output() + .await + .unwrap(); + assert!(commit_object.status.success()); + let commit_object = String::from_utf8_lossy(&commit_object.stdout); + assert!( + !commit_object.lines().any(|l| l.starts_with("gpgsig ")), + "commit.gpgsign=true must not be honoured, but HEAD carries a \ + signature: {commit_object}" + ); +} + +/// The config-inspection step must fail closed: if `git config --list +/// --local` cannot be read, that is not the same as "nothing to distrust", +/// and running the real command anyway would skip the check entirely. +#[cfg(unix)] +#[tokio::test] +async fn unreadable_repo_config_fails_closed_rather_than_running_anyway() { + use std::os::unix::fs::PermissionsExt; + + let tmp = TempDir::new().unwrap(); + init_git_repo(tmp.path()); + let config_path = tmp.path().join(".git").join("config"); + std::fs::set_permissions(&config_path, std::fs::Permissions::from_mode(0o000)).unwrap(); + + // Root (and some CI containers run as root) ignores this permission bit + // entirely, which would make the assertion below meaningless rather than + // wrong. Detect that up front instead of failing on an unrelated cause. + let permission_enforced = std::fs::File::open(&config_path).is_err(); + + let result = if permission_enforced { + let tool = test_tool(tmp.path()); + Some(tool.execute(json!({"operation": "status"})).await) + } else { + None + }; + + // Restore permissions before the TempDir is dropped, so cleanup doesn't + // fail on an unreadable file. + std::fs::set_permissions(&config_path, std::fs::Permissions::from_mode(0o644)).unwrap(); + + let Some(result) = result else { + eprintln!( + "skipping: file permissions are not enforced against this process (running as root?)" + ); + return; + }; + let msg = error_text(&result); + + assert!( + msg.contains("could not inspect its repository config"), + "an unreadable repo config must refuse, not silently proceed, got: {msg}" + ); +} + +#[test] +fn a_subsection_is_elided_so_one_entry_covers_every_remote() { + assert_eq!(normalise_config_key("remote.origin.url"), "remote.url"); + assert_eq!(normalise_config_key("remote.a.b.c.url"), "remote.url"); + assert_eq!(normalise_config_key("core.fileMode"), "core.filemode"); + assert_eq!(normalise_config_key("core.fsmonitor"), "core.fsmonitor"); + // The subsection itself contains dots; the first and last components + // remain the reliable ones. + assert_eq!( + normalise_config_key("includeIf.gitdir:~/x.y/.path"), + "includeif.path" + ); + // A key with no dot at all is returned unchanged rather than panicking. + assert_eq!(normalise_config_key("bare"), "bare"); +} + +// ── External diff suppression ───────────────────────────────────────────── + +/// An ordinary repository's `diff` must produce its patch. +/// +/// That reads like it could not possibly regress, and it did. Suppression of +/// an external diff was attempted with `-c diff.external=` in +/// `NEUTRALISED_CONFIG`, and an empty value does not disable one — git tries +/// to *execute* the empty string, so **every** diff died with +/// `error: cannot run : No such file or directory` / +/// `fatal: external diff died`, on every repository, hostile or not. The +/// hardening removed the operation instead of hardening it. `--no-ext-diff` +/// on the diff command is the real suppression. +/// +/// This lives in the lib suite deliberately. The integration test that caught +/// it sits in `raw_coverage_all`, and a change to `git_operations.rs` maps to +/// the `openhuman::tools` libtest filter — which never selects that target. So +/// the lane the change picks did not run the test covering the change, and the +/// breakage sat until an unrelated PR happened to select the other filter. +/// A test here runs whenever this file is touched. +/// +/// A repository that *sets* `diff.external` is a separate matter and is +/// already refused before reaching the invocation: the key is on neither +/// allowlist, so `first_disallowed_repo_config_key` rejects the repository +/// outright. `--no-ext-diff` is the second layer, covering the gap between +/// that inspection and the command. +#[tokio::test] +async fn an_ordinary_repository_still_produces_a_diff() { + let tmp = TempDir::new().unwrap(); + init_git_repo(tmp.path()); + + // A committer identity has to be set in the repository itself. `hermetic` + // closes the global and system config, which is the point of it — so on a + // CI container with no identity of its own `git commit` fails with + // "Author identity unknown". Both keys are on `ALLOWED_REPO_CONFIG`, so + // setting them does not trip the repository-config refusal. + set_config(tmp.path(), "user.email", "test@example.invalid"); + set_config(tmp.path(), "user.name", "Test"); + + let tracked = tmp.path().join("tracked.txt"); + std::fs::write(&tracked, "first\n").unwrap(); + // `hermetic` closes the ambient git config the way the fixtures do — + // without it a developer's global `commit.gpgsign` or hooks path can fail + // this commit for reasons unrelated to the test. + // Slices, not arrays: `["add", "tracked.txt"]` and `["commit", "-m", "one"]` + // are `[&str; 2]` and `[&str; 3]`, which are different types and cannot + // share an array literal. + for args in [&["add", "tracked.txt"][..], &["commit", "-m", "one"][..]] { + let ok = hermetic( + std::process::Command::new("git") + .args(args) + .current_dir(tmp.path()), + ) + .status() + .unwrap() + .success(); + assert!(ok, "failed to run `git {}` in the test workspace", args[0]); + } + std::fs::write(&tracked, "first\nsecond\n").unwrap(); + + let tool = test_tool(tmp.path()); + let result = tool + .execute(json!({"operation": "diff", "files": "tracked.txt"})) + .await + .expect("a diff on a plain repository must not error out"); + + assert!(!result.is_error, "got: {}", result.output()); + assert!( + result.output().contains("second"), + "the added line must appear in the patch: {}", + result.output() + ); + assert!( + !result.output().contains("external diff died"), + "git must not be handed an empty `diff.external` to execute: {}", + result.output() + ); +} + +/// The shell subset must stay a subset, so widening the policy is a decision. +/// +/// `SHELL_NEUTRALISED_CONFIG` exists because the `shell` tool cannot use the +/// `-c` layer `hardened_git` builds. The risk is drift in one direction: +/// someone adds a newly-dangerous key to `NEUTRALISED_CONFIG`, the shell path +/// silently keeps running without it, and the gap is invisible. This test +/// cannot force the addition — some entries genuinely must not cross, and the +/// doc comment says which and why — but it does force every shell entry to be +/// one the main policy already recognises. +#[test] +fn the_shell_subset_never_invents_a_key_the_main_policy_does_not_have() { + for entry in SHELL_NEUTRALISED_CONFIG { + let known = NEUTRALISED_CONFIG.contains(entry) + // `hardened_git` adds this one as an argument rather than through + // the static list; it is still the same policy. + || *entry == "commit.gpgSign=false"; + assert!( + known, + "`{entry}` is neutralised for the shell but is not part of the \ + git_operations policy it is supposed to mirror" + ); + } +} + +/// The three deliberate exclusions must stay excluded, with their reasons. +/// +/// Each would break ordinary work rather than harden it: an empty +/// `core.sshCommand` is a command git tries to execute (the same trap the +/// `diff.external` note records), while overriding `core.hooksPath` would +/// suppress repository hooks. Re-adding any of them looks like tightening +/// security and is actually an outage, so it should be a conscious edit here +/// first. +#[test] +fn the_shell_subset_keeps_its_documented_exclusions() { + for excluded in ["core.sshCommand=", "diff.external=", "core.hooksPath="] { + assert!( + !SHELL_NEUTRALISED_CONFIG.contains(&excluded), + "`{excluded}` is excluded from the shell subset on purpose — see \ + the doc comment before changing this" + ); + } +} + +#[test] +fn shell_git_environment_forces_the_safe_config_subset() { + let env = shell_git_env(); + let parameters = env + .get(std::ffi::OsStr::new("GIT_CONFIG_PARAMETERS")) + .expect("the shell git environment sets GIT_CONFIG_PARAMETERS") + .to_string_lossy(); + + for expected in SHELL_NEUTRALISED_CONFIG { + let (key, value) = expected.split_once('=').unwrap(); + assert!( + parameters.contains(&format!("'{key}'='{value}'")), + "`{expected}` is missing from GIT_CONFIG_PARAMETERS: {parameters}" + ); + } +} + +/// Exercise the environment through Git itself rather than only checking its +/// serialization. A syntactically plausible `GIT_CONFIG_PARAMETERS` value is +/// not useful if Git rejects it, and `core.fsmonitor=` must actually override +/// the command-valued setting in the repository config. +#[cfg(unix)] +#[test] +fn shell_git_environment_is_accepted_and_neutralises_repository_fsmonitor() { + let tmp = TempDir::new().unwrap(); + init_git_repo(tmp.path()); + let marker = plant_fsmonitor_hook(tmp.path()); + + // Prove this Git version and repository really do invoke the planted + // fsmonitor hook. Without this control, an absent marker below could be a + // false positive caused by Git never consulting the setting. + let plain = hermetic( + std::process::Command::new("git") + .arg("status") + .current_dir(tmp.path()), + ) + .output() + .expect("failed to run the unhardened control command"); + assert!( + plain.status.success(), + "the unhardened control command failed: {}", + String::from_utf8_lossy(&plain.stderr) + ); + assert!( + marker.exists(), + "the unhardened control did not execute the planted fsmonitor hook" + ); + std::fs::remove_file(&marker).unwrap(); + + let env = shell_git_env(); + let mut hardened = std::process::Command::new("git"); + hardened.arg("status").current_dir(tmp.path()); + hermetic(&mut hardened).envs(env); + let output = hardened + .output() + .expect("failed to run git with the shell hardening environment"); + + assert!( + output.status.success(), + "Git rejected the shell hardening environment: {}", + String::from_utf8_lossy(&output.stderr) + ); + assert!( + !marker.exists(), + "Git executed repository core.fsmonitor despite the shell hardening environment" + ); +} diff --git a/crates/tinytools-std/src/filesystem/git_operations/mod.rs b/crates/tinytools-std/src/filesystem/git_operations/mod.rs new file mode 100644 index 0000000..5c369d5 --- /dev/null +++ b/crates/tinytools-std/src/filesystem/git_operations/mod.rs @@ -0,0 +1,652 @@ +use super::gate::FsGate; +use async_trait::async_trait; +use serde_json::json; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use tinytools::ToolRunContext; +use tinytools::{Tool, ToolCallOptions, ToolResult}; + +mod config; +mod render; + +pub use self::config::shell_git_env; +use self::config::{disallowed_config_refusal, first_disallowed_repo_config_key, hardened_git}; +use self::render::{render_branch_markdown, render_log_markdown, render_status_markdown}; + +/// Git operations tool for structured repository management. +/// Provides safe, parsed git operations with JSON output. +#[derive(Debug)] +pub struct GitOperationsTool { + gate: Arc, + action_dir: PathBuf, +} + +impl GitOperationsTool { + /// Create the tool. + #[must_use] + pub fn new(gate: Arc, action_dir: PathBuf) -> Self { + Self { gate, action_dir } + } + + /// Resolve the working directory for git operations. + /// + /// Returns the per-worker git-worktree checkout when the tinyagents harness + /// threaded a [`WorkspaceDescriptor`] into this call's + /// [`ToolExecutionContext`] — an edit-capable worker running with + /// `isolation = "worktree"`, whose isolated worktree root is carried on the + /// run context (`RunContext::with_workspace`) and surfaced per tool call via + /// `ToolExecutionContext::from_run_context`. Otherwise falls back to the + /// tool's configured `action_dir`, which preserves the non-isolated + /// behaviour exactly. See #3376, #4249 (08.5). + fn effective_action_dir_for_context(&self, context: Option<&dyn ToolRunContext>) -> PathBuf { + if let Some(workspace) = context.and_then(|ctx| ctx.workspace()) { + tracing::debug!( + workspace_root = %workspace.root.display(), + policy_id = %workspace.policy_id, + "[git_operations] using TinyAgents workspace descriptor as action dir" + ); + return workspace.root.clone(); + } + self.action_dir.clone() + } + + /// Sanitize git arguments to prevent injection attacks + fn sanitize_git_args(&self, args: &str) -> anyhow::Result> { + let mut result = Vec::new(); + for arg in args.split_whitespace() { + // Block dangerous git options that could lead to command injection + let arg_lower = arg.to_lowercase(); + if arg_lower.starts_with("--exec=") + || arg_lower.starts_with("--upload-pack=") + || arg_lower.starts_with("--receive-pack=") + || arg_lower.starts_with("--pager=") + || arg_lower.starts_with("--editor=") + || arg_lower == "--no-verify" + || arg_lower.contains("$(") + || arg_lower.contains('`') + || arg.contains('|') + || arg.contains(';') + || arg.contains('>') + { + anyhow::bail!("Blocked potentially dangerous git argument: {arg}"); + } + // Block `-c` config injection (exact match or `-c=...` prefix). + // This must not false-positive on `--cached` or `-cached`. + if arg_lower == "-c" || arg_lower.starts_with("-c=") { + anyhow::bail!("Blocked potentially dangerous git argument: {arg}"); + } + result.push(arg.to_string()); + } + Ok(result) + } + + /// Check if an operation requires write access + fn requires_write_access(&self, operation: &str) -> bool { + matches!( + operation, + "commit" | "add" | "checkout" | "stash" | "reset" | "revert" + ) + } + + /// Check if an operation is read-only + #[allow(dead_code)] // exercised by the tests; kept with `requires_write_access` + fn is_read_only(&self, operation: &str) -> bool { + matches!( + operation, + "status" | "diff" | "log" | "show" | "branch" | "rev-parse" + ) + } + + async fn run_git_command_in(&self, cwd: &Path, args: &[&str]) -> anyhow::Result { + if let Some(key) = first_disallowed_repo_config_key(cwd).await? { + tracing::debug!( + "[git_operations] refusing to run git: dir={}, disallowed_config_key={key}", + cwd.display() + ); + anyhow::bail!("{}", disallowed_config_refusal(cwd, &key)) + } + + let output = hardened_git(cwd).args(args).output().await?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!("Git command failed: {stderr}"); + } + + Ok(String::from_utf8_lossy(&output.stdout).to_string()) + } + + async fn git_status(&self, cwd: &Path, _args: serde_json::Value) -> anyhow::Result { + let output = self + .run_git_command_in(cwd, &["status", "--porcelain=2", "--branch"]) + .await?; + + // Parse git status output into structured format + let mut result = serde_json::Map::new(); + let mut branch = String::new(); + let mut staged = Vec::new(); + let mut unstaged = Vec::new(); + let mut untracked = Vec::new(); + + for line in output.lines() { + if line.starts_with("# branch.head ") { + branch = line.trim_start_matches("# branch.head ").to_string(); + } else if let Some(rest) = line.strip_prefix("1 ") { + // Ordinary changed entry + let mut parts = rest.splitn(3, ' '); + if let (Some(staging), Some(path)) = (parts.next(), parts.next()) + && !staging.is_empty() + { + let status_char = staging.chars().next().unwrap_or(' '); + if status_char != '.' && status_char != ' ' { + staged.push(json!({"path": path, "status": status_char})); + } + let status_char = staging.chars().nth(1).unwrap_or(' '); + if status_char != '.' && status_char != ' ' { + unstaged.push(json!({"path": path, "status": status_char})); + } + } + } else if let Some(rest) = line.strip_prefix("? ") { + untracked.push(rest.to_string()); + } + } + + result.insert("branch".to_string(), json!(branch)); + result.insert("staged".to_string(), json!(staged)); + result.insert("unstaged".to_string(), json!(unstaged)); + result.insert("untracked".to_string(), json!(untracked)); + result.insert( + "clean".to_string(), + json!(staged.is_empty() && unstaged.is_empty() && untracked.is_empty()), + ); + + let mut tr = ToolResult::success(serde_json::to_string_pretty(&result).unwrap_or_default()); + tr.markdown_formatted = Some(render_status_markdown(&result)); + Ok(tr) + } + + async fn git_diff(&self, cwd: &Path, args: serde_json::Value) -> anyhow::Result { + let files = args.get("files").and_then(|v| v.as_str()).unwrap_or("."); + let cached = args + .get("cached") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + + // Validate files argument against injection patterns + self.sanitize_git_args(files)?; + + // `--no-ext-diff` is where external-diff suppression has to live: it is + // a diff-command flag, and the `-c diff.external=` that used to stand in + // for it made git exec the empty string instead of disabling anything. + // + // `--no-textconv` is a SEPARATE mechanism and needs its own flag. + // `--no-ext-diff` covers `diff.external` and `diff..command`; + // it does not touch `diff..textconv`, which a `.gitattributes` + // line (`*.bin diff=evil`) can select and which git then EXECUTES to + // render a binary file as text. Verified against a scratch repo: with + // `--no-ext-diff` alone the textconv script ran; adding + // `--no-textconv` it did not. `hardened_git`'s `-c` list cannot close + // this — driver names are arbitrary, so there is no finite set of keys + // to neutralise, and the suppression has to be a command flag. + let mut git_args = vec!["diff", "--no-ext-diff", "--no-textconv", "--unified=3"]; + if cached { + git_args.push("--cached"); + } + git_args.push("--"); + git_args.push(files); + + let output = self.run_git_command_in(cwd, &git_args).await?; + + // Parse diff into structured hunks + let mut result = serde_json::Map::new(); + let mut hunks = Vec::new(); + let mut current_file = String::new(); + let mut current_hunk = serde_json::Map::new(); + let mut lines = Vec::new(); + + for line in output.lines() { + if line.starts_with("diff --git ") { + if !lines.is_empty() { + current_hunk.insert("lines".to_string(), json!(lines)); + if !current_hunk.is_empty() { + hunks.push(serde_json::Value::Object(current_hunk.clone())); + } + lines = Vec::new(); + current_hunk = serde_json::Map::new(); + } + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() >= 4 { + current_file = parts[3].trim_start_matches("b/").to_string(); + current_hunk.insert("file".to_string(), json!(current_file)); + } + } else if line.starts_with("@@ ") { + if !lines.is_empty() { + current_hunk.insert("lines".to_string(), json!(lines)); + if !current_hunk.is_empty() { + hunks.push(serde_json::Value::Object(current_hunk.clone())); + } + lines = Vec::new(); + current_hunk = serde_json::Map::new(); + current_hunk.insert("file".to_string(), json!(current_file)); + } + current_hunk.insert("header".to_string(), json!(line)); + } else if !line.is_empty() { + lines.push(json!({ + "text": line, + "type": if line.starts_with('+') { "add" } + else if line.starts_with('-') { "delete" } + else { "context" } + })); + } + } + + if !lines.is_empty() { + current_hunk.insert("lines".to_string(), json!(lines)); + if !current_hunk.is_empty() { + hunks.push(serde_json::Value::Object(current_hunk)); + } + } + + result.insert("hunks".to_string(), json!(hunks)); + result.insert("file_count".to_string(), json!(hunks.len())); + + Ok(ToolResult::success( + serde_json::to_string_pretty(&result).unwrap_or_default(), + )) + } + + async fn git_log(&self, cwd: &Path, args: serde_json::Value) -> anyhow::Result { + let limit_raw = args + .get("limit") + .and_then(serde_json::Value::as_u64) + .unwrap_or(10); + let limit = usize::try_from(limit_raw).unwrap_or(usize::MAX).min(1000); + let limit_str = limit.to_string(); + + let output = self + .run_git_command_in( + cwd, + &[ + "log", + &format!("-{limit_str}"), + "--pretty=format:%H|%an|%ae|%ad|%s", + "--date=iso", + ], + ) + .await?; + + let mut commits = Vec::new(); + + for line in output.lines() { + let parts: Vec<&str> = line.split('|').collect(); + if parts.len() >= 5 { + commits.push(json!({ + "hash": parts[0], + "author": parts[1], + "email": parts[2], + "date": parts[3], + "message": parts[4] + })); + } + } + + let mut tr = ToolResult::success( + serde_json::to_string_pretty(&json!({ "commits": commits })).unwrap_or_default(), + ); + tr.markdown_formatted = Some(render_log_markdown(&commits)); + Ok(tr) + } + + async fn git_branch(&self, cwd: &Path, _args: serde_json::Value) -> anyhow::Result { + let output = self + .run_git_command_in(cwd, &["branch", "--format=%(refname:short)|%(HEAD)"]) + .await?; + + let mut branches = Vec::new(); + let mut current = String::new(); + + for line in output.lines() { + if let Some((name, head)) = line.split_once('|') { + let is_current = head == "*"; + if is_current { + current = name.to_string(); + } + branches.push(json!({ + "name": name, + "current": is_current + })); + } + } + + let mut tr = ToolResult::success( + serde_json::to_string_pretty(&json!({ + "current": current, + "branches": branches + })) + .unwrap_or_default(), + ); + tr.markdown_formatted = Some(render_branch_markdown(¤t, &branches)); + Ok(tr) + } + + fn truncate_commit_message(message: &str) -> String { + if message.chars().count() > 2000 { + format!("{}...", message.chars().take(1997).collect::()) + } else { + message.to_string() + } + } + + async fn git_commit(&self, cwd: &Path, args: serde_json::Value) -> anyhow::Result { + let message = args + .get("message") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing 'message' parameter"))?; + + // Sanitize commit message + let sanitized = message + .lines() + .map(str::trim) + .filter(|l| !l.is_empty()) + .collect::>() + .join("\n"); + + if sanitized.is_empty() { + anyhow::bail!("Commit message cannot be empty"); + } + + // Limit message length + let message = Self::truncate_commit_message(&sanitized); + + let output = self + .run_git_command_in(cwd, &["commit", "-m", &message]) + .await; + + match output { + Ok(_) => Ok(ToolResult::success(format!("Committed: {message}"))), + Err(e) => Ok(ToolResult::error(format!("Commit failed: {e}"))), + } + } + + async fn git_add(&self, cwd: &Path, args: serde_json::Value) -> anyhow::Result { + let paths = args + .get("paths") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing 'paths' parameter"))?; + + // Validate paths against injection patterns + self.sanitize_git_args(paths)?; + + let output = self.run_git_command_in(cwd, &["add", "--", paths]).await; + + match output { + Ok(_) => Ok(ToolResult::success(format!("Staged: {paths}"))), + Err(e) => Ok(ToolResult::error(format!("Add failed: {e}"))), + } + } + + async fn git_checkout( + &self, + cwd: &Path, + args: serde_json::Value, + ) -> anyhow::Result { + let branch = args + .get("branch") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing 'branch' parameter"))?; + + // Sanitize branch name + let sanitized = self.sanitize_git_args(branch)?; + + if sanitized.is_empty() || sanitized.len() > 1 { + anyhow::bail!("Invalid branch specification"); + } + + let branch_name = &sanitized[0]; + + // Block dangerous branch names + if branch_name.contains('@') || branch_name.contains('^') || branch_name.contains('~') { + anyhow::bail!("Branch name contains invalid characters"); + } + + let output = self + .run_git_command_in(cwd, &["checkout", branch_name]) + .await; + + match output { + Ok(_) => Ok(ToolResult::success(format!( + "Switched to branch: {branch_name}" + ))), + Err(e) => Ok(ToolResult::error(format!("Checkout failed: {e}"))), + } + } + + async fn git_stash(&self, cwd: &Path, args: serde_json::Value) -> anyhow::Result { + let action = args + .get("action") + .and_then(|v| v.as_str()) + .unwrap_or("push"); + + let output = match action { + "push" | "save" => { + self.run_git_command_in(cwd, &["stash", "push", "-m", "auto-stash"]) + .await + } + "pop" => self.run_git_command_in(cwd, &["stash", "pop"]).await, + "list" => self.run_git_command_in(cwd, &["stash", "list"]).await, + "drop" => { + let index_raw = args + .get("index") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0); + let index = i32::try_from(index_raw) + .map_err(|_| anyhow::anyhow!("stash index too large: {index_raw}"))?; + self.run_git_command_in(cwd, &["stash", "drop", &format!("stash@{{{index}}}")]) + .await + } + _ => anyhow::bail!("Unknown stash action: {action}. Use: push, pop, list, drop"), + }; + + match output { + Ok(out) => Ok(ToolResult::success(out)), + Err(e) => Ok(ToolResult::error(format!("Stash {action} failed: {e}"))), + } + } +} + +#[async_trait] +impl Tool for GitOperationsTool { + fn name(&self) -> &'static str { + "git_operations" + } + + fn description(&self) -> &'static str { + "Perform structured Git operations (status, diff, log, branch, commit, add, checkout, stash). Provides parsed JSON output and integrates with security policy for autonomy controls." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "operation": { + "type": "string", + "enum": ["status", "diff", "log", "branch", "commit", "add", "checkout", "stash"], + "description": "Git operation to perform" + }, + "message": { + "type": "string", + "description": "Commit message (for 'commit' operation)" + }, + "paths": { + "type": "string", + "description": "File paths to stage (for 'add' operation)" + }, + "branch": { + "type": "string", + "description": "Branch name (for 'checkout' operation)" + }, + "files": { + "type": "string", + "description": "File or path to diff (for 'diff' operation, default: '.')" + }, + "cached": { + "type": "boolean", + "description": "Show staged changes (for 'diff' operation)" + }, + "limit": { + "type": "integer", + "description": "Number of log entries (for 'log' operation, default: 10)" + }, + "action": { + "type": "string", + "enum": ["push", "pop", "list", "drop"], + "description": "Stash action (for 'stash' operation)" + }, + "index": { + "type": "integer", + "description": "Stash index (for 'stash' with 'drop' action)" + } + }, + "required": ["operation"] + }) + } + + fn supports_markdown(&self) -> bool { + true + } + + /// Write git operations (commit/add/checkout/stash/…) route through the + /// human approval gate in ask-before-edit; read operations (status/diff/ + /// log/branch) never prompt. In Full, writes run; read-only is blocked in + /// `execute` via the existing `can_act()` / autonomy check. + fn external_effect_with_args(&self, args: &serde_json::Value) -> bool { + let operation = args.get("operation").and_then(|v| v.as_str()).unwrap_or(""); + self.requires_write_access(operation) && self.gate.write_needs_approval() + } + + async fn execute_with_options( + &self, + args: serde_json::Value, + _options: ToolCallOptions, + ) -> anyhow::Result { + // git_operations always populates `markdown_formatted` for the + // structured sub-operations (status/diff/log/branch). The harness + // picks it up when `prefer_markdown` is on; the JSON content + // block is preserved for callers that want the raw structure. + self.execute_in_context(args, None).await + } + + async fn execute_with_context( + &self, + args: serde_json::Value, + _options: ToolCallOptions, + context: Option<&dyn ToolRunContext>, + ) -> anyhow::Result { + self.execute_in_context(args, context).await + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + self.execute_in_context(args, None).await + } +} + +impl GitOperationsTool { + async fn execute_in_context( + &self, + args: serde_json::Value, + context: Option<&dyn ToolRunContext>, + ) -> anyhow::Result { + let operation = match args.get("operation").and_then(|v| v.as_str()) { + Some(op) => op, + None => { + return Ok(ToolResult::error("Missing 'operation' parameter")); + } + }; + + let effective_dir = self.effective_action_dir_for_context(context); + // Validate the repository instead of trusting the presence of a `.git` + // path. This handles linked-worktree gitfiles and rejects malformed + // ancestor markers that Git itself cannot open. + // + // Probed with `hardened_git` directly rather than through + // `run_git_command_in`. That helper refuses up front when the + // repository config carries a disallowed key, and routing the probe + // through it made the refusal indistinguishable from "there is no + // repository here": `is_ok_and` discarded the error, so a repo setting + // `core.fsmonitor` / `credential.helper` / `core.worktree` was reported + // as **not a git repository** and the message naming the key was lost + // (#5494). `first_disallowed_repo_config_key` even documents the + // ordering this restores — it expects the caller to have established + // that `dir` is a repository before it runs. + // + // Safe to probe unguarded: `hardened_git` already neutralises every + // config key that names a command, so this invocation cannot be turned + // into an execution primitive, and the real command below still goes + // through the full allow-list check in `run_git_command_in`. + let is_worktree = hardened_git(&effective_dir) + .args(["rev-parse", "--is-inside-work-tree"]) + .output() + .await + .is_ok_and(|output| { + output.status.success() && String::from_utf8_lossy(&output.stdout).trim() == "true" + }); + if !is_worktree { + // The probe can fail *because* the repository is misconfigured: a + // `core.worktree` redirect git cannot follow, or a config file it + // cannot read. Ask the guard before concluding there is nothing + // here, so a refusal is reported as a refusal — "Not in a git + // repository" is both false and un-actionable for those (#5494). + return Ok( + match first_disallowed_repo_config_key(&effective_dir).await { + Ok(Some(key)) => { + ToolResult::error(disallowed_config_refusal(&effective_dir, &key)) + } + Err(error) => ToolResult::error(error.to_string()), + Ok(None) => ToolResult::error("Not in a git repository"), + }, + ); + } + + // Check autonomy level for write operations + if self.requires_write_access(operation) { + if !self.gate.can_act() { + return Ok(ToolResult::error( + "[policy-blocked] Action blocked: git write operations require higher autonomy level", + )); + } + + if self.gate.is_read_only() { + return Ok(ToolResult::error( + "[policy-blocked] Action blocked: read-only mode", + )); + } + } + + // Record action for rate limiting + if !self.gate.record_action() { + return Ok(ToolResult::error("Action blocked: rate limit exceeded")); + } + + // Execute the requested operation + match operation { + "status" => self.git_status(&effective_dir, args).await, + "diff" => self.git_diff(&effective_dir, args).await, + "log" => self.git_log(&effective_dir, args).await, + "branch" => self.git_branch(&effective_dir, args).await, + "commit" => self.git_commit(&effective_dir, args).await, + "add" => self.git_add(&effective_dir, args).await, + "checkout" => self.git_checkout(&effective_dir, args).await, + "stash" => self.git_stash(&effective_dir, args).await, + _ => Ok(ToolResult::error(format!("Unknown operation: {operation}"))), + } + } +} + +#[cfg(test)] +mod config_test; +#[cfg(test)] +mod ops_test; +#[cfg(test)] +mod render_test; +#[cfg(test)] +mod test; diff --git a/crates/tinytools-std/src/filesystem/git_operations/ops_test.rs b/crates/tinytools-std/src/filesystem/git_operations/ops_test.rs new file mode 100644 index 0000000..dd24399 --- /dev/null +++ b/crates/tinytools-std/src/filesystem/git_operations/ops_test.rs @@ -0,0 +1,368 @@ +//! End-to-end tests of the `git_operations` sub-operations against real, +//! hermetic repositories: parsing, success paths, failure paths and gate +//! refusals. + +#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)] + +use super::test::{hermetic, init_git_repo, test_tool}; +use super::*; +use crate::filesystem::test_support::{AutonomyLevel, TestGate, WorkspaceContext}; +use tempfile::TempDir; + +fn git(dir: &std::path::Path, args: &[&str]) { + let out = hermetic( + std::process::Command::new("git") + .args(args) + .current_dir(dir), + ) + .output() + .expect("spawn git"); + assert!( + out.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&out.stderr) + ); +} + +/// A repo with one commit of `a.txt` and identity configured locally. +fn repo_with_commit() -> TempDir { + let tmp = TempDir::new().unwrap(); + init_git_repo(tmp.path()); + git(tmp.path(), &["config", "user.email", "t@example.invalid"]); + git(tmp.path(), &["config", "user.name", "Tester"]); + git(tmp.path(), &["checkout", "-b", "main"]); + std::fs::write(tmp.path().join("a.txt"), "one\ntwo\nthree\n").unwrap(); + git(tmp.path(), &["add", "a.txt"]); + git(tmp.path(), &["commit", "-m", "first commit"]); + tmp +} + +async fn run(tool: &GitOperationsTool, args: serde_json::Value) -> ToolResult { + tool.execute(args).await.unwrap() +} + +#[test] +fn supports_markdown_is_advertised() { + let tmp = TempDir::new().unwrap(); + assert!(test_tool(tmp.path()).supports_markdown()); +} + +#[tokio::test] +async fn status_parses_staged_unstaged_and_untracked() { + let tmp = repo_with_commit(); + std::fs::write(tmp.path().join("a.txt"), "one\nTWO\nthree\n").unwrap(); + git(tmp.path(), &["add", "a.txt"]); + std::fs::write(tmp.path().join("a.txt"), "changed again\n").unwrap(); + std::fs::write(tmp.path().join("new.txt"), "n").unwrap(); + let tool = test_tool(tmp.path()); + + let result = run(&tool, json!({"operation": "status"})).await; + assert!(!result.is_error, "{}", result.output()); + let parsed: serde_json::Value = serde_json::from_str(&result.output()).unwrap(); + assert!(!parsed["staged"].as_array().unwrap().is_empty()); + assert!(!parsed["unstaged"].as_array().unwrap().is_empty()); + assert!( + parsed["untracked"] + .as_array() + .unwrap() + .contains(&json!("new.txt")) + ); + assert!(result.markdown_formatted.is_some()); +} + +#[tokio::test] +async fn diff_reports_hunks_for_unstaged_and_cached_changes() { + let tmp = repo_with_commit(); + std::fs::write(tmp.path().join("a.txt"), "one\nTWO\nthree\n").unwrap(); + std::fs::write(tmp.path().join("b.txt"), "x\n").unwrap(); + git(tmp.path(), &["add", "b.txt"]); + let tool = test_tool(tmp.path()); + + let unstaged = run(&tool, json!({"operation": "diff"})).await; + let parsed: serde_json::Value = serde_json::from_str(&unstaged.output()).unwrap(); + assert!(parsed["file_count"].as_u64().unwrap() >= 1); + assert_eq!(parsed["hunks"][0]["file"], "a.txt"); + let kinds: Vec<&str> = parsed["hunks"] + .as_array() + .unwrap() + .iter() + .flat_map(|h| h["lines"].as_array().unwrap().iter()) + .map(|l| l["type"].as_str().unwrap()) + .collect(); + assert!(kinds.contains(&"add") && kinds.contains(&"delete") && kinds.contains(&"context")); + + let cached = run(&tool, json!({"operation": "diff", "cached": true})).await; + let parsed: serde_json::Value = serde_json::from_str(&cached.output()).unwrap(); + assert_eq!(parsed["hunks"][0]["file"], "b.txt"); +} + +#[tokio::test] +async fn diff_with_several_files_and_hunks_splits_them() { + let tmp = repo_with_commit(); + let long: String = (0..40).fold(String::new(), |acc, i| acc + &format!("line{i}\n")); + std::fs::write(tmp.path().join("big.txt"), &long).unwrap(); + git(tmp.path(), &["add", "big.txt"]); + git(tmp.path(), &["commit", "-m", "big"]); + let edited = long + .replace("line1\n", "LINE1\n") + .replace("line38\n", "LINE38\n"); + std::fs::write(tmp.path().join("big.txt"), edited).unwrap(); + std::fs::write(tmp.path().join("a.txt"), "different\n").unwrap(); + let tool = test_tool(tmp.path()); + + let result = run(&tool, json!({"operation": "diff"})).await; + let parsed: serde_json::Value = serde_json::from_str(&result.output()).unwrap(); + assert!(parsed["file_count"].as_u64().unwrap() >= 3); + assert!( + parsed["hunks"] + .as_array() + .unwrap() + .iter() + .any(|h| h["header"].is_string()) + ); +} + +#[tokio::test] +async fn diff_rejects_injected_file_arguments() { + let tmp = repo_with_commit(); + let tool = test_tool(tmp.path()); + let result = tool + .execute(json!({"operation": "diff", "files": "--exec=evil"})) + .await; + assert!(result.is_err() || result.unwrap().is_error); +} + +#[tokio::test] +async fn log_lists_commits_with_markdown() { + let tmp = repo_with_commit(); + let tool = test_tool(tmp.path()); + let result = run(&tool, json!({"operation": "log", "limit": 5})).await; + assert!(!result.is_error, "{}", result.output()); + let parsed: serde_json::Value = serde_json::from_str(&result.output()).unwrap(); + assert_eq!(parsed["commits"][0]["message"], "first commit"); + assert_eq!(parsed["commits"][0]["author"], "Tester"); + assert!(result.markdown_formatted.unwrap().contains("first commit")); +} + +#[tokio::test] +async fn branch_lists_the_current_branch() { + let tmp = repo_with_commit(); + git(tmp.path(), &["branch", "side"]); + let tool = test_tool(tmp.path()); + let result = run(&tool, json!({"operation": "branch"})).await; + let parsed: serde_json::Value = serde_json::from_str(&result.output()).unwrap(); + assert_eq!(parsed["current"], "main"); + assert_eq!(parsed["branches"].as_array().unwrap().len(), 2); + assert!(result.markdown_formatted.unwrap().contains("← current")); +} + +#[tokio::test] +async fn add_then_commit_succeeds() { + let tmp = repo_with_commit(); + std::fs::write(tmp.path().join("c.txt"), "c").unwrap(); + let tool = test_tool(tmp.path()); + + let added = run(&tool, json!({"operation": "add", "paths": "c.txt"})).await; + assert_eq!(added.output(), "Staged: c.txt"); + + let committed = run( + &tool, + json!({"operation": "commit", "message": " add c \n\n more "}), + ) + .await; + assert!(!committed.is_error, "{}", committed.output()); + assert_eq!(committed.output(), "Committed: add c\nmore"); +} + +#[tokio::test] +async fn add_of_a_missing_path_reports_failure() { + let tmp = repo_with_commit(); + let tool = test_tool(tmp.path()); + let result = run(&tool, json!({"operation": "add", "paths": "nope.txt"})).await; + assert!(result.is_error); + assert!(result.output().starts_with("Add failed:")); +} + +#[tokio::test] +async fn commit_with_nothing_staged_reports_failure() { + let tmp = repo_with_commit(); + let tool = test_tool(tmp.path()); + let result = run(&tool, json!({"operation": "commit", "message": "empty"})).await; + assert!(result.is_error); + assert!(result.output().starts_with("Commit failed:")); +} + +#[tokio::test] +async fn commit_with_blank_message_is_an_error() { + let tmp = repo_with_commit(); + let tool = test_tool(tmp.path()); + let result = tool + .execute(json!({"operation": "commit", "message": " \n "})) + .await; + assert!(result.unwrap_err().to_string().contains("cannot be empty")); +} + +#[test] +fn overlong_commit_messages_are_truncated() { + let long = "x".repeat(2500); + let out = GitOperationsTool::truncate_commit_message(&long); + assert_eq!(out.chars().count(), 2000); + assert!(out.ends_with("...")); + assert_eq!(GitOperationsTool::truncate_commit_message("short"), "short"); +} + +#[tokio::test] +async fn checkout_switches_and_reports_failures() { + let tmp = repo_with_commit(); + git(tmp.path(), &["branch", "side"]); + let tool = test_tool(tmp.path()); + + let ok = run(&tool, json!({"operation": "checkout", "branch": "side"})).await; + assert_eq!(ok.output(), "Switched to branch: side"); + + let missing = run(&tool, json!({"operation": "checkout", "branch": "ghost"})).await; + assert!(missing.is_error); + assert!(missing.output().starts_with("Checkout failed:")); + + let invalid = tool + .execute(json!({"operation": "checkout", "branch": "--evil"})) + .await; + assert!(invalid.is_err() || invalid.unwrap().is_error); +} + +#[tokio::test] +async fn stash_push_list_pop_and_drop() { + let tmp = repo_with_commit(); + let tool = test_tool(tmp.path()); + std::fs::write(tmp.path().join("a.txt"), "dirty\n").unwrap(); + + let push = run(&tool, json!({"operation": "stash", "action": "push"})).await; + assert!(!push.is_error, "{}", push.output()); + let list = run(&tool, json!({"operation": "stash", "action": "list"})).await; + assert!(list.output().contains("auto-stash")); + let pop = run(&tool, json!({"operation": "stash", "action": "pop"})).await; + assert!(!pop.is_error, "{}", pop.output()); + + git(tmp.path(), &["stash", "push", "-m", "again"]); + let drop = run( + &tool, + json!({"operation": "stash", "action": "drop", "index": 0}), + ) + .await; + assert!(!drop.is_error, "{}", drop.output()); + + let bad = run( + &tool, + json!({"operation": "stash", "action": "drop", "index": 7}), + ) + .await; + assert!(bad.is_error); + assert!(bad.output().starts_with("Stash drop failed:")); + + let huge = tool + .execute(json!({"operation": "stash", "action": "drop", "index": 4_000_000_000u64})) + .await; + assert!(huge.unwrap_err().to_string().contains("too large")); +} + +#[tokio::test] +async fn pop_with_no_stash_reports_failure() { + let tmp = repo_with_commit(); + let tool = test_tool(tmp.path()); + let result = run(&tool, json!({"operation": "stash", "action": "pop"})).await; + assert!(result.is_error); + assert!(result.output().starts_with("Stash pop failed:")); +} + +#[tokio::test] +async fn write_operations_are_refused_when_the_gate_is_read_only() { + let tmp = repo_with_commit(); + let gate = TestGate::with(tmp.path().to_path_buf(), AutonomyLevel::ReadOnly, 1_000_000); + let tool = GitOperationsTool::new(gate, tmp.path().to_path_buf()); + let result = run(&tool, json!({"operation": "add", "paths": "a.txt"})).await; + assert!(result.is_error); + assert!(result.output().contains("higher autonomy level")); +} + +/// A gate that permits acting but reports strict read-only mode. +#[derive(Debug)] +struct ReadOnlyButActing(std::path::PathBuf); + +#[async_trait] +impl FsGate for ReadOnlyButActing { + fn can_act(&self) -> bool { + true + } + fn is_read_only(&self) -> bool { + true + } + fn is_rate_limited(&self) -> bool { + false + } + fn record_action(&self) -> bool { + true + } + fn write_needs_approval(&self) -> bool { + false + } + fn action_dir(&self) -> &Path { + &self.0 + } + fn is_path_string_allowed(&self, _path: &str) -> bool { + true + } + async fn validate_path(&self, path: &str) -> Result { + Ok(PathBuf::from(path)) + } + async fn validate_parent_path(&self, path: &str) -> Result { + Ok(PathBuf::from(path)) + } + fn scoped_to_workspace(&self, root: &Path) -> Arc { + Arc::new(Self(root.to_path_buf())) + } +} + +#[tokio::test] +async fn read_only_mode_blocks_writes_even_when_the_gate_allows_acting() { + let tmp = repo_with_commit(); + let gate: Arc = Arc::new(ReadOnlyButActing(tmp.path().to_path_buf())); + let tool = GitOperationsTool::new(gate, tmp.path().to_path_buf()); + let result = run(&tool, json!({"operation": "add", "paths": "a.txt"})).await; + assert_eq!( + result.output(), + "[policy-blocked] Action blocked: read-only mode" + ); +} + +#[tokio::test] +async fn an_exhausted_action_budget_blocks_the_operation() { + let tmp = repo_with_commit(); + let gate = TestGate::with(tmp.path().to_path_buf(), AutonomyLevel::Supervised, 0); + let tool = GitOperationsTool::new(gate, tmp.path().to_path_buf()); + let result = run(&tool, json!({"operation": "status"})).await; + assert_eq!(result.output(), "Action blocked: rate limit exceeded"); +} + +#[tokio::test] +async fn the_execute_wrappers_agree_and_honour_a_workspace_descriptor() { + let unrelated = TempDir::new().unwrap(); + let repo = repo_with_commit(); + let tool = test_tool(unrelated.path()); + let context = WorkspaceContext::at(repo.path()); + + let with_options = tool + .execute_with_options(json!({"operation": "branch"}), ToolCallOptions::default()) + .await + .unwrap(); + assert!(with_options.is_error, "the unrelated dir is not a repo"); + + let with_context = tool + .execute_with_context( + json!({"operation": "branch"}), + ToolCallOptions::default(), + Some(&context), + ) + .await + .unwrap(); + assert!(!with_context.is_error, "{}", with_context.output()); +} diff --git a/crates/tinytools-std/src/filesystem/git_operations/render.rs b/crates/tinytools-std/src/filesystem/git_operations/render.rs new file mode 100644 index 0000000..124c355 --- /dev/null +++ b/crates/tinytools-std/src/filesystem/git_operations/render.rs @@ -0,0 +1,91 @@ +//! Markdown renderers for [`super::git_operations`] results. +//! +//! Split out of `git_operations.rs` for the Rust layout gate. Pure formatting: +//! these take already-parsed JSON and produce the human-readable summary, with +//! no git invocation of their own. + +pub(super) fn render_status_markdown( + result: &serde_json::Map, +) -> String { + let mut out = String::new(); + if let Some(branch) = result.get("branch").and_then(|v| v.as_str()) { + out.push_str(&format!("**branch**: `{branch}`\n")); + } + let clean = result + .get("clean") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + if clean { + out.push_str("_Working tree clean._\n"); + return out; + } + let push_section = |out: &mut String, label: &str, items: Option<&Vec>| { + if let Some(items) = items + && !items.is_empty() + { + out.push_str(&format!("\n**{label}** ({})\n", items.len())); + for it in items { + if let (Some(p), Some(s)) = ( + it.get("path").and_then(|v| v.as_str()), + it.get("status").and_then(|v| v.as_str()), + ) { + out.push_str(&format!("- `{s}` {p}\n")); + } + } + } + }; + push_section( + &mut out, + "staged", + result.get("staged").and_then(|v| v.as_array()), + ); + push_section( + &mut out, + "unstaged", + result.get("unstaged").and_then(|v| v.as_array()), + ); + if let Some(items) = result.get("untracked").and_then(|v| v.as_array()) + && !items.is_empty() + { + out.push_str(&format!("\n**untracked** ({})\n", items.len())); + for it in items { + if let Some(p) = it.as_str() { + out.push_str(&format!("- {p}\n")); + } + } + } + out +} + +pub(super) fn render_log_markdown(commits: &[serde_json::Value]) -> String { + if commits.is_empty() { + return "_No commits._".to_string(); + } + let mut out = format!("# Commits ({})\n", commits.len()); + for c in commits { + let hash = c.get("hash").and_then(|v| v.as_str()).unwrap_or(""); + let short = hash.get(..hash.len().min(8)).unwrap_or(hash); + let author = c.get("author").and_then(|v| v.as_str()).unwrap_or(""); + let date = c.get("date").and_then(|v| v.as_str()).unwrap_or(""); + let msg = c.get("message").and_then(|v| v.as_str()).unwrap_or(""); + out.push_str(&format!("- `{short}` {msg} _(by {author}, {date})_\n")); + } + out +} + +pub(super) fn render_branch_markdown(current: &str, branches: &[serde_json::Value]) -> String { + let mut out = format!("**current**: `{current}`\n\n## Branches\n"); + for b in branches { + let name = b.get("name").and_then(|v| v.as_str()).unwrap_or(""); + let cur = b + .get("current") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + if cur { + out.push_str(&format!("- **{name}** ← current\n")); + } else { + out.push_str(&format!("- {name}\n")); + } + } + out +} diff --git a/crates/tinytools-std/src/filesystem/git_operations/render_test.rs b/crates/tinytools-std/src/filesystem/git_operations/render_test.rs new file mode 100644 index 0000000..f41c507 --- /dev/null +++ b/crates/tinytools-std/src/filesystem/git_operations/render_test.rs @@ -0,0 +1,83 @@ +//! Tests for the markdown renderers: literal JSON in, literal markdown out. + +#![allow(clippy::unwrap_used)] + +use super::render::{render_branch_markdown, render_log_markdown, render_status_markdown}; +use serde_json::{Value, json}; + +fn status(value: &Value) -> String { + render_status_markdown(value.as_object().unwrap()) +} + +#[test] +fn clean_status_stops_after_the_branch_line() { + let md = + status(&json!({"branch": "main", "clean": true, "staged": [{"path": "a", "status": "M"}]})); + assert_eq!(md, "**branch**: `main`\n_Working tree clean._\n"); +} + +#[test] +fn dirty_status_lists_each_section_with_counts() { + let md = status(&json!({ + "branch": "feat", + "clean": false, + "staged": [{"path": "a.rs", "status": "M"}, {"path": "b.rs", "status": "A"}], + "unstaged": [{"path": "c.rs", "status": "D"}], + "untracked": ["new.txt"], + })); + assert_eq!( + md, + "**branch**: `feat`\n\ + \n**staged** (2)\n- `M` a.rs\n- `A` b.rs\n\ + \n**unstaged** (1)\n- `D` c.rs\n\ + \n**untracked** (1)\n- new.txt\n" + ); +} + +#[test] +fn status_skips_empty_sections_and_malformed_entries() { + let md = status(&json!({ + "staged": [], + "unstaged": [{"path": "only-path"}, {"status": "M"}], + "untracked": [42], + })); + assert_eq!(md, "\n**unstaged** (2)\n\n**untracked** (1)\n"); +} + +#[test] +fn status_without_a_branch_or_clean_flag_renders_nothing() { + assert_eq!(status(&json!({})), ""); +} + +#[test] +fn log_renders_short_hashes_and_defaults_missing_fields() { + assert_eq!(render_log_markdown(&[]), "_No commits._"); + let md = render_log_markdown(&[ + json!({"hash": "0123456789abcdef", "author": "Ann", "date": "2026-01-02", "message": "fix"}), + json!({"hash": "abc"}), + json!({}), + ]); + assert_eq!( + md, + "# Commits (3)\n\ + - `01234567` fix _(by Ann, 2026-01-02)_\n\ + - `abc` _(by , )_\n\ + - `` _(by , )_\n" + ); +} + +#[test] +fn branches_mark_the_current_one() { + let md = render_branch_markdown( + "main", + &[ + json!({"name": "main", "current": true}), + json!({"name": "dev"}), + json!({}), + ], + ); + assert_eq!( + md, + "**current**: `main`\n\n## Branches\n- **main** ← current\n- dev\n- \n" + ); +} diff --git a/crates/tinytools-std/src/filesystem/git_operations/test.rs b/crates/tinytools-std/src/filesystem/git_operations/test.rs new file mode 100644 index 0000000..5b836b9 --- /dev/null +++ b/crates/tinytools-std/src/filesystem/git_operations/test.rs @@ -0,0 +1,455 @@ +//! Behavior tests for the `git_operations` tool, driven through a fake [`FsGate`]. + +#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)] + +use super::config::NULL_CONFIG_PATH; +use super::*; +use crate::filesystem::test_support::{AutonomyLevel, TestGate, WorkspaceContext}; +use tempfile::TempDir; + +pub(super) fn test_tool(dir: &std::path::Path) -> GitOperationsTool { + let security = TestGate::with(dir.to_path_buf(), AutonomyLevel::Supervised, 1_000_000); + GitOperationsTool::new(security, dir.to_path_buf()) +} + +#[test] +fn sanitize_git_blocks_injection() { + let tmp = TempDir::new().unwrap(); + let tool = test_tool(tmp.path()); + + // Should block dangerous arguments + assert!(tool.sanitize_git_args("--exec=rm -rf /").is_err()); + assert!(tool.sanitize_git_args("$(echo pwned)").is_err()); + assert!(tool.sanitize_git_args("`malicious`").is_err()); + assert!(tool.sanitize_git_args("arg | cat").is_err()); + assert!(tool.sanitize_git_args("arg; rm file").is_err()); +} + +#[test] +fn sanitize_git_blocks_pager_editor_injection() { + let tmp = TempDir::new().unwrap(); + let tool = test_tool(tmp.path()); + + assert!(tool.sanitize_git_args("--pager=less").is_err()); + assert!(tool.sanitize_git_args("--editor=vim").is_err()); +} + +#[test] +fn sanitize_git_blocks_config_injection() { + let tmp = TempDir::new().unwrap(); + let tool = test_tool(tmp.path()); + + // Exact `-c` flag (config injection) + assert!(tool.sanitize_git_args("-c core.sshCommand=evil").is_err()); + assert!(tool.sanitize_git_args("-c=core.pager=less").is_err()); +} + +#[test] +fn sanitize_git_blocks_no_verify() { + let tmp = TempDir::new().unwrap(); + let tool = test_tool(tmp.path()); + + assert!(tool.sanitize_git_args("--no-verify").is_err()); +} + +#[test] +fn sanitize_git_blocks_redirect_in_args() { + let tmp = TempDir::new().unwrap(); + let tool = test_tool(tmp.path()); + + assert!(tool.sanitize_git_args("file.txt > /tmp/out").is_err()); +} + +#[test] +fn sanitize_git_cached_not_blocked() { + let tmp = TempDir::new().unwrap(); + let tool = test_tool(tmp.path()); + + // --cached must NOT be blocked by the `-c` check + assert!(tool.sanitize_git_args("--cached").is_ok()); + // Other safe flags starting with -c prefix + assert!(tool.sanitize_git_args("-cached").is_ok()); +} + +#[test] +fn sanitize_git_allows_safe() { + let tmp = TempDir::new().unwrap(); + let tool = test_tool(tmp.path()); + + // Should allow safe arguments + assert!(tool.sanitize_git_args("main").is_ok()); + assert!(tool.sanitize_git_args("feature/test-branch").is_ok()); + assert!(tool.sanitize_git_args("--cached").is_ok()); + assert!(tool.sanitize_git_args("src/main.rs").is_ok()); + assert!(tool.sanitize_git_args(".").is_ok()); +} + +/// Parity guard for the worktree-isolation action-dir override (#3376, +/// #4249 08.5). A worktree-isolated worker's git operation MUST resolve its CWD +/// from the carried `WorkspaceDescriptor` (the isolated worktree), never the +/// tool's configured `action_dir`. WITHOUT a descriptor it falls back to +/// `action_dir` — the non-isolated path, byte-identical to before. This encodes +/// the behaviour the deleted `worktree_context.rs` task-local used to provide. +#[test] +fn git_resolves_cwd_from_workspace_descriptor() { + let action_tmp = TempDir::new().unwrap(); + let worktree_tmp = TempDir::new().unwrap(); + let tool = test_tool(action_tmp.path()); + + // WITH a descriptor → the worktree root wins. + let tool_ctx = WorkspaceContext::at(worktree_tmp.path()); + assert_eq!( + tool.effective_action_dir_for_context(Some(&tool_ctx)), + worktree_tmp.path().to_path_buf(), + "git with a WorkspaceDescriptor must resolve CWD to the worktree root" + ); + + // WITHOUT a descriptor → configured action_dir (non-isolated parity). + assert_eq!( + tool.effective_action_dir_for_context(None), + action_tmp.path().to_path_buf(), + "git with no descriptor must fall back to the configured action_dir" + ); +} + +#[test] +fn requires_write_detection() { + let tmp = TempDir::new().unwrap(); + let tool = test_tool(tmp.path()); + + assert!(tool.requires_write_access("commit")); + assert!(tool.requires_write_access("add")); + assert!(tool.requires_write_access("checkout")); + + assert!(!tool.requires_write_access("status")); + assert!(!tool.requires_write_access("diff")); + assert!(!tool.requires_write_access("log")); +} + +#[test] +fn branch_is_not_write_gated() { + let tmp = TempDir::new().unwrap(); + let tool = test_tool(tmp.path()); + + // Branch listing is read-only; it must not require write access + assert!(!tool.requires_write_access("branch")); + assert!(tool.is_read_only("branch")); +} + +#[test] +fn is_read_only_detection() { + let tmp = TempDir::new().unwrap(); + let tool = test_tool(tmp.path()); + + assert!(tool.is_read_only("status")); + assert!(tool.is_read_only("diff")); + assert!(tool.is_read_only("log")); + assert!(tool.is_read_only("branch")); + + assert!(!tool.is_read_only("commit")); + assert!(!tool.is_read_only("add")); +} + +#[tokio::test] +async fn blocks_readonly_mode_for_write_ops() { + let tmp = TempDir::new().unwrap(); + init_git_repo(tmp.path()); + + let security = TestGate::with(tmp.path().to_path_buf(), AutonomyLevel::ReadOnly, 1_000_000); + let tool = GitOperationsTool::new(security, tmp.path().to_path_buf()); + + let result = tool + .execute(json!({"operation": "commit", "message": "test"})) + .await + .unwrap(); + assert!(result.is_error); + // can_act() returns false for ReadOnly, so we get the "higher autonomy level" message + assert!(result.output().contains("higher autonomy")); +} + +#[tokio::test] +async fn allows_branch_listing_in_readonly_mode() { + let tmp = TempDir::new().unwrap(); + init_git_repo(tmp.path()); + + let security = TestGate::with(tmp.path().to_path_buf(), AutonomyLevel::ReadOnly, 1_000_000); + let tool = GitOperationsTool::new(security, tmp.path().to_path_buf()); + + let result = tool.execute(json!({"operation": "branch"})).await.unwrap(); + // Branch listing must not be blocked by read-only autonomy + let error_msg = result.output(); + assert!( + !error_msg.contains("read-only") && !error_msg.contains("higher autonomy"), + "branch listing should not be blocked in read-only mode, got: {error_msg}" + ); +} + +#[tokio::test] +async fn allows_readonly_ops_in_readonly_mode() { + let tmp = TempDir::new().unwrap(); + let security = TestGate::with(tmp.path().to_path_buf(), AutonomyLevel::ReadOnly, 1_000_000); + let tool = GitOperationsTool::new(security, tmp.path().to_path_buf()); + + // This will fail because there's no git repo, but it shouldn't be blocked by autonomy + let result = tool.execute(json!({"operation": "status"})).await.unwrap(); + // The error should be about git (not about autonomy/read-only mode) + assert!(result.is_error, "Expected failure due to missing git repo"); + let error_msg = result.output(); + assert!( + !error_msg.is_empty(), + "Expected a git-related error message" + ); + assert!( + !error_msg.contains("read-only") && !error_msg.contains("autonomy"), + "Error should be about git, not about autonomy restrictions: {error_msg}" + ); +} + +#[tokio::test] +async fn rejects_missing_operation() { + let tmp = TempDir::new().unwrap(); + let tool = test_tool(tmp.path()); + + let result = tool.execute(json!({})).await.unwrap(); + assert!(result.is_error); + assert!(result.output().contains("Missing 'operation'")); +} + +#[tokio::test] +async fn rejects_unknown_operation() { + let tmp = TempDir::new().unwrap(); + init_git_repo(tmp.path()); + + let tool = test_tool(tmp.path()); + + let result = tool.execute(json!({"operation": "push"})).await.unwrap(); + assert!(result.is_error); + assert!(result.output().contains("Unknown operation")); +} + +#[test] +fn truncates_multibyte_commit_message_without_panicking() { + let long = "🦀".repeat(2500); + let truncated = GitOperationsTool::truncate_commit_message(&long); + + assert_eq!(truncated.chars().count(), 2000); +} + +// ── truncate_commit_message: short messages pass through unchanged ───────── + +#[test] +fn truncate_short_message_unchanged() { + let msg = "Fix the bug"; + assert_eq!(GitOperationsTool::truncate_commit_message(msg), msg); +} + +#[test] +fn truncate_exact_2000_chars_unchanged() { + let msg = "a".repeat(2000); + let result = GitOperationsTool::truncate_commit_message(&msg); + assert_eq!(result.chars().count(), 2000); + assert!(!result.ends_with("...")); +} + +#[test] +fn truncate_2001_chars_adds_ellipsis() { + let msg = "a".repeat(2001); + let result = GitOperationsTool::truncate_commit_message(&msg); + assert!(result.ends_with("...")); + assert_eq!(result.chars().count(), 2000); +} + +// ── sanitize_git_args: allow leading dash that is not -c ───────────────── + +#[test] +fn sanitize_git_allows_other_flags() { + let tmp = TempDir::new().unwrap(); + let tool = test_tool(tmp.path()); + assert!(tool.sanitize_git_args("--follow").is_ok()); + assert!(tool.sanitize_git_args("-p").is_ok()); + assert!(tool.sanitize_git_args("-n5").is_ok()); +} + +// ── requires_write_access completeness ──────────────────────────────────── + +#[test] +fn requires_write_access_covers_all_write_ops() { + let tmp = TempDir::new().unwrap(); + let tool = test_tool(tmp.path()); + for op in ["commit", "add", "checkout", "stash", "reset", "revert"] { + assert!( + tool.requires_write_access(op), + "'{op}' should require write access" + ); + } +} + +// ── schema validation ───────────────────────────────────────────────────── + +#[test] +fn schema_has_required_operation() { + let tmp = TempDir::new().unwrap(); + let tool = test_tool(tmp.path()); + let schema = tool.parameters_schema(); + let required = schema["required"].as_array().unwrap(); + assert!( + required.contains(&serde_json::json!("operation")), + "schema required should include 'operation'" + ); +} + +#[test] +fn schema_enumerates_operations() { + let tmp = TempDir::new().unwrap(); + let tool = test_tool(tmp.path()); + let schema = tool.parameters_schema(); + let ops = schema["properties"]["operation"]["enum"] + .as_array() + .unwrap(); + let op_names: Vec<&str> = ops.iter().map(|v| v.as_str().unwrap()).collect(); + for expected in [ + "status", "diff", "log", "branch", "commit", "add", "checkout", "stash", + ] { + assert!( + op_names.contains(&expected), + "schema should include '{expected}'" + ); + } +} + +// ── git_operations tool name / description ──────────────────────────────── + +#[test] +fn tool_name_and_description() { + let tmp = TempDir::new().unwrap(); + let tool = test_tool(tmp.path()); + assert_eq!(tool.name(), "git_operations"); + assert!(!tool.description().is_empty()); + assert!(tool.description().contains("Git")); +} + +// ── not_in_git_repo returns error (covers the git-repo check) ───────────── + +#[tokio::test] +async fn not_in_git_repo_returns_error() { + let tmp = TempDir::new().unwrap(); + // Do NOT init a git repo + let tool = test_tool(tmp.path()); + let result = tool.execute(json!({"operation": "status"})).await.unwrap(); + assert!(result.is_error); + assert!(result.output().contains("Not in a git repository")); +} + +/// Suppress the developer's own system/global git config on a raw +/// `std::process::Command`, so a machine-local `init.templateDir` or similar +/// cannot write extra keys into a test repository's `.git/config` and make +/// these tests depend on ambient environment. Mirrors [`hardened_git`]'s two +/// env vars; the production code under test applies its own suppression when +/// it later reads this same config, so this only affects setup. +pub(super) fn hermetic(cmd: &mut std::process::Command) -> &mut std::process::Command { + cmd.env("GIT_CONFIG_NOSYSTEM", "1") + .env("GIT_CONFIG_GLOBAL", NULL_CONFIG_PATH) + .env_remove("GIT_CONFIG_PARAMETERS") + .env_remove("GIT_CONFIG_COUNT") +} + +/// Initialise a git repo at `path` and fail the test if `git init` +/// itself didn't succeed (so we don't misread later assertion failures +/// as product bugs when the real problem is a missing/broken git). +pub(super) fn init_git_repo(path: &std::path::Path) { + let output = hermetic( + std::process::Command::new("git") + .args(["init"]) + .current_dir(path), + ) + .output() + .expect("failed to spawn `git init`"); + assert!( + output.status.success(), + "`git init` failed: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + +/// Extract the error text from a Result — whether the +/// failure came through `Err(anyhow::Error)` or `Ok(ToolResult::error)`. +pub(super) fn error_text(result: &anyhow::Result) -> String { + match result { + Ok(r) => { + assert!(r.is_error, "expected a tool-error ToolResult"); + r.output().clone() + } + Err(e) => e.to_string(), + } +} + +// ── stash: unknown action returns error ──────────────────────────────────── + +#[tokio::test] +async fn stash_unknown_action_returns_error() { + let tmp = TempDir::new().unwrap(); + init_git_repo(tmp.path()); + + let tool = test_tool(tmp.path()); + let result = tool + .execute(json!({"operation": "stash", "action": "squash"})) + .await; + let msg = error_text(&result); + assert!( + msg.contains("Unknown stash action"), + "expected 'Unknown stash action' in error, got: {msg}" + ); +} + +// ── checkout: dangerous characters ──────────────────────────────────────── + +#[tokio::test] +async fn checkout_rejects_dangerous_branch_names() { + let tmp = TempDir::new().unwrap(); + init_git_repo(tmp.path()); + + let tool = test_tool(tmp.path()); + + for dangerous in ["main@{1}", "HEAD^", "v1~2"] { + let result = tool + .execute(json!({"operation": "checkout", "branch": dangerous})) + .await; + let msg = error_text(&result); + assert!( + msg.contains("invalid characters") || msg.contains("Invalid branch"), + "expected a dangerous-branch rejection for '{dangerous}', got: {msg}" + ); + } +} + +// ── commit: missing message ──────────────────────────────────────────────── + +#[tokio::test] +async fn commit_missing_message_returns_error() { + let tmp = TempDir::new().unwrap(); + init_git_repo(tmp.path()); + + let tool = test_tool(tmp.path()); + let result = tool.execute(json!({"operation": "commit"})).await; + let msg = error_text(&result); + assert!( + msg.contains("Missing 'message' parameter"), + "expected missing-message error, got: {msg}" + ); +} + +// ── add: missing paths ───────────────────────────────────────────────────── + +#[tokio::test] +async fn add_missing_paths_returns_error() { + let tmp = TempDir::new().unwrap(); + init_git_repo(tmp.path()); + + let tool = test_tool(tmp.path()); + let result = tool.execute(json!({"operation": "add"})).await; + let msg = error_text(&result); + assert!( + msg.contains("Missing 'paths' parameter"), + "expected missing-paths error, got: {msg}" + ); +} diff --git a/crates/tinytools-std/src/filesystem/glob_search/extra_test.rs b/crates/tinytools-std/src/filesystem/glob_search/extra_test.rs new file mode 100644 index 0000000..d9706bc --- /dev/null +++ b/crates/tinytools-std/src/filesystem/glob_search/extra_test.rs @@ -0,0 +1,105 @@ +//! Budget, root-validation, truncation and policy-filter tests for `glob`. + +#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)] + +use super::*; +use crate::filesystem::test_support::{TestGate, WorkspaceContext, WrapGate}; +use tempfile::TempDir; + +#[test] +fn glob_is_concurrency_safe() { + let dir = TempDir::new().unwrap(); + let tool = GlobTool::new(TestGate::supervised(dir.path().to_path_buf())); + assert!(tool.is_concurrency_safe(&json!({}))); +} + +#[tokio::test] +async fn rate_limit_and_exhausted_budget_are_refused() { + let dir = TempDir::new().unwrap(); + let limited = GlobTool::new(TestGate::with( + dir.path().to_path_buf(), + crate::filesystem::test_support::AutonomyLevel::Supervised, + 0, + )); + let r = limited.execute(json!({"pattern": "*"})).await.unwrap(); + assert!(r.output().contains("too many actions in the last hour")); + + let gate = WrapGate::new(TestGate::supervised(dir.path().to_path_buf())) + .no_budget() + .arc(); + let r = GlobTool::new(gate) + .execute(json!({"pattern": "*"})) + .await + .unwrap(); + assert!(r.output().contains("action budget exhausted")); +} + +#[tokio::test] +async fn a_root_that_vanishes_after_validation_is_reported() { + let dir = TempDir::new().unwrap(); + let gate = WrapGate::new(TestGate::supervised(dir.path().to_path_buf())) + .fixed_path(dir.path().join("gone")) + .arc(); + let r = GlobTool::new(gate) + .execute(json!({"pattern": "*"})) + .await + .unwrap(); + assert!(r.is_error); + assert!(r.output().contains("is not accessible")); +} + +#[tokio::test] +async fn a_missing_action_dir_falls_back_to_its_raw_path() { + let dir = TempDir::new().unwrap(); + std::fs::write(dir.path().join("a.txt"), "").unwrap(); + let gate = WrapGate::new(TestGate::supervised(dir.path().to_path_buf())) + .action_dir_override(dir.path().join("does-not-exist")) + .fixed_path(dir.path().to_path_buf()) + .arc(); + let r = GlobTool::new(gate) + .execute(json!({"pattern": "*.txt"})) + .await + .unwrap(); + assert!(!r.is_error, "{}", r.output()); + assert!(r.output().contains("a.txt")); +} + +#[tokio::test] +async fn results_report_truncation() { + let dir = TempDir::new().unwrap(); + std::fs::write(dir.path().join("a.txt"), "").unwrap(); + std::fs::write(dir.path().join("b.txt"), "").unwrap(); + let tool = GlobTool::new(TestGate::supervised(dir.path().to_path_buf())); + let ctx = WorkspaceContext::at(dir.path()); + let r = tool + .execute_with_context( + json!({"pattern": "*.txt", "max_results": 1}), + ToolCallOptions::default(), + Some(&ctx), + ) + .await + .unwrap(); + assert!( + r.output().starts_with("1 match(es) (truncated at 1)"), + "{}", + r.output() + ); +} + +#[cfg(unix)] +#[tokio::test] +async fn hits_the_gate_refuses_are_filtered_out() { + let dir = TempDir::new().unwrap(); + let outside = TempDir::new().unwrap(); + std::fs::write(outside.path().join("secret.txt"), "s").unwrap(); + std::fs::write(dir.path().join("ok.txt"), "").unwrap(); + std::os::unix::fs::symlink( + outside.path().join("secret.txt"), + dir.path().join("link.txt"), + ) + .unwrap(); + let tool = GlobTool::new(TestGate::supervised(dir.path().to_path_buf())); + let r = tool.execute(json!({"pattern": "*.txt"})).await.unwrap(); + assert!(r.output().contains("ok.txt")); + assert!(!r.output().contains("link.txt"), "{}", r.output()); +} diff --git a/crates/tinytools-std/src/filesystem/glob_search/mod.rs b/crates/tinytools-std/src/filesystem/glob_search/mod.rs new file mode 100644 index 0000000..a2ef5c0 --- /dev/null +++ b/crates/tinytools-std/src/filesystem/glob_search/mod.rs @@ -0,0 +1,326 @@ +//! `glob` — find files by glob pattern. +//! +//! Coding-harness baseline tool (issue #1205): pure file discovery +//! by pattern (e.g. `src/**/*.rs`). +//! +//! ## Path-root contract (issue #3357) +//! +//! `glob` resolves its search root through the **same** seam the reader tools +//! use — [`SecurityPolicy::validate_path`] — and filters every hit through +//! [`SecurityPolicy::is_path_string_allowed`]. This guarantees the core +//! invariant a discovery tool must hold: **every path `glob` returns is a path +//! `file_read`/`grep`/`list` can open, and nothing else.** +//! +//! Previously `glob` walked `workspace_dir` (internal product state) and +//! returned paths relative to it, while the readers resolve relative paths +//! against `action_dir`. The two roots normally differ, so a path `glob` +//! returned was meaningless to the readers — every follow-up read failed with +//! "No such file or directory (os error 2)" (#3357). Walking `workspace_dir` +//! also let the agent enumerate internal state (memory DBs, sessions, tokens). +//! +//! The optional `path` argument lets the agent search any location it is +//! allowed to read (the action sandbox by default, or a granted `trusted_root`) +//! — it is resolved through `validate_path`, so it can never widen access +//! beyond what the readers already permit. + +use super::gate::{FsGate, gate_for_context}; +use async_trait::async_trait; +use glob::Pattern; +use serde_json::json; +use std::path::Path; +use std::sync::Arc; +use tinytools::ToolRunContext; +use tinytools::{PermissionLevel, Tool, ToolCallOptions, ToolResult}; +use walkdir::WalkDir; + +const DEFAULT_MAX_RESULTS: usize = 500; + +/// Find files by glob pattern, newest first. +#[derive(Debug)] +pub struct GlobTool { + gate: Arc, +} + +impl GlobTool { + /// Create the tool. + #[must_use] + pub fn new(gate: Arc) -> Self { + Self { gate } + } +} + +#[async_trait] +impl Tool for GlobTool { + fn name(&self) -> &'static str { + "glob" + } + + fn description(&self) -> &'static str { + "Find files matching a glob pattern (e.g. `src/**/*.rs`), searching the action sandbox unless `path` says otherwise. Returns paths newest-first, ready to pass straight to `file_read` / `grep`." + } + + 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). Matched against paths relative to the search directory." + }, + "path": { + "type": "string", + "description": "Directory to search within. Defaults to the working directory (action sandbox). Must be a location you're allowed to read; relative paths resolve against the working directory." + }, + "max_results": { + "type": "integer", + "description": "Cap on returned paths (default 500).", + "minimum": 1 + } + }, + "required": ["pattern"] + }) + } + + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::ReadOnly + } + + /// Pure read — safe to fan out across parallel `glob` calls. + fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool { + true + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + self.execute_in_context(args, None).await + } + + async fn execute_with_context( + &self, + args: serde_json::Value, + _options: ToolCallOptions, + context: Option<&dyn ToolRunContext>, + ) -> anyhow::Result { + self.execute_in_context(args, context).await + } +} + +impl GlobTool { + async fn execute_in_context( + &self, + args: serde_json::Value, + context: Option<&dyn ToolRunContext>, + ) -> anyhow::Result { + let pattern_str = args + .get("pattern") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing 'pattern' parameter"))?; + // Default the search root to "." — which `validate_path` resolves to the + // action sandbox (action_dir), the same root file_read/grep/list use. + let search_path = args + .get("path") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .unwrap_or("."); + let max_results = args + .get("max_results") + .and_then(serde_json::Value::as_u64) + .map_or(DEFAULT_MAX_RESULTS, |n| (n as usize).max(1)); + + if self.gate.is_rate_limited() { + return Ok(ToolResult::error( + "Rate limit exceeded: too many actions in the last hour", + )); + } + if !self.gate.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 path_policy = gate_for_context(&self.gate, context, "glob"); + + log::debug!( + "[tools:glob] search start: pattern='{pattern_str}' path='{search_path}' max_results={max_results}" + ); + + // Resolve + authorize the search root through the readers' validation + // seam. A disallowed or missing root yields a clear, path-naming error + // instead of the opaque "No such file or directory" the old workspace_dir + // root produced (#3357). + let base = match path_policy.validate_path(search_path).await { + Ok(p) => p, + Err(e) => { + log::debug!("[tools:glob] search path rejected: path='{search_path}' error={e}"); + return Ok(ToolResult::error(format!( + "glob search path '{search_path}' is not accessible: {e}" + ))); + } + }; + // `validate_path` can authorize a *file* as well as a directory. Walking a + // file root yields the file itself, whose path relative to `base` is empty, + // so the pattern never matches and the agent gets a misleading "0 match(es)" + // instead of a clear error. Reject non-directory roots explicitly. + match tokio::fs::metadata(&base).await { + Ok(meta) if meta.is_dir() => {} + Ok(_) => { + log::debug!("[tools:glob] search path is not a directory: path='{search_path}'"); + return Ok(ToolResult::error(format!( + "glob search path '{search_path}' is not a directory" + ))); + } + Err(e) => { + log::debug!( + "[tools:glob] search path not accessible: path='{search_path}' error={e}" + ); + return Ok(ToolResult::error(format!( + "glob search path '{search_path}' is not accessible: {e}" + ))); + } + } + log::debug!( + "[tools:glob] resolved search root: '{}' (action_dir='{}')", + base.display(), + path_policy.action_dir().display() + ); + + // Canonical action sandbox, used to decide whether a hit is rendered + // relative (inside the sandbox → directly file_read-able by relative + // path) or absolute (in a granted trusted root → file_read-able as-is). + // `base` is already canonical (validate_path canonicalizes), so if this + // fallback fires the two roots become asymmetric and strip_prefix below + // may miss for an in-sandbox hit, rendering it absolute. Harmless: the + // absolute path is still readable and the per-hit filter still applies. + let action_root = tokio::fs::canonicalize(path_policy.action_dir()) + .await + .unwrap_or_else(|e| { + log::trace!( + "[tools:glob] action_dir canonicalize fallback: path='{}' error={e}", + path_policy.action_dir().display() + ); + path_policy.action_dir().to_path_buf() + }); + + let result = tokio::task::spawn_blocking(move || { + collect_matches( + &base, + &action_root, + path_policy.as_ref(), + &pattern, + max_results, + ) + }) + .await + .map_err(|e| anyhow::anyhow!("scan task failed: {e}"))?; + + let (paths, truncated) = result; + log::debug!( + "[tools:glob] scan complete: {} match(es) truncated={}", + paths.len(), + truncated + ); + 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)) + } +} + +/// Walk `base`, returning glob matches as paths the reader tools can open. +/// +/// - The pattern is matched against each file's path **relative to `base`** +/// (the searched directory), the conventional glob behavior. +/// - The returned string is **relative to `action_root`** when the hit lives +/// inside the action sandbox (so `file_read("src/a.rs")` works), otherwise +/// the **absolute** path (so `file_read` resolves it as-is via a trusted +/// root). +/// - Every hit is filtered through [`SecurityPolicy::is_path_string_allowed`] +/// on that exact returned string, so `glob` never surfaces a path the readers +/// would reject (internal state, forbidden trees, symlink escapes). +/// +/// `max_results` bounds the returned list, not the walk: the full tree under +/// `base` is always traversed and only the output is truncated (pre-existing +/// behavior — the cap caps results, not work). +fn collect_matches( + base: &Path, + action_root: &Path, + gate: &dyn FsGate, + pattern: &Pattern, + max_results: usize, +) -> (Vec, bool) { + let mut hits: Vec<(std::time::SystemTime, String)> = Vec::new(); + + for entry in WalkDir::new(base) + .follow_links(false) + .into_iter() + .filter_entry(|e| !is_skipped(e.file_name().to_string_lossy().as_ref())) + .filter_map(std::result::Result::ok) + { + if !entry.file_type().is_file() { + continue; + } + let abs = entry.path(); + + // Match the pattern against the path relative to the searched root. + let rel_to_base = match abs.strip_prefix(base) { + Ok(p) => p.to_string_lossy().replace('\\', "/"), + Err(_) => continue, + }; + if !pattern.matches(&rel_to_base) { + continue; + } + + // Render the path the way the agent should hand it to file_read: + // sandbox-relative when inside action_dir, absolute otherwise. + let display = match abs.strip_prefix(action_root) { + Ok(rel) => rel.to_string_lossy().replace('\\', "/"), + Err(_) => abs.to_string_lossy().to_string(), + }; + + // Fail-closed: only surface paths the readers would also accept. + if !gate.is_path_string_allowed(&display) { + log::trace!("[tools:glob] path filtered by policy: '{display}'"); + continue; + } + + let mtime = entry + .metadata() + .ok() + .and_then(|m| m.modified().ok()) + .unwrap_or(std::time::SystemTime::UNIX_EPOCH); + hits.push((mtime, display)); + } + + // Newest first. + hits.sort_by_key(|item| std::cmp::Reverse(item.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 test; + +#[cfg(test)] +mod extra_test; diff --git a/crates/tinytools-std/src/filesystem/glob_search/test.rs b/crates/tinytools-std/src/filesystem/glob_search/test.rs new file mode 100644 index 0000000..1d66445 --- /dev/null +++ b/crates/tinytools-std/src/filesystem/glob_search/test.rs @@ -0,0 +1,264 @@ +//! Behavior tests for the `glob` tool, driven through a fake [`FsGate`]. + +#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)] + +use super::*; +use crate::filesystem::test_support::TestGate; + +fn test_security(workspace: std::path::PathBuf) -> Arc { + TestGate::supervised(workspace) +} + +/// Gate with an action sandbox and extra granted roots: the production shape, +/// and the configuration that surfaced #3357. +fn test_security_split( + action_dir: std::path::PathBuf, + _workspace_dir: std::path::PathBuf, + extra_roots: Vec, +) -> Arc { + TestGate::split(action_dir, extra_roots) +} + +#[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; +} + +/// Regression for #3357: glob roots at `action_dir` (so its hits are readable), +/// and never surfaces files living under the internal `workspace_dir`. +#[tokio::test] +async fn glob_roots_at_action_dir_and_excludes_workspace() { + let root = std::env::temp_dir().join("openhuman_test_glob_split"); + let action = root.join("action"); + let workspace = root.join("workspace"); + let _ = tokio::fs::remove_dir_all(&root).await; + tokio::fs::create_dir_all(&action).await.unwrap(); + tokio::fs::create_dir_all(&workspace).await.unwrap(); + tokio::fs::write(action.join("keep.txt"), "keep") + .await + .unwrap(); + tokio::fs::write(workspace.join("secret.txt"), "secret") + .await + .unwrap(); + + let security = test_security_split(action.clone(), workspace.clone(), vec![]); + let tool = GlobTool::new(security.clone()); + let result = tool.execute(json!({"pattern": "**/*.txt"})).await.unwrap(); + assert!(!result.is_error, "{}", result.output()); + let output = result.output(); + // Action-sandbox file is found, rendered relative... + assert!(output.contains("keep.txt"), "missing keep.txt: {output}"); + // ...and the internal workspace file is NOT enumerated. + assert!( + !output.contains("secret.txt"), + "leaked workspace file: {output}" + ); + + // The glob hit must be directly readable by the reader tools. + assert!( + security.validate_path("keep.txt").await.is_ok(), + "glob hit not resolvable by validate_path" + ); + + let _ = tokio::fs::remove_dir_all(&root).await; +} + +/// "As wide as the readers": glob can search any granted trusted root via +/// the `path` arg, returning absolute paths that the readers accept as-is. +#[tokio::test] +async fn glob_searches_named_trusted_root() { + let root = std::env::temp_dir().join("openhuman_test_glob_trusted"); + let action = root.join("action"); + let workspace = root.join("workspace"); + let granted = root.join("granted"); + let _ = tokio::fs::remove_dir_all(&root).await; + tokio::fs::create_dir_all(&action).await.unwrap(); + tokio::fs::create_dir_all(&workspace).await.unwrap(); + tokio::fs::create_dir_all(&granted).await.unwrap(); + tokio::fs::write(granted.join("data.csv"), "x") + .await + .unwrap(); + + let security = test_security_split(action.clone(), workspace.clone(), vec![granted.clone()]); + let tool = GlobTool::new(security.clone()); + let granted_abs = granted.to_string_lossy().to_string(); + let result = tool + .execute(json!({"pattern": "**/*.csv", "path": granted_abs})) + .await + .unwrap(); + assert!(!result.is_error, "{}", result.output()); + let output = result.output(); + assert!(output.contains("data.csv"), "missing data.csv: {output}"); + // Rendered absolute (outside the action sandbox). + assert!( + output.contains(&granted.to_string_lossy().to_string()), + "expected absolute path: {output}" + ); + + let _ = tokio::fs::remove_dir_all(&root).await; +} + +/// A search root the policy disallows yields a clear error, not ENOENT. +#[tokio::test] +async fn glob_rejects_disallowed_search_path() { + let root = std::env::temp_dir().join("openhuman_test_glob_reject"); + let action = root.join("action"); + let workspace = root.join("workspace"); + let _ = tokio::fs::remove_dir_all(&root).await; + tokio::fs::create_dir_all(&action).await.unwrap(); + tokio::fs::create_dir_all(&workspace).await.unwrap(); + + let security = test_security_split(action.clone(), workspace.clone(), vec![]); + let tool = GlobTool::new(security); + // Absolute path outside any trusted root, under workspace_only. + let result = tool + .execute(json!({"pattern": "**/*", "path": "/etc"})) + .await + .unwrap(); + assert!(result.is_error); + assert!( + result.output().contains("not accessible"), + "{}", + result.output() + ); + + let _ = tokio::fs::remove_dir_all(&root).await; +} + +/// Security backstop: a symlink inside the sandbox pointing OUTSIDE it must +/// never leak the target. Two layers cover this and this test exercises +/// both: the walk runs with `follow_links(false)`, so the symlink entry is +/// dropped by the `is_file()` gate (a symlink is not a regular file) and is +/// never descended; and `is_path_string_allowed` is the fail-closed per-hit +/// backstop — assert directly that it rejects the escape's resolved string, +/// since that is the check `collect_matches` leans on if the walk gate is +/// ever bypassed. A legitimate in-sandbox file is still found. +#[cfg(unix)] +#[tokio::test] +async fn glob_does_not_leak_symlink_escape() { + let root = std::env::temp_dir().join("openhuman_test_glob_symlink"); + let action = root.join("action"); + let workspace = root.join("workspace"); + let outside = root.join("outside"); + let _ = tokio::fs::remove_dir_all(&root).await; + tokio::fs::create_dir_all(action.join("sub")).await.unwrap(); + tokio::fs::create_dir_all(&workspace).await.unwrap(); + tokio::fs::create_dir_all(&outside).await.unwrap(); + tokio::fs::write(action.join("sub/ok.txt"), "ok") + .await + .unwrap(); + tokio::fs::write(outside.join("secret.txt"), "secret") + .await + .unwrap(); + // Symlink inside the sandbox pointing at the outside (untrusted) tree. + std::os::unix::fs::symlink(&outside, action.join("escape")).unwrap(); + + // `outside` is deliberately NOT registered as a trusted root. + let security = test_security_split(action.clone(), workspace.clone(), vec![]); + let tool = GlobTool::new(security.clone()); + let result = tool.execute(json!({"pattern": "**/*.txt"})).await.unwrap(); + assert!(!result.is_error, "{}", result.output()); + let output = result.output(); + // The in-sandbox file is found, the escape target is not enumerated. + assert!( + output.contains("ok.txt"), + "missing in-sandbox file: {output}" + ); + assert!( + !output.contains("secret"), + "leaked symlink-escape target: {output}" + ); + // The fail-closed backstop, exercised directly: the resolved escape + // path is rejected as a string regardless of how it was reached. + let escaped = outside.join("secret.txt").to_string_lossy().to_string(); + assert!( + !security.is_path_string_allowed(&escaped), + "policy must reject the escape target string: {escaped}" + ); + + let _ = tokio::fs::remove_dir_all(&root).await; +} + +/// A `path` that resolves to a *file* (not a directory) yields a clear +/// "not a directory" error rather than a misleading "0 match(es)". +#[tokio::test] +async fn glob_rejects_file_search_path() { + let dir = std::env::temp_dir().join("openhuman_test_glob_file_root"); + let _ = tokio::fs::remove_dir_all(&dir).await; + tokio::fs::create_dir_all(&dir).await.unwrap(); + tokio::fs::write(dir.join("file.txt"), "x").await.unwrap(); + + let tool = GlobTool::new(test_security(dir.clone())); + let result = tool + .execute(json!({"pattern": "**/*", "path": "file.txt"})) + .await + .unwrap(); + assert!(result.is_error, "{}", result.output()); + assert!( + result.output().contains("not a directory"), + "{}", + result.output() + ); + + let _ = tokio::fs::remove_dir_all(&dir).await; +} diff --git a/crates/tinytools-std/src/filesystem/grep/extra_test.rs b/crates/tinytools-std/src/filesystem/grep/extra_test.rs new file mode 100644 index 0000000..825ac12 --- /dev/null +++ b/crates/tinytools-std/src/filesystem/grep/extra_test.rs @@ -0,0 +1,54 @@ +//! Budget, delegation and unreadable-file tests for `grep`. + +#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)] + +use super::*; +use crate::filesystem::test_support::{TestGate, WorkspaceContext, WrapGate}; +use tempfile::TempDir; + +#[test] +fn grep_is_concurrency_safe() { + let dir = TempDir::new().unwrap(); + let tool = GrepTool::new(TestGate::supervised(dir.path().to_path_buf())); + assert!(tool.is_concurrency_safe(&json!({}))); +} + +#[tokio::test] +async fn rate_limit_and_exhausted_budget_are_refused() { + let dir = TempDir::new().unwrap(); + let limited = GrepTool::new(TestGate::with( + dir.path().to_path_buf(), + crate::filesystem::test_support::AutonomyLevel::Supervised, + 0, + )); + let r = limited.execute(json!({"pattern": "x"})).await.unwrap(); + assert!(r.output().contains("too many actions in the last hour")); + + let gate = WrapGate::new(TestGate::supervised(dir.path().to_path_buf())) + .no_budget() + .arc(); + let r = GrepTool::new(gate) + .execute(json!({"pattern": "x"})) + .await + .unwrap(); + assert!(r.output().contains("action budget exhausted")); +} + +#[tokio::test] +async fn non_utf8_files_are_skipped_and_others_still_match() { + let dir = TempDir::new().unwrap(); + std::fs::write(dir.path().join("bin.dat"), [0xff, 0xfe, 0x00, 0xc3]).unwrap(); + std::fs::write(dir.path().join("t.txt"), "needle here\n").unwrap(); + let tool = GrepTool::new(TestGate::supervised(dir.path().to_path_buf())); + let ctx = WorkspaceContext::at(dir.path()); + let r = tool + .execute_with_context( + json!({"pattern": "needle"}), + ToolCallOptions::default(), + Some(&ctx), + ) + .await + .unwrap(); + assert!(r.output().contains("t.txt"), "{}", r.output()); + assert!(!r.output().contains("bin.dat")); +} diff --git a/crates/tinytools-std/src/filesystem/grep/mod.rs b/crates/tinytools-std/src/filesystem/grep/mod.rs new file mode 100644 index 0000000..fec62f3 --- /dev/null +++ b/crates/tinytools-std/src/filesystem/grep/mod.rs @@ -0,0 +1,239 @@ +//! `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 super::gate::{FsGate, gate_for_context}; +use async_trait::async_trait; +use regex::Regex; +use serde_json::json; +use std::path::Path; +use std::sync::Arc; +use tinytools::ToolRunContext; +use tinytools::{PermissionLevel, Tool, ToolCallOptions, ToolResult}; +use walkdir::WalkDir; + +const DEFAULT_MAX_MATCHES: usize = 200; +const MAX_LINE_BYTES: usize = 2_000; +const MAX_FILE_BYTES: u64 = 5 * 1024 * 1024; + +/// Search file contents with a regular expression. +#[derive(Debug)] +pub struct GrepTool { + gate: Arc, +} + +impl GrepTool { + /// Create the tool. + #[must_use] + pub fn new(gate: Arc) -> Self { + Self { gate } + } +} + +#[async_trait] +impl Tool for GrepTool { + fn name(&self) -> &'static str { + "grep" + } + + fn description(&self) -> &'static 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 + } + + /// Pure read — safe to fan out across parallel `grep` calls. + fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool { + true + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + self.execute_in_context(args, None).await + } + + async fn execute_with_context( + &self, + args: serde_json::Value, + _options: ToolCallOptions, + context: Option<&dyn ToolRunContext>, + ) -> anyhow::Result { + self.execute_in_context(args, context).await + } +} + +impl GrepTool { + async fn execute_in_context( + &self, + args: serde_json::Value, + context: Option<&dyn ToolRunContext>, + ) -> 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(serde_json::Value::as_u64) + .map_or(DEFAULT_MAX_MATCHES, |n| (n as usize).max(1)); + let case_insensitive = args + .get("case_insensitive") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + + if self.gate.is_rate_limited() { + return Ok(ToolResult::error( + "Rate limit exceeded: too many actions in the last hour", + )); + } + if !self.gate.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 path_policy = gate_for_context(&self.gate, context, "grep"); + + // Security check: validate path string, resolve symlinks, confirm workspace containment. + let resolved_root = match path_policy.validate_path(sub_path).await { + Ok(p) => p, + Err(msg) => return Ok(ToolResult::error(msg)), + }; + + let workspace = path_policy.action_dir().to_path_buf(); + 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(std::result::Result::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) { + // `MAX_LINE_BYTES` is a BYTE budget — use the byte-aware + // helper. The earlier migration to `truncate_with_suffix` + // mis-typed this as a char budget; for multi-byte text + // (CJK / emoji) the rendered line could balloon to ~3× + // the intended cap. Per CodeRabbit critical review on + // PR #1549. + let display_line = super::text::truncate_at_byte_boundary(line, MAX_LINE_BYTES); + 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 test; + +#[cfg(test)] +mod extra_test; diff --git a/crates/tinytools-std/src/filesystem/grep/test.rs b/crates/tinytools-std/src/filesystem/grep/test.rs new file mode 100644 index 0000000..5dcc763 --- /dev/null +++ b/crates/tinytools-std/src/filesystem/grep/test.rs @@ -0,0 +1,184 @@ +//! Behavior tests for the `grep` tool, driven through a fake [`FsGate`]. + +#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)] + +use super::*; +use crate::filesystem::test_support::TestGate; + +fn test_security(workspace: std::path::PathBuf) -> Arc { + TestGate::supervised(workspace) +} + +#[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; +} + +/// A file over `MAX_FILE_BYTES` is silently excluded rather than read — this +/// pins that the exclusion is at least honest about it: the file does not +/// count toward `scanned`, so the report never claims to have searched bytes +/// it skipped. It does not surface the file was too large; a caller who +/// expected a match in it sees only a lower `scanned` count with no reason. +#[tokio::test] +async fn grep_excludes_an_oversized_file_and_does_not_count_it_scanned() { + let dir = std::env::temp_dir().join("openhuman_test_grep_oversized"); + let _ = tokio::fs::remove_dir_all(&dir).await; + tokio::fs::create_dir_all(&dir).await.unwrap(); + + let small = format!("{}\nneedle\n", "line ".repeat(10)); + tokio::fs::write(dir.join("small.txt"), &small) + .await + .unwrap(); + let huge = "needle\n".repeat(1_000_000); + tokio::fs::write(dir.join("huge.txt"), &huge).await.unwrap(); + + let tool = GrepTool::new(test_security(dir.clone())); + let result = tool.execute(json!({"pattern": "needle"})).await.unwrap(); + assert!(!result.is_error, "{}", result.output()); + assert!( + result.output().contains("small.txt"), + "the small file's match must still be reported: {}", + result.output() + ); + assert!( + !result.output().contains("huge.txt"), + "the oversized file must not be scanned at all: {}", + result.output() + ); + assert!( + result.output().contains("scanned 1 file"), + "the scanned count must not include the file it skipped for size: {}", + result.output() + ); + + let _ = tokio::fs::remove_dir_all(&dir).await; +} diff --git a/crates/tinytools-std/src/filesystem/list_files/extra_test.rs b/crates/tinytools-std/src/filesystem/list_files/extra_test.rs new file mode 100644 index 0000000..76d3c2f --- /dev/null +++ b/crates/tinytools-std/src/filesystem/list_files/extra_test.rs @@ -0,0 +1,52 @@ +//! Budget, delegation and entry-cap tests for `list_files`. + +#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)] + +use super::*; +use crate::filesystem::test_support::{TestGate, WorkspaceContext, WrapGate}; +use tempfile::TempDir; + +#[tokio::test] +async fn rate_limit_and_exhausted_budget_are_refused() { + let dir = TempDir::new().unwrap(); + let limited = ListFilesTool::new(TestGate::with( + dir.path().to_path_buf(), + crate::filesystem::test_support::AutonomyLevel::Supervised, + 0, + )); + let r = limited.execute(json!({})).await.unwrap(); + assert!(r.output().contains("too many actions in the last hour")); + + let gate = WrapGate::new(TestGate::supervised(dir.path().to_path_buf())) + .no_budget() + .arc(); + let r = ListFilesTool::new(gate).execute(json!({})).await.unwrap(); + assert!(r.output().contains("action budget exhausted")); +} + +#[tokio::test] +async fn execute_with_context_delegates() { + let dir = TempDir::new().unwrap(); + std::fs::write(dir.path().join("f.txt"), "").unwrap(); + let tool = ListFilesTool::new(TestGate::supervised(dir.path().to_path_buf())); + let ctx = WorkspaceContext::at(dir.path()); + let r = tool + .execute_with_context(json!({}), ToolCallOptions::default(), Some(&ctx)) + .await + .unwrap(); + assert!(r.output().contains("file\tf.txt")); +} + +#[tokio::test] +async fn listing_stops_at_the_entry_cap() { + let dir = TempDir::new().unwrap(); + for i in 0..(MAX_ENTRIES + 5) { + std::fs::write(dir.path().join(format!("f{i:05}")), "").unwrap(); + } + let tool = ListFilesTool::new(TestGate::supervised(dir.path().to_path_buf())); + let r = tool.execute(json!({})).await.unwrap(); + assert!( + r.output() + .starts_with(&format!("{MAX_ENTRIES} entr(ies) in .")) + ); +} diff --git a/crates/tinytools-std/src/filesystem/list_files/mod.rs b/crates/tinytools-std/src/filesystem/list_files/mod.rs new file mode 100644 index 0000000..672b60c --- /dev/null +++ b/crates/tinytools-std/src/filesystem/list_files/mod.rs @@ -0,0 +1,145 @@ +//! `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 super::gate::{FsGate, gate_for_context}; +use async_trait::async_trait; +use serde_json::json; +use std::sync::Arc; +use tinytools::ToolRunContext; +use tinytools::{PermissionLevel, Tool, ToolCallOptions, ToolResult}; + +const MAX_ENTRIES: usize = 1_000; + +/// List the entries of a workspace directory. +#[derive(Debug)] +pub struct ListFilesTool { + gate: Arc, +} + +impl ListFilesTool { + /// Create the tool. + #[must_use] + pub fn new(gate: Arc) -> Self { + Self { gate } + } +} + +#[async_trait] +impl Tool for ListFilesTool { + fn name(&self) -> &'static str { + "list" + } + + fn description(&self) -> &'static 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 { + self.execute_in_context(args, None).await + } + + async fn execute_with_context( + &self, + args: serde_json::Value, + _options: ToolCallOptions, + context: Option<&dyn ToolRunContext>, + ) -> anyhow::Result { + self.execute_in_context(args, context).await + } +} + +impl ListFilesTool { + async fn execute_in_context( + &self, + args: serde_json::Value, + context: Option<&dyn ToolRunContext>, + ) -> anyhow::Result { + let path = args.get("path").and_then(|v| v.as_str()).unwrap_or("."); + + if self.gate.is_rate_limited() { + return Ok(ToolResult::error( + "Rate limit exceeded: too many actions in the last hour", + )); + } + if !self.gate.record_action() { + return Ok(ToolResult::error( + "Rate limit exceeded: action budget exhausted", + )); + } + + // Security check: validate path string, resolve symlinks, confirm workspace containment. + let path_policy = gate_for_context(&self.gate, context, "list"); + let resolved = match path_policy.validate_path(path).await { + Ok(p) => p, + Err(msg) => return Ok(ToolResult::error(msg)), + }; + + 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 test; + +#[cfg(test)] +mod extra_test; diff --git a/crates/tinytools-std/src/filesystem/list_files/test.rs b/crates/tinytools-std/src/filesystem/list_files/test.rs new file mode 100644 index 0000000..6b6f176 --- /dev/null +++ b/crates/tinytools-std/src/filesystem/list_files/test.rs @@ -0,0 +1,76 @@ +//! Behavior tests for the `list_files` tool, driven through a fake [`FsGate`]. + +#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)] + +use super::*; +use crate::filesystem::test_support::TestGate; + +fn test_security(workspace: std::path::PathBuf) -> Arc { + TestGate::supervised(workspace) +} + +#[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; +} + +#[tokio::test] +async fn list_a_plain_file_reports_the_read_dir_failure() { + // Distinct from `list_missing_dir`: the path resolves (the file exists), + // so `validate_path` succeeds; the failure is `read_dir` itself refusing + // a non-directory (ENOTDIR), one step later in the same function. + let dir = std::env::temp_dir().join("openhuman_test_list_not_a_dir"); + let _ = tokio::fs::remove_dir_all(&dir).await; + tokio::fs::create_dir_all(&dir).await.unwrap(); + tokio::fs::write(dir.join("plain.txt"), "not a directory") + .await + .unwrap(); + + let tool = ListFilesTool::new(test_security(dir.clone())); + let result = tool.execute(json!({"path": "plain.txt"})).await.unwrap(); + assert!( + result.is_error, + "listing a plain file must be a refusal, not an empty-looking success" + ); + assert!(result.output().contains("Failed to read directory")); + + let _ = tokio::fs::remove_dir_all(&dir).await; +} diff --git a/crates/tinytools-std/src/filesystem/mod.rs b/crates/tinytools-std/src/filesystem/mod.rs new file mode 100644 index 0000000..c63b712 --- /dev/null +++ b/crates/tinytools-std/src/filesystem/mod.rs @@ -0,0 +1,64 @@ +//! Filesystem tools: read/write/edit/patch, grep/glob/list, diffs, CSV export, +//! git operations, linter and test runners, and the workspace memory index. +//! +//! Every tool that can touch a path takes an `Arc`: the tool +//! performs the I/O, the host's gate decides whether it is allowed. The gate +//! trait ([`FsGate`]) is the whole seam; nothing here knows what an autonomy +//! level, an approval prompt or a workspace boundary is. + +// These tools moved here verbatim from a host crate; their behavior, messages +// and control flow are pinned by tests and by the fixtures in `fixtures/`, so +// the purely stylistic pedantic lints below are allowed rather than reshaping +// working code (long `execute` bodies, `usize as f64` size labels, and so on). +#![allow( + clippy::too_many_lines, + clippy::cast_precision_loss, + clippy::cast_possible_truncation, + clippy::similar_names, + clippy::used_underscore_binding, + clippy::manual_let_else, + clippy::format_push_string, + clippy::unwrap_used, + clippy::unused_self, + clippy::unnecessary_debug_formatting +)] + +mod apply_patch; +mod csv_export; +mod edit_file; +mod file_read; +mod file_sink; +mod file_write; +mod gate; +mod git_operations; +mod glob_search; +mod grep; +mod list_files; +mod read_diff; +mod run_linter; +mod run_tests; +mod text; +mod update_memory_md; + +#[cfg(test)] +mod gate_test; +#[cfg(test)] +mod test_support; + +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 gate::FsGate; +pub use git_operations::{GitOperationsTool, shell_git_env}; +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; +pub use update_memory_md::UpdateMemoryMdTool; + +#[cfg(test)] +mod contract_test; diff --git a/crates/tinytools-std/src/filesystem/read_diff/mod.rs b/crates/tinytools-std/src/filesystem/read_diff/mod.rs new file mode 100644 index 0000000..24df54c --- /dev/null +++ b/crates/tinytools-std/src/filesystem/read_diff/mod.rs @@ -0,0 +1,138 @@ +//! Tool: `read_diff` — structured git diff output for the Critic archetype. + +use async_trait::async_trait; +use serde_json::json; +use std::path::PathBuf; +use tinytools::ToolRunContext; +use tinytools::{PermissionLevel, Tool, ToolCallOptions, ToolResult}; + +/// Returns `git diff` output in a structured format. +#[derive(Debug)] +pub struct ReadDiffTool { + workspace_dir: PathBuf, +} + +impl ReadDiffTool { + #[must_use] + /// Create the tool. + pub fn new(workspace_dir: PathBuf) -> Self { + Self { workspace_dir } + } + + fn workspace_dir_for_context(&self, context: Option<&dyn ToolRunContext>) -> PathBuf { + if let Some(workspace) = context.and_then(|ctx| ctx.workspace()) { + tracing::debug!( + workspace_root = %workspace.root.display(), + policy_id = %workspace.policy_id, + "[read_diff] using TinyAgents workspace descriptor as workspace dir" + ); + return workspace.root.clone(); + } + self.workspace_dir.clone() + } +} + +#[async_trait] +impl Tool for ReadDiffTool { + fn exposure(&self) -> tinytools::ToolExposure { + tinytools::ToolExposure::Deferred + } + + fn name(&self) -> &'static str { + "read_diff" + } + + fn description(&self) -> &'static str { + "Get the git diff of current changes. Can diff staged, unstaged, or against a \ + specific base branch/commit. Returns file paths and hunks." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "base": { + "type": "string", + "description": "Base ref to diff against (e.g. 'main', 'HEAD~3'). Default: unstaged changes." + }, + "staged": { + "type": "boolean", + "description": "Show staged changes only (--cached). Default: false." + }, + "path_filter": { + "type": "string", + "description": "Limit diff to a specific path or glob." + } + } + }) + } + + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::ReadOnly + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + self.execute_with_context(args, ToolCallOptions::default(), None) + .await + } + + async fn execute_with_context( + &self, + args: serde_json::Value, + _options: ToolCallOptions, + context: Option<&dyn ToolRunContext>, + ) -> anyhow::Result { + let workspace_dir = self.workspace_dir_for_context(context); + let base = args.get("base").and_then(|v| v.as_str()); + let staged = args + .get("staged") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + let path_filter = args.get("path_filter").and_then(|v| v.as_str()); + + let mut git_args = vec!["diff", "--stat", "-p"]; + + if staged { + git_args.push("--cached"); + } + + let base_str = base.map(std::string::ToString::to_string); + if let Some(ref bs) = base_str { + git_args.push(bs); + } + + if let Some(pf) = path_filter { + git_args.push("--"); + git_args.push(pf); + } + + tracing::debug!( + workspace = %workspace_dir.display(), + ?git_args, + "[read_diff] running git diff" + ); + + let output = tokio::process::Command::new("git") + .args(&git_args) + .current_dir(&workspace_dir) + .output() + .await?; + + if output.status.success() { + let diff = String::from_utf8_lossy(&output.stdout); + tracing::debug!("[read_diff] success, diff length={}", diff.len()); + if diff.trim().is_empty() { + Ok(ToolResult::success("No changes found.")) + } else { + Ok(ToolResult::success(diff.to_string())) + } + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + tracing::debug!("[read_diff] failed: {stderr}"); + Ok(ToolResult::error(stderr.to_string())) + } + } +} + +#[cfg(test)] +mod test; diff --git a/crates/tinytools-std/src/filesystem/read_diff/test.rs b/crates/tinytools-std/src/filesystem/read_diff/test.rs new file mode 100644 index 0000000..23b3ee8 --- /dev/null +++ b/crates/tinytools-std/src/filesystem/read_diff/test.rs @@ -0,0 +1,144 @@ +#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)] + +use super::*; +use serde_json::json; +use tempfile::TempDir; + +fn make_tool(dir: &TempDir) -> ReadDiffTool { + ReadDiffTool::new(dir.path().to_path_buf()) +} + +#[test] +fn name_is_correct() { + let tmp = TempDir::new().unwrap(); + assert_eq!(make_tool(&tmp).name(), "read_diff"); +} + +#[test] +fn description_is_non_empty() { + let tmp = TempDir::new().unwrap(); + assert!(!make_tool(&tmp).description().is_empty()); +} + +#[test] +fn schema_is_object_type() { + let tmp = TempDir::new().unwrap(); + let schema = make_tool(&tmp).parameters_schema(); + assert_eq!(schema["type"], "object"); +} + +#[test] +fn permission_level_is_read_only() { + let tmp = TempDir::new().unwrap(); + assert_eq!( + make_tool(&tmp).permission_level(), + PermissionLevel::ReadOnly + ); +} + +#[tokio::test] +async fn execute_returns_error_for_non_git_dir() { + let tmp = TempDir::new().unwrap(); + let result = make_tool(&tmp).execute(json!({})).await.unwrap(); + // Non-git dir: git will fail, tool returns error + assert!(result.is_error); +} + +#[tokio::test] +async fn execute_no_changes_in_clean_git_repo() { + let tmp = TempDir::new().unwrap(); + // Init a git repo and make an initial commit so there's nothing to diff + let _ = std::process::Command::new("git") + .args(["init"]) + .current_dir(tmp.path()) + .output(); + let _ = std::process::Command::new("git") + .args(["commit", "--allow-empty", "-m", "init"]) + .current_dir(tmp.path()) + .env("GIT_AUTHOR_NAME", "test") + .env("GIT_AUTHOR_EMAIL", "t@t.com") + .env("GIT_COMMITTER_NAME", "test") + .env("GIT_COMMITTER_EMAIL", "t@t.com") + .output(); + let result = make_tool(&tmp).execute(json!({})).await.unwrap(); + assert!(!result.is_error); + assert!(result.output().contains("No changes found.")); +} + +fn git_in(dir: &std::path::Path, args: &[&str]) { + let out = std::process::Command::new("git") + .args(["-c", "user.email=t@example.invalid", "-c", "user.name=T"]) + .args(args) + .current_dir(dir) + .env("GIT_CONFIG_NOSYSTEM", "1") + .env("GIT_CONFIG_GLOBAL", "/dev/null") + .output() + .unwrap(); + assert!( + out.status.success(), + "{}", + String::from_utf8_lossy(&out.stderr) + ); +} + +fn repo_with_change() -> TempDir { + let tmp = TempDir::new().unwrap(); + git_in(tmp.path(), &["init"]); + std::fs::write(tmp.path().join("a.txt"), "one\n").unwrap(); + std::fs::write(tmp.path().join("b.txt"), "one\n").unwrap(); + git_in(tmp.path(), &["add", "."]); + git_in(tmp.path(), &["commit", "-m", "init"]); + std::fs::write(tmp.path().join("a.txt"), "two\n").unwrap(); + std::fs::write(tmp.path().join("b.txt"), "two\n").unwrap(); + tmp +} + +#[tokio::test] +async fn path_filter_limits_the_diff() { + let tmp = repo_with_change(); + let result = make_tool(&tmp) + .execute(json!({"path_filter": "a.txt"})) + .await + .unwrap(); + assert!(!result.is_error); + assert!(result.output().contains("a.txt")); + assert!(!result.output().contains("b.txt")); +} + +#[tokio::test] +async fn base_ref_and_staged_flags_are_passed_to_git() { + let tmp = repo_with_change(); + git_in(tmp.path(), &["add", "a.txt"]); + let staged = make_tool(&tmp) + .execute(json!({"staged": true})) + .await + .unwrap(); + assert!(staged.output().contains("a.txt")); + assert!(!staged.output().contains("b.txt")); + + let against_head = make_tool(&tmp) + .execute(json!({"base": "HEAD"})) + .await + .unwrap(); + assert!(against_head.output().contains("b.txt")); + + let bad_base = make_tool(&tmp) + .execute(json!({"base": "no-such-ref"})) + .await + .unwrap(); + assert!(bad_base.is_error); +} + +#[tokio::test] +async fn a_workspace_descriptor_overrides_the_configured_directory() { + use crate::filesystem::test_support::WorkspaceContext; + let elsewhere = TempDir::new().unwrap(); + let repo = repo_with_change(); + let context = WorkspaceContext::at(repo.path()); + let result = make_tool(&elsewhere) + .execute_with_context(json!({}), ToolCallOptions::default(), Some(&context)) + .await + .unwrap(); + assert!(!result.is_error, "{}", result.output()); + assert!(result.output().contains("a.txt")); +} diff --git a/crates/tinytools-std/src/filesystem/run_linter/mod.rs b/crates/tinytools-std/src/filesystem/run_linter/mod.rs new file mode 100644 index 0000000..5839689 --- /dev/null +++ b/crates/tinytools-std/src/filesystem/run_linter/mod.rs @@ -0,0 +1,158 @@ +//! Tool: `run_linter` — run linting tools for the Critic archetype. + +use async_trait::async_trait; +use serde_json::json; +use std::path::PathBuf; +use tinytools::ToolRunContext; +use tinytools::{PermissionLevel, Tool, ToolCallOptions, ToolResult}; + +/// Runs linters (cargo clippy, eslint) and returns structured findings. +#[derive(Debug)] +pub struct RunLinterTool { + workspace_dir: PathBuf, +} + +impl RunLinterTool { + #[must_use] + /// Create the tool. + pub fn new(workspace_dir: PathBuf) -> Self { + Self { workspace_dir } + } + + fn workspace_dir_for_context(&self, context: Option<&dyn ToolRunContext>) -> PathBuf { + if let Some(workspace) = context.and_then(|ctx| ctx.workspace()) { + tracing::debug!( + workspace_root = %workspace.root.display(), + policy_id = %workspace.policy_id, + "[run_linter] using TinyAgents workspace descriptor as workspace dir" + ); + return workspace.root.clone(); + } + self.workspace_dir.clone() + } +} + +#[async_trait] +impl Tool for RunLinterTool { + fn exposure(&self) -> tinytools::ToolExposure { + tinytools::ToolExposure::Deferred + } + + fn name(&self) -> &'static str { + "run_linter" + } + + fn description(&self) -> &'static str { + "Run linting tools on the codebase. Supports 'clippy' for Rust and 'eslint' for \ + TypeScript/JavaScript. Returns warnings and errors." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "linter": { + "type": "string", + "enum": ["clippy", "eslint", "auto"], + "description": "Which linter to run. 'auto' detects from project files.", + "default": "auto" + }, + "path": { + "type": "string", + "description": "Limit linting to a specific path." + } + } + }) + } + + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::Execute + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + self.execute_with_context(args, ToolCallOptions::default(), None) + .await + } + + async fn execute_with_context( + &self, + args: serde_json::Value, + _options: ToolCallOptions, + context: Option<&dyn ToolRunContext>, + ) -> anyhow::Result { + let workspace_dir = self.workspace_dir_for_context(context); + let linter = args + .get("linter") + .and_then(|v| v.as_str()) + .unwrap_or("auto"); + + let linter = if linter == "auto" { + if workspace_dir.join("Cargo.toml").exists() { + "clippy" + } else if workspace_dir.join("package.json").exists() { + "eslint" + } else { + return Ok(ToolResult::error( + "Could not detect project type for linting.", + )); + } + } else { + linter + }; + + let output = match linter { + "clippy" => { + tokio::process::Command::new("cargo") + .args([ + "clippy", + "--message-format=short", + "--", + "-W", + "clippy::all", + ]) + .current_dir(&workspace_dir) + .output() + .await? + } + "eslint" => { + let path = args.get("path").and_then(|v| v.as_str()).unwrap_or("."); + if path.starts_with('/') || path.contains("..") { + return Ok(ToolResult::error( + "path must be a relative path within the workspace \ + (no absolute paths or '..')", + )); + } + tokio::process::Command::new("npx") + .args(["eslint", "--format", "compact", path]) + .current_dir(&workspace_dir) + .output() + .await? + } + other => { + return Ok(ToolResult::error(format!("Unknown linter: {other}"))); + } + }; + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + let combined = if stdout.is_empty() { + stderr.to_string() + } else { + format!("{stdout}\n{stderr}") + }; + + if output.status.success() { + Ok(ToolResult::success(combined)) + } else { + Ok(ToolResult::error(format!( + "Linter exited with code {:?}\n{}", + output.status.code(), + combined + ))) + } + } +} + +#[cfg(test)] +mod test; diff --git a/crates/tinytools-std/src/filesystem/run_linter/test.rs b/crates/tinytools-std/src/filesystem/run_linter/test.rs new file mode 100644 index 0000000..c1d9619 --- /dev/null +++ b/crates/tinytools-std/src/filesystem/run_linter/test.rs @@ -0,0 +1,97 @@ +#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)] + +use super::*; +use serde_json::json; +use tempfile::TempDir; + +fn make_tool(dir: &TempDir) -> RunLinterTool { + RunLinterTool::new(dir.path().to_path_buf()) +} + +#[test] +fn name_is_correct() { + let tmp = TempDir::new().unwrap(); + assert_eq!(make_tool(&tmp).name(), "run_linter"); +} + +#[test] +fn description_is_non_empty() { + let tmp = TempDir::new().unwrap(); + assert!(!make_tool(&tmp).description().is_empty()); +} + +#[test] +fn schema_is_object_type() { + let tmp = TempDir::new().unwrap(); + let schema = make_tool(&tmp).parameters_schema(); + assert_eq!(schema["type"], "object"); +} + +#[test] +fn permission_level_is_execute() { + let tmp = TempDir::new().unwrap(); + assert_eq!(make_tool(&tmp).permission_level(), PermissionLevel::Execute); +} + +#[tokio::test] +async fn auto_returns_error_when_no_project_files() { + let tmp = TempDir::new().unwrap(); + let result = make_tool(&tmp) + .execute(json!({"linter": "auto"})) + .await + .unwrap(); + assert!(result.is_error); + assert!(result.output().contains("Could not detect project type")); +} + +#[tokio::test] +async fn unknown_linter_returns_error() { + let tmp = TempDir::new().unwrap(); + let result = make_tool(&tmp) + .execute(json!({"linter": "rubocop"})) + .await + .unwrap(); + assert!(result.is_error); + assert!(result.output().contains("Unknown linter")); +} + +#[tokio::test] +async fn eslint_rejects_absolute_path() { + let tmp = TempDir::new().unwrap(); + // Create a package.json so linter resolves to eslint + std::fs::write(tmp.path().join("package.json"), "{}").unwrap(); + let result = make_tool(&tmp) + .execute(json!({"linter": "eslint", "path": "/etc/passwd"})) + .await + .unwrap(); + assert!(result.is_error); + assert!(result.output().contains("relative path")); +} + +#[tokio::test] +async fn eslint_rejects_path_traversal() { + let tmp = TempDir::new().unwrap(); + std::fs::write(tmp.path().join("package.json"), "{}").unwrap(); + let result = make_tool(&tmp) + .execute(json!({"linter": "eslint", "path": "../secret"})) + .await + .unwrap(); + assert!(result.is_error); + assert!(result.output().contains("relative path")); +} + +#[tokio::test] +async fn a_workspace_descriptor_overrides_the_configured_directory() { + use crate::filesystem::test_support::WorkspaceContext; + let configured = TempDir::new().unwrap(); + let isolated = TempDir::new().unwrap(); + let context = WorkspaceContext::at(isolated.path()); + // Neither directory has project files; the answer only proves the call + // ran, and the descriptor branch is what resolved the directory. + let result = make_tool(&configured) + .execute_with_context(json!({}), ToolCallOptions::default(), Some(&context)) + .await + .unwrap(); + assert!(result.is_error); + assert!(result.output().contains("Could not detect project type")); +} diff --git a/crates/tinytools-std/src/filesystem/run_tests/mod.rs b/crates/tinytools-std/src/filesystem/run_tests/mod.rs new file mode 100644 index 0000000..2bfa9e0 --- /dev/null +++ b/crates/tinytools-std/src/filesystem/run_tests/mod.rs @@ -0,0 +1,199 @@ +//! Tool: `run_tests` — run test suites for the Critic archetype. + +use async_trait::async_trait; +use serde_json::json; +use std::path::PathBuf; +use tinytools::ToolRunContext; +use tinytools::{PermissionLevel, Tool, ToolCallOptions, ToolResult}; + +/// Runs test suites (cargo test, vitest) and returns pass/fail with output. +#[derive(Debug)] +pub struct RunTestsTool { + workspace_dir: PathBuf, +} + +impl RunTestsTool { + #[must_use] + /// Create the tool. + pub fn new(workspace_dir: PathBuf) -> Self { + Self { workspace_dir } + } + + fn workspace_dir_for_context(&self, context: Option<&dyn ToolRunContext>) -> PathBuf { + if let Some(workspace) = context.and_then(|ctx| ctx.workspace()) { + tracing::debug!( + workspace_root = %workspace.root.display(), + policy_id = %workspace.policy_id, + "[run_tests] using TinyAgents workspace descriptor as workspace dir" + ); + return workspace.root.clone(); + } + self.workspace_dir.clone() + } +} + +#[async_trait] +impl Tool for RunTestsTool { + fn exposure(&self) -> tinytools::ToolExposure { + tinytools::ToolExposure::Deferred + } + + fn name(&self) -> &'static str { + "run_tests" + } + + fn description(&self) -> &'static str { + "Run the project test suite. Supports 'cargo_test' for Rust and 'vitest' for \ + TypeScript/JavaScript. Returns pass/fail results with output." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "runner": { + "type": "string", + "enum": ["cargo_test", "vitest", "auto"], + "description": "Which test runner to use. 'auto' detects from project files.", + "default": "auto" + }, + "filter": { + "type": "string", + "description": "Filter to run specific tests (e.g. test name or module)." + }, + "timeout_secs": { + "type": "integer", + "description": "Timeout in seconds (default: 120).", + "default": 120 + } + } + }) + } + + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::Execute + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + self.execute_with_context(args, ToolCallOptions::default(), None) + .await + } + + async fn execute_with_context( + &self, + args: serde_json::Value, + _options: ToolCallOptions, + context: Option<&dyn ToolRunContext>, + ) -> anyhow::Result { + let workspace_dir = self.workspace_dir_for_context(context); + let runner = args + .get("runner") + .and_then(|v| v.as_str()) + .unwrap_or("auto"); + let filter = args.get("filter").and_then(|v| v.as_str()); + let timeout_secs = args + .get("timeout_secs") + .and_then(serde_json::Value::as_u64) + .unwrap_or(120); + + let runner = if runner == "auto" { + if workspace_dir.join("Cargo.toml").exists() { + "cargo_test" + } else if workspace_dir.join("package.json").exists() { + "vitest" + } else { + return Ok(ToolResult::error( + "Could not detect project type for testing.", + )); + } + } else { + runner + }; + + let mut cmd = match runner { + "cargo_test" => { + let mut c = tokio::process::Command::new("cargo"); + c.arg("test"); + if let Some(f) = filter { + c.arg(f); + } + c + } + "vitest" => { + let mut c = tokio::process::Command::new("npx"); + c.args(["vitest", "run"]); + if let Some(f) = filter { + c.arg(f); + } + c + } + other => { + return Ok(ToolResult::error(format!("Unknown test runner: {other}"))); + } + }; + + tracing::debug!( + workspace = %workspace_dir.display(), + "[run_tests] runner={runner}, filter={filter:?}, timeout={timeout_secs}s" + ); + + cmd.current_dir(&workspace_dir); + cmd.kill_on_drop(true); + + let output = + match tokio::time::timeout(std::time::Duration::from_secs(timeout_secs), cmd.output()) + .await + { + Ok(Ok(output)) => output, + Ok(Err(e)) => { + return Ok(ToolResult::error(format!( + "failed to spawn test runner: {e}" + ))); + } + Err(_) => { + return Ok(ToolResult::error(format!( + "test execution timed out after {timeout_secs}s" + ))); + } + }; + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let combined = format!("{stdout}\n{stderr}"); + + // Truncate on a safe UTF-8 char boundary. + let truncated = if combined.len() > 8000 { + let safe_end = combined + .char_indices() + .take_while(|(i, _)| *i <= 8000) + .last() + .map_or(0, |(i, c)| i + c.len_utf8()); + format!( + "{}...\n[truncated, {} total chars]", + &combined[..safe_end], + combined.len() + ) + } else { + combined + }; + + tracing::debug!( + "[run_tests] exit_code={:?}, output_len={}", + output.status.code(), + truncated.len() + ); + + if output.status.success() { + Ok(ToolResult::success(truncated)) + } else { + Ok(ToolResult::error(format!( + "Tests exited with code {:?}\n\n{}", + output.status.code(), + truncated + ))) + } + } +} + +#[cfg(test)] +mod test; diff --git a/crates/tinytools-std/src/filesystem/run_tests/test.rs b/crates/tinytools-std/src/filesystem/run_tests/test.rs new file mode 100644 index 0000000..10d3cba --- /dev/null +++ b/crates/tinytools-std/src/filesystem/run_tests/test.rs @@ -0,0 +1,164 @@ +//! Behavior tests for the `run_tests` tool. + +#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)] + +use super::*; +use crate::filesystem::test_support::WorkspaceContext; +use serde_json::json; +use tempfile::TempDir; + +fn make_tool(dir: &TempDir) -> RunTestsTool { + RunTestsTool::new(dir.path().to_path_buf()) +} + +/// A throwaway cargo package whose single test body is `test_body`. +fn cargo_project(test_body: &str) -> TempDir { + let tmp = TempDir::new().unwrap(); + std::fs::write( + tmp.path().join("Cargo.toml"), + "[package]\nname = \"fixture\"\nversion = \"0.0.0\"\nedition = \"2021\"\n\n[workspace]\n", + ) + .unwrap(); + std::fs::create_dir(tmp.path().join("src")).unwrap(); + std::fs::write( + tmp.path().join("src/lib.rs"), + format!("#[test]\nfn fixture_test() {{\n{test_body}\n}}\n"), + ) + .unwrap(); + tmp +} + +#[test] +fn name_and_description_are_pinned() { + let tmp = TempDir::new().unwrap(); + let tool = make_tool(&tmp); + assert_eq!(tool.name(), "run_tests"); + assert_eq!( + tool.description(), + "Run the project test suite. Supports 'cargo_test' for Rust and 'vitest' for \ + TypeScript/JavaScript. Returns pass/fail results with output." + ); +} + +#[test] +fn schema_is_pinned() { + let tmp = TempDir::new().unwrap(); + assert_eq!( + make_tool(&tmp).parameters_schema(), + json!({ + "type": "object", + "properties": { + "runner": { + "type": "string", + "enum": ["cargo_test", "vitest", "auto"], + "description": "Which test runner to use. 'auto' detects from project files.", + "default": "auto" + }, + "filter": { + "type": "string", + "description": "Filter to run specific tests (e.g. test name or module)." + }, + "timeout_secs": { + "type": "integer", + "description": "Timeout in seconds (default: 120).", + "default": 120 + } + } + }) + ); +} + +#[test] +fn permission_level_and_exposure() { + let tmp = TempDir::new().unwrap(); + let tool = make_tool(&tmp); + assert_eq!(tool.permission_level(), PermissionLevel::Execute); + assert_eq!(tool.exposure(), tinytools::ToolExposure::Deferred); +} + +#[tokio::test] +async fn auto_returns_error_when_no_project_files() { + let tmp = TempDir::new().unwrap(); + let result = make_tool(&tmp).execute(json!({})).await.unwrap(); + assert!(result.is_error); + assert!( + result + .output() + .contains("Could not detect project type for testing.") + ); +} + +#[tokio::test] +async fn unknown_runner_returns_error() { + let tmp = TempDir::new().unwrap(); + let result = make_tool(&tmp) + .execute(json!({"runner": "pytest"})) + .await + .unwrap(); + assert!(result.is_error); + assert_eq!(result.output(), "Unknown test runner: pytest"); +} + +#[tokio::test] +async fn auto_detects_a_node_project_as_vitest() { + let tmp = TempDir::new().unwrap(); + std::fs::write(tmp.path().join("package.json"), "{}").unwrap(); + // The runner may or may not be installed; either way the tool must have + // picked `vitest` rather than reporting an undetectable project. + let result = make_tool(&tmp) + .execute(json!({"timeout_secs": 1})) + .await + .unwrap(); + assert!(!result.output().contains("Could not detect project type")); +} + +#[tokio::test] +async fn passing_cargo_project_reports_success() { + let tmp = cargo_project("assert_eq!(1 + 1, 2);"); + let result = make_tool(&tmp) + .execute(json!({"runner": "cargo_test", "filter": "fixture_test"})) + .await + .unwrap(); + assert!(!result.is_error, "{}", result.output()); + assert!(result.output().contains("test result: ok")); +} + +#[tokio::test] +async fn failing_cargo_project_reports_the_exit_code_and_truncates_output() { + let tmp = cargo_project("panic!(\"{}\", \"x\".repeat(20_000));"); + let result = make_tool(&tmp).execute(json!({})).await.unwrap(); + assert!(result.is_error); + let output = result.output(); + assert!(output.starts_with("Tests exited with code Some(101)")); + assert!(output.contains("[truncated, ")); +} + +#[tokio::test] +async fn a_timeout_is_reported() { + let tmp = cargo_project("assert!(true);"); + let result = make_tool(&tmp) + .execute(json!({"runner": "cargo_test", "timeout_secs": 0})) + .await + .unwrap(); + assert!(result.is_error); + assert_eq!(result.output(), "test execution timed out after 0s"); +} + +#[tokio::test] +async fn a_workspace_descriptor_overrides_the_configured_directory() { + let configured = TempDir::new().unwrap(); + let isolated = TempDir::new().unwrap(); + std::fs::write(isolated.path().join("package.json"), "{}").unwrap(); + let context = WorkspaceContext::at(isolated.path()); + // The configured directory has no project files; only the isolated + // workspace does, so detection succeeding proves the override was used. + let result = make_tool(&configured) + .execute_with_context( + json!({"timeout_secs": 1}), + ToolCallOptions::default(), + Some(&context), + ) + .await + .unwrap(); + assert!(!result.output().contains("Could not detect project type")); +} diff --git a/crates/tinytools-std/src/filesystem/test_support.rs b/crates/tinytools-std/src/filesystem/test_support.rs new file mode 100644 index 0000000..0158707 --- /dev/null +++ b/crates/tinytools-std/src/filesystem/test_support.rs @@ -0,0 +1,357 @@ +//! A small, self-contained [`FsGate`] for the filesystem tools' unit tests. +//! +//! It models just enough of a host policy to exercise the tools: an autonomy +//! tier, an action budget, an approval flag, and a set of allowed roots that +//! paths must resolve into. It is *not* a copy of any host's policy; the +//! host's own semantics are tested in the host, through its adapter. + +#![allow( + clippy::expect_used, + clippy::unwrap_used, + missing_docs, + unreachable_pub +)] + +use std::path::{Component, Path, PathBuf}; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use async_trait::async_trait; + +use super::gate::FsGate; + +/// Prefix the fake puts on refusals a real host would mark as policy-blocked. +pub const POLICY_BLOCKED_MARKER: &str = "[policy-blocked]"; + +/// Autonomy tier of the fake gate. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum AutonomyLevel { + /// Nothing that changes state may run. + ReadOnly, + /// Writes ask for approval. + Supervised, + /// Writes run unprompted. + Full, +} + +/// A fake host policy. +#[derive(Debug)] +pub struct TestGate { + autonomy: AutonomyLevel, + action_dir: PathBuf, + roots: Vec, + max_actions: usize, + used: AtomicUsize, +} + +impl TestGate { + /// Supervised gate whose only root is `dir`. + pub fn supervised(dir: PathBuf) -> Arc { + Self::with(dir, AutonomyLevel::Supervised, 1_000_000) + } + + /// Gate over `dir` with an explicit tier and action budget. + pub fn with(dir: PathBuf, autonomy: AutonomyLevel, max_actions: usize) -> Arc { + Arc::new(Self { + autonomy, + roots: vec![dir.clone()], + action_dir: dir, + max_actions, + used: AtomicUsize::new(0), + }) + } + + /// Supervised gate resolving relative paths against `action_dir`, with + /// `extra_roots` also readable and writable. + pub fn split(action_dir: PathBuf, extra_roots: Vec) -> Arc { + let mut roots = vec![action_dir.clone()]; + roots.extend(extra_roots); + Arc::new(Self { + autonomy: AutonomyLevel::Supervised, + roots, + action_dir, + max_actions: 1_000_000, + used: AtomicUsize::new(0), + }) + } + + fn full_path(&self, path: &str) -> PathBuf { + if Path::new(path).is_absolute() { + PathBuf::from(path) + } else { + self.action_dir.join(path) + } + } + + /// Canonicalize the deepest existing ancestor of `full` and test it + /// against the roots, so a symlink out of a root is caught. + fn resolved_inside_roots(&self, full: &Path) -> bool { + let mut existing = full.to_path_buf(); + while !existing.exists() { + match existing.parent() { + Some(parent) => existing = parent.to_path_buf(), + None => return false, + } + } + let Ok(canonical) = existing.canonicalize() else { + return false; + }; + self.roots.iter().any(|root| { + root.canonicalize() + .is_ok_and(|root| canonical.starts_with(root)) + }) + } + + fn blocked(path: &str) -> String { + format!( + "{POLICY_BLOCKED_MARKER} Path not allowed by security policy: {path}. Do not retry \ + this path; use an allowed location (the workspace or a granted folder)." + ) + } +} + +#[async_trait] +impl FsGate for TestGate { + fn can_act(&self) -> bool { + self.autonomy != AutonomyLevel::ReadOnly + } + + fn is_read_only(&self) -> bool { + self.autonomy == AutonomyLevel::ReadOnly + } + + fn is_rate_limited(&self) -> bool { + self.used.load(Ordering::SeqCst) >= self.max_actions + } + + fn record_action(&self) -> bool { + self.used.fetch_add(1, Ordering::SeqCst) < self.max_actions + } + + fn write_needs_approval(&self) -> bool { + self.autonomy == AutonomyLevel::Supervised + } + + fn action_dir(&self) -> &Path { + &self.action_dir + } + + fn is_path_string_allowed(&self, path: &str) -> bool { + if path.contains('\0') { + return false; + } + let candidate = Path::new(path); + if candidate + .components() + .any(|component| component == Component::ParentDir) + { + return false; + } + self.resolved_inside_roots(&self.full_path(path)) + } + + async fn validate_path(&self, path: &str) -> Result { + if !self.is_path_string_allowed(path) { + return Err(Self::blocked(path)); + } + tokio::fs::canonicalize(self.full_path(path)) + .await + .map_err(|e| format!("Failed to resolve path '{path}': {e}")) + } + + async fn validate_parent_path(&self, path: &str) -> Result { + if !self.is_path_string_allowed(path) { + return Err(Self::blocked(path)); + } + let full = self.full_path(path); + let parent = full + .parent() + .ok_or_else(|| format!("Invalid path (no parent): {path}"))?; + let file_name = full + .file_name() + .ok_or_else(|| format!("Invalid path (no filename): {path}"))?; + let mut existing = parent.to_path_buf(); + while !existing.exists() { + match existing.parent() { + Some(next) => existing = next.to_path_buf(), + None => break, + } + } + let canonical = tokio::fs::canonicalize(&existing) + .await + .map_err(|e| format!("Failed to resolve parent of '{path}': {e}"))?; + let suffix = parent.strip_prefix(&existing).unwrap_or(Path::new("")); + Ok(canonical.join(suffix).join(file_name)) + } + + fn scoped_to_workspace(&self, root: &Path) -> Arc { + let mut roots = self.roots.clone(); + roots.push(root.to_path_buf()); + Arc::new(Self { + autonomy: self.autonomy, + action_dir: root.to_path_buf(), + roots, + max_actions: self.max_actions, + used: AtomicUsize::new(self.used.load(Ordering::SeqCst)), + }) + } +} + +/// A run context that carries an isolated workspace, as a harness threads one +/// into a worktree-isolated worker. +#[derive(Debug)] +pub struct WorkspaceContext(pub tinytools::WorkspaceDescriptor); + +impl WorkspaceContext { + /// Context whose workspace is rooted at `root`. + pub fn at(root: &Path) -> Self { + Self( + tinytools::WorkspaceDescriptor::new(root.to_path_buf()).with_policy_id("test-worktree"), + ) + } +} + +impl tinytools::ToolRunContext for WorkspaceContext { + fn workspace(&self) -> Option<&tinytools::WorkspaceDescriptor> { + Some(&self.0) + } +} + +/// A gate whose budget check passes but whose `record_action` refuses, the +/// interleaving where another call spends the last unit between the two. +#[derive(Debug)] +pub struct RacyGate(pub Arc); + +#[async_trait] +impl FsGate for RacyGate { + fn can_act(&self) -> bool { + self.0.can_act() + } + fn is_read_only(&self) -> bool { + self.0.is_read_only() + } + fn is_rate_limited(&self) -> bool { + false + } + fn record_action(&self) -> bool { + false + } + fn write_needs_approval(&self) -> bool { + self.0.write_needs_approval() + } + fn action_dir(&self) -> &Path { + self.0.action_dir() + } + fn is_path_string_allowed(&self, path: &str) -> bool { + self.0.is_path_string_allowed(path) + } + async fn validate_path(&self, path: &str) -> Result { + self.0.validate_path(path).await + } + async fn validate_parent_path(&self, path: &str) -> Result { + self.0.validate_parent_path(path).await + } + fn scoped_to_workspace(&self, root: &Path) -> Arc { + self.0.scoped_to_workspace(root) + } +} + +/// A [`TestGate`] wrapper that can force conditions the plain fake cannot +/// reach: a budget that reports "not limited" yet refuses to record, a path +/// resolver pinned to one answer, or an action directory that does not exist. +#[derive(Debug)] +pub struct WrapGate { + inner: Arc, + no_budget: bool, + fixed_path: Option, + action_dir: Option, +} + +impl WrapGate { + /// Wrap `inner` without overriding anything yet. + pub fn new(inner: Arc) -> Self { + Self { + inner, + no_budget: false, + fixed_path: None, + action_dir: None, + } + } + + /// `is_rate_limited` stays `false` but `record_action` returns `false`. + #[must_use] + pub fn no_budget(mut self) -> Self { + self.no_budget = true; + self + } + + /// Both validators answer `Ok(path)` regardless of input. + #[must_use] + pub fn fixed_path(mut self, path: PathBuf) -> Self { + self.fixed_path = Some(path); + self + } + + /// Report `dir` as the action directory. + #[must_use] + pub fn action_dir_override(mut self, dir: PathBuf) -> Self { + self.action_dir = Some(dir); + self + } + + /// Erase into the trait object the tools take. + pub fn arc(self) -> Arc { + Arc::new(self) + } +} + +#[async_trait] +impl FsGate for WrapGate { + fn can_act(&self) -> bool { + self.inner.can_act() + } + + fn is_read_only(&self) -> bool { + self.inner.is_read_only() + } + + fn is_rate_limited(&self) -> bool { + !self.no_budget && self.inner.is_rate_limited() + } + + fn record_action(&self) -> bool { + !self.no_budget && self.inner.record_action() + } + + fn write_needs_approval(&self) -> bool { + self.inner.write_needs_approval() + } + + fn action_dir(&self) -> &Path { + self.action_dir + .as_deref() + .unwrap_or_else(|| self.inner.action_dir()) + } + + fn is_path_string_allowed(&self, path: &str) -> bool { + self.inner.is_path_string_allowed(path) + } + + async fn validate_path(&self, path: &str) -> Result { + match &self.fixed_path { + Some(fixed) => Ok(fixed.clone()), + None => self.inner.validate_path(path).await, + } + } + + async fn validate_parent_path(&self, path: &str) -> Result { + match &self.fixed_path { + Some(fixed) => Ok(fixed.clone()), + None => self.inner.validate_parent_path(path).await, + } + } + + fn scoped_to_workspace(&self, root: &Path) -> Arc { + self.inner.scoped_to_workspace(root) + } +} diff --git a/crates/tinytools-std/src/filesystem/text.rs b/crates/tinytools-std/src/filesystem/text.rs new file mode 100644 index 0000000..b9e8082 --- /dev/null +++ b/crates/tinytools-std/src/filesystem/text.rs @@ -0,0 +1,49 @@ +//! Small text helpers shared by the filesystem tools. + +/// Truncate `s` to at most `max_bytes` bytes, appending a single-character +/// ellipsis `…` (3 bytes) if truncated. The returned string's total byte +/// length never exceeds `max_bytes`. +pub(super) fn truncate_at_byte_boundary(s: &str, max_bytes: usize) -> String { + if s.len() <= max_bytes { + return s.to_string(); + } + let ellipsis = "…"; + let ellipsis_len = ellipsis.len(); + if max_bytes < ellipsis_len { + return String::new(); + } + let mut end = max_bytes - ellipsis_len; + while end > 0 && !s.is_char_boundary(end) { + end -= 1; + } + format!("{}{}", &s[..end], ellipsis) +} + +#[cfg(test)] +mod test { + use super::truncate_at_byte_boundary; + + #[test] + fn short_strings_pass_through() { + assert_eq!(truncate_at_byte_boundary("abc", 3), "abc"); + } + + #[test] + fn long_strings_end_in_an_ellipsis_within_budget() { + let out = truncate_at_byte_boundary("abcdefghij", 6); + assert_eq!(out, "abc…"); + assert!(out.len() <= 6); + } + + #[test] + fn never_splits_a_multibyte_character() { + // 'é' is two bytes; a budget landing mid-character backs up. + let out = truncate_at_byte_boundary("ééééé", 6); + assert_eq!(out, "é…"); + } + + #[test] + fn a_budget_smaller_than_the_ellipsis_yields_nothing() { + assert_eq!(truncate_at_byte_boundary("abcdef", 2), ""); + } +} diff --git a/crates/tinytools-std/src/filesystem/update_memory_md/mod.rs b/crates/tinytools-std/src/filesystem/update_memory_md/mod.rs new file mode 100644 index 0000000..672cc0d --- /dev/null +++ b/crates/tinytools-std/src/filesystem/update_memory_md/mod.rs @@ -0,0 +1,412 @@ +//! Tool: `update_memory_md` — append or update sections in MEMORY.md or SKILL.md. + +use async_trait::async_trait; +use serde_json::json; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, LazyLock, Mutex}; +use tinytools::ToolRunContext; +use tinytools::{PermissionLevel, Tool, ToolCallOptions, ToolResult}; + +/// Allowed workspace markdown files this tool may modify. +const ALLOWED_FILES: &[&str] = &["MEMORY.md", "SKILL.md"]; + +/// Process-global registry of per-workspace write locks (#4458). +/// +/// `update_memory_md` performs a read-modify-write on `MEMORY.md`/`SKILL.md`. +/// The per-run `MemoryProtocolTracker` has zero cross-run awareness, so two +/// concurrent runs (parallel forks, cron) racing the same workspace file would +/// otherwise clobber each other's append. We serialize every write to a given +/// workspace directory through a shared async mutex, keyed by the canonicalized +/// (falling back to raw) workspace path, so concurrent index updates queue +/// instead of racing. Combined with the temp-file + atomic-rename write below, +/// a killed process can never leave a truncated file. +static WORKSPACE_WRITE_LOCKS: LazyLock>>>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + +/// Monotonic counter for temp-file uniqueness within a process. +static TEMP_FILE_SEQ: AtomicU64 = AtomicU64::new(0); + +/// Return (creating if needed) the shared async write lock for `workspace_dir`. +/// +/// The lock key is the canonicalized workspace path when it resolves (so two +/// spellings of the same directory share one lock), else the raw path. +fn workspace_write_lock(workspace_dir: &Path) -> Arc> { + let key = workspace_dir + .canonicalize() + .unwrap_or_else(|_| workspace_dir.to_path_buf()); + let mut map = WORKSPACE_WRITE_LOCKS + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + Arc::clone( + map.entry(key) + .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(()))), + ) +} + +/// Acquire an **inter-process** advisory write lock for `workspace_dir` (#4458). +/// +/// [`WORKSPACE_WRITE_LOCKS`] only serializes writers inside this OS process, but +/// cron launches work via separate `tokio::process::Command` subprocesses that +/// don't share that mutex — so two cron runs could still clobber the same +/// `MEMORY.md` mid read-modify-write. This takes an `fs2` exclusive `flock` on a +/// sentinel `.memory-write.lock` file in the workspace; the returned `File` +/// holds the lock until it is dropped (end of the write). `flock` acquisition +/// blocks, so it runs on a blocking thread. +async fn acquire_cross_process_write_lock(workspace_dir: &Path) -> anyhow::Result { + let lock_path = workspace_dir.join(".memory-write.lock"); + tokio::task::spawn_blocking(move || { + use fs2::FileExt; + if let Some(parent) = lock_path.parent() { + let _ = std::fs::create_dir_all(parent); + } + // Reject a symlinked lock file: a project-controlled `.memory-write.lock` + // symlink (including a dangling one) could otherwise redirect this + // create/open/lock to a path OUTSIDE the already-containment-checked + // workspace, bypassing the symlink hardening applied to MEMORY.md / + // SKILL.md. If it exists it must be a regular file. + if let Ok(meta) = std::fs::symlink_metadata(&lock_path) + && meta.file_type().is_symlink() + { + return Err(anyhow::anyhow!( + "workspace lock file {lock_path:?} is a symlink; refusing to follow it" + )); + } + let mut opts = std::fs::OpenOptions::new(); + opts.create(true).write(true).truncate(false); + #[cfg(unix)] + { + // O_NOFOLLOW closes the TOCTOU window: if a symlink is swapped in + // after the check above, the open fails (ELOOP) rather than follows. + use std::os::unix::fs::OpenOptionsExt; + opts.custom_flags(libc::O_NOFOLLOW); + } + let file = opts + .open(&lock_path) + .map_err(|e| anyhow::anyhow!("open workspace lock file {lock_path:?}: {e}"))?; + file.lock_exclusive() + .map_err(|e| anyhow::anyhow!("acquire workspace write flock: {e}"))?; + Ok::(file) + }) + .await + .map_err(|e| anyhow::anyhow!("workspace lock task join failed: {e}"))? +} + +/// Atomically replace `path`'s contents with `content`. +/// +/// Writes to a sibling temp file in the same directory (so the rename stays on +/// one filesystem and is atomic) and `rename`s it over the target. A crash +/// mid-write leaves either the old file or the complete new file — never a +/// half-written truncation. Callers MUST hold the per-workspace write lock so +/// the read-modify-write is serialized end-to-end. +async fn atomic_write(path: &Path, file: &str, content: &str) -> anyhow::Result<()> { + let dir = path + .parent() + .ok_or_else(|| anyhow::anyhow!("target path has no parent directory"))?; + let seq = TEMP_FILE_SEQ.fetch_add(1, Ordering::Relaxed); + let tmp_name = format!(".{file}.{}.{seq}.tmp", std::process::id()); + let tmp_path = dir.join(tmp_name); + + tracing::debug!( + tmp = %tmp_path.display(), + target = %path.display(), + bytes = content.len(), + "[update_memory_md] atomic write: staging temp file" + ); + + if let Err(e) = tokio::fs::write(&tmp_path, content).await { + // Clean up a partially-written temp file so a failed stage doesn't + // litter the workspace (CodeRabbit: temp not cleaned on initial write). + let _ = tokio::fs::remove_file(&tmp_path).await; + return Err(anyhow::anyhow!("Failed to stage temp file for {file}: {e}")); + } + + if let Err(e) = tokio::fs::rename(&tmp_path, path).await { + // Best-effort cleanup so a failed rename doesn't litter the workspace. + let _ = tokio::fs::remove_file(&tmp_path).await; + return Err(anyhow::anyhow!("Failed to atomically write {file}: {e}")); + } + + tracing::debug!( + target = %path.display(), + "[update_memory_md] atomic write: rename committed" + ); + Ok(()) +} + +/// Appends or replaces a named section in MEMORY.md or SKILL.md. +/// +/// Supports two actions: +/// - `append`: adds `content` to the end of the file. +/// - `replace_section`: locates the first `## {section_title}` heading and +/// replaces the body (lines until the next `##` heading or EOF) with `content`. +#[derive(Debug)] +pub struct UpdateMemoryMdTool { + workspace_dir: PathBuf, +} + +impl UpdateMemoryMdTool { + #[must_use] + /// Create the tool. + pub fn new(workspace_dir: PathBuf) -> Self { + Self { workspace_dir } + } + + fn workspace_dir_for_context(&self, context: Option<&dyn ToolRunContext>) -> PathBuf { + if let Some(workspace) = context.and_then(|ctx| ctx.workspace()) { + tracing::debug!( + workspace_root = %workspace.root.display(), + policy_id = %workspace.policy_id, + "[update_memory_md] using TinyAgents workspace descriptor as workspace dir" + ); + return workspace.root.clone(); + } + self.workspace_dir.clone() + } +} + +#[async_trait] +impl Tool for UpdateMemoryMdTool { + fn name(&self) -> &'static str { + "update_memory_md" + } + + fn description(&self) -> &'static str { + "Append or update sections in MEMORY.md or SKILL.md workspace files. \ + Use 'append' to add new notes at the end, or 'replace_section' to \ + overwrite the body under a named '## Section' heading." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "required": ["file", "action", "content"], + "properties": { + "file": { + "type": "string", + "enum": ["MEMORY.md", "SKILL.md"], + "description": "Which workspace markdown file to modify." + }, + "action": { + "type": "string", + "enum": ["append", "replace_section"], + "description": "'append' adds content at the end; \ + 'replace_section' replaces the body of the named section." + }, + "section_title": { + "type": "string", + "description": "Required for 'replace_section': the heading text (without '## ')." + }, + "content": { + "type": "string", + "description": "The markdown text to write." + } + } + }) + } + + fn permission_level(&self) -> PermissionLevel { + PermissionLevel::Write + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + self.execute_with_context(args, ToolCallOptions::default(), None) + .await + } + + async fn execute_with_context( + &self, + args: serde_json::Value, + _options: ToolCallOptions, + context: Option<&dyn ToolRunContext>, + ) -> anyhow::Result { + let workspace_dir = self.workspace_dir_for_context(context); + let file = args + .get("file") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing 'file' parameter"))?; + + let action = args + .get("action") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing 'action' parameter"))?; + + let content = args + .get("content") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow::anyhow!("Missing 'content' parameter"))?; + + // Guard: only allow MEMORY.md and SKILL.md. + if !ALLOWED_FILES.contains(&file) { + return Ok(ToolResult::error(format!( + "File '{file}' is not allowed. Permitted files: MEMORY.md, SKILL.md" + ))); + } + + let target_path = workspace_dir.join(file); + + // Prevent symlink-based workspace escape. + let workspace_canon = self + .workspace_dir_for_context(context) + .canonicalize() + .map_err(|e| anyhow::anyhow!("Failed to canonicalize workspace: {e}"))?; + // Check parent dir exists and canonicalize to detect symlinks. + let parent = target_path.parent().unwrap_or(&workspace_dir); + let parent_canon = parent + .canonicalize() + .unwrap_or_else(|_| parent.to_path_buf()); + if !parent_canon.starts_with(&workspace_canon) { + return Ok(ToolResult::error(format!( + "File path '{file}' resolves outside workspace" + ))); + } + + tracing::debug!("[update_memory_md] action={action} file={file} path={target_path:?}"); + + // #4458: serialize the whole read-modify-write against concurrent runs + // targeting the same workspace. The guard is held across read + atomic + // write so no interleaving append can be lost. + let lock = workspace_write_lock(&workspace_dir); + let _guard = lock.lock().await; + // Also take a cross-process advisory lock so cron subprocesses (which + // don't share the in-process mutex above) can't clobber the same file + // mid-RMW. Held across read + atomic write; released on drop. + let _file_lock = acquire_cross_process_write_lock(&workspace_dir).await?; + tracing::debug!( + workspace = %workspace_dir.display(), + "[update_memory_md] acquired per-workspace write lock (in-process + cross-process flock)" + ); + + match action { + "append" => self.do_append(&target_path, file, content).await, + "replace_section" => { + let section_title = args + .get("section_title") + .and_then(|v| v.as_str()) + .ok_or_else(|| { + anyhow::anyhow!("'section_title' is required for 'replace_section' action") + })?; + self.do_replace_section(&target_path, file, section_title, content) + .await + } + other => Ok(ToolResult::error(format!( + "Unknown action '{other}'. Use 'append' or 'replace_section'." + ))), + } + } +} + +impl UpdateMemoryMdTool { + /// Append `content` to the end of `path`, creating the file if it does not exist. + async fn do_append( + &self, + path: &std::path::Path, + file: &str, + content: &str, + ) -> anyhow::Result { + // Read existing content (empty string if file not found). + let existing = read_or_empty(path).await?; + + let separator = if existing.is_empty() || existing.ends_with('\n') { + "" + } else { + "\n" + }; + let new_content = format!("{existing}{separator}{content}\n"); + + atomic_write(path, file, &new_content).await?; + + let bytes = new_content.len(); + tracing::info!( + "[update_memory_md] appended {} bytes to {file}", + content.len() + ); + + Ok(ToolResult::success(format!( + "Appended {} bytes to {file} ({bytes} bytes total).", + content.len() + ))) + } + + /// Replace the body of the section headed `## {section_title}` in `path`. + /// + /// If the section is not found it is appended as a new section at the end. + async fn do_replace_section( + &self, + path: &std::path::Path, + file: &str, + section_title: &str, + content: &str, + ) -> anyhow::Result { + let existing = read_or_empty(path).await?; + let heading = format!("## {section_title}"); + + let lines: Vec<&str> = existing.lines().collect(); + let section_start = lines.iter().position(|l| l.trim() == heading.as_str()); + + let new_file_content = if let Some(start_idx) = section_start { + // Find where the next ## heading begins (or end of file). + let body_start = start_idx + 1; + let next_heading = lines[body_start..] + .iter() + .position(|l| l.starts_with("## ")) + .map(|rel| body_start + rel); + + let before: String = lines[..=start_idx].join("\n"); + let after: String = match next_heading { + Some(end_idx) => { + let tail = lines[end_idx..].join("\n"); + format!("\n{tail}") + } + None => String::new(), + }; + + // Ensure content is separated from the heading by a blank line. + let body = if content.trim().is_empty() { + String::new() + } else { + format!("\n{content}") + }; + + format!("{before}{body}{after}\n") + } else { + // Section not found — append it. + tracing::debug!( + "[update_memory_md] section '{section_title}' not found in {file}, appending" + ); + let separator = if existing.is_empty() || existing.ends_with('\n') { + "" + } else { + "\n" + }; + format!("{existing}{separator}{heading}\n{content}\n") + }; + + atomic_write(path, file, &new_file_content).await?; + + tracing::info!( + "[update_memory_md] replaced section '{}' in {file} ({} bytes written)", + section_title, + new_file_content.len() + ); + + Ok(ToolResult::success(format!( + "Section '{}' updated in {file} ({} bytes).", + section_title, + new_file_content.len() + ))) + } +} + +/// Read file to string, returning an empty string when the file does not exist. +async fn read_or_empty(path: &std::path::Path) -> anyhow::Result { + match tokio::fs::read_to_string(path).await { + Ok(s) => Ok(s), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(String::new()), + Err(e) => Err(anyhow::anyhow!("Failed to read {}: {e}", path.display())), + } +} + +#[cfg(test)] +mod test; diff --git a/crates/tinytools-std/src/filesystem/update_memory_md/test.rs b/crates/tinytools-std/src/filesystem/update_memory_md/test.rs new file mode 100644 index 0000000..526446d --- /dev/null +++ b/crates/tinytools-std/src/filesystem/update_memory_md/test.rs @@ -0,0 +1,357 @@ +#![allow( + clippy::expect_used, + clippy::panic, + clippy::unwrap_used, + clippy::items_after_statements, + clippy::single_match_else, + clippy::single_match +)] + +use super::*; + +fn make_tool(dir: &std::path::Path) -> UpdateMemoryMdTool { + UpdateMemoryMdTool::new(dir.to_path_buf()) +} + +#[tokio::test] +async fn append_creates_file_if_missing() { + let dir = tempfile::tempdir().unwrap(); + let tool = make_tool(dir.path()); + let result = tool + .execute(json!({ + "file": "MEMORY.md", + "action": "append", + "content": "first note" + })) + .await + .unwrap(); + assert!(!result.is_error, "{:?}", result.output()); + let text = std::fs::read_to_string(dir.path().join("MEMORY.md")).unwrap(); + assert!(text.contains("first note")); +} + +#[tokio::test] +async fn append_adds_to_existing() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("MEMORY.md"); + std::fs::write(&path, "existing\n").unwrap(); + let tool = make_tool(dir.path()); + tool.execute(json!({ + "file": "MEMORY.md", + "action": "append", + "content": "second note" + })) + .await + .unwrap(); + let text = std::fs::read_to_string(&path).unwrap(); + assert!(text.contains("existing")); + assert!(text.contains("second note")); +} + +#[tokio::test] +async fn replace_section_overwrites_body() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("MEMORY.md"); + std::fs::write(&path, "## Lessons\nold body\n## Other\nkept\n").unwrap(); + let tool = make_tool(dir.path()); + tool.execute(json!({ + "file": "MEMORY.md", + "action": "replace_section", + "section_title": "Lessons", + "content": "new body" + })) + .await + .unwrap(); + let text = std::fs::read_to_string(&path).unwrap(); + assert!(text.contains("new body"), "new body missing: {text}"); + assert!( + !text.contains("old body"), + "old body should be gone: {text}" + ); + assert!(text.contains("## Other"), "other section missing: {text}"); + assert!(text.contains("kept"), "other section body missing: {text}"); +} + +#[tokio::test] +async fn replace_section_appends_when_not_found() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("SKILL.md"); + std::fs::write(&path, "# Header\n").unwrap(); + let tool = make_tool(dir.path()); + tool.execute(json!({ + "file": "SKILL.md", + "action": "replace_section", + "section_title": "New Section", + "content": "brand new" + })) + .await + .unwrap(); + let text = std::fs::read_to_string(&path).unwrap(); + assert!(text.contains("## New Section"), "heading missing: {text}"); + assert!(text.contains("brand new"), "content missing: {text}"); +} + +#[tokio::test] +async fn replace_section_with_empty_content() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("MEMORY.md"); + std::fs::write(&path, "## Notes\nold stuff\n## End\ndone\n").unwrap(); + let tool = make_tool(dir.path()); + tool.execute(json!({ + "file": "MEMORY.md", + "action": "replace_section", + "section_title": "Notes", + "content": "" + })) + .await + .unwrap(); + let text = std::fs::read_to_string(&path).unwrap(); + assert!( + !text.contains("old stuff"), + "old body should be gone: {text}" + ); + assert!(text.contains("## End"), "other section missing: {text}"); +} + +#[tokio::test] +async fn append_to_empty_memory_file() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("MEMORY.md"); + std::fs::write(&path, "").unwrap(); + let tool = make_tool(dir.path()); + let result = tool + .execute(json!({ + "file": "MEMORY.md", + "action": "append", + "content": "first line" + })) + .await + .unwrap(); + assert!(!result.is_error, "unexpected error: {}", result.output()); + let text = std::fs::read_to_string(&path).unwrap(); + assert!(text.contains("first line")); +} + +#[tokio::test] +async fn replace_section_creates_memory_file_if_missing() { + let dir = tempfile::tempdir().unwrap(); + let tool = make_tool(dir.path()); + let result = tool + .execute(json!({ + "file": "MEMORY.md", + "action": "replace_section", + "section_title": "First", + "content": "hello" + })) + .await + .unwrap(); + assert!(!result.is_error, "unexpected error: {}", result.output()); + let text = std::fs::read_to_string(dir.path().join("MEMORY.md")).unwrap(); + assert!(text.contains("## First")); + assert!(text.contains("hello")); +} + +#[tokio::test] +async fn rejects_unknown_action() { + let dir = tempfile::tempdir().unwrap(); + let tool = make_tool(dir.path()); + let result = tool + .execute(json!({ + "file": "MEMORY.md", + "action": "delete_all", + "content": "x" + })) + .await + .unwrap(); + assert!(result.is_error); +} + +#[tokio::test] +async fn replace_section_missing_section_title_errors() { + let dir = tempfile::tempdir().unwrap(); + let tool = make_tool(dir.path()); + let result = tool + .execute(json!({ + "file": "MEMORY.md", + "action": "replace_section", + "content": "x" + })) + .await; + // May return Err or Ok with is_error + match result { + Ok(r) => assert!(r.is_error), + Err(_) => {} // also acceptable + } +} + +#[test] +fn tool_name_and_description() { + let dir = tempfile::tempdir().unwrap(); + let tool = make_tool(dir.path()); + assert_eq!(tool.name(), "update_memory_md"); + assert!(!tool.description().is_empty()); +} + +#[test] +fn parameters_schema_has_required_fields() { + let dir = tempfile::tempdir().unwrap(); + let tool = make_tool(dir.path()); + let schema = tool.parameters_schema(); + let required = schema["required"].as_array().unwrap(); + assert!(required.contains(&json!("file"))); + assert!(required.contains(&json!("action"))); +} + +#[tokio::test] +async fn rejects_disallowed_file() { + let dir = tempfile::tempdir().unwrap(); + let tool = make_tool(dir.path()); + let result = tool + .execute(json!({ + "file": "../../etc/passwd", + "action": "append", + "content": "evil" + })) + .await + .unwrap(); + assert!(result.is_error); + assert!(result.output().contains("not allowed")); +} + +#[tokio::test] +async fn missing_parameters_are_errors() { + let dir = tempfile::tempdir().unwrap(); + let tool = make_tool(dir.path()); + for (args, needle) in [ + (json!({"action": "append", "content": "x"}), "'file'"), + (json!({"file": "MEMORY.md", "content": "x"}), "'action'"), + ( + json!({"file": "MEMORY.md", "action": "append"}), + "'content'", + ), + ] { + let err = tool.execute(args).await.unwrap_err(); + assert!(err.to_string().contains(needle), "{err}"); + } +} + +#[tokio::test] +async fn a_missing_workspace_fails_to_canonicalize() { + let dir = tempfile::tempdir().unwrap(); + let tool = make_tool(&dir.path().join("does-not-exist")); + let err = tool + .execute(json!({"file": "MEMORY.md", "action": "append", "content": "x"})) + .await + .unwrap_err(); + assert!(err.to_string().contains("Failed to canonicalize workspace")); +} + +#[tokio::test] +async fn an_unreadable_target_is_reported() { + let dir = tempfile::tempdir().unwrap(); + // A directory named MEMORY.md cannot be read as text (and is not NotFound). + std::fs::create_dir(dir.path().join("MEMORY.md")).unwrap(); + let err = make_tool(dir.path()) + .execute(json!({"file": "MEMORY.md", "action": "append", "content": "x"})) + .await + .unwrap_err(); + assert!(err.to_string().contains("Failed to read"), "{err}"); +} + +#[tokio::test] +async fn atomic_write_reports_staging_and_rename_failures() { + let dir = tempfile::tempdir().unwrap(); + let missing_dir_target = dir.path().join("nope").join("MEMORY.md"); + let err = atomic_write(&missing_dir_target, "MEMORY.md", "x") + .await + .unwrap_err(); + assert!(err.to_string().contains("Failed to stage temp file")); + + // Renaming a file over a directory fails after the temp file was staged. + let dir_target = dir.path().join("MEMORY.md"); + std::fs::create_dir(&dir_target).unwrap(); + let err = atomic_write(&dir_target, "MEMORY.md", "x") + .await + .unwrap_err(); + assert!(err.to_string().contains("Failed to atomically write")); + let leftovers: Vec<_> = std::fs::read_dir(dir.path()) + .unwrap() + .filter_map(Result::ok) + .filter(|e| e.file_name().to_string_lossy().ends_with(".tmp")) + .collect(); + assert!(leftovers.is_empty()); +} + +#[cfg(unix)] +#[tokio::test] +async fn a_symlinked_lock_file_is_refused() { + let dir = tempfile::tempdir().unwrap(); + let outside = tempfile::tempdir().unwrap(); + std::os::unix::fs::symlink( + outside.path().join("target"), + dir.path().join(".memory-write.lock"), + ) + .unwrap(); + let err = make_tool(dir.path()) + .execute(json!({"file": "MEMORY.md", "action": "append", "content": "x"})) + .await + .unwrap_err(); + assert!(err.to_string().contains("is a symlink"), "{err}"); +} + +#[tokio::test] +async fn a_workspace_descriptor_overrides_the_configured_directory() { + use crate::filesystem::test_support::WorkspaceContext; + let configured = tempfile::tempdir().unwrap(); + let isolated = tempfile::tempdir().unwrap(); + let context = WorkspaceContext::at(isolated.path()); + let result = make_tool(configured.path()) + .execute_with_context( + json!({"file": "MEMORY.md", "action": "append", "content": "hi"}), + ToolCallOptions::default(), + Some(&context), + ) + .await + .unwrap(); + assert!(!result.is_error); + assert!(isolated.path().join("MEMORY.md").exists()); + assert!(!configured.path().join("MEMORY.md").exists()); +} + +#[tokio::test] +async fn append_separates_from_content_lacking_a_trailing_newline() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("MEMORY.md"), "no newline").unwrap(); + make_tool(dir.path()) + .execute(json!({"file": "MEMORY.md", "action": "append", "content": "next"})) + .await + .unwrap(); + assert_eq!( + std::fs::read_to_string(dir.path().join("MEMORY.md")).unwrap(), + "no newline\nnext\n" + ); +} + +#[tokio::test] +async fn replace_section_before_another_heading_keeps_the_tail() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("MEMORY.md"), "## A\nold\n## B\nkeep\n").unwrap(); + make_tool(dir.path()) + .execute(json!({"file": "MEMORY.md", "action": "replace_section", "section_title": "A", "content": "new"})) + .await + .unwrap(); + let text = std::fs::read_to_string(dir.path().join("MEMORY.md")).unwrap(); + assert!(text.contains("## A\nnew\n## B\nkeep"), "{text}"); +} + +#[tokio::test] +async fn replace_section_appends_after_a_file_without_trailing_newline() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("MEMORY.md"), "intro").unwrap(); + make_tool(dir.path()) + .execute(json!({"file": "MEMORY.md", "action": "replace_section", "section_title": "New", "content": "body"})) + .await + .unwrap(); + let text = std::fs::read_to_string(dir.path().join("MEMORY.md")).unwrap(); + assert_eq!(text, "intro\n## New\nbody\n"); +} diff --git a/crates/tinytools-std/src/lib.rs b/crates/tinytools-std/src/lib.rs index 8d7ea62..f9be29c 100644 --- a/crates/tinytools-std/src/lib.rs +++ b/crates/tinytools-std/src/lib.rs @@ -6,6 +6,8 @@ //! //! - [`file_state`] — cross-agent read/write stamps and per-path locks, so a //! sibling agent's edit is noticed before a stale overwrite. +//! - [`filesystem`] — file read/write/edit/patch, search, git and check-runner +//! tools, gated by a host-implemented [`filesystem::FsGate`]. //! - [`url_guard`] — URL validation with SSRF checks, plus DNS resolution //! that returns the vetted addresses for the caller to pin its connection to. //! - [`detect_tools`] — `PATH` probing and the read-only `detect_tools` tool. @@ -29,4 +31,5 @@ pub mod detect_tools; pub mod file_state; +pub mod filesystem; pub mod url_guard;