From 2c360d76e9f8e472e8b0f810129a2d07915b12f3 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Thu, 22 Oct 2026 21:48:19 +0000 Subject: [PATCH 1/4] chore: remove tinytools-std crate The tinytools-std crate and all of its tool implementations have been deleted, leaving the workspace with the tinytools contract definitions only. The README now states that concrete tools belong in their owning host or module rather than in this repository. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- README.md | 13 +- crates/tinytools-std/Cargo.toml | 54 -- crates/tinytools-std/README.md | 13 - crates/tinytools-std/src/detect_tools/mod.rs | 184 ---- .../src/detect_tools/mod_tests.rs | 125 --- .../src/file_state/agent_context.rs | 24 - crates/tinytools-std/src/file_state/mod.rs | 31 - crates/tinytools-std/src/file_state/ops.rs | 212 ----- .../src/file_state/test/agent_context.rs | 37 - .../tinytools-std/src/file_state/test/mod.rs | 5 - .../tinytools-std/src/file_state/test/ops.rs | 318 ------- crates/tinytools-std/src/file_state/types.rs | 122 --- .../src/filesystem/apply_patch/mod.rs | 474 ---------- .../src/filesystem/apply_patch/mod_tests.rs | 648 -------------- .../src/filesystem/contract_tests.rs | 152 ---- .../src/filesystem/csv_export/extra_tests.rs | 161 ---- .../src/filesystem/csv_export/mod.rs | 270 ------ .../src/filesystem/csv_export/mod_tests.rs | 234 ----- .../src/filesystem/edit_file/mod.rs | 242 ------ .../src/filesystem/edit_file/mod_tests.rs | 316 ------- .../src/filesystem/file_read/extra_tests.rs | 96 --- .../src/filesystem/file_read/mod.rs | 240 ------ .../src/filesystem/file_read/mod_tests.rs | 431 --------- .../tinytools-std/src/filesystem/file_sink.rs | 59 -- .../src/filesystem/file_write/mod.rs | 234 ----- .../src/filesystem/file_write/mod_tests.rs | 502 ----------- .../src/filesystem/fixtures/apply_patch.json | 46 - .../src/filesystem/fixtures/csv_export.json | 30 - .../src/filesystem/fixtures/edit_file.json | 33 - .../src/filesystem/fixtures/file_read.json | 23 - .../src/filesystem/fixtures/file_write.json | 23 - .../filesystem/fixtures/git_operations.json | 66 -- .../src/filesystem/fixtures/glob_search.json | 27 - .../src/filesystem/fixtures/grep.json | 33 - .../src/filesystem/fixtures/image_info.json | 22 - .../src/filesystem/fixtures/list_files.json | 16 - .../src/filesystem/fixtures/read_diff.json | 23 - .../fixtures/read_workspace_state.json | 21 - .../src/filesystem/fixtures/run_linter.json | 25 - .../src/filesystem/fixtures/run_tests.json | 30 - .../filesystem/fixtures/update_memory_md.json | 40 - crates/tinytools-std/src/filesystem/gate.rs | 129 --- .../src/filesystem/gate_tests.rs | 95 -- .../src/filesystem/git_operations/config.rs | 376 -------- .../filesystem/git_operations/config_tests.rs | 671 -------------- .../src/filesystem/git_operations/mod.rs | 656 -------------- .../filesystem/git_operations/mod_tests.rs | 455 ---------- .../filesystem/git_operations/ops_tests.rs | 368 -------- .../src/filesystem/git_operations/render.rs | 91 -- .../filesystem/git_operations/render_tests.rs | 83 -- .../src/filesystem/glob_search/extra_tests.rs | 153 ---- .../src/filesystem/glob_search/mod.rs | 344 -------- .../src/filesystem/glob_search/mod_tests.rs | 264 ------ .../src/filesystem/grep/extra_tests.rs | 54 -- .../tinytools-std/src/filesystem/grep/mod.rs | 241 ------ .../src/filesystem/grep/mod_tests.rs | 184 ---- .../src/filesystem/image_info/mod.rs | 211 ----- .../src/filesystem/image_info/mod_tests.rs | 342 -------- .../src/filesystem/list_files/extra_tests.rs | 52 -- .../src/filesystem/list_files/mod.rs | 147 ---- .../src/filesystem/list_files/mod_tests.rs | 76 -- crates/tinytools-std/src/filesystem/mod.rs | 73 -- .../src/filesystem/read_diff/mod.rs | 139 --- .../src/filesystem/read_diff/mod_tests.rs | 144 ---- .../tinytools-std/src/filesystem/read_only.rs | 92 -- .../src/filesystem/read_only_tests.rs | 58 -- .../src/filesystem/run_linter/mod.rs | 159 ---- .../src/filesystem/run_linter/mod_tests.rs | 97 --- .../src/filesystem/run_tests/mod.rs | 200 ----- .../src/filesystem/run_tests/mod_tests.rs | 164 ---- .../src/filesystem/test_support.rs | 385 --------- crates/tinytools-std/src/filesystem/text.rs | 24 - .../src/filesystem/text_test_tests.rs | 25 - .../src/filesystem/update_memory_md/mod.rs | 413 --------- .../filesystem/update_memory_md/mod_tests.rs | 357 -------- .../src/filesystem/workspace_state/mod.rs | 158 ---- .../filesystem/workspace_state/mod_tests.rs | 289 ------- crates/tinytools-std/src/lib.rs | 40 - .../src/network/contract_tests.rs | 126 --- crates/tinytools-std/src/network/curl.rs | 429 --------- .../tinytools-std/src/network/curl_tests.rs | 324 ------- .../src/network/fixtures/curl.json | 27 - .../src/network/fixtures/http_request.json | 32 - .../src/network/fixtures/pushover.json | 30 - .../src/network/fixtures/web_fetch.json | 27 - crates/tinytools-std/src/network/gate.rs | 223 ----- .../tinytools-std/src/network/gate_tests.rs | 183 ---- .../tinytools-std/src/network/http_request.rs | 483 ----------- .../src/network/http_request_tests.rs | 670 -------------- crates/tinytools-std/src/network/mod.rs | 50 -- crates/tinytools-std/src/network/pushover.rs | 217 ----- .../src/network/pushover_tests.rs | 209 ----- .../tinytools-std/src/network/test_support.rs | 176 ---- crates/tinytools-std/src/network/web_fetch.rs | 674 --------------- .../src/network/web_fetch_tests.rs | 816 ------------------ .../src/timeout/command_environment.rs | 70 -- .../tinytools-std/src/timeout/group_exit.rs | 59 -- .../src/timeout/group_exit_tests.rs | 44 - crates/tinytools-std/src/timeout/mod.rs | 39 - crates/tinytools-std/src/timeout/mod_tests.rs | 424 --------- crates/tinytools-std/src/timeout/output.rs | 227 ----- .../src/timeout/process_cleanup.rs | 94 -- crates/tinytools-std/src/timeout/settings.rs | 164 ---- crates/tinytools-std/src/url_guard/README.md | 33 - crates/tinytools-std/src/url_guard/mod.rs | 496 ----------- .../tinytools-std/src/url_guard/mod_tests.rs | 770 ----------------- 106 files changed, 10 insertions(+), 20570 deletions(-) delete mode 100644 crates/tinytools-std/Cargo.toml delete mode 100644 crates/tinytools-std/README.md delete mode 100644 crates/tinytools-std/src/detect_tools/mod.rs delete mode 100644 crates/tinytools-std/src/detect_tools/mod_tests.rs delete mode 100644 crates/tinytools-std/src/file_state/agent_context.rs delete mode 100644 crates/tinytools-std/src/file_state/mod.rs delete mode 100644 crates/tinytools-std/src/file_state/ops.rs delete mode 100644 crates/tinytools-std/src/file_state/test/agent_context.rs delete mode 100644 crates/tinytools-std/src/file_state/test/mod.rs delete mode 100644 crates/tinytools-std/src/file_state/test/ops.rs delete mode 100644 crates/tinytools-std/src/file_state/types.rs delete mode 100644 crates/tinytools-std/src/filesystem/apply_patch/mod.rs delete mode 100644 crates/tinytools-std/src/filesystem/apply_patch/mod_tests.rs delete mode 100644 crates/tinytools-std/src/filesystem/contract_tests.rs delete mode 100644 crates/tinytools-std/src/filesystem/csv_export/extra_tests.rs delete mode 100644 crates/tinytools-std/src/filesystem/csv_export/mod.rs delete mode 100644 crates/tinytools-std/src/filesystem/csv_export/mod_tests.rs delete mode 100644 crates/tinytools-std/src/filesystem/edit_file/mod.rs delete mode 100644 crates/tinytools-std/src/filesystem/edit_file/mod_tests.rs delete mode 100644 crates/tinytools-std/src/filesystem/file_read/extra_tests.rs delete mode 100644 crates/tinytools-std/src/filesystem/file_read/mod.rs delete mode 100644 crates/tinytools-std/src/filesystem/file_read/mod_tests.rs delete mode 100644 crates/tinytools-std/src/filesystem/file_sink.rs delete mode 100644 crates/tinytools-std/src/filesystem/file_write/mod.rs delete mode 100644 crates/tinytools-std/src/filesystem/file_write/mod_tests.rs delete mode 100644 crates/tinytools-std/src/filesystem/fixtures/apply_patch.json delete mode 100644 crates/tinytools-std/src/filesystem/fixtures/csv_export.json delete mode 100644 crates/tinytools-std/src/filesystem/fixtures/edit_file.json delete mode 100644 crates/tinytools-std/src/filesystem/fixtures/file_read.json delete mode 100644 crates/tinytools-std/src/filesystem/fixtures/file_write.json delete mode 100644 crates/tinytools-std/src/filesystem/fixtures/git_operations.json delete mode 100644 crates/tinytools-std/src/filesystem/fixtures/glob_search.json delete mode 100644 crates/tinytools-std/src/filesystem/fixtures/grep.json delete mode 100644 crates/tinytools-std/src/filesystem/fixtures/image_info.json delete mode 100644 crates/tinytools-std/src/filesystem/fixtures/list_files.json delete mode 100644 crates/tinytools-std/src/filesystem/fixtures/read_diff.json delete mode 100644 crates/tinytools-std/src/filesystem/fixtures/read_workspace_state.json delete mode 100644 crates/tinytools-std/src/filesystem/fixtures/run_linter.json delete mode 100644 crates/tinytools-std/src/filesystem/fixtures/run_tests.json delete mode 100644 crates/tinytools-std/src/filesystem/fixtures/update_memory_md.json delete mode 100644 crates/tinytools-std/src/filesystem/gate.rs delete mode 100644 crates/tinytools-std/src/filesystem/gate_tests.rs delete mode 100644 crates/tinytools-std/src/filesystem/git_operations/config.rs delete mode 100644 crates/tinytools-std/src/filesystem/git_operations/config_tests.rs delete mode 100644 crates/tinytools-std/src/filesystem/git_operations/mod.rs delete mode 100644 crates/tinytools-std/src/filesystem/git_operations/mod_tests.rs delete mode 100644 crates/tinytools-std/src/filesystem/git_operations/ops_tests.rs delete mode 100644 crates/tinytools-std/src/filesystem/git_operations/render.rs delete mode 100644 crates/tinytools-std/src/filesystem/git_operations/render_tests.rs delete mode 100644 crates/tinytools-std/src/filesystem/glob_search/extra_tests.rs delete mode 100644 crates/tinytools-std/src/filesystem/glob_search/mod.rs delete mode 100644 crates/tinytools-std/src/filesystem/glob_search/mod_tests.rs delete mode 100644 crates/tinytools-std/src/filesystem/grep/extra_tests.rs delete mode 100644 crates/tinytools-std/src/filesystem/grep/mod.rs delete mode 100644 crates/tinytools-std/src/filesystem/grep/mod_tests.rs delete mode 100644 crates/tinytools-std/src/filesystem/image_info/mod.rs delete mode 100644 crates/tinytools-std/src/filesystem/image_info/mod_tests.rs delete mode 100644 crates/tinytools-std/src/filesystem/list_files/extra_tests.rs delete mode 100644 crates/tinytools-std/src/filesystem/list_files/mod.rs delete mode 100644 crates/tinytools-std/src/filesystem/list_files/mod_tests.rs delete mode 100644 crates/tinytools-std/src/filesystem/mod.rs delete mode 100644 crates/tinytools-std/src/filesystem/read_diff/mod.rs delete mode 100644 crates/tinytools-std/src/filesystem/read_diff/mod_tests.rs delete mode 100644 crates/tinytools-std/src/filesystem/read_only.rs delete mode 100644 crates/tinytools-std/src/filesystem/read_only_tests.rs delete mode 100644 crates/tinytools-std/src/filesystem/run_linter/mod.rs delete mode 100644 crates/tinytools-std/src/filesystem/run_linter/mod_tests.rs delete mode 100644 crates/tinytools-std/src/filesystem/run_tests/mod.rs delete mode 100644 crates/tinytools-std/src/filesystem/run_tests/mod_tests.rs delete mode 100644 crates/tinytools-std/src/filesystem/test_support.rs delete mode 100644 crates/tinytools-std/src/filesystem/text.rs delete mode 100644 crates/tinytools-std/src/filesystem/text_test_tests.rs delete mode 100644 crates/tinytools-std/src/filesystem/update_memory_md/mod.rs delete mode 100644 crates/tinytools-std/src/filesystem/update_memory_md/mod_tests.rs delete mode 100644 crates/tinytools-std/src/filesystem/workspace_state/mod.rs delete mode 100644 crates/tinytools-std/src/filesystem/workspace_state/mod_tests.rs delete mode 100644 crates/tinytools-std/src/lib.rs delete mode 100644 crates/tinytools-std/src/network/contract_tests.rs delete mode 100644 crates/tinytools-std/src/network/curl.rs delete mode 100644 crates/tinytools-std/src/network/curl_tests.rs delete mode 100644 crates/tinytools-std/src/network/fixtures/curl.json delete mode 100644 crates/tinytools-std/src/network/fixtures/http_request.json delete mode 100644 crates/tinytools-std/src/network/fixtures/pushover.json delete mode 100644 crates/tinytools-std/src/network/fixtures/web_fetch.json delete mode 100644 crates/tinytools-std/src/network/gate.rs delete mode 100644 crates/tinytools-std/src/network/gate_tests.rs delete mode 100644 crates/tinytools-std/src/network/http_request.rs delete mode 100644 crates/tinytools-std/src/network/http_request_tests.rs delete mode 100644 crates/tinytools-std/src/network/mod.rs delete mode 100644 crates/tinytools-std/src/network/pushover.rs delete mode 100644 crates/tinytools-std/src/network/pushover_tests.rs delete mode 100644 crates/tinytools-std/src/network/test_support.rs delete mode 100644 crates/tinytools-std/src/network/web_fetch.rs delete mode 100644 crates/tinytools-std/src/network/web_fetch_tests.rs delete mode 100644 crates/tinytools-std/src/timeout/command_environment.rs delete mode 100644 crates/tinytools-std/src/timeout/group_exit.rs delete mode 100644 crates/tinytools-std/src/timeout/group_exit_tests.rs delete mode 100644 crates/tinytools-std/src/timeout/mod.rs delete mode 100644 crates/tinytools-std/src/timeout/mod_tests.rs delete mode 100644 crates/tinytools-std/src/timeout/output.rs delete mode 100644 crates/tinytools-std/src/timeout/process_cleanup.rs delete mode 100644 crates/tinytools-std/src/timeout/settings.rs delete mode 100644 crates/tinytools-std/src/url_guard/README.md delete mode 100644 crates/tinytools-std/src/url_guard/mod.rs delete mode 100644 crates/tinytools-std/src/url_guard/mod_tests.rs diff --git a/README.md b/README.md index 4bc3603c..37b41a3d 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,9 @@ # TinyTools -The vocabulary an agent tool is written against: the `Tool` trait, the -`ToolResult` it returns, and the classifications a host enforces around a call. +TinyTools defines the vocabulary an agent tool is written against: the `Tool` +trait, the `ToolResult` it returns, and the classifications a host enforces +around a call. It contains definitions only; tool implementations live in +their owning host or module. ```rust use tinytools::{Tool, ToolResult}; @@ -26,7 +28,8 @@ impl Tool for Echo { } ``` -That is a complete tool. Everything else in the trait has a default. +This shows the shape of a tool implementation. Everything else in the trait +has a default. `Tool` is async, so implementing it needs the `async-trait` shim above and beyond `tinytools` itself — this crate depends on it internally but does not @@ -80,6 +83,10 @@ transport. ## What is deliberately not here +**No tool implementations.** This workspace defines shared tool contracts, +types, policies, and call protocols. Concrete tools belong in their owning +host or module. + **No enforcement.** Nothing in this crate checks a `PermissionLevel`, applies a `ToolTimeout`, or decides whether an `external_effect` needs approval. A tool *describes* itself and a host *decides*, because the decision depends on that diff --git a/crates/tinytools-std/Cargo.toml b/crates/tinytools-std/Cargo.toml deleted file mode 100644 index b3264cd2..00000000 --- a/crates/tinytools-std/Cargo.toml +++ /dev/null @@ -1,54 +0,0 @@ -[package] -name = "tinytools-std" -version.workspace = true -edition.workspace = true -rust-version.workspace = true -license.workspace = true -repository.workspace = true -description = "Host-independent building blocks for agent tools: filesystem, git and network tools behind host gates, cross-agent file staleness tracking, SSRF-safe URL validation, and PATH probing." -documentation = "https://docs.rs/tinytools-std" -readme = "README.md" -publish = false - -[dependencies] -anyhow = { workspace = true } -async-trait = { workspace = true } -# `image_info` base64 data URIs. -base64 = "0.23" -# `curl` streams the response body chunk by chunk to disk. -futures-util = { version = "0.3", default-features = false } -# 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 } -# The network tools' HTTP client. rustls only: a host that wants another TLS -# backend adds it to its own `reqwest` dependency and cargo unifies the features. -# `stream` drives `curl`'s chunked download; `multipart` builds the `pushover` form. -reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream", "multipart"] } -# Safe effective-credential executable checks for Unix PATH candidates. -rustix = { version = "1", default-features = false, features = ["fs", "process"] } -serde_json = { workspace = true } -# `curl` reports the SHA-256 of what it downloaded. -sha2 = "0.11" -tinytools = { path = "../tinytools", version = "0.6.0" } -# `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", "io-util", "macros"] } -tracing = { workspace = true } -# `grep` and `glob_search` directory walks. -walkdir = "2" - -[dev-dependencies] -tempfile = "3" -tokio = { workspace = true, features = ["test-util"] } - -[lints] -workspace = true diff --git a/crates/tinytools-std/README.md b/crates/tinytools-std/README.md deleted file mode 100644 index 4e2c7d07..00000000 --- a/crates/tinytools-std/README.md +++ /dev/null @@ -1,13 +0,0 @@ -# tinytools-std - -Host-independent building blocks for agent tools, extracted from OpenHuman. - -| Module | What it is | -| --- | --- | -| `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`, `image_info`, `read_workspace_state`. 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. `FsGate::paths_string_allowed` is the batch form of the string-level path check (default: the single-path check mapped over the slice); `glob` asks it once per search, and a wrong-length answer refuses every hit. See `docs/specs/fsgate-batch-path-check.md`. | -| `network` | The network tools: `http_request`, `web_fetch`, `curl`, `pushover`. Every tool takes an `Arc`: the tool does the I/O, the host's gate (autonomy, action budget, approval, privacy mode, proxy) decides whether it may and how. Two smaller seams keep host behavior out: `PaymentHook` answers a `402 Payment Required` for `http_request`, and `HtmlExtractor` converts pages to Markdown for `web_fetch`. `WebFetchTool::new_async` accepts a fallible `AsyncHtmlExtractor` for transforms supplied by a remote service or loadable module; detection and extraction are awaited with the configured request timeout applied to each operation, and failures propagate without a local retry. Both constructors extract before applying the output cap, and skip extraction for raw or explicitly non-HTML responses. Names, descriptions and schemas are pinned by `src/network/fixtures/`. | -| `detect_tools` | `find_on_path` and the read-only `detect_tools` tool. | - -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/detect_tools/mod.rs b/crates/tinytools-std/src/detect_tools/mod.rs deleted file mode 100644 index d05ff6f7..00000000 --- a/crates/tinytools-std/src/detect_tools/mod.rs +++ /dev/null @@ -1,184 +0,0 @@ -//! Tool: `detect_tools` — report which developer toolchains are installed on PATH. -//! -//! Lets the agent ground its plans in what the host actually has rather than -//! assuming. Read-only: it only scans `$PATH` for executables (no subprocesses, -//! no writes), so it is safe in every access mode. - -use async_trait::async_trait; -use serde_json::json; -use std::path::PathBuf; -use tinytools::{PermissionLevel, Tool, ToolResult}; - -/// Common developer tools probed when the caller doesn't specify a list. -const DEFAULT_CANDIDATES: &[&str] = &[ - "node", "npm", "npx", "pnpm", "yarn", "bun", "deno", "python3", "python", "pip3", "pip", "uv", - "pipx", "cargo", "rustc", "go", "gcc", "cc", "clang", "make", "git", "gh", "docker", "podman", - "kubectl", "rg", "jq", "fd", "curl", "wget", -]; - -/// Read-only tool reporting which developer toolchains are on `PATH`. -#[derive(Debug)] -pub struct DetectToolsTool; - -impl DetectToolsTool { - #[must_use] - /// Create the tool. - pub fn new() -> Self { - Self - } -} - -impl Default for DetectToolsTool { - fn default() -> Self { - Self::new() - } -} - -/// Fallback executable extensions when Windows has no `PATHEXT` set. -const DEFAULT_PATHEXT: &str = ".EXE;.CMD;.BAT"; - -/// Locate `name` on `$PATH`, honoring `PATHEXT` on Windows. Returns the first -/// matching executable path, or `None` if not found. -#[must_use] -pub fn find_on_path(name: &str) -> Option { - let path = std::env::var_os("PATH")?; - let pathext = cfg!(windows) - .then(|| std::env::var("PATHEXT").unwrap_or_else(|_| DEFAULT_PATHEXT.to_string())); - let file_names = candidate_file_names(name, pathext.as_deref()); - for dir in std::env::split_paths(&path) { - for file_name in &file_names { - let candidate = dir.join(file_name); - if is_executable_file(&candidate) { - return Some(candidate); - } - } - } - None -} - -/// Whether `path` names a regular file the current process can execute. -fn is_executable_file(path: &std::path::Path) -> bool { - if !path.is_file() { - return false; - } - // On Unix, checking mode bits alone ignores the current process's - // effective credentials and supplementary groups. - #[cfg(unix)] - { - rustix::fs::accessat( - rustix::fs::CWD, - path.as_os_str().as_encoded_bytes(), - rustix::fs::Access::EXEC_OK, - rustix::fs::AtFlags::EACCESS, - ) - .is_ok() - } - #[cfg(not(unix))] - { - true - } -} - -/// The file names to probe in each `PATH` directory for `name`. -/// -/// `pathext` is the Windows `PATHEXT` list (`;`-separated), or `None` on -/// platforms that run a bare file name. A name that already ends in one of -/// those extensions (compared case-insensitively, as Windows does) is probed -/// unchanged; any other name is probed once per extension. -fn candidate_file_names(name: &str, pathext: Option<&str>) -> Vec { - let extensions: Vec<&str> = pathext - .into_iter() - .flat_map(|list| list.split(';')) - .filter(|ext| !ext.is_empty()) - .collect(); - let lower_name = name.to_ascii_lowercase(); - let has_extension = extensions - .iter() - .any(|ext| lower_name.ends_with(&ext.to_ascii_lowercase())); - if extensions.is_empty() || has_extension { - return vec![name.to_string()]; - } - extensions - .iter() - .map(|ext| format!("{name}{ext}")) - .collect() -} - -#[async_trait] -impl Tool for DetectToolsTool { - fn name(&self) -> &'static str { - "detect_tools" - } - - fn description(&self) -> &'static str { - "Detect which developer tools / language runtimes are installed on the host PATH \ - (e.g. node, python3, cargo, docker, git, rg). Use this before assuming a tool \ - exists or before proposing to install one. Read-only — scans PATH only." - } - - fn parameters_schema(&self) -> serde_json::Value { - json!({ - "type": "object", - "properties": { - "tools": { - "type": "array", - "items": { "type": "string" }, - "description": "Optional list of tool names to probe. If omitted, a default \ - catalog of common developer tools is probed." - } - } - }) - } - - fn permission_level(&self) -> PermissionLevel { - PermissionLevel::ReadOnly - } - - async fn execute(&self, args: serde_json::Value) -> anyhow::Result { - let requested: Vec = args - .get("tools") - .and_then(|v| v.as_array()) - .map(|a| { - a.iter() - .filter_map(|x| x.as_str().map(str::to_string)) - .collect() - }) - .unwrap_or_default(); - let candidates: Vec = if requested.is_empty() { - DEFAULT_CANDIDATES - .iter() - .map(|s| (*s).to_string()) - .collect() - } else { - requested - }; - - let mut available = Vec::new(); - let mut missing = Vec::new(); - for name in &candidates { - match find_on_path(name) { - Some(p) => available.push(json!({ - "name": name, - "path": p.to_string_lossy(), - })), - None => missing.push(name.clone()), - } - } - - tracing::debug!( - probed = candidates.len(), - available = available.len(), - "[detect_tools] PATH scan complete" - ); - let payload = json!({ - "available": available, - "missing": missing, - "probed": candidates.len(), - }); - Ok(ToolResult::success(serde_json::to_string_pretty(&payload)?)) - } -} - -#[cfg(test)] -#[path = "mod_tests.rs"] -mod test; diff --git a/crates/tinytools-std/src/detect_tools/mod_tests.rs b/crates/tinytools-std/src/detect_tools/mod_tests.rs deleted file mode 100644 index f88a3992..00000000 --- a/crates/tinytools-std/src/detect_tools/mod_tests.rs +++ /dev/null @@ -1,125 +0,0 @@ -//! Unit tests for `PATH` probing, `PATHEXT` candidate names, and the -//! `detect_tools` tool's payload. - -use super::*; - -#[cfg(unix)] -#[test] -fn executable_lookup_requires_current_process_access() -> anyhow::Result<()> { - use std::os::unix::fs::PermissionsExt; - - let dir = std::env::temp_dir().join(format!("tinytools-exec-check-{}", std::process::id())); - std::fs::create_dir_all(&dir)?; - let file = dir.join("candidate"); - std::fs::write(&file, "binary")?; - std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o600))?; - assert!(!is_executable_file(&file)); - std::fs::set_permissions(&file, std::fs::Permissions::from_mode(0o700))?; - assert!(is_executable_file(&file)); - assert!(!is_executable_file(&dir)); - std::fs::remove_dir_all(dir)?; - Ok(()) -} - -#[test] -fn name_and_permission() { - let tool = DetectToolsTool::new(); - assert_eq!(tool.name(), "detect_tools"); - assert_eq!(tool.permission_level(), PermissionLevel::ReadOnly); -} - -#[test] -fn default_and_metadata_contracts_are_available() { - let tool = ::default(); - assert!(tool.description().contains("PATH")); - assert_eq!( - tool.parameters_schema()["properties"]["tools"]["type"], - "array" - ); -} - -#[tokio::test] -async fn non_string_tool_names_fall_back_to_the_default_catalog() -> anyhow::Result<()> { - let result = DetectToolsTool::new() - .execute(json!({"tools": [null, 3]})) - .await?; - let payload: serde_json::Value = serde_json::from_str(&result.output())?; - assert_eq!(payload["probed"], super::DEFAULT_CANDIDATES.len()); - Ok(()) -} - -#[tokio::test] -async fn missing_tool_reported_missing() -> anyhow::Result<()> { - let tool = DetectToolsTool::new(); - let result = tool - .execute(json!({ "tools": ["definitely_not_a_real_binary_xyz_123"] })) - .await?; - assert!(!result.is_error); - let payload: serde_json::Value = serde_json::from_str(&result.output())?; - assert_eq!(payload["probed"], 1); - assert_eq!(payload["available"], json!([])); - assert_eq!( - payload["missing"], - json!(["definitely_not_a_real_binary_xyz_123"]) - ); - Ok(()) -} - -#[tokio::test] -async fn available_plus_missing_equals_probed() -> anyhow::Result<()> { - let tool = DetectToolsTool::new(); - let result = tool - .execute(json!({ "tools": ["sh", "definitely_not_a_real_binary_xyz_123"] })) - .await?; - let payload: serde_json::Value = serde_json::from_str(&result.output())?; - let avail = payload["available"].as_array().map_or(0, Vec::len); - let miss = payload["missing"].as_array().map_or(0, Vec::len); - assert_eq!(avail + miss, 2); - Ok(()) -} - -#[test] -fn bare_name_is_probed_unchanged_without_pathext() { - assert_eq!(candidate_file_names("git", None), vec!["git".to_string()]); -} - -#[test] -fn extensionless_name_gets_each_pathext_extension() { - assert_eq!( - candidate_file_names("git", Some(".EXE;.CMD")), - vec!["git.EXE".to_string(), "git.CMD".to_string()] - ); -} - -#[test] -fn name_already_carrying_a_pathext_extension_is_probed_unchanged() { - // Windows must probe `git.exe`, not `git.exe.EXE`. - assert_eq!( - candidate_file_names("git.exe", Some(".EXE;.CMD;.BAT")), - vec!["git.exe".to_string()] - ); - assert_eq!( - candidate_file_names("build.Cmd", Some(".EXE;.CMD")), - vec!["build.Cmd".to_string()] - ); -} - -#[test] -fn name_with_a_non_pathext_extension_still_gets_pathext_appended() { - assert_eq!( - candidate_file_names("python3.11", Some(".EXE")), - vec!["python3.11.EXE".to_string()] - ); -} - -#[test] -fn empty_pathext_entries_are_ignored() { - assert_eq!( - candidate_file_names("git", Some(".EXE;;")), - vec!["git.EXE".to_string()] - ); - assert_eq!( - candidate_file_names("git", Some("")), - vec!["git".to_string()] - ); -} diff --git a/crates/tinytools-std/src/file_state/agent_context.rs b/crates/tinytools-std/src/file_state/agent_context.rs deleted file mode 100644 index 1624adfb..00000000 --- a/crates/tinytools-std/src/file_state/agent_context.rs +++ /dev/null @@ -1,24 +0,0 @@ -//! Task-local carrier for the currently-executing agent's identity so -//! file tools can attribute reads/writes without widening the `Tool` trait. -//! -//! Set by the agent harness around tool execution; tools read via [`current_file_state_agent_id`]. - -tokio::task_local! { - static FILE_STATE_AGENT_ID: String; -} - -/// Returns the current agent's identity for file-state tracking, if set. -/// -/// Returns `None` outside an agent turn (CLI, JSON-RPC direct, unit tests). -#[must_use] -pub fn current_file_state_agent_id() -> Option { - FILE_STATE_AGENT_ID.try_with(std::clone::Clone::clone).ok() -} - -/// Run `future` with `agent_id` installed as the file-state identity. -pub async fn with_file_state_agent_id(agent_id: String, future: F) -> R -where - F: std::future::Future, -{ - FILE_STATE_AGENT_ID.scope(agent_id, future).await -} diff --git a/crates/tinytools-std/src/file_state/mod.rs b/crates/tinytools-std/src/file_state/mod.rs deleted file mode 100644 index 35a14b66..00000000 --- a/crates/tinytools-std/src/file_state/mod.rs +++ /dev/null @@ -1,31 +0,0 @@ -//! Process-wide file state coordinator for cross-agent staleness detection. -//! -//! Parallel subagents and worker threads share a workspace. Without -//! coordination one worker can read a file, a sibling can edit it, and -//! the first worker can later write based on stale content. This module -//! tracks per-agent read stamps and, for every path, each agent's latest -//! write stamp so that write tools can detect the conflict and return a -//! model-facing error requiring the agent to re-read. A read counts as stale -//! when *any* other agent wrote after it, not only the most recent writer. -//! -//! A read tool captures `Instant::now()` *before* it opens the file and hands -//! that stamp to [`record_read`] afterwards, so a sibling write racing the -//! read's I/O is ordered after the read and still reported stale. -//! -//! The guard is opt-in for the process: the host calls [`init_global`] with -//! `enabled` (typically derived from its own configuration or environment). -//! Until it does, or when it passes `false`, every operation is a no-op. - -mod agent_context; -mod ops; -mod types; - -pub use agent_context::{current_file_state_agent_id, with_file_state_agent_id}; -pub use ops::{ - acquire_path_lock, check_partial_read, check_stale_read, init_global, parent_stale_files, - record_read, record_write, try_global, -}; -pub use types::{FileStateCoordinator, ReadStamp}; - -#[cfg(test)] -mod test; diff --git a/crates/tinytools-std/src/file_state/ops.rs b/crates/tinytools-std/src/file_state/ops.rs deleted file mode 100644 index da098ca0..00000000 --- a/crates/tinytools-std/src/file_state/ops.rs +++ /dev/null @@ -1,212 +0,0 @@ -//! Operational API for the file state coordinator. - -use std::path::{Path, PathBuf}; -use std::sync::{Arc, OnceLock}; -use std::time::{Instant, SystemTime}; -use tokio::sync::{Mutex, OwnedMutexGuard}; - -use super::types::{FileStateCoordinator, ReadStamp, writers_after_read}; - -// ── Singleton ──────────────────────────────────────────────────────────── - -static GLOBAL: OnceLock> = OnceLock::new(); - -/// Initialise the process-global coordinator when `enabled`. Safe to call -/// multiple times; only the first enabling call wins. Passing `false` leaves the -/// guard off, so [`try_global`] keeps returning `None` and every tracking -/// operation stays a no-op. -pub fn init_global(enabled: bool) { - if !enabled { - tracing::debug!("[file_state] guard disabled by host"); - return; - } - let _ = GLOBAL.set(Arc::new(FileStateCoordinator::new())); - tracing::debug!("[file_state] coordinator initialised"); -} - -/// Returns the global coordinator, or `None` when disabled / not yet initialised. -pub fn try_global() -> Option> { - GLOBAL.get().cloned() -} - -// ── Read tracking ──────────────────────────────────────────────────────── - -/// Record that `agent_id` read `resolved_path`. -/// -/// `read_started` must be captured with [`Instant::now`] *before* the file -/// is opened, not after its contents were read. A sibling write that lands -/// while the read is in flight is then ordered after the read and reported -/// stale by [`check_stale_read`], instead of being silently absorbed. -/// -/// ``` -/// use std::path::PathBuf; -/// use std::time::{Instant, SystemTime}; -/// use tinytools_std::file_state::record_read; -/// -/// let path = PathBuf::from("/workspace/notes.txt"); -/// let read_started = Instant::now(); -/// // ... open and read the file, then stat it for its mtime ... -/// record_read("agent-1", path, SystemTime::now(), false, read_started); -/// ``` -pub fn record_read( - agent_id: &str, - resolved_path: PathBuf, - mtime: SystemTime, - partial: bool, - read_started: Instant, -) { - let Some(coord) = try_global() else { return }; - coord.record_read(agent_id, resolved_path, mtime, partial, read_started); -} - -impl FileStateCoordinator { - /// Record a read on this coordinator; [`record_read`] delegates here. - pub(crate) fn record_read( - &self, - agent_id: &str, - resolved_path: PathBuf, - mtime: SystemTime, - partial: bool, - read_started: Instant, - ) { - tracing::trace!( - agent = agent_id, - path = %resolved_path.display(), - partial, - "[file_state] record_read" - ); - self.reads.write().insert( - (agent_id.to_string(), resolved_path), - ReadStamp { - mtime, - timestamp: read_started, - partial, - }, - ); - } -} - -// ── Write tracking ─────────────────────────────────────────────────────── - -/// Record that `agent_id` wrote `resolved_path`. -pub fn record_write(agent_id: &str, resolved_path: PathBuf) { - let Some(coord) = try_global() else { return }; - coord.record_write(agent_id, resolved_path); -} - -impl FileStateCoordinator { - /// Record a write on this coordinator; [`record_write`] delegates here. - pub(crate) fn record_write(&self, agent_id: &str, resolved_path: PathBuf) { - tracing::trace!( - agent = agent_id, - path = %resolved_path.display(), - "[file_state] record_write" - ); - let now = Instant::now(); - self.writes - .write() - .entry(resolved_path.clone()) - .or_default() - .insert(agent_id.to_string(), now); - // Also update this agent's own read stamp so its own subsequent - // writes don't trigger self-staleness. - self.reads.write().insert( - (agent_id.to_string(), resolved_path), - ReadStamp { - mtime: SystemTime::now(), - timestamp: now, - partial: false, - }, - ); - } -} - -// ── Staleness checks ───────────────────────────────────────────────────── - -/// Check whether `agent_id`'s view of `resolved_path` is stale because -/// another agent wrote to it after this agent's last read. Returns an -/// error message when stale, `None` when safe. -#[must_use] -pub fn check_stale_read(agent_id: &str, resolved_path: &Path) -> Option { - try_global()?.check_stale_read(agent_id, resolved_path) -} - -impl FileStateCoordinator { - /// [`check_stale_read`] against this coordinator. - pub(crate) fn check_stale_read(&self, agent_id: &str, resolved_path: &Path) -> Option { - let reads = self.reads.read(); - let writes = self.writes.read(); - let read_key = (agent_id.to_string(), resolved_path.to_path_buf()); - let read_stamp = reads.get(&read_key)?; - let path_writers = writes.get(resolved_path)?; - // Name the most recent of the writers that landed after the read; - // on a tie, the alphabetically first, so the message is deterministic. - let (latest, _) = writers_after_read(path_writers, agent_id, read_stamp.timestamp) - .max_by_key(|&(name, written_at)| (written_at, std::cmp::Reverse(name)))?; - let display_path = resolved_path.display(); - Some(format!( - "Stale read: file '{display_path}' was modified by agent '{latest}' after your last \ - read. Re-read the file before editing." - )) - } -} - -/// Check whether `agent_id`'s last read of `resolved_path` was partial. -/// Returns an error message when partial, `None` when safe. -#[must_use] -pub fn check_partial_read(agent_id: &str, resolved_path: &Path) -> Option { - let coord = try_global()?; - let reads = coord.reads.read(); - let read_key = (agent_id.to_string(), resolved_path.to_path_buf()); - let read_stamp = reads.get(&read_key)?; - if read_stamp.partial { - let display_path = resolved_path.display(); - Some(format!( - "Partial read: your last read of '{display_path}' was partial (paginated). \ - Perform a full read before overwriting." - )) - } else { - None - } -} - -// ── Path locking ───────────────────────────────────────────────────────── - -/// Acquire an async lock on `resolved_path` for a read-modify-write -/// section. Returns an `OwnedMutexGuard` that releases when dropped. -/// Returns `None` when the coordinator is disabled. -pub async fn acquire_path_lock(resolved_path: &Path) -> Option> { - let coord = try_global()?; - let mutex = { - let mut locks = coord.path_locks.write(); - locks - .entry(resolved_path.to_path_buf()) - .or_insert_with(|| Arc::new(Mutex::new(()))) - .clone() - }; - Some(mutex.lock_owned().await) -} - -// ── Parent reminder ────────────────────────────────────────────────────── - -/// Return resolved paths that `parent_agent_id` had previously read but -/// were subsequently written by any agent in `child_agent_ids`. -#[must_use] -pub fn parent_stale_files(parent_agent_id: &str, child_agent_ids: &[String]) -> Vec { - try_global().map_or_else(Vec::new, |coord| { - coord.parent_stale_files(parent_agent_id, child_agent_ids) - }) -} - -impl FileStateCoordinator { - /// [`parent_stale_files`] against this coordinator. - pub(crate) fn parent_stale_files( - &self, - parent_agent_id: &str, - child_agent_ids: &[String], - ) -> Vec { - self.stale_reads(parent_agent_id, |writer| { - child_agent_ids.iter().any(|child| child == writer) - }) - } -} diff --git a/crates/tinytools-std/src/file_state/test/agent_context.rs b/crates/tinytools-std/src/file_state/test/agent_context.rs deleted file mode 100644 index de922a0f..00000000 --- a/crates/tinytools-std/src/file_state/test/agent_context.rs +++ /dev/null @@ -1,37 +0,0 @@ -//! Tests for the task-local file-state agent identity. - -use crate::file_state::{current_file_state_agent_id, with_file_state_agent_id}; - -#[tokio::test] -async fn returns_none_outside_scope() { - assert_eq!(current_file_state_agent_id(), None); -} - -#[tokio::test] -async fn installs_and_reads_agent_id() { - let observed = - with_file_state_agent_id("agent-1".into(), async { current_file_state_agent_id() }).await; - assert_eq!(observed, Some("agent-1".to_string())); -} - -#[tokio::test] -async fn does_not_leak_across_scopes() { - with_file_state_agent_id("agent-1".into(), async { - assert_eq!(current_file_state_agent_id(), Some("agent-1".to_string())); - }) - .await; - assert_eq!(current_file_state_agent_id(), None); -} - -#[tokio::test] -async fn nested_scope_overrides_outer() { - with_file_state_agent_id("parent".into(), async { - assert_eq!(current_file_state_agent_id(), Some("parent".to_string())); - with_file_state_agent_id("child".into(), async { - assert_eq!(current_file_state_agent_id(), Some("child".to_string())); - }) - .await; - assert_eq!(current_file_state_agent_id(), Some("parent".to_string())); - }) - .await; -} diff --git a/crates/tinytools-std/src/file_state/test/mod.rs b/crates/tinytools-std/src/file_state/test/mod.rs deleted file mode 100644 index 6adf096f..00000000 --- a/crates/tinytools-std/src/file_state/test/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -//! Unit tests for the file state coordinator: staleness, partial reads, -//! write attribution, path locks, and the task-local agent identity. - -mod agent_context; -mod ops; diff --git a/crates/tinytools-std/src/file_state/test/ops.rs b/crates/tinytools-std/src/file_state/test/ops.rs deleted file mode 100644 index 1842bd3d..00000000 --- a/crates/tinytools-std/src/file_state/test/ops.rs +++ /dev/null @@ -1,318 +0,0 @@ -//! Tests for read/write tracking, staleness checks, and path locks. - -use crate::file_state::{FileStateCoordinator, ReadStamp}; -use std::path::{Path, PathBuf}; -use std::sync::Arc; -use std::time::{Duration, Instant, SystemTime}; -use tokio::sync::Mutex; - -fn fresh_coordinator() -> Arc { - Arc::new(FileStateCoordinator::new()) -} - -#[test] -fn record_and_check_no_staleness() { - let coord = fresh_coordinator(); - let path = PathBuf::from("/tmp/test/a.txt"); - coord.reads.write().insert( - ("agent-a".to_string(), path.clone()), - ReadStamp { - mtime: SystemTime::now(), - timestamp: Instant::now(), - partial: false, - }, - ); - let partial = coord - .reads - .read() - .get(&("agent-a".to_string(), path.clone())) - .map(|rs| rs.partial); - assert_eq!(partial, Some(false)); - assert!(coord.writes.read().get(&path).is_none()); -} - -#[test] -fn detect_sibling_write_staleness() { - let coord = fresh_coordinator(); - let path = PathBuf::from("/tmp/test/b.txt"); - coord.record_read( - "agent-a", - path.clone(), - SystemTime::now(), - false, - Instant::now(), - ); - std::thread::sleep(Duration::from_millis(5)); - coord.record_write("agent-b", path.clone()); - let stale = coord.stale_reads_for_parent("agent-a"); - assert_eq!(stale, vec![path]); -} - -#[test] -fn own_write_does_not_trigger_staleness() { - let coord = fresh_coordinator(); - let path = PathBuf::from("/tmp/test/c.txt"); - coord.record_read( - "agent-a", - path.clone(), - SystemTime::now(), - false, - Instant::now(), - ); - std::thread::sleep(Duration::from_millis(5)); - coord.record_write("agent-a", path.clone()); - let stale = coord.stale_reads_for_parent("agent-a"); - assert_eq!(stale.len(), 0); - assert_eq!(coord.check_stale_read("agent-a", &path), None); -} - -#[test] -fn partial_read_detected() { - let coord = fresh_coordinator(); - let path = PathBuf::from("/tmp/test/d.txt"); - coord.reads.write().insert( - ("agent-a".to_string(), path.clone()), - ReadStamp { - mtime: SystemTime::now(), - timestamp: Instant::now(), - partial: true, - }, - ); - let partial = coord - .reads - .read() - .get(&("agent-a".to_string(), path.clone())) - .map(|rs| rs.partial); - assert_eq!(partial, Some(true)); -} - -#[test] -fn parent_stale_files_detects_child_writes() { - let coord = fresh_coordinator(); - let path = PathBuf::from("/tmp/test/e.txt"); - coord.record_read( - "parent", - path.clone(), - SystemTime::now(), - false, - Instant::now(), - ); - std::thread::sleep(Duration::from_millis(5)); - coord.record_write("child-1", path.clone()); - assert_eq!(coord.stale_reads_for_parent("parent"), vec![path.clone()]); - assert_eq!( - coord.parent_stale_files("parent", &["child-1".to_string()]), - vec![path] - ); - assert_eq!( - coord - .parent_stale_files("parent", &["someone-else".to_string()]) - .len(), - 0 - ); -} - -#[test] -fn paths_written_by_collects_correctly() { - let coord = fresh_coordinator(); - let p1 = PathBuf::from("/tmp/test/f1.txt"); - let p2 = PathBuf::from("/tmp/test/f2.txt"); - coord.record_write("child-1", p1.clone()); - coord.record_write("child-2", p2); - let result = coord.paths_written_by(&["child-1".to_string()]); - assert_eq!(result.len(), 1); - assert_eq!(result.get("child-1"), Some(&vec![p1])); -} - -#[tokio::test] -async fn path_lock_serialises_access() { - let coord = fresh_coordinator(); - let path = PathBuf::from("/tmp/test/lock.txt"); - let mutex = { - let mut locks = coord.path_locks.write(); - locks - .entry(path.clone()) - .or_insert_with(|| Arc::new(Mutex::new(()))) - .clone() - }; - - let guard = mutex.lock().await; - assert!(mutex.try_lock().is_err()); - drop(guard); - assert!(mutex.try_lock().is_ok()); -} - -#[tokio::test] -async fn global_api_tracks_reads_writes_and_locks() -> anyhow::Result<()> { - use crate::file_state::{ - acquire_path_lock, check_partial_read, check_stale_read, init_global, parent_stale_files, - record_read, record_write, try_global, - }; - - init_global(false); - init_global(true); - assert!(try_global().is_some()); - - let path = PathBuf::from("/tmp/test/global-flow.txt"); - record_read( - "reader", - path.clone(), - SystemTime::now(), - true, - Instant::now(), - ); - assert!(check_partial_read("reader", &path).is_some()); - assert!(check_stale_read("reader", &path).is_none()); - - record_read( - "reader", - path.clone(), - SystemTime::now(), - false, - Instant::now(), - ); - assert!(check_partial_read("reader", &path).is_none()); - - std::thread::sleep(Duration::from_millis(5)); - record_write("writer", path.clone()); - let msg = check_stale_read("reader", &path) - .ok_or_else(|| anyhow::anyhow!("expected a stale read after the sibling write"))?; - assert!(msg.contains("writer")); - assert_eq!( - parent_stale_files("reader", &["writer".to_string()]), - vec![path.clone()] - ); - assert!(check_stale_read("writer", &path).is_none()); - - let guard = acquire_path_lock(&path).await; - assert!(guard.is_some()); - Ok(()) -} - -#[test] -fn paths_written_by_keeps_a_path_after_another_agent_overwrites_it() { - let coord = fresh_coordinator(); - let path = PathBuf::from("/tmp/test/history-shared.txt"); - coord.record_write("child-1", path.clone()); - coord.record_write("child-2", path.clone()); - - let result = coord.paths_written_by(&["child-1".to_string(), "child-2".to_string()]); - assert_eq!(result.get("child-1"), Some(&vec![path.clone()])); - assert_eq!(result.get("child-2"), Some(&vec![path])); -} - -#[test] -fn sibling_write_during_an_in_flight_read_is_reported_stale() { - let coord = fresh_coordinator(); - let path = PathBuf::from("/tmp/test/in-flight.txt"); - let read_started = Instant::now(); - std::thread::sleep(Duration::from_millis(2)); - // The sibling write lands after the reader opened the file but before - // the reader got round to recording the read. - coord.record_write("sibling", path.clone()); - std::thread::sleep(Duration::from_millis(2)); - coord.record_read( - "reader", - path.clone(), - SystemTime::now(), - false, - read_started, - ); - - assert_eq!(coord.stale_reads_for_parent("reader"), vec![path]); -} - -// ── A later writer must not mask an earlier one ───────────── - -/// Parent reads `path`, then child-1 and child-2 write it in that order. -fn parent_read_then_two_child_writes(coord: &FileStateCoordinator, path: &Path) { - coord.record_read( - "parent", - path.to_path_buf(), - SystemTime::now(), - false, - Instant::now(), - ); - std::thread::sleep(Duration::from_millis(2)); - coord.record_write("child-1", path.to_path_buf()); - std::thread::sleep(Duration::from_millis(2)); - coord.record_write("child-2", path.to_path_buf()); -} - -#[test] -fn parent_stale_files_reports_an_earlier_child_write_masked_by_a_later_one() { - let coord = fresh_coordinator(); - let path = PathBuf::from("/tmp/test/masked-child.txt"); - parent_read_then_two_child_writes(&coord, &path); - - assert_eq!( - coord.parent_stale_files("parent", &["child-1".to_string()]), - vec![path.clone()] - ); - assert_eq!( - coord.parent_stale_files("parent", &["child-2".to_string()]), - vec![path] - ); -} - -#[test] -fn stale_reads_for_parent_reports_a_path_written_by_two_children() { - let coord = fresh_coordinator(); - let path = PathBuf::from("/tmp/test/two-children.txt"); - parent_read_then_two_child_writes(&coord, &path); - - assert_eq!(coord.stale_reads_for_parent("parent"), vec![path]); -} - -#[test] -fn own_later_write_does_not_mask_a_sibling_write_during_an_in_flight_read() { - // The reader opens the file, a sibling writes it, the reader then writes - // it through another tool, and only afterwards records the read that - // started before the sibling's write. The latest writer is the reader - // itself, but the content it holds may predate the sibling's change. - let coord = fresh_coordinator(); - let path = PathBuf::from("/tmp/test/own-write-masks.txt"); - let read_started = Instant::now(); - std::thread::sleep(Duration::from_millis(2)); - coord.record_write("sibling", path.clone()); - std::thread::sleep(Duration::from_millis(2)); - coord.record_write("reader", path.clone()); - coord.record_read( - "reader", - path.clone(), - SystemTime::now(), - false, - read_started, - ); - - assert_eq!(coord.stale_reads_for_parent("reader"), vec![path.clone()]); - let msg = coord.check_stale_read("reader", &path); - assert!( - msg.as_deref().is_some_and(|m| m.contains("'sibling'")), - "got: {msg:?}" - ); -} - -#[test] -fn a_write_before_the_read_is_not_stale() { - let coord = fresh_coordinator(); - let path = PathBuf::from("/tmp/test/write-then-read.txt"); - coord.record_write("child-1", path.clone()); - std::thread::sleep(Duration::from_millis(2)); - coord.record_read( - "parent", - path.clone(), - SystemTime::now(), - false, - Instant::now(), - ); - - assert_eq!(coord.stale_reads_for_parent("parent").len(), 0); - assert_eq!( - coord - .parent_stale_files("parent", &["child-1".to_string()]) - .len(), - 0 - ); - assert_eq!(coord.check_stale_read("parent", &path), None); -} diff --git a/crates/tinytools-std/src/file_state/types.rs b/crates/tinytools-std/src/file_state/types.rs deleted file mode 100644 index 28a4fa90..00000000 --- a/crates/tinytools-std/src/file_state/types.rs +++ /dev/null @@ -1,122 +0,0 @@ -//! Core types for the file state coordinator. - -use parking_lot::RwLock; -use std::collections::HashMap; -use std::path::PathBuf; -use std::sync::Arc; -use std::time::{Instant, SystemTime}; -use tokio::sync::Mutex; - -/// Snapshot of a single file read by an agent. -#[derive(Debug, Clone)] -pub struct ReadStamp { - /// Filesystem mtime at the moment of the read. - pub mtime: SystemTime, - /// Monotonic clock timestamp taken just before the read's I/O began, so - /// any write that lands during the read orders after it. - pub timestamp: Instant, - /// Whether the read was partial (paginated / offset+limit). - pub partial: bool, -} - -/// For one path: each agent that wrote it, mapped to the monotonic instant -/// of that agent's latest write. -pub(crate) type PathWriters = HashMap; - -/// Process-global coordinator that tracks file reads and writes across -/// all agents in the process. Thread-safe via `RwLock`. -#[derive(Debug)] -pub struct FileStateCoordinator { - /// Per-agent, per-resolved-path read stamps. - /// Key: `(agent_id, canonical_path)`. - pub(crate) reads: RwLock>, - - /// Per-resolved-path writers, each with its own latest write instant. - /// A later write by one agent never erases another agent's entry, so a - /// staleness check sees every writer, not just the most recent. - pub(crate) writes: RwLock>, - - /// Per-resolved-path async mutex for serialising read-modify-write - /// sections (used by `edit` and `apply_patch`). - pub(crate) path_locks: RwLock>>>, -} - -impl Default for FileStateCoordinator { - fn default() -> Self { - Self::new() - } -} - -impl FileStateCoordinator { - #[must_use] - /// Create an empty coordinator. - pub fn new() -> Self { - Self { - reads: RwLock::new(HashMap::new()), - writes: RwLock::new(HashMap::new()), - path_locks: RwLock::new(HashMap::new()), - } - } - - /// Return the set of resolved paths that `parent_agent_id` has read - /// but were subsequently written by any other agent. - #[must_use] - pub fn stale_reads_for_parent(&self, parent_agent_id: &str) -> Vec { - self.stale_reads(parent_agent_id, |_| true) - } - - /// Paths `reader` read that some other agent accepted by `counts` wrote - /// after that read. Sorted. - pub(crate) fn stale_reads(&self, reader: &str, counts: impl Fn(&str) -> bool) -> Vec { - let reads = self.reads.read(); - let writes = self.writes.read(); - let mut stale: Vec = reads - .iter() - .filter(|((agent_id, _), _)| agent_id == reader) - .filter(|((_, path), read_stamp)| { - writes.get(path).is_some_and(|writers| { - writers_after_read(writers, reader, read_stamp.timestamp) - .any(|(writer, _)| counts(writer)) - }) - }) - .map(|((_, path), _)| path.clone()) - .collect(); - stale.sort(); - stale - } - - /// Collect every path written by each agent in `agent_ids`, keyed by - /// agent. A path appears under every listed agent that ever wrote it, - /// even when a different agent wrote it afterwards. Paths are sorted; - /// agents with no writes are absent. - #[must_use] - pub fn paths_written_by(&self, agent_ids: &[String]) -> HashMap> { - let writes = self.writes.read(); - let mut result: HashMap> = HashMap::new(); - for (path, writers) in writes.iter() { - for agent_id in agent_ids.iter().filter(|id| writers.contains_key(*id)) { - result - .entry(agent_id.clone()) - .or_default() - .push(path.clone()); - } - } - for paths in result.values_mut() { - paths.sort(); - } - result - } -} - -/// The agents other than `reader` whose latest write to a path came after -/// `read_at`, each with that write's instant. -pub(crate) fn writers_after_read<'a>( - writers: &'a PathWriters, - reader: &'a str, - read_at: Instant, -) -> impl Iterator + 'a { - writers - .iter() - .filter(move |(writer, written_at)| writer.as_str() != reader && **written_at > read_at) - .map(|(writer, written_at)| (writer.as_str(), *written_at)) -} diff --git a/crates/tinytools-std/src/filesystem/apply_patch/mod.rs b/crates/tinytools-std/src/filesystem/apply_patch/mod.rs deleted file mode 100644 index 5311d144..00000000 --- a/crates/tinytools-std/src/filesystem/apply_patch/mod.rs +++ /dev/null @@ -1,474 +0,0 @@ -//! `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?}`; \ - a top-level `path` is the default for edits that omit their own. \ - 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": { - "path": { - "type": "string", - "description": "Default file for edits that omit their own `path`." - }, - "edits": { - "type": "array", - "description": "Ordered list of edits.", - "items": { - "type": "object", - "properties": { - "path": { - "type": "string", - "description": "File to edit; defaults to the top-level `path`." - }, - "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": ["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"); - - // "One file, several edits" is a natural call shape, and models write - // it with the path once at the top level (4 of 10 calls in one run; - // the per-edit `path` requirement rejected every one of them and the - // run halted on the fourth). The top-level path is the default; an - // edit's own path still wins. - let default_path = match args.get("path") { - Some(value) => Some( - value - .as_str() - .ok_or_else(|| anyhow::anyhow!("top-level `path` must be a string"))?, - ), - None => None, - }; - - // Parse + group edits by file. - let mut parsed: Vec = Vec::with_capacity(edits.len()); - for (i, raw) in edits.iter().enumerate() { - let path = match raw.get("path") { - Some(value) => value.as_str().ok_or_else(|| { - anyhow::anyhow!("edit[{i}]: `path` must be a string") - })?, - None => default_path.ok_or_else(|| { - anyhow::anyhow!( - "edit[{i}]: missing `path` (give each edit a `path`, or one top-level `path` for all edits)" - ) - })?, - }; - 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)] -#[path = "mod_tests.rs"] -mod test; diff --git a/crates/tinytools-std/src/filesystem/apply_patch/mod_tests.rs b/crates/tinytools-std/src/filesystem/apply_patch/mod_tests.rs deleted file mode 100644 index 5cb7c9e3..00000000 --- a/crates/tinytools-std/src/filesystem/apply_patch/mod_tests.rs +++ /dev/null @@ -1,648 +0,0 @@ -//! 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_takes_a_top_level_path_as_the_default_for_every_edit() { - // The shape a model writes for "one file, several edits": the path once, - // at the top level. Each such call used to be rejected for a missing - // `edits[0].path`, and four in a row halted a run with 26 minutes left. - let dir = std::env::temp_dir().join("openhuman_test_patch_top_level_path"); - let _ = tokio::fs::remove_dir_all(&dir).await; - tokio::fs::create_dir_all(&dir).await.unwrap(); - tokio::fs::write(dir.join("a.c"), "int x = 1;\nint y = 2;\n") - .await - .unwrap(); - - let tool = ApplyPatchTool::new(test_security(dir.clone())); - let result = tool - .execute(json!({ - "path": "a.c", - "edits": [ - { "old_string": "int x = 1;", "new_string": "int x = 10;" }, - { "old_string": "int y = 2;", "new_string": "int y = 20;" } - ] - })) - .await - .unwrap(); - assert!(!result.is_error, "{}", result.output()); - assert_eq!( - tokio::fs::read_to_string(dir.join("a.c")).await.unwrap(), - "int x = 10;\nint y = 20;\n" - ); -} - -#[tokio::test] -async fn apply_patch_rejects_malformed_edit_path_instead_of_using_default() { - let dir = std::env::temp_dir().join("openhuman_test_patch_malformed_path"); - let _ = tokio::fs::remove_dir_all(&dir).await; - tokio::fs::create_dir_all(&dir).await.unwrap(); - tokio::fs::write(dir.join("default.txt"), "original") - .await - .unwrap(); - - let tool = ApplyPatchTool::new(test_security(dir.clone())); - let result = tool - .execute(json!({ - "path": "default.txt", - "edits": [{ "path": null, "old_string": "original", "new_string": "changed" }] - })) - .await; - - assert!(result.is_err()); - assert!( - result - .unwrap_err() - .to_string() - .contains("`path` must be a string") - ); - assert_eq!( - tokio::fs::read_to_string(dir.join("default.txt")) - .await - .unwrap(), - "original" - ); - let _ = tokio::fs::remove_dir_all(&dir).await; -} - -#[tokio::test] -async fn apply_patch_rejects_a_malformed_top_level_path() { - let dir = std::env::temp_dir().join("openhuman_test_patch_malformed_top_level_path"); - let _ = tokio::fs::remove_dir_all(&dir).await; - tokio::fs::create_dir_all(&dir).await.unwrap(); - tokio::fs::write(dir.join("a.txt"), "original") - .await - .unwrap(); - - let tool = ApplyPatchTool::new(test_security(dir.clone())); - let result = tool - .execute(json!({ - "path": 123, - "edits": [{ "path": "a.txt", "old_string": "original", "new_string": "changed" }] - })) - .await; - - assert!(result.is_err()); - assert!( - result - .unwrap_err() - .to_string() - .contains("top-level `path` must be a string") - ); - assert_eq!( - tokio::fs::read_to_string(dir.join("a.txt")).await.unwrap(), - "original" - ); - let _ = tokio::fs::remove_dir_all(&dir).await; -} - -#[tokio::test] -async fn apply_patch_lets_an_edits_own_path_win_over_the_top_level_one() { - let dir = std::env::temp_dir().join("openhuman_test_patch_top_level_path_override"); - 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())); - let result = tool - .execute(json!({ - "path": "a.txt", - "edits": [ - { "old_string": "alpha", "new_string": "ALPHA" }, - { "path": "b.txt", "old_string": "bravo", "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_names_both_ways_to_give_a_path_when_neither_is_given() { - let dir = std::env::temp_dir().join("openhuman_test_patch_no_path_anywhere"); - 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 err = tool - .execute(json!({ - "edits": [ { "old_string": "a", "new_string": "b" } ] - })) - .await - .expect_err("no path anywhere is an argument error"); - let text = err.to_string(); - assert!(text.contains("edit[0]: missing `path`"), "{text}"); - assert!(text.contains("one top-level `path`"), "{text}"); -} - -#[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_tests.rs b/crates/tinytools-std/src/filesystem/contract_tests.rs deleted file mode 100644 index f86b22db..00000000 --- a/crates/tinytools-std/src/filesystem/contract_tests.rs +++ /dev/null @@ -1,152 +0,0 @@ -//! 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()))), - ("image_info", Box::new(ImageInfoTool::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.clone())), - ), - ( - "read_workspace_state", - Box::new(WorkspaceStateTool::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"), - "image_info" => include_str!("fixtures/image_info.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"), - "read_workspace_state" => include_str!("fixtures/read_workspace_state.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_tests.rs b/crates/tinytools-std/src/filesystem/csv_export/extra_tests.rs deleted file mode 100644 index 664f61cb..00000000 --- a/crates/tinytools-std/src/filesystem/csv_export/extra_tests.rs +++ /dev/null @@ -1,161 +0,0 @@ -//! 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_eq!(resolve_columns(&[json!(1), json!(2)], None).len(), 0); -} - -#[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 deleted file mode 100644 index 354c6ce1..00000000 --- a/crates/tinytools-std/src/filesystem/csv_export/mod.rs +++ /dev/null @@ -1,270 +0,0 @@ -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)] -#[path = "mod_tests.rs"] -mod test; - -#[cfg(test)] -#[path = "extra_tests.rs"] -mod extra_test; diff --git a/crates/tinytools-std/src/filesystem/csv_export/mod_tests.rs b/crates/tinytools-std/src/filesystem/csv_export/mod_tests.rs deleted file mode 100644 index d2d8cc0e..00000000 --- a/crates/tinytools-std/src/filesystem/csv_export/mod_tests.rs +++ /dev/null @@ -1,234 +0,0 @@ -//! 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 deleted file mode 100644 index 95fa5e95..00000000 --- a/crates/tinytools-std/src/filesystem/edit_file/mod.rs +++ /dev/null @@ -1,242 +0,0 @@ -//! `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)] -#[path = "mod_tests.rs"] -mod test; diff --git a/crates/tinytools-std/src/filesystem/edit_file/mod_tests.rs b/crates/tinytools-std/src/filesystem/edit_file/mod_tests.rs deleted file mode 100644 index af290758..00000000 --- a/crates/tinytools-std/src/filesystem/edit_file/mod_tests.rs +++ /dev/null @@ -1,316 +0,0 @@ -//! 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_tests.rs b/crates/tinytools-std/src/filesystem/file_read/extra_tests.rs deleted file mode 100644 index b5a07e2b..00000000 --- a/crates/tinytools-std/src/filesystem/file_read/extra_tests.rs +++ /dev/null @@ -1,96 +0,0 @@ -//! 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 deleted file mode 100644 index 0dd1a14a..00000000 --- a/crates/tinytools-std/src/filesystem/file_read/mod.rs +++ /dev/null @@ -1,240 +0,0 @@ -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)] -#[path = "mod_tests.rs"] -mod test; - -#[cfg(test)] -#[path = "extra_tests.rs"] -mod extra_test; diff --git a/crates/tinytools-std/src/filesystem/file_read/mod_tests.rs b/crates/tinytools-std/src/filesystem/file_read/mod_tests.rs deleted file mode 100644 index 6eef9e52..00000000 --- a/crates/tinytools-std/src/filesystem/file_read/mod_tests.rs +++ /dev/null @@ -1,431 +0,0 @@ -//! 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 deleted file mode 100644 index a50dcf20..00000000 --- a/crates/tinytools-std/src/filesystem/file_sink.rs +++ /dev/null @@ -1,59 +0,0 @@ -//! 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 deleted file mode 100644 index 0efcd668..00000000 --- a/crates/tinytools-std/src/filesystem/file_write/mod.rs +++ /dev/null @@ -1,234 +0,0 @@ -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)] -#[path = "mod_tests.rs"] -mod test; diff --git a/crates/tinytools-std/src/filesystem/file_write/mod_tests.rs b/crates/tinytools-std/src/filesystem/file_write/mod_tests.rs deleted file mode 100644 index 73407b38..00000000 --- a/crates/tinytools-std/src/filesystem/file_write/mod_tests.rs +++ /dev/null @@ -1,502 +0,0 @@ -//! 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 deleted file mode 100644 index 7b98f41e..00000000 --- a/crates/tinytools-std/src/filesystem/fixtures/apply_patch.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "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?}`; a top-level `path` is the default for edits that omit their own. 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": { - "path": { - "description": "Default file for edits that omit their own `path`.", - "type": "string" - }, - "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": { - "description": "File to edit; defaults to the top-level `path`.", - "type": "string" - }, - "replace_all": { - "default": false, - "type": "boolean" - } - }, - "required": [ - "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 deleted file mode 100644 index dd4e4d5a..00000000 --- a/crates/tinytools-std/src/filesystem/fixtures/csv_export.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "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 deleted file mode 100644 index b6d95817..00000000 --- a/crates/tinytools-std/src/filesystem/fixtures/edit_file.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "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 deleted file mode 100644 index fa9744fd..00000000 --- a/crates/tinytools-std/src/filesystem/fixtures/file_read.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "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 deleted file mode 100644 index 8621d0bd..00000000 --- a/crates/tinytools-std/src/filesystem/fixtures/file_write.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "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 deleted file mode 100644 index 302536c1..00000000 --- a/crates/tinytools-std/src/filesystem/fixtures/git_operations.json +++ /dev/null @@ -1,66 +0,0 @@ -{ - "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 deleted file mode 100644 index 926937d5..00000000 --- a/crates/tinytools-std/src/filesystem/fixtures/glob_search.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "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 deleted file mode 100644 index 039c98fd..00000000 --- a/crates/tinytools-std/src/filesystem/fixtures/grep.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "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/image_info.json b/crates/tinytools-std/src/filesystem/fixtures/image_info.json deleted file mode 100644 index 3125c3d1..00000000 --- a/crates/tinytools-std/src/filesystem/fixtures/image_info.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "description": "Read image file metadata (format, dimensions, size) and optionally return base64-encoded data.", - "exposure": "Direct", - "name": "image_info", - "permission_level": "ReadOnly", - "schema": { - "properties": { - "include_base64": { - "description": "Include base64-encoded image data in output (default: false)", - "type": "boolean" - }, - "path": { - "description": "Path to the image file (absolute or relative to workspace)", - "type": "string" - } - }, - "required": [ - "path" - ], - "type": "object" - } -} diff --git a/crates/tinytools-std/src/filesystem/fixtures/list_files.json b/crates/tinytools-std/src/filesystem/fixtures/list_files.json deleted file mode 100644 index 98415d48..00000000 --- a/crates/tinytools-std/src/filesystem/fixtures/list_files.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "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 deleted file mode 100644 index f32196a0..00000000 --- a/crates/tinytools-std/src/filesystem/fixtures/read_diff.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "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/read_workspace_state.json b/crates/tinytools-std/src/filesystem/fixtures/read_workspace_state.json deleted file mode 100644 index 962bb1bb..00000000 --- a/crates/tinytools-std/src/filesystem/fixtures/read_workspace_state.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "description": "Get a read-only overview of the workspace: git status (modified/untracked files), recent commits, and top-level directory structure. Useful for understanding the current project state before planning tasks.", - "exposure": "Direct", - "name": "read_workspace_state", - "permission_level": "ReadOnly", - "schema": { - "properties": { - "include_tree": { - "default": true, - "description": "Include top-level directory tree (default: true).", - "type": "boolean" - }, - "recent_commits": { - "default": 5, - "description": "Number of recent commits to show (default: 5).", - "type": "integer" - } - }, - "type": "object" - } -} diff --git a/crates/tinytools-std/src/filesystem/fixtures/run_linter.json b/crates/tinytools-std/src/filesystem/fixtures/run_linter.json deleted file mode 100644 index 4c16bbdf..00000000 --- a/crates/tinytools-std/src/filesystem/fixtures/run_linter.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "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 deleted file mode 100644 index f0f5dc9f..00000000 --- a/crates/tinytools-std/src/filesystem/fixtures/run_tests.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "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 deleted file mode 100644 index 16237185..00000000 --- a/crates/tinytools-std/src/filesystem/fixtures/update_memory_md.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "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 deleted file mode 100644 index ca9965e9..00000000 --- a/crates/tinytools-std/src/filesystem/gate.rs +++ /dev/null @@ -1,129 +0,0 @@ -//! 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; - - /// Batch form of [`is_path_string_allowed`](Self::is_path_string_allowed). - /// - /// Answers are in request order and the result has the same length as - /// `paths`. The default asks the single-path question once per entry, so - /// every existing gate behaves exactly as before. A host that can answer a - /// whole batch more cheaply (one round trip to a policy module instead of - /// one per path) overrides this. - /// - /// A caller must treat an answer whose length differs from `paths` as - /// every path refused. - fn paths_string_allowed(&self, paths: &[String]) -> Vec { - paths - .iter() - .map(|path| self.is_path_string_allowed(path)) - .collect() - } - - /// 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_tests.rs b/crates/tinytools-std/src/filesystem/gate_tests.rs deleted file mode 100644 index 727a3b04..00000000 --- a/crates/tinytools-std/src/filesystem/gate_tests.rs +++ /dev/null @@ -1,95 +0,0 @@ -//! 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")); -} - -#[test] -fn the_default_batch_answer_maps_the_single_path_check_in_order() { - let dir = tempfile::tempdir().unwrap(); - let gate = TestGate::supervised(dir.path().to_path_buf()); - let paths = vec![ - "ok.txt".to_string(), - "../escape.txt".to_string(), - "a\0b".to_string(), - "sub/ok.rs".to_string(), - ]; - let batch = gate.paths_string_allowed(&paths); - let single: Vec = paths - .iter() - .map(|p| gate.is_path_string_allowed(p)) - .collect(); - assert_eq!(batch, single); - assert_eq!(batch, vec![true, false, false, true]); - assert_eq!(gate.paths_string_allowed(&[]), Vec::::new()); -} diff --git a/crates/tinytools-std/src/filesystem/git_operations/config.rs b/crates/tinytools-std/src/filesystem/git_operations/config.rs deleted file mode 100644 index ebc2b842..00000000 --- a/crates/tinytools-std/src/filesystem/git_operations/config.rs +++ /dev/null @@ -1,376 +0,0 @@ -//! 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(crate) 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(crate) 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(crate) 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(crate) 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(crate) 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(crate) 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(crate) 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(crate) 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_tests.rs b/crates/tinytools-std/src/filesystem/git_operations/config_tests.rs deleted file mode 100644 index 12a329ee..00000000 --- a/crates/tinytools-std/src/filesystem/git_operations/config_tests.rs +++ /dev/null @@ -1,671 +0,0 @@ -//! 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 deleted file mode 100644 index fadcbb06..00000000 --- a/crates/tinytools-std/src/filesystem/git_operations/mod.rs +++ /dev/null @@ -1,656 +0,0 @@ -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}; - -pub(crate) 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)] -#[path = "config_tests.rs"] -mod config_test; -#[cfg(test)] -#[path = "ops_tests.rs"] -mod ops_test; -#[cfg(test)] -#[path = "render_tests.rs"] -mod render_test; -#[cfg(test)] -#[path = "mod_tests.rs"] -mod test; diff --git a/crates/tinytools-std/src/filesystem/git_operations/mod_tests.rs b/crates/tinytools-std/src/filesystem/git_operations/mod_tests.rs deleted file mode 100644 index 6aa8b903..00000000 --- a/crates/tinytools-std/src/filesystem/git_operations/mod_tests.rs +++ /dev/null @@ -1,455 +0,0 @@ -//! 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_ne!(tool.description().len(), 0); - 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/git_operations/ops_tests.rs b/crates/tinytools-std/src/filesystem/git_operations/ops_tests.rs deleted file mode 100644 index 438a6a39..00000000 --- a/crates/tinytools-std/src/filesystem/git_operations/ops_tests.rs +++ /dev/null @@ -1,368 +0,0 @@ -//! 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_ne!(parsed["staged"].as_array().unwrap().len(), 0); - assert_ne!(parsed["unstaged"].as_array().unwrap().len(), 0); - 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 deleted file mode 100644 index 124c355a..00000000 --- a/crates/tinytools-std/src/filesystem/git_operations/render.rs +++ /dev/null @@ -1,91 +0,0 @@ -//! 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_tests.rs b/crates/tinytools-std/src/filesystem/git_operations/render_tests.rs deleted file mode 100644 index f41c5077..00000000 --- a/crates/tinytools-std/src/filesystem/git_operations/render_tests.rs +++ /dev/null @@ -1,83 +0,0 @@ -//! 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/glob_search/extra_tests.rs b/crates/tinytools-std/src/filesystem/glob_search/extra_tests.rs deleted file mode 100644 index 379abf91..00000000 --- a/crates/tinytools-std/src/filesystem/glob_search/extra_tests.rs +++ /dev/null @@ -1,153 +0,0 @@ -//! 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()); -} - -fn seed_many(dir: &std::path::Path, n: usize) { - std::fs::create_dir_all(dir.join("sub")).unwrap(); - for i in 0..n { - std::fs::write(dir.join(format!("sub/f{i}.txt")), "x").unwrap(); - } -} - -#[tokio::test] -async fn glob_asks_the_gate_once_per_search_however_many_hits() { - let dir = TempDir::new().unwrap(); - seed_many(dir.path(), 40); - let wrap = WrapGate::new(TestGate::supervised(dir.path().to_path_buf())); - let calls = wrap.batch_calls(); - let tool = GlobTool::new(wrap.arc()); - let r = tool.execute(json!({"pattern": "**/*.txt"})).await.unwrap(); - assert!(!r.is_error, "{}", r.output()); - assert!(r.output().starts_with("40 match(es)"), "{}", r.output()); - assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1); -} - -#[tokio::test] -async fn glob_refuses_every_hit_when_the_batch_answer_has_the_wrong_length() { - let dir = TempDir::new().unwrap(); - seed_many(dir.path(), 5); - let wrap = WrapGate::new(TestGate::supervised(dir.path().to_path_buf())).wrong_length_batch(); - let tool = GlobTool::new(wrap.arc()); - let r = tool.execute(json!({"pattern": "**/*.txt"})).await.unwrap(); - assert!(!r.is_error, "{}", r.output()); - assert_eq!(r.output().trim(), "0 match(es)"); -} - -#[tokio::test] -async fn glob_batch_filtering_matches_the_per_hit_verdicts() { - let dir = TempDir::new().unwrap(); - seed_many(dir.path(), 6); - let gate = TestGate::supervised(dir.path().to_path_buf()); - let tool = GlobTool::new(gate.clone()); - let r = tool.execute(json!({"pattern": "**/*.txt"})).await.unwrap(); - let out = r.output(); - let mut got: Vec<&str> = out.lines().skip(1).collect(); - got.sort_unstable(); - let mut want: Vec = (0..6).map(|i| format!("sub/f{i}.txt")).collect(); - want.retain(|p| gate.is_path_string_allowed(p)); - want.sort_unstable(); - assert_eq!(got, want); - assert_eq!(got.len(), 6); -} diff --git a/crates/tinytools-std/src/filesystem/glob_search/mod.rs b/crates/tinytools-std/src/filesystem/glob_search/mod.rs deleted file mode 100644 index d37a74e5..00000000 --- a/crates/tinytools-std/src/filesystem/glob_search/mod.rs +++ /dev/null @@ -1,344 +0,0 @@ -//! `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 — [`FsGate::validate_path`] — and filters every hit through -//! [`FsGate::paths_string_allowed`] (one batch call per search). 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 [`FsGate::paths_string_allowed`] (one batch -/// call per search) 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 candidates: 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(), - }; - - let mtime = entry - .metadata() - .ok() - .and_then(|m| m.modified().ok()) - .unwrap_or(std::time::SystemTime::UNIX_EPOCH); - candidates.push((mtime, display)); - } - - // Fail-closed: only surface paths the readers would also accept. The whole - // walk is asked in ONE batch so a host that answers over a bus pays a - // single round trip per search, not one per hit. - let displays: Vec = candidates.iter().map(|(_, p)| p.clone()).collect(); - let verdicts = gate.paths_string_allowed(&displays); - if verdicts.len() != candidates.len() { - log::warn!( - "[tools:glob] gate answered {} verdict(s) for {} path(s); refusing every hit", - verdicts.len(), - candidates.len() - ); - candidates.clear(); - } - let mut hits: Vec<(std::time::SystemTime, String)> = Vec::with_capacity(candidates.len()); - for ((mtime, display), allowed) in candidates.into_iter().zip(verdicts) { - if allowed { - hits.push((mtime, display)); - } else { - log::trace!("[tools:glob] path filtered by policy: '{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)] -#[path = "mod_tests.rs"] -mod test; - -#[cfg(test)] -#[path = "extra_tests.rs"] -mod extra_test; diff --git a/crates/tinytools-std/src/filesystem/glob_search/mod_tests.rs b/crates/tinytools-std/src/filesystem/glob_search/mod_tests.rs deleted file mode 100644 index 1d66445f..00000000 --- a/crates/tinytools-std/src/filesystem/glob_search/mod_tests.rs +++ /dev/null @@ -1,264 +0,0 @@ -//! 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_tests.rs b/crates/tinytools-std/src/filesystem/grep/extra_tests.rs deleted file mode 100644 index 825ac122..00000000 --- a/crates/tinytools-std/src/filesystem/grep/extra_tests.rs +++ /dev/null @@ -1,54 +0,0 @@ -//! 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 deleted file mode 100644 index 5ffbcb3d..00000000 --- a/crates/tinytools-std/src/filesystem/grep/mod.rs +++ /dev/null @@ -1,241 +0,0 @@ -//! `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)] -#[path = "mod_tests.rs"] -mod test; - -#[cfg(test)] -#[path = "extra_tests.rs"] -mod extra_test; diff --git a/crates/tinytools-std/src/filesystem/grep/mod_tests.rs b/crates/tinytools-std/src/filesystem/grep/mod_tests.rs deleted file mode 100644 index 5dcc763c..00000000 --- a/crates/tinytools-std/src/filesystem/grep/mod_tests.rs +++ /dev/null @@ -1,184 +0,0 @@ -//! 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/image_info/mod.rs b/crates/tinytools-std/src/filesystem/image_info/mod.rs deleted file mode 100644 index 0d8e0bfc..00000000 --- a/crates/tinytools-std/src/filesystem/image_info/mod.rs +++ /dev/null @@ -1,211 +0,0 @@ -use super::gate::FsGate; -use async_trait::async_trait; -use serde_json::json; -use std::fmt::Write; -use std::sync::Arc; -use tinytools::{Tool, ToolResult}; - -/// Maximum file size we will read and base64-encode (5 MB). -const MAX_IMAGE_BYTES: u64 = 5_242_880; - -/// Tool to read image metadata and optionally return base64-encoded data. -/// -/// Since providers are currently text-only, this tool extracts what it can -/// (file size, format, dimensions from header bytes) and provides base64 -/// data for future multimodal provider support. -#[derive(Debug)] -pub struct ImageInfoTool { - gate: Arc, -} - -impl ImageInfoTool { - /// An `image_info` tool that reads only what `gate` allows. - pub fn new(gate: Arc) -> Self { - Self { gate } - } - - /// Detect image format from first few bytes (magic numbers). - fn detect_format(bytes: &[u8]) -> &'static str { - if bytes.len() < 4 { - return "unknown"; - } - if bytes.starts_with(b"\x89PNG") { - "png" - } else if bytes.starts_with(b"\xFF\xD8\xFF") { - "jpeg" - } else if bytes.starts_with(b"GIF8") { - "gif" - } else if bytes.starts_with(b"RIFF") && bytes.len() >= 12 && &bytes[8..12] == b"WEBP" { - "webp" - } else if bytes.starts_with(b"BM") { - "bmp" - } else { - "unknown" - } - } - - /// Try to extract dimensions from image header bytes. - /// Returns (width, height) if detectable. - fn extract_dimensions(bytes: &[u8], format: &str) -> Option<(u32, u32)> { - match format { - "png" => { - // PNG IHDR chunk: bytes 16-19 = width, 20-23 = height (big-endian) - if bytes.len() >= 24 { - let w = u32::from_be_bytes([bytes[16], bytes[17], bytes[18], bytes[19]]); - let h = u32::from_be_bytes([bytes[20], bytes[21], bytes[22], bytes[23]]); - Some((w, h)) - } else { - None - } - } - "gif" => { - // GIF: bytes 6-7 = width, 8-9 = height (little-endian) - if bytes.len() >= 10 { - let w = u32::from(u16::from_le_bytes([bytes[6], bytes[7]])); - let h = u32::from(u16::from_le_bytes([bytes[8], bytes[9]])); - Some((w, h)) - } else { - None - } - } - "bmp" => { - // BMP: bytes 18-21 = width, 22-25 = height (little-endian, signed) - if bytes.len() >= 26 { - let w = u32::from_le_bytes([bytes[18], bytes[19], bytes[20], bytes[21]]); - let h_raw = i32::from_le_bytes([bytes[22], bytes[23], bytes[24], bytes[25]]); - let h = h_raw.unsigned_abs(); - Some((w, h)) - } else { - None - } - } - "jpeg" => Self::jpeg_dimensions(bytes), - _ => None, - } - } - - /// Parse JPEG SOF markers to extract dimensions. - fn jpeg_dimensions(bytes: &[u8]) -> Option<(u32, u32)> { - let mut i = 2; // skip SOI marker - while i + 1 < bytes.len() { - if bytes[i] != 0xFF { - return None; - } - let marker = bytes[i + 1]; - i += 2; - - // SOF0..SOF3 markers contain dimensions - if (0xC0..=0xC3).contains(&marker) { - if i + 7 <= bytes.len() { - let h = u32::from(u16::from_be_bytes([bytes[i + 3], bytes[i + 4]])); - let w = u32::from(u16::from_be_bytes([bytes[i + 5], bytes[i + 6]])); - return Some((w, h)); - } - return None; - } - - // Skip this segment - if i + 1 < bytes.len() { - let seg_len = u16::from_be_bytes([bytes[i], bytes[i + 1]]) as usize; - if seg_len < 2 { - return None; // Malformed segment (valid segments have length >= 2) - } - i += seg_len; - } else { - return None; - } - } - None - } -} - -#[async_trait] -impl Tool for ImageInfoTool { - fn name(&self) -> &'static str { - "image_info" - } - - fn description(&self) -> &'static str { - "Read image file metadata (format, dimensions, size) and optionally return base64-encoded data." - } - - fn parameters_schema(&self) -> serde_json::Value { - json!({ - "type": "object", - "properties": { - "path": { - "type": "string", - "description": "Path to the image file (absolute or relative to workspace)" - }, - "include_base64": { - "type": "boolean", - "description": "Include base64-encoded image data in output (default: false)" - } - }, - "required": ["path"] - }) - } - - async fn execute(&self, args: serde_json::Value) -> anyhow::Result { - let path_str = args - .get("path") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("Missing 'path' parameter"))?; - - let include_base64 = args - .get("include_base64") - .and_then(serde_json::Value::as_bool) - .unwrap_or(false); - - // Security check: validate path string, resolve symlinks, confirm workspace containment. - let resolved = match self.gate.validate_path(path_str).await { - Ok(p) => p, - Err(msg) => return Ok(ToolResult::error(msg)), - }; - - let metadata = tokio::fs::metadata(&resolved) - .await - .map_err(|e| anyhow::anyhow!("Failed to read file metadata: {e}"))?; - - let file_size = metadata.len(); - - if file_size > MAX_IMAGE_BYTES { - return Ok(ToolResult::error(format!( - "Image too large: {file_size} bytes (max {MAX_IMAGE_BYTES} bytes)" - ))); - } - - let bytes = tokio::fs::read(&resolved) - .await - .map_err(|e| anyhow::anyhow!("Failed to read image file: {e}"))?; - - let format = Self::detect_format(&bytes); - let dimensions = Self::extract_dimensions(&bytes, format); - - let mut output = format!("File: {path_str}\nFormat: {format}\nSize: {file_size} bytes"); - - if let Some((w, h)) = dimensions { - let _ = write!(output, "\nDimensions: {w}x{h}"); - } - - if include_base64 { - use base64::Engine; - let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes); - let mime = match format { - "png" => "image/png", - "jpeg" => "image/jpeg", - "gif" => "image/gif", - "webp" => "image/webp", - "bmp" => "image/bmp", - _ => "application/octet-stream", - }; - let _ = write!(output, "\ndata:{mime};base64,{encoded}"); - } - - Ok(ToolResult::success(output)) - } -} - -#[cfg(test)] -#[path = "mod_tests.rs"] -mod test; diff --git a/crates/tinytools-std/src/filesystem/image_info/mod_tests.rs b/crates/tinytools-std/src/filesystem/image_info/mod_tests.rs deleted file mode 100644 index ff5200b3..00000000 --- a/crates/tinytools-std/src/filesystem/image_info/mod_tests.rs +++ /dev/null @@ -1,342 +0,0 @@ -#![allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)] - -use super::*; -use crate::filesystem::test_support::{AutonomyLevel, TestGate}; - -fn test_security() -> Arc { - TestGate::with(std::env::temp_dir(), AutonomyLevel::Full, 1_000_000) -} - -#[test] -fn image_info_tool_name() { - let tool = ImageInfoTool::new(test_security()); - assert_eq!(tool.name(), "image_info"); -} - -#[test] -fn image_info_tool_description() { - let tool = ImageInfoTool::new(test_security()); - assert_ne!(tool.description().len(), 0); - assert!(tool.description().contains("image")); -} - -#[test] -fn image_info_tool_schema() { - let tool = ImageInfoTool::new(test_security()); - let schema = tool.parameters_schema(); - assert!(schema["properties"]["path"].is_object()); - assert!(schema["properties"]["include_base64"].is_object()); - let required = schema["required"].as_array().unwrap(); - assert!(required.contains(&json!("path"))); -} - -#[test] -fn image_info_tool_spec() { - let tool = ImageInfoTool::new(test_security()); - let spec = tool.spec(); - assert_eq!(spec.name, "image_info"); - assert!(spec.parameters.is_object()); -} - -// ── Format detection ──────────────────────────────────────── - -#[test] -fn detect_png() { - let bytes = b"\x89PNG\r\n\x1a\n"; - assert_eq!(ImageInfoTool::detect_format(bytes), "png"); -} - -#[test] -fn detect_jpeg() { - let bytes = b"\xFF\xD8\xFF\xE0"; - assert_eq!(ImageInfoTool::detect_format(bytes), "jpeg"); -} - -#[test] -fn detect_gif() { - let bytes = b"GIF89a"; - assert_eq!(ImageInfoTool::detect_format(bytes), "gif"); -} - -#[test] -fn detect_webp() { - let bytes = b"RIFF\x00\x00\x00\x00WEBP"; - assert_eq!(ImageInfoTool::detect_format(bytes), "webp"); -} - -#[test] -fn detect_bmp() { - let bytes = b"BM\x00\x00"; - assert_eq!(ImageInfoTool::detect_format(bytes), "bmp"); -} - -#[test] -fn detect_unknown_short() { - let bytes = b"\x00\x01"; - assert_eq!(ImageInfoTool::detect_format(bytes), "unknown"); -} - -#[test] -fn detect_unknown_garbage() { - let bytes = b"this is not an image"; - assert_eq!(ImageInfoTool::detect_format(bytes), "unknown"); -} - -// ── Dimension extraction ──────────────────────────────────── - -#[test] -fn png_dimensions() { - // Minimal PNG IHDR: 8-byte signature + 4-byte length + 4-byte IHDR + 4-byte width + 4-byte height - let mut bytes = vec![ - 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature - 0x00, 0x00, 0x00, 0x0D, // IHDR length - 0x49, 0x48, 0x44, 0x52, // "IHDR" - 0x00, 0x00, 0x03, 0x20, // width: 800 - 0x00, 0x00, 0x02, 0x58, // height: 600 - ]; - bytes.extend_from_slice(&[0u8; 10]); // padding - let dims = ImageInfoTool::extract_dimensions(&bytes, "png"); - assert_eq!(dims, Some((800, 600))); -} - -#[test] -fn gif_dimensions() { - let bytes = [ - 0x47, 0x49, 0x46, 0x38, 0x39, 0x61, // GIF89a - 0x40, 0x01, // width: 320 (LE) - 0xF0, 0x00, // height: 240 (LE) - ]; - let dims = ImageInfoTool::extract_dimensions(&bytes, "gif"); - assert_eq!(dims, Some((320, 240))); -} - -#[test] -fn bmp_dimensions() { - let mut bytes = vec![0u8; 26]; - bytes[0] = b'B'; - bytes[1] = b'M'; - // width at offset 18 (LE): 1024 - bytes[18] = 0x00; - bytes[19] = 0x04; - bytes[20] = 0x00; - bytes[21] = 0x00; - // height at offset 22 (LE): 768 - bytes[22] = 0x00; - bytes[23] = 0x03; - bytes[24] = 0x00; - bytes[25] = 0x00; - let dims = ImageInfoTool::extract_dimensions(&bytes, "bmp"); - assert_eq!(dims, Some((1024, 768))); -} - -#[test] -fn jpeg_dimensions() { - // Minimal JPEG-like byte sequence with SOF0 marker - let mut bytes: Vec = vec![ - 0xFF, 0xD8, // SOI - 0xFF, 0xE0, // APP0 marker - 0x00, 0x10, // APP0 length = 16 - ]; - bytes.extend_from_slice(&[0u8; 14]); // APP0 payload - bytes.extend_from_slice(&[ - 0xFF, 0xC0, // SOF0 marker - 0x00, 0x11, // SOF0 length - 0x08, // precision - 0x01, 0xE0, // height: 480 - 0x02, 0x80, // width: 640 - ]); - let dims = ImageInfoTool::extract_dimensions(&bytes, "jpeg"); - assert_eq!(dims, Some((640, 480))); -} - -#[test] -fn jpeg_malformed_zero_length_segment() { - // Zero-length segment should return None instead of looping forever - let bytes: Vec = vec![ - 0xFF, 0xD8, // SOI - 0xFF, 0xE0, // APP0 marker - 0x00, 0x00, // length = 0 (malformed) - ]; - let dims = ImageInfoTool::extract_dimensions(&bytes, "jpeg"); - assert!(dims.is_none()); -} - -#[test] -fn unknown_format_no_dimensions() { - let bytes = b"random data here"; - let dims = ImageInfoTool::extract_dimensions(bytes, "unknown"); - assert!(dims.is_none()); -} - -// ── Execute tests ─────────────────────────────────────────── - -#[tokio::test] -async fn execute_missing_path() { - let tool = ImageInfoTool::new(test_security()); - let result = tool.execute(json!({})).await; - assert!(result.is_err()); -} - -#[tokio::test] -async fn execute_nonexistent_file() { - let tool = ImageInfoTool::new(test_security()); - let result = tool - .execute(json!({"path": "nonexistent_image_xyz.png"})) - .await - .unwrap(); - assert!(result.is_error); - assert!( - result.output().contains("not allowed") || result.output().contains("Failed to resolve"), - "unexpected error: {}", - result.output() - ); -} - -#[tokio::test] -async fn execute_real_file() { - // Create a minimal valid PNG - let dir = std::env::temp_dir().join("tinytools_image_info_test"); - let _ = tokio::fs::create_dir_all(&dir).await; - let png_path = dir.join("test.png"); - - // Minimal 1x1 red PNG (67 bytes) - let png_bytes: Vec = vec![ - 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // signature - 0x00, 0x00, 0x00, 0x0D, // IHDR length - 0x49, 0x48, 0x44, 0x52, // IHDR - 0x00, 0x00, 0x00, 0x01, // width: 1 - 0x00, 0x00, 0x00, 0x01, // height: 1 - 0x08, 0x02, 0x00, 0x00, 0x00, // bit depth, color type, etc. - 0x90, 0x77, 0x53, 0xDE, // CRC - 0x00, 0x00, 0x00, 0x0C, // IDAT length - 0x49, 0x44, 0x41, 0x54, // IDAT - 0x08, 0xD7, 0x63, 0xF8, 0xCF, 0xC0, 0x00, 0x00, 0x00, 0x02, 0x00, 0x01, 0xE2, 0x21, 0xBC, - 0x33, // CRC - 0x00, 0x00, 0x00, 0x00, // IEND length - 0x49, 0x45, 0x4E, 0x44, // IEND - 0xAE, 0x42, 0x60, 0x82, // CRC - ]; - tokio::fs::write(&png_path, &png_bytes).await.unwrap(); - - let tool = ImageInfoTool::new(test_security()); - let result = tool - .execute(json!({"path": png_path.to_string_lossy()})) - .await - .unwrap(); - assert!(!result.is_error); - assert!(result.output().contains("Format: png")); - assert!(result.output().contains("Dimensions: 1x1")); - assert!(!result.output().contains("data:")); - - // Clean up - let _ = tokio::fs::remove_dir_all(&dir).await; -} - -/// A decompression-bomb-shaped PNG (a header claiming an enormous canvas, -/// backed by a file well under `MAX_IMAGE_BYTES`) must not blow up memory or -/// hang: `extract_dimensions` reads four fixed IHDR bytes and never inflates -/// pixel data, so the claimed 60000x60000 canvas is read as fast as any other -/// header and no allocation is proportional to it. -#[tokio::test] -async fn execute_reports_a_bomb_shaped_header_without_decoding_pixels() { - let dir = std::env::temp_dir().join("tinytools_image_info_bomb"); - let _ = tokio::fs::create_dir_all(&dir).await; - let png_path = dir.join("bomb.png"); - - let mut bytes = vec![ - 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // signature - 0x00, 0x00, 0x00, 0x0D, // IHDR length - 0x49, 0x48, 0x44, 0x52, // "IHDR" - 0x00, 0x00, 0xEA, 0x60, // width: 60000 - 0x00, 0x00, 0xEA, 0x60, // height: 60000 - ]; - bytes.extend_from_slice(&[0u8; 10]); // pad past the 24 bytes IHDR parsing reads - tokio::fs::write(&png_path, &bytes).await.unwrap(); - - let tool = ImageInfoTool::new(test_security()); - let result = tokio::time::timeout( - std::time::Duration::from_secs(2), - tool.execute(json!({"path": png_path.to_string_lossy()})), - ) - .await - .expect("image-info execution must settle within two seconds") - .unwrap(); - assert!(!result.is_error, "{}", result.output()); - assert!(result.output().contains("Dimensions: 60000x60000")); - - let _ = tokio::fs::remove_dir_all(&dir).await; -} - -#[tokio::test] -async fn execute_with_base64() { - let dir = std::env::temp_dir().join("tinytools_image_info_b64"); - let _ = tokio::fs::create_dir_all(&dir).await; - let png_path = dir.join("test_b64.png"); - - // Minimal 1x1 PNG - let png_bytes: Vec = vec![ - 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, - 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, - 0x77, 0x53, 0xDE, 0x00, 0x00, 0x00, 0x0C, 0x49, 0x44, 0x41, 0x54, 0x08, 0xD7, 0x63, 0xF8, - 0xCF, 0xC0, 0x00, 0x00, 0x00, 0x02, 0x00, 0x01, 0xE2, 0x21, 0xBC, 0x33, 0x00, 0x00, 0x00, - 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82, - ]; - tokio::fs::write(&png_path, &png_bytes).await.unwrap(); - - let tool = ImageInfoTool::new(test_security()); - let result = tool - .execute(json!({"path": png_path.to_string_lossy(), "include_base64": true})) - .await - .unwrap(); - assert!(!result.is_error); - assert!(result.output().contains("data:image/png;base64,")); - - let _ = tokio::fs::remove_dir_all(&dir).await; -} - -#[test] -fn truncated_headers_yield_no_dimensions() { - for format in ["png", "gif", "bmp"] { - assert_eq!( - ImageInfoTool::extract_dimensions(&[0u8; 5], format), - None, - "{format}" - ); - } -} - -#[test] -fn jpeg_dimension_scan_rejects_bad_structure() { - // A non-0xFF byte where a marker should be. - assert_eq!( - ImageInfoTool::extract_dimensions(&[0xFF, 0xD8, 0x00, 0x00], "jpeg"), - None - ); - // SOF marker with too few bytes after it. - assert_eq!( - ImageInfoTool::extract_dimensions(&[0xFF, 0xD8, 0xFF, 0xC0, 0, 0], "jpeg"), - None - ); - // A skippable marker with no length bytes left. - assert_eq!( - ImageInfoTool::extract_dimensions(&[0xFF, 0xD8, 0xFF, 0xE0], "jpeg"), - None - ); - // A well-formed segment that runs out before any SOF marker. - assert_eq!( - ImageInfoTool::extract_dimensions(&[0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x02], "jpeg"), - None - ); -} - -#[tokio::test] -async fn execute_on_a_directory_reports_a_read_failure() { - let dir = std::env::temp_dir().join("tinytools_image_info_dir_test"); - tokio::fs::create_dir_all(&dir).await.unwrap(); - let tool = ImageInfoTool::new(test_security()); - let err = tool - .execute(json!({"path": dir.to_string_lossy()})) - .await - .unwrap_err(); - assert!(err.to_string().contains("Failed to read image file")); -} diff --git a/crates/tinytools-std/src/filesystem/list_files/extra_tests.rs b/crates/tinytools-std/src/filesystem/list_files/extra_tests.rs deleted file mode 100644 index 76d3c2f8..00000000 --- a/crates/tinytools-std/src/filesystem/list_files/extra_tests.rs +++ /dev/null @@ -1,52 +0,0 @@ -//! 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 deleted file mode 100644 index 1a2172a6..00000000 --- a/crates/tinytools-std/src/filesystem/list_files/mod.rs +++ /dev/null @@ -1,147 +0,0 @@ -//! `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)] -#[path = "mod_tests.rs"] -mod test; - -#[cfg(test)] -#[path = "extra_tests.rs"] -mod extra_test; diff --git a/crates/tinytools-std/src/filesystem/list_files/mod_tests.rs b/crates/tinytools-std/src/filesystem/list_files/mod_tests.rs deleted file mode 100644 index 6b6f1762..00000000 --- a/crates/tinytools-std/src/filesystem/list_files/mod_tests.rs +++ /dev/null @@ -1,76 +0,0 @@ -//! 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 deleted file mode 100644 index e95054d4..00000000 --- a/crates/tinytools-std/src/filesystem/mod.rs +++ /dev/null @@ -1,73 +0,0 @@ -//! Filesystem tools: read/write/edit/patch, grep/glob/list, diffs, CSV export, -//! git operations, linter and test runners, the workspace memory index, a -//! read-only workspace overview and image metadata. -//! -//! 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 image_info; -mod list_files; -mod read_diff; -mod read_only; -mod run_linter; -mod run_tests; -mod text; -mod update_memory_md; -mod workspace_state; - -#[cfg(test)] -#[path = "gate_tests.rs"] -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 image_info::ImageInfoTool; -pub use list_files::ListFilesTool; -pub use read_diff::ReadDiffTool; -pub use read_only::{is_read_only_call, is_read_only_command}; -pub use run_linter::RunLinterTool; -pub use run_tests::RunTestsTool; -pub use update_memory_md::UpdateMemoryMdTool; -pub use workspace_state::WorkspaceStateTool; - -#[cfg(test)] -#[path = "contract_tests.rs"] -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 deleted file mode 100644 index c9d99362..00000000 --- a/crates/tinytools-std/src/filesystem/read_diff/mod.rs +++ /dev/null @@ -1,139 +0,0 @@ -//! 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)] -#[path = "mod_tests.rs"] -mod test; diff --git a/crates/tinytools-std/src/filesystem/read_diff/mod_tests.rs b/crates/tinytools-std/src/filesystem/read_diff/mod_tests.rs deleted file mode 100644 index ab347da3..00000000 --- a/crates/tinytools-std/src/filesystem/read_diff/mod_tests.rs +++ /dev/null @@ -1,144 +0,0 @@ -#![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_ne!(make_tool(&tmp).description().len(), 0); -} - -#[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/read_only.rs b/crates/tinytools-std/src/filesystem/read_only.rs deleted file mode 100644 index a008504b..00000000 --- a/crates/tinytools-std/src/filesystem/read_only.rs +++ /dev/null @@ -1,92 +0,0 @@ -//! Which tool calls only read files the agent can read again. -//! -//! A host skips an LLM summary of tool output for these. A summary of source code is a paraphrase, and an agent about to -//! edit a file needs its exact text: on live coding runs every summarized -//! `cat` of a source file was followed by `juice_retrieve` of the whole -//! original or a fresh `sed -n` read of the file. The summary bought nothing -//! and cost a model call plus up to `TinyJuice`'s 8-second summary timeout, -//! which it hit on most attempts. -//! -//! The test is deliberately narrow: a file-reading tool, or a shell command -//! made only of `cd` and plain reads (`cat`, `head`, `tail`, `nl`, `sed` -//! without `-i`, `echo`/`printf` separators), with no redirects, heredocs, -//! substitutions or other programs. Anything else (a build, a test run, a -//! search) keeps the normal pipeline: its output is not a file on disk. - -use serde_json::Value; - -/// Tools whose whole job is returning a file's contents. -const FILE_READ_TOOLS: &[&str] = &["file_read", "read_file", "read", "view_file"]; - -/// Shell tools, by the names hosts and common harness adapters use. -const SHELL_TOOLS: &[&str] = &["shell", "bash", "exec", "run_command", "terminal"]; - -/// Programs that print a file (`sed` only without in-place editing). -const READERS: &[&str] = &["cat", "head", "tail", "nl", "sed"]; - -/// Programs allowed beside the readers: navigation and separators. -const NEUTRAL: &[&str] = &["cd", "echo", "printf", "true", "pwd"]; - -/// Whether this call only prints files the agent can re-read (see the module -/// docs). `use_skill` is followed into the tool it wraps. -#[must_use] -pub fn is_read_only_call(tool_name: &str, args: &Value) -> bool { - let (name, args) = if tool_name == "use_skill" { - match (args.get("tool").and_then(Value::as_str), args.get("args")) { - (Some(inner), Some(inner_args)) => (inner, inner_args), - _ => return false, - } - } else { - (tool_name, args) - }; - if FILE_READ_TOOLS.contains(&name) { - return true; - } - if !SHELL_TOOLS.contains(&name) { - return false; - } - args.get("command") - .or_else(|| args.get("cmd")) - .and_then(Value::as_str) - .is_some_and(is_read_only_command) -} - -/// Whether a shell command line only prints files: every segment (split on -/// `&&`, `||`, `;`, `|` and newlines) runs a reader or a neutral program, at -/// least one runs a reader, and nothing redirects, substitutes or feeds a -/// heredoc. -#[must_use] -pub fn is_read_only_command(command: &str) -> bool { - if command.contains(['>', '<', '`', '$']) { - return false; - } - let mut reads = false; - for segment in command - .split(['\n', ';', '|', '&']) - .map(str::trim) - .filter(|s| !s.is_empty()) - { - let mut words = segment.split_whitespace(); - let Some(program) = words.next() else { - continue; - }; - let program = program.rsplit('/').next().unwrap_or(program); - if program == "sed" - && segment - .split_whitespace() - .any(|w| w.starts_with("-i") || w == "--in-place" || w.starts_with("--in-place=")) - { - return false; - } - if READERS.contains(&program) { - reads = true; - } else if !NEUTRAL.contains(&program) { - return false; - } - } - reads -} - -#[cfg(test)] -#[path = "read_only_tests.rs"] -mod tests; diff --git a/crates/tinytools-std/src/filesystem/read_only_tests.rs b/crates/tinytools-std/src/filesystem/read_only_tests.rs deleted file mode 100644 index ce034c70..00000000 --- a/crates/tinytools-std/src/filesystem/read_only_tests.rs +++ /dev/null @@ -1,58 +0,0 @@ -use super::*; -use serde_json::json; - -#[test] -fn plain_reads_of_files_are_file_reads() { - for command in [ - "cat vm/vmExprFunction.go", - "cd /app && cat vm/vmExprFunction.go", - "cd /app && cat -n parser/lexer.go", - "cd /app && sed -n '120,300p' vm/vmExprFunction.go", - "cd /app && cat parser/Makefile && echo \"=== parser.go.y ===\" && cat parser/parser.go.y", - "cd /app && sed -n '125,150p' ast/expr.go && echo \"=== vmExprFunction.go ===\" && cat vm/vmExprFunction.go", - "head -50 README.md; tail -20 CHANGELOG.md", - "cat -n src/lib.rs | sed -n '1,80p'", - ] { - assert!(is_read_only_command(command), "{command}"); - } -} - -#[test] -fn anything_that_runs_writes_or_searches_is_not() { - for command in [ - "go test ./vm/...", - "cd /app && git diff", - "grep -rn FuncExpr .", - "cat foo.go > bar.go", - "sed -i 's/a/b/' foo.go", - "sed --in-place 's/a/b/' foo.go", - "sed --in-place=.bak -n 's/old/new/p' file", - "cat <<'EOF' > x.go\npackage x\nEOF", - "cat $(git ls-files)", - "cat README.md && printf '%s\\n' \"$BUILD_LOG\"", - "cd /app && cat a.go && go build ./...", - "echo hello", - "cd /app", - ] { - assert!(!is_read_only_command(command), "{command}"); - } -} - -#[test] -fn file_read_tools_and_wrapped_calls_are_recognised() { - assert!(is_read_only_call("file_read", &json!({"path": "a.rs"}))); - assert!(is_read_only_call("shell", &json!({"command": "cat a.rs"}))); - assert!(!is_read_only_call( - "shell", - &json!({"command": "cargo test"}) - )); - assert!(!is_read_only_call( - "web_fetch", - &json!({"url": "https://example.com"}) - )); - assert!(is_read_only_call( - "use_skill", - &json!({"tool": "shell", "args": {"command": "cat a.rs"}}) - )); - assert!(!is_read_only_call("use_skill", &json!({"tool": "shell"}))); -} diff --git a/crates/tinytools-std/src/filesystem/run_linter/mod.rs b/crates/tinytools-std/src/filesystem/run_linter/mod.rs deleted file mode 100644 index 7527b3d6..00000000 --- a/crates/tinytools-std/src/filesystem/run_linter/mod.rs +++ /dev/null @@ -1,159 +0,0 @@ -//! 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)] -#[path = "mod_tests.rs"] -mod test; diff --git a/crates/tinytools-std/src/filesystem/run_linter/mod_tests.rs b/crates/tinytools-std/src/filesystem/run_linter/mod_tests.rs deleted file mode 100644 index 5a05573d..00000000 --- a/crates/tinytools-std/src/filesystem/run_linter/mod_tests.rs +++ /dev/null @@ -1,97 +0,0 @@ -#![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_ne!(make_tool(&tmp).description().len(), 0); -} - -#[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 deleted file mode 100644 index a522a680..00000000 --- a/crates/tinytools-std/src/filesystem/run_tests/mod.rs +++ /dev/null @@ -1,200 +0,0 @@ -//! 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)] -#[path = "mod_tests.rs"] -mod test; diff --git a/crates/tinytools-std/src/filesystem/run_tests/mod_tests.rs b/crates/tinytools-std/src/filesystem/run_tests/mod_tests.rs deleted file mode 100644 index 10d3cba9..00000000 --- a/crates/tinytools-std/src/filesystem/run_tests/mod_tests.rs +++ /dev/null @@ -1,164 +0,0 @@ -//! 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 deleted file mode 100644 index 1a3ffb58..00000000 --- a/crates/tinytools-std/src/filesystem/test_support.rs +++ /dev/null @@ -1,385 +0,0 @@ -//! 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, - batch_calls: Arc, - wrong_length_batch: bool, -} - -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, - batch_calls: Arc::new(AtomicUsize::new(0)), - wrong_length_batch: false, - } - } - - /// Answer every batch with one verdict too few. - #[must_use] - pub fn wrong_length_batch(mut self) -> Self { - self.wrong_length_batch = true; - self - } - - /// Counter of `paths_string_allowed` calls this gate has received. - pub fn batch_calls(&self) -> Arc { - Arc::clone(&self.batch_calls) - } - - /// `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) - } - - fn paths_string_allowed(&self, paths: &[String]) -> Vec { - self.batch_calls.fetch_add(1, Ordering::SeqCst); - let mut answers: Vec = paths - .iter() - .map(|path| self.inner.is_path_string_allowed(path)) - .collect(); - if self.wrong_length_batch { - answers.pop(); - } - answers - } - - 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 deleted file mode 100644 index c8a84c78..00000000 --- a/crates/tinytools-std/src/filesystem/text.rs +++ /dev/null @@ -1,24 +0,0 @@ -//! 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)] -#[path = "text_test_tests.rs"] -mod test; diff --git a/crates/tinytools-std/src/filesystem/text_test_tests.rs b/crates/tinytools-std/src/filesystem/text_test_tests.rs deleted file mode 100644 index abd85f2f..00000000 --- a/crates/tinytools-std/src/filesystem/text_test_tests.rs +++ /dev/null @@ -1,25 +0,0 @@ -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 deleted file mode 100644 index 8797b200..00000000 --- a/crates/tinytools-std/src/filesystem/update_memory_md/mod.rs +++ /dev/null @@ -1,413 +0,0 @@ -//! 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)] -#[path = "mod_tests.rs"] -mod test; diff --git a/crates/tinytools-std/src/filesystem/update_memory_md/mod_tests.rs b/crates/tinytools-std/src/filesystem/update_memory_md/mod_tests.rs deleted file mode 100644 index ca2cfb1c..00000000 --- a/crates/tinytools-std/src/filesystem/update_memory_md/mod_tests.rs +++ /dev/null @@ -1,357 +0,0 @@ -#![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_ne!(tool.description().len(), 0); -} - -#[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/filesystem/workspace_state/mod.rs b/crates/tinytools-std/src/filesystem/workspace_state/mod.rs deleted file mode 100644 index 7af822a0..00000000 --- a/crates/tinytools-std/src/filesystem/workspace_state/mod.rs +++ /dev/null @@ -1,158 +0,0 @@ -//! Tool: `read_workspace_state` — read-only workspace overview for Orchestrator/Planner. - -use super::git_operations::config::{ - disallowed_config_refusal, first_disallowed_repo_config_key, hardened_git, -}; -use async_trait::async_trait; -use serde_json::json; -use std::path::PathBuf; -use tinytools::{PermissionLevel, Tool, ToolResult}; - -/// Returns a summary of the workspace: git status, file tree, recent commits. -#[derive(Debug)] -pub struct WorkspaceStateTool { - workspace_dir: PathBuf, -} - -impl WorkspaceStateTool { - /// A `read_workspace_state` tool reporting on `workspace_dir`. - #[must_use] - pub fn new(workspace_dir: PathBuf) -> Self { - Self { workspace_dir } - } -} - -#[async_trait] -impl Tool for WorkspaceStateTool { - fn name(&self) -> &'static str { - "read_workspace_state" - } - - fn description(&self) -> &'static str { - "Get a read-only overview of the workspace: git status (modified/untracked files), \ - recent commits, and top-level directory structure. Useful for understanding the \ - current project state before planning tasks." - } - - fn parameters_schema(&self) -> serde_json::Value { - json!({ - "type": "object", - "properties": { - "include_tree": { - "type": "boolean", - "description": "Include top-level directory tree (default: true).", - "default": true - }, - "recent_commits": { - "type": "integer", - "description": "Number of recent commits to show (default: 5).", - "default": 5 - } - } - }) - } - - fn permission_level(&self) -> PermissionLevel { - PermissionLevel::ReadOnly - } - - async fn execute(&self, args: serde_json::Value) -> anyhow::Result { - let include_tree = args - .get("include_tree") - .and_then(serde_json::Value::as_bool) - .unwrap_or(true); - let recent_commits = args - .get("recent_commits") - .and_then(serde_json::Value::as_u64) - .unwrap_or(5) as usize; - - tracing::debug!( - "[workspace_state] dir={}, include_tree={include_tree}, recent_commits={recent_commits}", - self.workspace_dir.display() - ); - - let mut output = String::new(); - let dir = &self.workspace_dir; - - // Git status - output.push_str("## Git Status\n"); - match run_git(dir, &["status", "--porcelain"]).await { - Ok(status) if status.trim().is_empty() => { - output.push_str("Clean working tree.\n"); - } - Ok(status) => { - output.push_str(&status); - } - Err(e) => { - output.push_str(&format!("(not a git repo or error: {e})\n")); - } - } - - // Recent commits - output.push_str(&format!("\n## Recent Commits (last {recent_commits})\n")); - let log_arg = format!("-{recent_commits}"); - match run_git(dir, &["log", &log_arg, "--oneline", "--no-decorate"]).await { - Ok(log) => output.push_str(&log), - Err(e) => output.push_str(&format!("(error: {e})\n")), - } - - // Directory tree (top-level only) - if include_tree { - output.push_str("\n## Directory Tree (top-level)\n"); - match tokio::fs::read_dir(dir).await { - Ok(mut entries) => { - let mut names = Vec::new(); - while let Ok(Some(entry)) = entries.next_entry().await { - let name = entry.file_name().to_string_lossy().to_string(); - if !name.starts_with('.') { - let suffix = if entry.file_type().await.is_ok_and(|ft| ft.is_dir()) { - "/" - } else { - "" - }; - names.push(format!("{name}{suffix}")); - } - } - names.sort(); - for name in &names { - output.push_str(&format!(" {name}\n")); - } - } - Err(e) => output.push_str(&format!("(error reading dir: {e})\n")), - } - } - - tracing::debug!("[workspace_state] output length={}", output.len()); - Ok(ToolResult::success(output)) - } -} - -async fn run_git(dir: &std::path::Path, args: &[&str]) -> anyhow::Result { - if let Some(key) = first_disallowed_repo_config_key(dir).await? { - // The refusal is otherwise only visible folded into the tool's own - // output, which is not greppable when an operator is asking why a - // workspace stopped reporting. Correlation fields: the directory and - // the key that caused it. - tracing::debug!( - "[workspace_state] refusing to run git: dir={}, disallowed_config_key={key}", - dir.display() - ); - anyhow::bail!("{}", disallowed_config_refusal(dir, &key)) - } - - let output = hardened_git(dir).args(args).output().await?; - - if output.status.success() { - Ok(String::from_utf8_lossy(&output.stdout).to_string()) - } else { - anyhow::bail!( - "git {} failed: {}", - args.join(" "), - String::from_utf8_lossy(&output.stderr) - ) - } -} - -#[cfg(test)] -#[path = "mod_tests.rs"] -mod test; diff --git a/crates/tinytools-std/src/filesystem/workspace_state/mod_tests.rs b/crates/tinytools-std/src/filesystem/workspace_state/mod_tests.rs deleted file mode 100644 index a815bf74..00000000 --- a/crates/tinytools-std/src/filesystem/workspace_state/mod_tests.rs +++ /dev/null @@ -1,289 +0,0 @@ -use super::*; -use crate::filesystem::git_operations::config::normalise_config_key; -use serde_json::json; -use tempfile::TempDir; - -fn make_tool(dir: &TempDir) -> WorkspaceStateTool { - WorkspaceStateTool::new(dir.path().to_path_buf()) -} - -#[test] -fn name_is_correct() { - let tmp = TempDir::new().unwrap(); - assert_eq!(make_tool(&tmp).name(), "read_workspace_state"); -} - -#[test] -fn description_is_non_empty() { - let tmp = TempDir::new().unwrap(); - assert_ne!(make_tool(&tmp).description().len(), 0); -} - -#[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 output_contains_git_status_section() { - let tmp = TempDir::new().unwrap(); - let result = make_tool(&tmp).execute(json!({})).await.unwrap(); - assert!(!result.is_error); - assert!(result.output().contains("Git Status")); -} - -#[tokio::test] -async fn include_tree_false_omits_directory_tree() { - let tmp = TempDir::new().unwrap(); - let result = make_tool(&tmp) - .execute(json!({"include_tree": false})) - .await - .unwrap(); - assert!(!result.is_error); - assert!(!result.output().contains("Directory Tree")); -} - -/// 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. -/// -/// `git status` invokes `core.fsmonitor`, and the value is read from the -/// repository's own `.git/config` — a file inside the workspace, which is -/// where `file_write` puts things. The hook exits non-zero so git falls -/// back to a normal scan and `status` still succeeds; the point of the test -/// is whether the command ran at all, not what it returned. -fn plant_fsmonitor_hook(dir: &TempDir) -> std::path::PathBuf { - let hook = dir.path().join("hook.sh"); - let marker = dir.path().join("COMMAND_RAN"); - std::fs::write( - &hook, - format!("#!/bin/sh\ntouch {:?}\nexit 1\n", marker.to_string_lossy()), - ) - .unwrap(); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)).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 = std::process::Command::new("git") - .args(["config", "core.fsmonitor"]) - .arg(&hook) - .current_dir(dir.path()) - .status() - .unwrap() - .success(); - assert!(ok, "failed to plant the hook in the repository config"); - marker -} - -fn git_init(dir: &TempDir) { - // `.status().unwrap()` only unwraps the *spawn*: a non-zero exit would - // pass silently here and surface later as a confusing assertion about - // status output. Match `plant_fsmonitor_hook`, which already checks. - let ok = std::process::Command::new("git") - .args(["init", "-q", "."]) - .current_dir(dir.path()) - .status() - .unwrap() - .success(); - assert!(ok, "git init failed in the test workspace"); -} - -/// Set a repository config key with `git config`, asserting it took. -fn set_config(dir: &TempDir, key: &str, value: &str) { - let ok = std::process::Command::new("git") - .args(["config", key, value]) - .current_dir(dir.path()) - .status() - .unwrap() - .success(); - assert!(ok, "failed to set {key} in the test workspace"); -} - -/// Issue #459. The workspace this tool reads is the same directory the -/// agent's own file tools write to, so its `.git/config` is attacker- -/// controlled input. `core.fsmonitor` names a command that `git status` -/// executes. -/// -/// Revert either layer of the fix in `run_git` 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(); - git_init(&tmp); - let marker = plant_fsmonitor_hook(&tmp); - - let result = make_tool(&tmp).execute(json!({})).await.unwrap(); - - assert!( - !marker.exists(), - "`git status` executed the command named by the workspace's own \ - repository config — this tool is a code-execution primitive" - ); - assert!( - result.output().contains("fsmonitor"), - "the refusal should name the key that caused it, got: {}", - result.output() - ); -} - -/// The allowlist has to leave an ordinary repository working, or the fix is -/// just a different way of breaking the tool. -#[cfg(unix)] -#[tokio::test] -async fn an_ordinary_repository_still_reports_status_and_log() { - let tmp = TempDir::new().unwrap(); - git_init(&tmp); - std::fs::write(tmp.path().join("tracked.txt"), "hi").unwrap(); - - let result = make_tool(&tmp).execute(json!({})).await.unwrap(); - let out = result.output(); - - assert!( - out.contains("tracked.txt"), - "a plain `git init` workspace must still report status, got: {out}" - ); - assert!( - !out.contains("not on the allowlist"), - "`git init`'s own config must not trip the allowlist, got: {out}" - ); -} - -/// The first draft of the allowlist refused any repository carrying an -/// ordinary setting like `core.autocrlf`, which is most of them on Windows -/// and many elsewhere — the tool would have reported nothing useful for a -/// large class of real workspaces. Raised by `CodeRabbit` on the PR. -#[cfg(unix)] -#[tokio::test] -async fn an_inert_setting_an_ordinary_repository_carries_is_allowed() { - let tmp = TempDir::new().unwrap(); - git_init(&tmp); - set_config(&tmp, "core.autocrlf", "input"); - set_config(&tmp, "gc.auto", "0"); - set_config(&tmp, "remote.origin.prune", "true"); - std::fs::write(tmp.path().join("tracked.txt"), "hi").unwrap(); - - let out = make_tool(&tmp).execute(json!({})).await.unwrap().output(); - - assert!( - out.contains("tracked.txt"), - "an ordinary repository must still report status, got: {out}" - ); - assert!(!out.contains("not on the allowlist"), "got: {out}"); -} - -/// 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. Pinned so that -/// widening the allowlist for ergonomics cannot quietly admit it. -#[cfg(unix)] -#[tokio::test] -async fn an_lfs_clone_is_refused_because_its_filter_names_a_program() { - let tmp = TempDir::new().unwrap(); - git_init(&tmp); - // What `git lfs install` writes. `required` is inert and allowed; the - // three programs are not. - set_config(&tmp, "filter.lfs.required", "true"); - set_config(&tmp, "filter.lfs.clean", "git-lfs clean -- %f"); - - let out = make_tool(&tmp).execute(json!({})).await.unwrap().output(); - - assert!( - out.contains("filter.lfs.clean"), - "the refusal must name the key that caused it, got: {out}" - ); -} - -/// `credential.helper` reads like a preference and is command-valued: a -/// value beginning `!` is run as a shell command. `CodeRabbit`'s review -/// listed it among the inert keys to allow; it is not one, and allowlisting -/// it would have reopened the hole this PR closes. -#[cfg(unix)] -#[tokio::test] -async fn credential_helper_is_refused_despite_looking_like_a_preference() { - let tmp = TempDir::new().unwrap(); - git_init(&tmp); - set_config(&tmp, "credential.helper", "!echo pwned"); - - let out = make_tool(&tmp).execute(json!({})).await.unwrap().output(); - - assert!( - out.contains("credential.helper"), - "a command-valued key must be refused however inert it reads, got: {out}" - ); -} - -#[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"); -} - -#[tokio::test] -async fn lists_non_hidden_files_in_tree() { - let tmp = TempDir::new().unwrap(); - std::fs::write(tmp.path().join("readme.txt"), "hi").unwrap(); - std::fs::write(tmp.path().join(".hidden"), "skip").unwrap(); - let result = make_tool(&tmp) - .execute(json!({"include_tree": true, "recent_commits": 0})) - .await - .unwrap(); - assert!(!result.is_error); - let out = result.output(); - assert!(out.contains("readme.txt")); - assert!(!out.contains(".hidden")); -} - -/// The hardening now comes from `git_operations`' policy, which is stricter -/// than the copy this tool used to carry: `core.worktree` redirects the tree -/// git operates on and is not on the allowlist. -#[cfg(unix)] -#[tokio::test] -async fn core_worktree_is_refused_like_any_other_unrecognised_key() { - let tmp = TempDir::new().unwrap(); - git_init(&tmp); - set_config(&tmp, "core.worktree", "/tmp"); - - let out = make_tool(&tmp).execute(json!({})).await.unwrap().output(); - - assert!(out.contains("core.worktree"), "got: {out}"); - assert!(out.contains("not on the allowlist"), "got: {out}"); -} - -/// `git config --local` never shows a worktree-scoped key; the shared check -/// reads the merged view, so a hook path set there is still caught. -#[cfg(unix)] -#[tokio::test] -async fn a_worktree_scoped_command_key_is_refused() { - let tmp = TempDir::new().unwrap(); - git_init(&tmp); - set_config(&tmp, "extensions.worktreeConfig", "true"); - let ok = std::process::Command::new("git") - .args(["config", "--worktree", "core.fsmonitor", "/bin/true"]) - .current_dir(tmp.path()) - .status() - .unwrap() - .success(); - assert!(ok, "failed to set a worktree-scoped key"); - - let out = make_tool(&tmp).execute(json!({})).await.unwrap().output(); - - assert!(out.contains("fsmonitor"), "got: {out}"); -} diff --git a/crates/tinytools-std/src/lib.rs b/crates/tinytools-std/src/lib.rs deleted file mode 100644 index 8a7a344e..00000000 --- a/crates/tinytools-std/src/lib.rs +++ /dev/null @@ -1,40 +0,0 @@ -//! Host-independent building blocks for agent tools. -//! -//! `tinytools` is the vocabulary a tool is written against and deliberately -//! carries no behavior. This crate holds the small, reusable mechanisms that -//! several hosts' tools share, none of which encodes a host's policy: -//! -//! - [`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`]. -//! - [`network`] — `http_request`, `web_fetch`, `curl` and `pushover`, gated by a -//! host-implemented [`network::NetGate`]. -//! - [`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. -//! - [`timeout`] — tool timeout bounds, process-group kill and child reaping. -//! -//! # Example -//! -//! Probe `PATH` directly, or hand the host the read-only tool that does the -//! same for a model: -//! -//! ``` -//! use tinytools::{PermissionLevel, Tool}; -//! use tinytools_std::detect_tools::{DetectToolsTool, find_on_path}; -//! -//! // A missing binary is `None`, never an error. -//! assert_eq!(find_on_path("definitely-not-a-real-binary-7f3a"), None); -//! -//! let tool = DetectToolsTool::new(); -//! assert_eq!(tool.name(), "detect_tools"); -//! assert_eq!(tool.permission_level(), PermissionLevel::ReadOnly); -//! ``` - -pub mod detect_tools; -pub mod file_state; -pub mod filesystem; -pub mod network; -pub mod timeout; -pub mod url_guard; diff --git a/crates/tinytools-std/src/network/contract_tests.rs b/crates/tinytools-std/src/network/contract_tests.rs deleted file mode 100644 index 33ac31f0..00000000 --- a/crates/tinytools-std/src/network/contract_tests.rs +++ /dev/null @@ -1,126 +0,0 @@ -//! Wire-contract fixtures: the name, description, permission level, exposure -//! and JSON Schema each network 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::path::PathBuf; -use std::sync::Arc; - -use serde_json::{Value, json}; -use tinytools::Tool; - -use super::test_support::{AutonomyLevel, DEFAULT_LIMITS, TestHtml, TestNetGate}; -use super::*; - -fn tools() -> Vec<(&'static str, Box)> { - let gate = TestNetGate::supervised(); - vec![ - ( - "curl", - Box::new(CurlTool::new( - gate.clone(), - vec![], - PathBuf::from("."), - "downloads".into(), - 1024, - 30, - )) as Box, - ), - ( - "http_request", - Box::new(HttpRequestTool::new( - gate.clone(), - vec![], - 1024, - 30, - DEFAULT_LIMITS, - )), - ), - ( - "pushover", - Box::new(PushoverTool::new(gate.clone(), PathBuf::from("."))), - ), - ( - "web_fetch", - Box::new(WebFetchTool::new( - gate, - vec![], - None, - None, - DEFAULT_LIMITS, - Arc::new(TestHtml), - )), - ), - ] -} - -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 { - "curl" => include_str!("fixtures/curl.json"), - "http_request" => include_str!("fixtures/http_request.json"), - "pushover" => include_str!("fixtures/pushover.json"), - "web_fetch" => include_str!("fixtures/web_fetch.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 web_fetch_with_a_host_property_extends_only_the_properties() { - let tool = WebFetchTool::new( - TestNetGate::supervised(), - vec![], - None, - None, - DEFAULT_LIMITS, - Arc::new(TestHtml), - ) - .with_schema_property("summary_focus", json!({"type": "string"})); - let mut expected = fixture("web_fetch"); - expected["schema"]["properties"]["summary_focus"] = json!({"type": "string"}); - assert_eq!(describe(&tool), expected); -} - -#[test] -fn only_the_approval_gated_tools_route_through_approval() { - let args = json!({"url": "https://example.com"}); - for (autonomy, prompts) in [ - (AutonomyLevel::Supervised, true), - (AutonomyLevel::Full, false), - (AutonomyLevel::ReadOnly, false), - ] { - let gate = TestNetGate::with(autonomy, 100); - let curl = CurlTool::new(gate.clone(), vec![], ".".into(), "d".into(), 1, 1); - let http = HttpRequestTool::new(gate.clone(), vec![], 1, 1, DEFAULT_LIMITS); - let fetch = WebFetchTool::new(gate, vec![], None, None, DEFAULT_LIMITS, Arc::new(TestHtml)); - assert_eq!(curl.external_effect_with_args(&args), prompts); - assert_eq!(http.external_effect_with_args(&args), prompts); - assert!(!fetch.external_effect_with_args(&args)); - } -} diff --git a/crates/tinytools-std/src/network/curl.rs b/crates/tinytools-std/src/network/curl.rs deleted file mode 100644 index f553cd1c..00000000 --- a/crates/tinytools-std/src/network/curl.rs +++ /dev/null @@ -1,429 +0,0 @@ -//! `curl` — download files from the web to a path under the workspace. -//! -//! Distinct from `http_request`: instead of returning the body inline -//! (size-capped), `curl` streams to disk with a hard byte ceiling. Same -//! SSRF/allowlist guards (shared via `url_guard`), shares -//! `http_request.allowed_domains` so there is one allowlist to reason -//! about. - -use super::gate::{NetGate, host_of}; -use crate::url_guard::{ValidatedUrl, normalize_allowed_domains}; - -use super::gate::validate_destination; -use async_trait::async_trait; -use futures_util::StreamExt; -use sha2::{Digest, Sha256}; -use std::fmt::Write as _; -use std::path::{Component, Path, PathBuf}; -use std::sync::Arc; -use std::time::Duration; -use tinytools::{PermissionLevel, Tool, ToolResult}; -use tokio::fs; -use tokio::io::AsyncWriteExt; - -/// Downloads a file from an http(s) URL into the workspace. -#[derive(Debug)] -pub struct CurlTool { - gate: Arc, - allowed_domains: Vec, - workspace_dir: PathBuf, - dest_subdir: String, - max_download_bytes: u64, - timeout_secs: u64, -} - -impl CurlTool { - /// A `curl` tool over `gate`, downloading under `/`. - pub fn new( - gate: Arc, - allowed_domains: Vec, - workspace_dir: PathBuf, - dest_subdir: String, - max_download_bytes: u64, - timeout_secs: u64, - ) -> Self { - Self { - gate, - allowed_domains: normalize_allowed_domains(allowed_domains), - workspace_dir, - dest_subdir: sanitize_dest_subdir(&dest_subdir), - max_download_bytes, - timeout_secs, - } - } - - /// Resolve a user-supplied dest path to an absolute path inside - /// `/`. Rejects absolute paths, `..` - /// segments, and any other escape attempts. - fn resolve_dest(&self, dest: &str) -> anyhow::Result { - let trimmed = dest.trim(); - if trimmed.is_empty() { - anyhow::bail!("dest_path cannot be empty"); - } - - let p = Path::new(trimmed); - if p.is_absolute() { - anyhow::bail!("dest_path must be relative — got absolute path"); - } - - for component in p.components() { - match component { - Component::Normal(_) | Component::CurDir => {} - Component::ParentDir => { - anyhow::bail!("dest_path may not contain '..'"); - } - Component::Prefix(_) | Component::RootDir => { - anyhow::bail!("dest_path must be relative"); - } - } - } - - let root = self.workspace_dir.join(&self.dest_subdir); - let resolved = root.join(p); - - // Belt-and-braces: ensure the resolved path still lives under root. - // Lexical check is sufficient because we already rejected `..`. - if !resolved.starts_with(&root) { - anyhow::bail!("dest_path resolves outside the downloads root"); - } - - Ok(resolved) - } - - async fn validate_url( - &self, - raw_url: &str, - headers: &[(String, String)], - ) -> anyhow::Result { - validate_destination( - &*self.gate, - raw_url, - &self.allowed_domains, - "GET", - headers, - None, - ) - .await - } - - fn default_filename_from_url(url: &str) -> String { - let after_scheme = url.split_once("://").map_or(url, |(_, rest)| rest); - let path_part = after_scheme.split_once('/').map_or("", |(_, p)| p); - let last = path_part - .split('?') - .next() - .unwrap_or("") - .rsplit('/') - .next() - .unwrap_or(""); - let cleaned: String = last - .chars() - .filter(|c| c.is_alphanumeric() || matches!(c, '.' | '-' | '_')) - .collect(); - if cleaned.is_empty() { - "download.bin".into() - } else { - cleaned - } - } -} - -#[async_trait] -impl Tool for CurlTool { - fn exposure(&self) -> tinytools::ToolExposure { - tinytools::ToolExposure::Deferred - } - - fn name(&self) -> &'static str { - "curl" - } - - fn description(&self) -> &'static str { - "Download a file from an http(s) URL into the workspace. The body is streamed to disk \ - with a hard byte ceiling. Same allowlist as `http_request`. Returns the saved path, \ - bytes written, content-type, and SHA-256 of the file." - } - - fn parameters_schema(&self) -> serde_json::Value { - serde_json::json!({ - "type": "object", - "properties": { - "url": { - "type": "string", - "description": "HTTP or HTTPS URL of the file to download" - }, - "dest_path": { - "type": "string", - "description": "Destination path relative to the downloads root inside the workspace. No '..' or absolute paths. If omitted, the filename is inferred from the URL." - }, - "headers": { - "type": "object", - "description": "Optional HTTP headers (e.g. {\"Authorization\": \"Bearer …\"})", - "default": {} - } - }, - "required": ["url"] - }) - } - - /// Downloading from the network is the always-ask `Network` bucket — it - /// prompts the human in both ask-before-edit and Full; read-only is blocked - /// in `execute`. - fn external_effect_with_args(&self, _args: &serde_json::Value) -> bool { - self.gate.network_needs_approval() - } - - fn permission_level(&self) -> PermissionLevel { - PermissionLevel::Write - } - - async fn execute(&self, args: serde_json::Value) -> anyhow::Result { - let url = args - .get("url") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("Missing 'url' parameter"))?; - - let dest_arg = args.get("dest_path").and_then(|v| v.as_str()); - let headers_val = args - .get("headers") - .cloned() - .unwrap_or_else(|| serde_json::json!({})); - - if !self.gate.can_act() { - tracing::debug!(target: "[curl]", url = %url, "blocked: autonomy read-only"); - return Ok(ToolResult::error( - "[policy-blocked] Action blocked: autonomy is read-only", - )); - } - if !self.gate.record_action() { - tracing::debug!(target: "[curl]", url = %url, "blocked: rate limit"); - return Ok(ToolResult::error("Action blocked: rate limit exceeded")); - } - - // Local-only enforcement (privacy epic S7, #4441): mirror the read-only - // `can_act()` deny above — under LocalOnly, refuse the download before - // URL validation / DNS so nothing leaves the device. - { - let host = host_of(url); - if let Some(msg) = self.gate.local_only_block(&host) { - // Log only the host, never the full URL: a raw URL can carry - // secrets in its query string (pre-signed links, tokens). The - // gate helper already logs `desc.service` (host only) too. - tracing::debug!(target: "[curl]", host = %host, "blocked: local-only privacy mode"); - return Ok(ToolResult::error(msg)); - } - } - - let request_headers: Vec<(String, String)> = headers_val - .as_object() - .map(|obj| { - obj.iter() - .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string()))) - .collect() - }) - .unwrap_or_default(); - let target = match self.validate_url(url, &request_headers).await { - Ok(v) => v, - Err(e) => { - tracing::debug!(target: "[curl]", url = %url, reason = %e, "url validation failed"); - return Ok(ToolResult::error(e.to_string())); - } - }; - - let url = target.url.clone(); - - // Egress spine (privacy epic S2/S7, #4436/#4441): a curl download - // contacts an external host — disclose the destination (and that custom - // headers ride along) before the request. Enforcement already ran at the - // top of `execute`; this is the observe-only disclosure for permitted - // downloads. - { - let host = host_of(&url); - let has_headers = headers_val.as_object().is_some_and(|h| !h.is_empty()); - self.gate.disclose(&host, false, has_headers); - } - - let dest = match dest_arg { - Some(d) => d.to_string(), - None => Self::default_filename_from_url(&url), - }; - let dest_path = match self.resolve_dest(&dest) { - Ok(p) => p, - Err(e) => { - tracing::debug!(target: "[curl]", url = %url, dest = %dest, reason = %e, "dest_path rejected"); - return Ok(ToolResult::error(e.to_string())); - } - }; - - if let Some(parent) = dest_path.parent() - && let Err(e) = fs::create_dir_all(parent).await - { - tracing::error!(target: "[curl]", url = %url, dest = %dest_path.display(), reason = %e, "create_dir_all failed"); - return Ok(ToolResult::error(format!( - "Failed to create destination directory: {e}" - ))); - } - - let builder = reqwest::Client::builder() - .timeout(Duration::from_secs(self.timeout_secs)) - .connect_timeout(Duration::from_secs(10)) - .redirect(reqwest::redirect::Policy::none()); - let builder = self.gate.prepare_client("tool.curl", builder); - // Connect only to the vetted addresses; the URL keeps its host name. - let builder = builder.resolve_to_addrs(&target.host, &target.addrs); - let client = match builder.build() { - Ok(c) => c, - Err(e) => { - tracing::error!(target: "[curl]", reason = %e, "HTTP client build failed"); - return Ok(ToolResult::error(format!("HTTP client build failed: {e}"))); - } - }; - - let mut request = client.get(&url); - if let Some(obj) = headers_val.as_object() { - for (k, v) in obj { - if let Some(s) = v.as_str() { - request = request.header(k, s); - } - } - } - - tracing::debug!(target: "[curl]", url = %url, dest = %dest_path.display(), "starting download"); - - let response = match request.send().await { - Ok(r) => r, - Err(e) => { - tracing::error!(target: "[curl]", url = %url, reason = %e, "request send failed"); - return Ok(ToolResult::error(format!("Request failed: {e}"))); - } - }; - - let status = response.status(); - if !status.is_success() { - tracing::debug!(target: "[curl]", url = %url, status = %status.as_u16(), "non-success HTTP status"); - return Ok(ToolResult::error(format!( - "HTTP {} {}", - status.as_u16(), - status.canonical_reason().unwrap_or("Unknown") - ))); - } - - let content_type = response - .headers() - .get(reqwest::header::CONTENT_TYPE) - .and_then(|v| v.to_str().ok()) - .unwrap_or("application/octet-stream") - .to_string(); - - let mut file = match fs::File::create(&dest_path).await { - Ok(f) => f, - Err(e) => { - tracing::error!(target: "[curl]", dest = %dest_path.display(), reason = %e, "fs::File::create failed"); - return Ok(ToolResult::error(format!( - "Failed to create destination file: {e}" - ))); - } - }; - - let mut hasher = Sha256::new(); - let mut bytes_written: u64 = 0; - let mut stream = response.bytes_stream(); - - while let Some(chunk) = stream.next().await { - let chunk = match chunk { - Ok(c) => c, - Err(e) => { - drop(file); - if let Err(rm) = fs::remove_file(&dest_path).await { - tracing::debug!(target: "[curl]", dest = %dest_path.display(), reason = %rm, "cleanup remove_file failed"); - } - tracing::error!(target: "[curl]", url = %url, bytes_written, reason = %e, "stream error"); - return Ok(ToolResult::error(format!("Stream error: {e}"))); - } - }; - - if bytes_written.saturating_add(chunk.len() as u64) > self.max_download_bytes { - let _ = file.flush().await; - drop(file); - if let Err(rm) = fs::remove_file(&dest_path).await { - tracing::debug!(target: "[curl]", dest = %dest_path.display(), reason = %rm, "cleanup remove_file failed"); - } - tracing::error!(target: "[curl]", url = %url, bytes_written, max = self.max_download_bytes, "size cap exceeded — download aborted"); - return Ok(ToolResult::error(format!( - "Download exceeded max_download_bytes ({} bytes)", - self.max_download_bytes - ))); - } - - if let Err(e) = file.write_all(&chunk).await { - drop(file); - if let Err(rm) = fs::remove_file(&dest_path).await { - tracing::debug!(target: "[curl]", dest = %dest_path.display(), reason = %rm, "cleanup remove_file failed"); - } - tracing::error!(target: "[curl]", dest = %dest_path.display(), bytes_written, reason = %e, "write_all failed"); - return Ok(ToolResult::error(format!("Write failed: {e}"))); - } - hasher.update(&chunk); - bytes_written += chunk.len() as u64; - } - - if let Err(e) = file.flush().await { - drop(file); - if let Err(rm) = fs::remove_file(&dest_path).await { - tracing::debug!(target: "[curl]", dest = %dest_path.display(), reason = %rm, "cleanup remove_file failed"); - } - tracing::error!(target: "[curl]", dest = %dest_path.display(), bytes_written, reason = %e, "flush failed"); - return Ok(ToolResult::error(format!("Flush failed: {e}"))); - } - - let mut sha256 = String::with_capacity(64); - for byte in hasher.finalize() { - let _ = write!(sha256, "{byte:02x}"); - } - - tracing::debug!( - target: "[curl]", - url = %url, - dest = %dest_path.display(), - bytes = bytes_written, - content_type = %content_type, - sha256 = %sha256, - "download complete" - ); - - let payload = serde_json::json!({ - "path": dest_path.display().to_string(), - "bytes_written": bytes_written, - "content_type": content_type, - "sha256": sha256, - }); - Ok(ToolResult::success(payload.to_string())) - } -} - -/// Sanitize the configured `dest_subdir` so a malicious or misconfigured -/// `[curl].dest_subdir` cannot escape the workspace via absolute paths -/// or `..` segments. Drops disallowed components rather than panicking; -/// falls back to `"downloads"` if everything is filtered out. -fn sanitize_dest_subdir(raw: &str) -> String { - let trimmed = raw.trim(); - if trimmed.is_empty() { - return "downloads".into(); - } - let p = Path::new(trimmed); - let mut buf = PathBuf::new(); - for component in p.components() { - // Drop everything else: absolute roots, prefixes, parent dirs, cur dirs. - if let Component::Normal(c) = component { - buf.push(c); - } - } - if buf.as_os_str().is_empty() { - return "downloads".into(); - } - buf.to_string_lossy().into_owned() -} - -#[cfg(test)] -#[path = "curl_tests.rs"] -mod tests; diff --git a/crates/tinytools-std/src/network/curl_tests.rs b/crates/tinytools-std/src/network/curl_tests.rs deleted file mode 100644 index db54e80a..00000000 --- a/crates/tinytools-std/src/network/curl_tests.rs +++ /dev/null @@ -1,324 +0,0 @@ -use super::*; -use crate::network::test_support::{AutonomyLevel, TestNetGate}; -use tempfile::TempDir; -fn slash_norm(s: String) -> String { - s.replace('\\', "/") -} - -fn tool(tmp: &TempDir, allow: Vec<&str>) -> CurlTool { - CurlTool::new( - TestNetGate::supervised(), - allow.into_iter().map(String::from).collect(), - tmp.path().to_path_buf(), - "downloads".into(), - 1024 * 1024, - 30, - ) -} - -#[test] -fn sanitize_dest_subdir_strips_absolute_paths() { - assert_eq!( - slash_norm(sanitize_dest_subdir("/etc/passwd")), - "etc/passwd" - ); - assert_eq!(sanitize_dest_subdir("//foo"), "foo"); -} - -#[test] -fn sanitize_dest_subdir_strips_parent_segments() { - assert_eq!(sanitize_dest_subdir("../../etc"), "etc"); - assert_eq!(slash_norm(sanitize_dest_subdir("a/../b")), "a/b"); -} - -#[test] -fn sanitize_dest_subdir_falls_back_to_downloads() { - assert_eq!(sanitize_dest_subdir(""), "downloads"); - assert_eq!(sanitize_dest_subdir(" "), "downloads"); - assert_eq!(sanitize_dest_subdir(".."), "downloads"); - assert_eq!(sanitize_dest_subdir("/"), "downloads"); -} - -#[test] -fn sanitize_dest_subdir_keeps_normal_paths() { - assert_eq!(sanitize_dest_subdir("downloads"), "downloads"); - assert_eq!( - slash_norm(sanitize_dest_subdir("artifacts/build")), - "artifacts/build" - ); -} - -#[test] -fn new_sanitizes_malicious_dest_subdir() { - let tmp = TempDir::new().unwrap(); - let t = CurlTool::new( - TestNetGate::supervised(), - vec!["example.com".into()], - tmp.path().to_path_buf(), - "../../etc".into(), - 1024, - 30, - ); - let resolved = t.resolve_dest("file.txt").unwrap(); - // Sanitizer reduced "../../etc" to "etc"; resolution must stay under workspace. - assert!(resolved.starts_with(tmp.path().join("etc"))); - assert!(resolved.starts_with(tmp.path())); -} - -#[test] -fn resolve_dest_normal() { - let tmp = TempDir::new().unwrap(); - let t = tool(&tmp, vec!["example.com"]); - let p = t.resolve_dest("foo/bar.txt").unwrap(); - assert!(p.starts_with(tmp.path().join("downloads"))); - assert!(p.ends_with("foo/bar.txt")); -} - -#[test] -fn resolve_dest_rejects_absolute() { - let tmp = TempDir::new().unwrap(); - let t = tool(&tmp, vec!["example.com"]); - let err = t.resolve_dest("/etc/passwd").unwrap_err().to_string(); - assert!(err.contains("relative")); -} - -#[test] -fn resolve_dest_rejects_parent_dir() { - let tmp = TempDir::new().unwrap(); - let t = tool(&tmp, vec!["example.com"]); - let err = t.resolve_dest("../etc/passwd").unwrap_err().to_string(); - assert!(err.contains("..")); -} - -#[test] -fn resolve_dest_rejects_nested_parent_dir() { - let tmp = TempDir::new().unwrap(); - let t = tool(&tmp, vec!["example.com"]); - let err = t.resolve_dest("a/../../b").unwrap_err().to_string(); - assert!(err.contains("..")); -} - -#[test] -fn resolve_dest_rejects_empty() { - let tmp = TempDir::new().unwrap(); - let t = tool(&tmp, vec!["example.com"]); - assert!(t.resolve_dest("").is_err()); - assert!(t.resolve_dest(" ").is_err()); -} - -#[tokio::test] -async fn validate_url_rejects_disallowed_domain() { - let tmp = TempDir::new().unwrap(); - let t = tool(&tmp, vec!["example.com"]); - let err = t - .validate_url("https://evil.test/archive.tar.gz", &[]) - .await - .unwrap_err() - .to_string(); - assert!(err.contains("allowed websites")); -} - -#[test] -fn default_filename_from_url_basic() { - assert_eq!( - CurlTool::default_filename_from_url("https://example.com/foo/bar.zip"), - "bar.zip" - ); -} - -#[test] -fn default_filename_from_url_query_stripped() { - assert_eq!( - CurlTool::default_filename_from_url("https://example.com/file.tar.gz?token=x"), - "file.tar.gz" - ); -} - -#[test] -fn default_filename_from_url_root_falls_back() { - assert_eq!( - CurlTool::default_filename_from_url("https://example.com/"), - "download.bin" - ); -} - -#[tokio::test] -async fn execute_blocks_when_rate_limited() { - let tmp = TempDir::new().unwrap(); - let t = CurlTool::new( - TestNetGate::with(AutonomyLevel::Supervised, 0), - vec!["example.com".into()], - tmp.path().into(), - "downloads".into(), - 1024, - 30, - ); - let result = t - .execute(serde_json::json!({"url": "https://example.com/x"})) - .await - .unwrap(); - assert!(result.is_error); - assert!(result.output().contains("rate limit")); -} - -#[tokio::test] -async fn execute_blocked_under_local_only_privacy_mode() { - // Privacy epic S7 (#4441): under LocalOnly the download is refused with a - // `[policy-blocked]` result before URL validation / network. - let tmp = TempDir::new().unwrap(); - let t = CurlTool::new( - TestNetGate::local_only(), - vec!["example.com".into()], - tmp.path().to_path_buf(), - "downloads".into(), - 1024 * 1024, - 30, - ); - let result = t - .execute(serde_json::json!({"url": "https://example.com/x"})) - .await - .unwrap(); - assert!(result.is_error); - assert!( - result.output().contains("[policy-blocked]"), - "got: {}", - result.output() - ); - assert!( - result.output().contains("Local-only"), - "got: {}", - result.output() - ); -} - -/// Live integration smoke: downloads example.com (a tiny, stable -/// public page). Gated behind `OPENHUMAN_CURL_LIVE_TEST=1` so CI / -/// offline runs don't depend on the network. -#[tokio::test] -async fn live_download_example_com() { - if std::env::var("OPENHUMAN_CURL_LIVE_TEST").ok().as_deref() != Some("1") { - return; - } - let tmp = TempDir::new().unwrap(); - let t = tool(&tmp, vec!["example.com"]); - let result = t - .execute(serde_json::json!({ - "url": "https://example.com/", - "dest_path": "example.html" - })) - .await - .unwrap(); - assert!(!result.is_error, "live curl errored: {}", result.output()); - let payload: serde_json::Value = serde_json::from_str(&result.output()).unwrap(); - let bytes = payload["bytes_written"].as_u64().unwrap(); - assert!(bytes > 100, "unexpectedly small download: {bytes} bytes"); - let path = payload["path"].as_str().unwrap(); - let content = std::fs::read_to_string(path).unwrap(); - assert!(content.to_lowercase().contains("example domain")); -} - -#[tokio::test] -async fn execute_rejects_allowlist_miss() { - let tmp = TempDir::new().unwrap(); - let t = tool(&tmp, vec!["example.com"]); - let result = t - .execute(serde_json::json!({"url": "https://other.example.org/x"})) - .await - .unwrap(); - assert!(result.is_error); - assert!(result.output().contains("allowed websites")); -} - -/// The downloads root cannot be created (a plain file already occupies its -/// path) — `create_dir_all` must be reported as a tool error, never a panic, -/// and no network request is attempted before that check runs. The URL uses -/// a public IPv4 literal (never actually contacted — `create_dir_all` fails -/// and returns before any HTTP client is built) so `validate_url_with_dns_check` -/// takes its IP-literal short-circuit and this test needs no live DNS. -#[tokio::test] -async fn execute_reports_a_create_dir_all_failure_rather_than_panicking() { - let tmp = TempDir::new().unwrap(); - std::fs::write(tmp.path().join("downloads"), b"not a directory").unwrap(); - let t = tool(&tmp, vec!["8.8.8.8"]); - let result = t - .execute(serde_json::json!({"url": "https://8.8.8.8/x", "dest_path": "sub/file.txt"})) - .await - .unwrap(); - assert!( - result.is_error, - "an unwritable downloads root must be reported, not panic: {}", - result.output() - ); - assert!( - result - .output() - .contains("Failed to create destination directory"), - "must fail at the create_dir_all step specifically, not e.g. DNS resolution: {}", - result.output() - ); -} - -#[tokio::test] -async fn execute_blocks_readonly_mode() { - let tmp = TempDir::new().unwrap(); - let t = CurlTool::new( - TestNetGate::with(AutonomyLevel::ReadOnly, 100), - vec!["example.com".into()], - tmp.path().into(), - "downloads".into(), - 1024, - 30, - ); - let result = t - .execute(serde_json::json!({"url": "https://example.com/x"})) - .await - .unwrap(); - assert!(result.is_error); - assert!(result.output().contains("read-only")); -} - -#[test] -fn approval_follows_the_gate() { - let tmp = TempDir::new().unwrap(); - let args = serde_json::json!({"url": "https://example.com/x"}); - for (autonomy, prompts) in [ - (AutonomyLevel::Supervised, true), - (AutonomyLevel::Full, false), - ] { - let t = CurlTool::new( - TestNetGate::with(autonomy, 100), - vec!["example.com".into()], - tmp.path().into(), - "downloads".into(), - 1024, - 30, - ); - assert_eq!(t.external_effect_with_args(&args), prompts); - } -} - -#[tokio::test] -async fn a_download_discloses_its_destination_and_headers_before_the_request() { - let tmp = TempDir::new().unwrap(); - let gate = TestNetGate::supervised(); - // A public IPv4 literal short-circuits DNS; `create_dir_all` then fails - // (a file occupies the downloads root), so nothing is ever contacted. - std::fs::write(tmp.path().join("downloads"), b"not a directory").unwrap(); - let t = CurlTool::new( - gate.clone(), - vec!["8.8.8.8".into()], - tmp.path().into(), - "downloads".into(), - 1024, - 30, - ); - let result = t - .execute(serde_json::json!({ - "url": "https://8.8.8.8/x", - "headers": {"Authorization": "Bearer t"} - })) - .await - .unwrap(); - assert!(result.is_error); - assert_eq!(gate.disclosed(), vec![("8.8.8.8".to_string(), false, true)]); -} diff --git a/crates/tinytools-std/src/network/fixtures/curl.json b/crates/tinytools-std/src/network/fixtures/curl.json deleted file mode 100644 index 0cfc5856..00000000 --- a/crates/tinytools-std/src/network/fixtures/curl.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "description": "Download a file from an http(s) URL into the workspace. The body is streamed to disk with a hard byte ceiling. Same allowlist as `http_request`. Returns the saved path, bytes written, content-type, and SHA-256 of the file.", - "exposure": "Deferred", - "name": "curl", - "permission_level": "Write", - "schema": { - "properties": { - "dest_path": { - "description": "Destination path relative to the downloads root inside the workspace. No '..' or absolute paths. If omitted, the filename is inferred from the URL.", - "type": "string" - }, - "headers": { - "default": {}, - "description": "Optional HTTP headers (e.g. {\"Authorization\": \"Bearer …\"})", - "type": "object" - }, - "url": { - "description": "HTTP or HTTPS URL of the file to download", - "type": "string" - } - }, - "required": [ - "url" - ], - "type": "object" - } -} diff --git a/crates/tinytools-std/src/network/fixtures/http_request.json b/crates/tinytools-std/src/network/fixtures/http_request.json deleted file mode 100644 index c09b9e97..00000000 --- a/crates/tinytools-std/src/network/fixtures/http_request.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "description": "Make HTTP requests to external APIs. Supports GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS methods. Security constraints: allowlist-only domains, no local/private hosts, configurable timeout and response size limits.", - "exposure": "Direct", - "name": "http_request", - "permission_level": "Write", - "schema": { - "properties": { - "body": { - "description": "Optional request body (for POST, PUT, PATCH requests)", - "type": "string" - }, - "headers": { - "default": {}, - "description": "Optional HTTP headers as key-value pairs (e.g., {\"Authorization\": \"Bearer token\", \"Content-Type\": \"application/json\"})", - "type": "object" - }, - "method": { - "default": "GET", - "description": "HTTP method (GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS)", - "type": "string" - }, - "url": { - "description": "HTTP or HTTPS URL to request", - "type": "string" - } - }, - "required": [ - "url" - ], - "type": "object" - } -} diff --git a/crates/tinytools-std/src/network/fixtures/pushover.json b/crates/tinytools-std/src/network/fixtures/pushover.json deleted file mode 100644 index 7cea38c0..00000000 --- a/crates/tinytools-std/src/network/fixtures/pushover.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "description": "Send a Pushover notification to your device. Requires PUSHOVER_TOKEN and PUSHOVER_USER_KEY in .env file.", - "exposure": "Direct", - "name": "pushover", - "permission_level": "ReadOnly", - "schema": { - "properties": { - "message": { - "description": "The notification message to send", - "type": "string" - }, - "priority": { - "description": "Message priority: -2 (lowest/silent), -1 (low/no sound), 0 (normal), 1 (high), 2 (emergency/repeating)", - "type": "integer" - }, - "sound": { - "description": "Notification sound override (e.g., 'pushover', 'bike', 'bugle', 'cashregister', etc.)", - "type": "string" - }, - "title": { - "description": "Optional notification title", - "type": "string" - } - }, - "required": [ - "message" - ], - "type": "object" - } -} diff --git a/crates/tinytools-std/src/network/fixtures/web_fetch.json b/crates/tinytools-std/src/network/fixtures/web_fetch.json deleted file mode 100644 index a715af69..00000000 --- a/crates/tinytools-std/src/network/fixtures/web_fetch.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "description": "GET a URL and read the page. HTML returns as Markdown (links kept, scripts dropped); `raw: true` for the body as sent. For POST or custom headers use `http_request`.", - "exposure": "Direct", - "name": "web_fetch", - "permission_level": "ReadOnly", - "schema": { - "properties": { - "max_bytes": { - "description": "Cap the returned text at this many bytes (default 1_000_000). Bounds the OUTPUT — the extracted markdown, or the raw body with raw:true — never the markup the extractor reads, so lowering it cannot cost you content the page actually had.", - "minimum": 1, - "type": "integer" - }, - "raw": { - "description": "Return the body as sent.", - "type": "boolean" - }, - "url": { - "description": "Absolute http(s) URL.", - "type": "string" - } - }, - "required": [ - "url" - ], - "type": "object" - } -} diff --git a/crates/tinytools-std/src/network/gate.rs b/crates/tinytools-std/src/network/gate.rs deleted file mode 100644 index c28cc404..00000000 --- a/crates/tinytools-std/src/network/gate.rs +++ /dev/null @@ -1,223 +0,0 @@ -//! The host's say over every network tool call. -//! -//! The tools in this module reach the internet, but they do not decide whether -//! they are *allowed* to. Whether the agent may act at all, how many actions it -//! has left this hour, whether an outbound request needs a human's approval, -//! whether a privacy mode forbids leaving the device, and how the process-wide -//! proxy is applied to a client all belong to the host whose threat model and -//! configuration they depend on. A tool asks a [`NetGate`] instead. -//! -//! Like [`crate::filesystem::FsGate`], the trait carries no decision *types* of -//! its own, only booleans, strings and the `reqwest` builder the tool was -//! already holding, so a host maps its own policy onto it without translating -//! a vocabulary. - -/// What these tools call themselves on the wire. -/// -/// reqwest sends no `User-Agent` unless told to, and a missing one is not a -/// cosmetic omission. GitHub's REST API refuses the request outright: -/// -/// ```text -/// 403 Request forbidden by administrative rules. -/// Please make sure your request has a User-Agent header -/// ``` -/// -/// Reproducible on demand — the same URL in the same second answers 403 with -/// no header and 200 with one — so every `api.github.com` call through these -/// tools failed, always, and the 403 was then read as a credentials problem. -/// Several other APIs require one too, and anonymous traffic is the first a -/// rate limiter penalises. -/// -/// Identifying rather than disguised: a server that wants to throttle or block -/// this traffic should be able to name it. -pub(super) const USER_AGENT: &str = concat!("tinytools/", env!("CARGO_PKG_VERSION")); - -/// The host policy a network 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. -pub trait NetGate: std::fmt::Debug + Send + Sync { - /// Whether the host permits the agent to act at all right now. - /// - /// `false` blocks the tool before it touches the network. - fn can_act(&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 an outbound request 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 network_needs_approval(&self) -> bool; - - /// The refusal message when the host's privacy mode forbids contacting - /// `host`, or `None` when the request may proceed. - /// - /// Asked before URL validation and DNS, so a refused request leaks nothing, - /// not even a lookup. - fn local_only_block(&self, host: &str) -> Option; - - /// Tell the host that a request is about to leave the device for `host`. - /// - /// Observe-only: called after enforcement has already let the request - /// through. `has_body` is true when a request body rides along and - /// `has_headers` when caller-supplied headers do. - fn disclose(&self, host: &str, has_body: bool, has_headers: bool); - - /// Apply the host's process-wide proxy settings for `service` to a client - /// builder the tool has already configured. - fn prepare_client( - &self, - service: &str, - builder: reqwest::ClientBuilder, - ) -> reqwest::ClientBuilder; - - /// A ready-made client for `service` with the given total and connect - /// timeouts, proxied and TLS-configured the way the host does it. - fn timeout_client( - &self, - service: &str, - timeout_secs: u64, - connect_timeout_secs: u64, - ) -> reqwest::Client; - - /// Let the host authorize the destination of a request and say which socket - /// addresses the connection may use. - /// - /// Called after the lexical checks (scheme, allowlist, literal private - /// host) and before any DNS or connection. The answer decides how the tool - /// proceeds: - /// - /// * `None` (the default): the host takes no part, and the tool resolves and - /// vets the host itself ([`validate_url_with_dns_check`]). - /// * `Some(Ok(addrs))`: the host authorized the destination. The tool - /// connects only to `addrs` (no second DNS lookup) while keeping the URL's - /// host for the `Host` header, SNI and certificate checks. Addresses that - /// are private or local are refused regardless, so a host cannot widen the - /// tool beyond the public internet. - /// * `Some(Err(reason))`: the host refused; the tool returns `reason`. - /// - /// A host that already holds a decision engine for destinations (a security - /// module that resolves, applies an address floor and pins) answers here so - /// there is one URL guard, not one per tool. - /// - /// [`validate_url_with_dns_check`]: crate::url_guard::validate_url_with_dns_check - fn authorize_destination<'a>( - &'a self, - destination: &'a Destination<'a>, - ) -> DestinationFuture<'a> { - let _ = destination; - Box::pin(std::future::ready(None)) - } -} - -/// A request a tool is about to make, as the host sees it when it authorizes -/// the destination. Borrowed from the call; nothing here is retained. -#[derive(Debug, Clone, Copy)] -pub struct Destination<'a> { - /// The URL that passed the lexical checks. - pub url: &'a str, - /// Its host (a name or an IPv4 literal). - pub host: &'a str, - /// Its port (the scheme default when the URL names none). - pub port: u16, - /// HTTP method. - pub method: &'a str, - /// Caller-supplied headers. - pub headers: &'a [(String, String)], - /// Request body, if any. - pub body: Option<&'a str>, -} - -/// The host's answer to [`NetGate::authorize_destination`]; see there. -pub type DestinationFuture<'a> = std::pin::Pin< - Box< - dyn std::future::Future, String>>> - + Send - + 'a, - >, ->; - -/// Lexically validate `raw_url`, then resolve it: through the gate when the -/// host authorizes destinations, otherwise with the shared DNS check. The -/// returned addresses are the only ones the tool may connect to. -pub(super) async fn validate_destination( - gate: &dyn NetGate, - raw_url: &str, - allowed_domains: &[String], - method: &str, - headers: &[(String, String)], - body: Option<&str>, -) -> anyhow::Result { - use crate::url_guard::{ - ValidatedUrl, extract_host, extract_port, is_private_or_local_host, validate_url, - validate_url_with_dns_check, - }; - let url = validate_url(raw_url, allowed_domains)?; - let host = extract_host(&url)?; - let port = extract_port(&url)?; - let destination = Destination { - url: &url, - host: &host, - port, - method, - headers, - body, - }; - match gate.authorize_destination(&destination).await { - None => validate_url_with_dns_check(raw_url, allowed_domains).await, - Some(Err(reason)) => anyhow::bail!("{reason}"), - Some(Ok(addrs)) => { - if addrs.is_empty() { - anyhow::bail!("the host authorized '{host}' without an address"); - } - if let Some(blocked) = addrs - .iter() - .find(|addr| is_private_or_local_host(&addr.ip().to_string())) - { - anyhow::bail!( - "Blocked local/private address {} for '{host}'", - blocked.ip() - ); - } - Ok(ValidatedUrl { url, host, addrs }) - } - } -} - -/// The host of `url`, or `"unknown"` when it does not parse or has none. -/// -/// The value handed to [`NetGate::local_only_block`] and -/// [`NetGate::disclose`]. -pub(crate) fn host_of(url: &str) -> String { - reqwest::Url::parse(url) - .ok() - .and_then(|u| u.host_str().map(str::to_string)) - .unwrap_or_else(|| "unknown".to_string()) -} - -/// Response-size and timeout limits a tool falls back to when it is -/// constructed with a `0` (or absent) value. -/// -/// A `0` byte cap truncates every body to nothing and a `0` second timeout -/// fails every request instantly, so neither is ever a meaningful limit; it -/// only shows up when a stale configuration slips through. The host owns what -/// the sensible default is and passes it in. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct HttpLimits { - /// Response-body cap, in bytes. - pub max_response_size: usize, - /// Whole-request timeout, in seconds. - pub timeout_secs: u64, -} - -#[cfg(test)] -#[path = "gate_tests.rs"] -mod tests; diff --git a/crates/tinytools-std/src/network/gate_tests.rs b/crates/tinytools-std/src/network/gate_tests.rs deleted file mode 100644 index 1ac7a718..00000000 --- a/crates/tinytools-std/src/network/gate_tests.rs +++ /dev/null @@ -1,183 +0,0 @@ -use super::*; -use std::net::SocketAddr; -use std::sync::Mutex; - -/// What the gate was asked: url, host, port, method, headers, body. -type Asked = ( - String, - String, - u16, - String, - Vec<(String, String)>, - Option, -); - -/// A gate that answers `authorize_destination` with a canned result and keeps -/// what it was asked. -#[derive(Debug)] -struct Authorizing { - answer: Option, String>>, - seen: Mutex>, -} - -impl Authorizing { - fn new(answer: Option, String>>) -> Self { - Self { - answer, - seen: Mutex::new(Vec::new()), - } - } -} - -impl NetGate for Authorizing { - fn can_act(&self) -> bool { - true - } - fn is_rate_limited(&self) -> bool { - false - } - fn record_action(&self) -> bool { - true - } - fn network_needs_approval(&self) -> bool { - false - } - fn local_only_block(&self, _host: &str) -> Option { - None - } - fn disclose(&self, _host: &str, _has_body: bool, _has_headers: bool) {} - fn prepare_client( - &self, - _service: &str, - builder: reqwest::ClientBuilder, - ) -> reqwest::ClientBuilder { - builder - } - fn timeout_client( - &self, - _service: &str, - _timeout_secs: u64, - _connect_timeout_secs: u64, - ) -> reqwest::Client { - reqwest::Client::new() - } - fn authorize_destination<'a>( - &'a self, - destination: &'a Destination<'a>, - ) -> DestinationFuture<'a> { - if let Ok(mut seen) = self.seen.lock() { - seen.push(( - destination.url.to_string(), - destination.host.to_string(), - destination.port, - destination.method.to_string(), - destination.headers.to_vec(), - destination.body.map(str::to_string), - )); - } - Box::pin(std::future::ready(self.answer.clone())) - } -} - -fn public() -> SocketAddr { - "93.184.216.34:443".parse().unwrap() -} - -#[tokio::test] -async fn a_gate_that_takes_no_part_leaves_the_tools_own_check_in_force() { - // The trait default is `None`, so the shared DNS check runs; an IP literal - // needs no lookup and is returned as the single pinned address. - let gate = Authorizing::new(None); - let target = validate_destination(&gate, "http://93.184.216.34/x", &[], "GET", &[], None) - .await - .unwrap(); - assert_eq!(target.addrs, vec!["93.184.216.34:80".parse().unwrap()]); - let blocked = validate_destination(&gate, "http://169.254.169.254/", &[], "GET", &[], None) - .await - .unwrap_err(); - assert!(blocked.to_string().contains("Blocked"), "{blocked}"); -} - -#[tokio::test] -async fn an_authorizing_gate_supplies_the_addresses_and_sees_the_request() { - let gate = Authorizing::new(Some(Ok(vec![public()]))); - let headers = vec![("X-A".to_string(), "1".to_string())]; - let target = validate_destination( - &gate, - "https://example.test/path?q=1", - &[], - "POST", - &headers, - Some("payload"), - ) - .await - .unwrap(); - assert_eq!(target.url, "https://example.test/path?q=1"); - assert_eq!(target.host, "example.test"); - assert_eq!(target.addrs, vec![public()]); - let seen = gate.seen.lock().unwrap(); - assert_eq!( - seen[0], - ( - "https://example.test/path?q=1".to_string(), - "example.test".to_string(), - 443, - "POST".to_string(), - headers, - Some("payload".to_string()) - ) - ); -} - -#[tokio::test] -async fn the_gates_refusal_reason_reaches_the_caller() { - let gate = Authorizing::new(Some(Err("egress denied: Privacy".to_string()))); - let error = validate_destination(&gate, "https://example.test/", &[], "GET", &[], None) - .await - .unwrap_err(); - assert_eq!(error.to_string(), "egress denied: Privacy"); -} - -#[tokio::test] -async fn lexical_checks_run_before_the_gate_is_asked() { - let gate = Authorizing::new(Some(Ok(vec![public()]))); - for url in [ - "ftp://example.test/", - "http://127.0.0.1/", - "http://localhost/", - ] { - assert!( - validate_destination(&gate, url, &[], "GET", &[], None) - .await - .is_err(), - "{url}" - ); - } - let allowed = vec!["allowed.test".to_string()]; - assert!( - validate_destination(&gate, "https://other.test/", &allowed, "GET", &[], None) - .await - .is_err() - ); - assert!( - gate.seen.lock().unwrap().is_empty(), - "the gate must not see a URL the tool already refused" - ); -} - -#[tokio::test] -async fn a_gate_cannot_widen_the_tool_to_private_or_empty_answers() { - for bad in [ - vec!["127.0.0.1:443".parse().unwrap()], - vec!["10.0.0.5:443".parse().unwrap()], - vec![public(), "169.254.169.254:443".parse().unwrap()], - vec![], - ] { - let gate = Authorizing::new(Some(Ok(bad))); - assert!( - validate_destination(&gate, "https://example.test/", &[], "GET", &[], None) - .await - .is_err() - ); - } -} diff --git a/crates/tinytools-std/src/network/http_request.rs b/crates/tinytools-std/src/network/http_request.rs deleted file mode 100644 index 85e9d6ff..00000000 --- a/crates/tinytools-std/src/network/http_request.rs +++ /dev/null @@ -1,483 +0,0 @@ -use super::gate::{HttpLimits, NetGate, host_of}; -use async_trait::async_trait; -use serde_json::json; -use std::sync::Arc; -use std::time::Duration; -use tinytools::{PermissionLevel, Tool, ToolResult}; - -use crate::url_guard::{ValidatedUrl, normalize_allowed_domains}; - -use super::gate::validate_destination; - -/// What a [`PaymentHook`] hands back to retry a `402 Payment Required`. -pub struct PaymentAttempt { - /// Extra request headers that carry the payment (added to the original set). - pub headers: Vec<(String, String)>, - /// Called once with how the retried request came out, so the host can - /// settle whatever ledger entry it opened when it produced `headers`. - pub settle: Box, -} - -impl std::fmt::Debug for PaymentAttempt { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("PaymentAttempt") - .field("headers", &self.headers.len()) - .finish_non_exhaustive() - } -} - -/// How the request retried with payment headers came out. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct PaymentOutcome { - /// HTTP status of the retried response. - pub status: u16, - /// Whether that status is a success. - pub success: bool, - /// The retried response's `PAYMENT-RESPONSE` header, verbatim, when it had one. - pub payment_response: Option, -} - -/// A host's answer to a `402 Payment Required` that names a payment scheme. -/// -/// The tool sees the 402, asks the hook to pay, and retries once with the -/// headers it returns. What paying means (a wallet, a ledger, a budget) is -/// entirely the host's. -#[async_trait] -pub trait PaymentHook: std::fmt::Debug + Send + Sync { - /// Produce the payment headers for a 402 from `url`. - /// - /// `response_headers` are the 402 response's headers, which carry the - /// payment requirements. - /// - /// # Errors - /// - /// The message returned to the model as the tool error. - async fn pay( - &self, - url: &str, - response_headers: &reqwest::header::HeaderMap, - ) -> Result; -} - -/// HTTP request tool for API interactions. -/// Supports GET, POST, PUT, DELETE methods with configurable security. -#[derive(Debug)] -pub struct HttpRequestTool { - gate: Arc, - allowed_domains: Vec, - max_response_size: usize, - timeout_secs: u64, - payment: Option>, -} - -impl HttpRequestTool { - /// An `http_request` tool over `gate`. - /// - /// A `0` `max_response_size` or `timeout_secs` falls back to `defaults`. - pub fn new( - gate: Arc, - allowed_domains: Vec, - max_response_size: usize, - timeout_secs: u64, - defaults: HttpLimits, - ) -> Self { - // Treat `0` as "use default": a 0-byte cap or 0-second timeout is never - // a meaningful limit, only a footgun (see migration 5→6). Pull the - // fallbacks from `HttpRequestConfig::default()` so the tool, the schema - // default, and the migration share one source and can't drift. A `0` - // here means a stale/invalid config slipped past the migration, so - // surface it with a stable, grep-friendly, non-sensitive log line. - let max_response_size = if max_response_size == 0 { - log::warn!( - "[tool.http_request] coercing invalid limit field=max_response_size \ - from=0 to={} (stale/invalid config — see migration 5→6)", - defaults.max_response_size - ); - defaults.max_response_size - } else { - max_response_size - }; - let timeout_secs = if timeout_secs == 0 { - log::warn!( - "[tool.http_request] coercing invalid limit field=timeout_secs \ - from=0 to={} (stale/invalid config — see migration 5→6)", - defaults.timeout_secs - ); - defaults.timeout_secs - } else { - timeout_secs - }; - Self { - gate, - allowed_domains: normalize_allowed_domains(allowed_domains), - max_response_size, - timeout_secs, - payment: None, - } - } - - /// Answer a `402 Payment Required` through `hook` instead of returning it unpaid. - #[must_use] - pub fn with_payment_hook(mut self, hook: Arc) -> Self { - self.payment = Some(hook); - self - } - - async fn validate_url( - &self, - raw_url: &str, - method: &str, - headers: &[(String, String)], - body: Option<&str>, - ) -> anyhow::Result { - validate_destination( - &*self.gate, - raw_url, - &self.allowed_domains, - method, - headers, - body, - ) - .await - } - - fn validate_method(&self, method: &str) -> anyhow::Result { - match method.to_uppercase().as_str() { - "GET" => Ok(reqwest::Method::GET), - "POST" => Ok(reqwest::Method::POST), - "PUT" => Ok(reqwest::Method::PUT), - "DELETE" => Ok(reqwest::Method::DELETE), - "PATCH" => Ok(reqwest::Method::PATCH), - "HEAD" => Ok(reqwest::Method::HEAD), - "OPTIONS" => Ok(reqwest::Method::OPTIONS), - _ => anyhow::bail!( - "Unsupported HTTP method: {method}. Supported: GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS" - ), - } - } - - fn parse_headers(&self, headers: &serde_json::Value) -> Vec<(String, String)> { - let mut result = Vec::new(); - if let Some(obj) = headers.as_object() { - for (key, value) in obj { - if let Some(str_val) = value.as_str() { - result.push((key.clone(), str_val.to_string())); - } - } - } - result - } - - #[allow(dead_code)] - fn redact_headers_for_display(headers: &[(String, String)]) -> Vec<(String, String)> { - headers - .iter() - .map(|(key, value)| { - let lower = key.to_lowercase(); - let is_sensitive = lower.contains("authorization") - || lower.contains("api-key") - || lower.contains("apikey") - || lower.contains("token") - || lower.contains("secret"); - if is_sensitive { - (key.clone(), "***REDACTED***".into()) - } else { - (key.clone(), value.clone()) - } - }) - .collect() - } - - fn is_safe_response_header(name: &str) -> bool { - matches!( - name.to_ascii_lowercase().as_str(), - "content-type" - | "content-length" - | "retry-after" - | "x-ratelimit-limit" - | "x-ratelimit-remaining" - | "x-ratelimit-reset" - ) - } - - async fn execute_request( - &self, - target: &ValidatedUrl, - method: reqwest::Method, - headers: Vec<(String, String)>, - body: Option<&str>, - ) -> anyhow::Result { - let builder = reqwest::Client::builder() - .timeout(Duration::from_secs(self.timeout_secs)) - .connect_timeout(Duration::from_secs(10)) - .user_agent(super::gate::USER_AGENT) - .redirect(reqwest::redirect::Policy::none()); - let builder = self.gate.prepare_client("tool.http_request", builder); - // Connect only to the vetted addresses; the URL keeps its host name so - // `Host`, SNI and certificate checks are unchanged. - let builder = builder.resolve_to_addrs(&target.host, &target.addrs); - let client = builder.build()?; - - let mut request = client.request(method, &target.url); - - for (key, value) in headers { - request = request.header(&key, &value); - } - - if let Some(body_str) = body { - request = request.body(body_str.to_string()); - } - - Ok(request.send().await?) - } - - /// Retry a `402 Payment Required` once with the headers `hook` produced. - async fn handle_payment_required( - &self, - hook: &Arc, - initial_response: reqwest::Response, - target: &ValidatedUrl, - method: reqwest::Method, - headers: Vec<(String, String)>, - body: Option<&str>, - ) -> Result { - log::debug!( - "[tool.http_request] 402 received with PAYMENT-REQUIRED, attempting x402 payment for {}", - target.url - ); - - let attempt = hook.pay(&target.url, initial_response.headers()).await?; - - let mut retry_headers = headers; - retry_headers.extend(attempt.headers); - - let response = self - .execute_request(target, method, retry_headers, body) - .await - .map_err(|e| format!("x402 retry request failed: {e}"))?; - - (attempt.settle)(PaymentOutcome { - status: response.status().as_u16(), - success: response.status().is_success(), - payment_response: response - .headers() - .get("PAYMENT-RESPONSE") - .and_then(|v| v.to_str().ok()) - .map(str::to_string), - }); - - Ok(response) - } - - async fn format_response(&self, response: reqwest::Response) -> anyhow::Result { - let status = response.status(); - let status_code = status.as_u16(); - - // Name *and* value. This printed the name twice — `{}: {:?}` over - // `(k.as_str(), k.as_str())` — so every line read - // `x-ratelimit-remaining: "x-ratelimit-remaining"` and the header block - // carried no information at all. The redaction beside it only makes - // sense if values were meant to be shown, which is the reading taken - // here: rate-limit and retry-after headers are exactly what a caller - // needs on the failures this block renders. - let headers_text = response - .headers() - .iter() - .map(|(name, value)| { - // Response headers are untrusted and service-specific headers - // can carry credentials under arbitrary names. Only expose - // values from this small set of useful diagnostic headers. - if Self::is_safe_response_header(name.as_str()) { - format!( - "{}: {}", - name.as_str(), - value.to_str().unwrap_or("") - ) - } else { - format!("{}: ***REDACTED***", name.as_str()) - } - }) - .collect::>() - .join(", "); - - let response_text = match response.text().await { - Ok(text) => self.truncate_response(&text), - Err(e) => format!("[Failed to read response body: {e}]"), - }; - - let output = format!( - "Status: {} {}\nResponse Headers: {}\n\nResponse Body:\n{}", - status_code, - status.canonical_reason().unwrap_or("Unknown"), - headers_text, - response_text - ); - - if status.is_success() { - Ok(ToolResult::success(output)) - } else { - // The same `output` on both arms. This returned the bare string - // `HTTP 403` — twenty characters — having already built the status - // line, headers and body and then dropped them, so the one part of - // the response that said *why* never reached the caller. A real - // case: GitHub's 403 names the missing `User-Agent` header outright, - // with a documentation link, and all of it was discarded. - Ok(ToolResult::error(output)) - } - } - - fn truncate_response(&self, text: &str) -> String { - if text.len() > self.max_response_size { - let mut truncated = text - .chars() - .take(self.max_response_size) - .collect::(); - truncated.push_str("\n\n... [Response truncated due to size limit] ..."); - truncated - } else { - text.to_string() - } - } -} - -#[async_trait] -impl Tool for HttpRequestTool { - fn name(&self) -> &'static str { - "http_request" - } - - fn description(&self) -> &'static str { - "Make HTTP requests to external APIs. Supports GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS methods. \ - Security constraints: allowlist-only domains, no local/private hosts, configurable timeout and response size limits." - } - - fn parameters_schema(&self) -> serde_json::Value { - json!({ - "type": "object", - "properties": { - "url": { - "type": "string", - "description": "HTTP or HTTPS URL to request" - }, - "method": { - "type": "string", - "description": "HTTP method (GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS)", - "default": "GET" - }, - "headers": { - "type": "object", - "description": "Optional HTTP headers as key-value pairs (e.g., {\"Authorization\": \"Bearer token\", \"Content-Type\": \"application/json\"})", - "default": {} - }, - "body": { - "type": "string", - "description": "Optional request body (for POST, PUT, PATCH requests)" - } - }, - "required": ["url"] - }) - } - - /// Rich HTTP semantics (methods, headers, request bodies, and x402 retry) - /// are the same Network-class risk as `curl`: read-only autonomy is blocked - /// in `execute`, and supervised/full tiers route through ApprovalGate. - fn external_effect_with_args(&self, _args: &serde_json::Value) -> bool { - self.gate.network_needs_approval() - } - - fn permission_level(&self) -> PermissionLevel { - PermissionLevel::Write - } - - async fn execute(&self, args: serde_json::Value) -> anyhow::Result { - let url = args - .get("url") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("Missing 'url' parameter"))?; - - let method_str = args.get("method").and_then(|v| v.as_str()).unwrap_or("GET"); - let headers_val = args.get("headers").cloned().unwrap_or(json!({})); - let body = args.get("body").and_then(|v| v.as_str()); - - if !self.gate.can_act() { - return Ok(ToolResult::error( - "[policy-blocked] Action blocked: autonomy is read-only", - )); - } - - if !self.gate.record_action() { - return Ok(ToolResult::error("Action blocked: rate limit exceeded")); - } - - // Local-only enforcement (privacy epic S7, #4441): mirror the read-only - // `can_act()` deny above — under LocalOnly, refuse the outbound request - // before URL validation / DNS so nothing (not even a DNS lookup for the - // host) leaves the device. The post-validation `emit_external_transfer` - // below stays the S2 disclosure point for permitted requests. - { - let host = host_of(url); - if let Some(msg) = self.gate.local_only_block(&host) { - return Ok(ToolResult::error(msg)); - } - } - - let request_headers = self.parse_headers(&headers_val); - let target = match self - .validate_url(url, method_str, &request_headers, body) - .await - { - Ok(v) => v, - Err(e) => return Ok(ToolResult::error(e.to_string())), - }; - let url = target.url.clone(); - - // Egress spine (privacy epic S2, #4436): an agent-driven HTTP request to - // an allowlisted host leaves the device — disclose the destination and - // everything that rides with it (body + custom headers) before the - // round-trip. - { - let has_headers = headers_val.as_object().is_some_and(|h| !h.is_empty()); - self.gate - .disclose(&host_of(&url), body.is_some(), has_headers); - } - - let method = match self.validate_method(method_str) { - Ok(m) => m, - Err(e) => return Ok(ToolResult::error(e.to_string())), - }; - - let response = match self - .execute_request(&target, method.clone(), request_headers.clone(), body) - .await - { - Ok(r) => r, - Err(e) => return Ok(ToolResult::error(format!("HTTP request failed: {e}"))), - }; - - // Payment-required: if the server returns 402 with a PAYMENT-REQUIRED - // header and the host installed a hook, attempt to pay and retry. With - // no hook a 402 passes through unpaid. - let response = match &self.payment { - Some(hook) - if response.status() == reqwest::StatusCode::PAYMENT_REQUIRED - && (response.headers().get("PAYMENT-REQUIRED").is_some() - || response.headers().get("X-PAYMENT-REQUIRED").is_some()) => - { - match self - .handle_payment_required(hook, response, &target, method, request_headers, body) - .await - { - Ok(paid_response) => paid_response, - Err(msg) => return Ok(ToolResult::error(msg)), - } - } - _ => response, - }; - - self.format_response(response).await - } -} - -#[cfg(test)] -#[path = "http_request_tests.rs"] -mod tests; diff --git a/crates/tinytools-std/src/network/http_request_tests.rs b/crates/tinytools-std/src/network/http_request_tests.rs deleted file mode 100644 index d1196a61..00000000 --- a/crates/tinytools-std/src/network/http_request_tests.rs +++ /dev/null @@ -1,670 +0,0 @@ -use super::*; -use crate::network::test_support::{AutonomyLevel, DEFAULT_LIMITS, TestNetGate, pinned_to_local}; - -fn test_tool(allowed_domains: Vec<&str>) -> HttpRequestTool { - let security = TestNetGate::supervised(); - HttpRequestTool::new( - security, - allowed_domains.into_iter().map(String::from).collect(), - 1_000_000, - 30, - DEFAULT_LIMITS, - ) -} - -#[test] -fn zero_limits_fall_back_to_defaults() { - // Stale configs (or a bad write) can pass 0; a 0-second timeout fails - // every request and a 0-byte cap truncates every body. The constructor - // must coerce both to the module defaults — never let 0 reach reqwest. - let security = TestNetGate::supervised(); - let tool = HttpRequestTool::new( - security, - vec!["example.com".to_string()], - 0, - 0, - DEFAULT_LIMITS, - ); - let defaults = DEFAULT_LIMITS; - assert_eq!(tool.max_response_size, defaults.max_response_size); - assert_eq!(tool.timeout_secs, defaults.timeout_secs); - assert_ne!(tool.timeout_secs, 0); - assert_ne!(tool.max_response_size, 0); -} - -#[test] -fn nonzero_limits_are_preserved() { - let security = TestNetGate::supervised(); - let tool = HttpRequestTool::new( - security, - vec!["example.com".to_string()], - 2048, - 12, - DEFAULT_LIMITS, - ); - assert_eq!(tool.max_response_size, 2048); - assert_eq!(tool.timeout_secs, 12); -} - -#[test] -fn validate_accepts_valid_methods() { - let tool = test_tool(vec!["example.com"]); - assert!(tool.validate_method("GET").is_ok()); - assert!(tool.validate_method("POST").is_ok()); - assert!(tool.validate_method("PUT").is_ok()); - assert!(tool.validate_method("DELETE").is_ok()); - assert!(tool.validate_method("PATCH").is_ok()); - assert!(tool.validate_method("HEAD").is_ok()); - assert!(tool.validate_method("OPTIONS").is_ok()); -} - -#[test] -fn validate_rejects_invalid_method() { - let tool = test_tool(vec!["example.com"]); - let err = tool.validate_method("INVALID").unwrap_err().to_string(); - assert!(err.contains("Unsupported HTTP method")); -} - -#[tokio::test] -async fn validate_url_rejects_disallowed_domain() { - let tool = test_tool(vec!["example.com"]); - let err = tool - .validate_url("https://evil.test/path", "GET", &[], None) - .await - .unwrap_err() - .to_string(); - assert!(err.contains("allowed websites")); -} - -#[tokio::test] -async fn execute_blocks_readonly_mode() { - let security = TestNetGate::with(AutonomyLevel::ReadOnly, 100); - let tool = HttpRequestTool::new( - security, - vec!["example.com".into()], - 1_000_000, - 30, - DEFAULT_LIMITS, - ); - let result = tool - .execute(json!({"url": "https://example.com"})) - .await - .unwrap(); - assert!(result.is_error); - assert!(result.output().contains("read-only")); -} - -#[tokio::test] -async fn execute_blocks_when_rate_limited() { - let security = TestNetGate::with(AutonomyLevel::Supervised, 0); - let tool = HttpRequestTool::new( - security, - vec!["example.com".into()], - 1_000_000, - 30, - DEFAULT_LIMITS, - ); - let result = tool - .execute(json!({"url": "https://example.com"})) - .await - .unwrap(); - assert!(result.is_error); - assert!(result.output().contains("rate limit")); -} - -#[tokio::test] -async fn execute_blocked_under_local_only_privacy_mode() { - // Privacy epic S7 (#4441): under LocalOnly the request is refused with a - // `[policy-blocked]` result before URL validation / network. - let tool = HttpRequestTool::new( - TestNetGate::local_only(), - vec!["example.com".into()], - 1_000_000, - 30, - DEFAULT_LIMITS, - ); - let result = tool - .execute(json!({"url": "https://example.com"})) - .await - .unwrap(); - assert!(result.is_error); - assert!( - result.output().contains("[policy-blocked]"), - "got: {}", - result.output() - ); - assert!( - result.output().contains("Local-only"), - "got: {}", - result.output() - ); -} - -#[test] -fn truncate_response_within_limit() { - let tool = test_tool(vec!["example.com"]); - let text = "hello world"; - assert_eq!(tool.truncate_response(text), "hello world"); -} - -#[test] -fn truncate_response_over_limit() { - let tool = HttpRequestTool::new( - TestNetGate::supervised(), - vec!["example.com".into()], - 10, - 30, - DEFAULT_LIMITS, - ); - let text = "hello world this is long"; - let truncated = tool.truncate_response(text); - assert!(truncated.len() <= 10 + 60); - assert!(truncated.contains("[Response truncated")); -} - -#[test] -fn parse_headers_preserves_original_values() { - let tool = test_tool(vec!["example.com"]); - let headers = json!({ - "Authorization": "Bearer secret", - "Content-Type": "application/json", - "X-API-Key": "my-key" - }); - let parsed = tool.parse_headers(&headers); - assert_eq!(parsed.len(), 3); - assert!( - parsed - .iter() - .any(|(k, v)| k == "Authorization" && v == "Bearer secret") - ); - assert!( - parsed - .iter() - .any(|(k, v)| k == "X-API-Key" && v == "my-key") - ); - assert!( - parsed - .iter() - .any(|(k, v)| k == "Content-Type" && v == "application/json") - ); -} - -#[test] -fn redact_headers_for_display_redacts_sensitive() { - let headers = vec![ - ("Authorization".into(), "Bearer secret".into()), - ("Content-Type".into(), "application/json".into()), - ("X-API-Key".into(), "my-key".into()), - ("X-Secret-Token".into(), "tok-123".into()), - ]; - let redacted = HttpRequestTool::redact_headers_for_display(&headers); - assert_eq!(redacted.len(), 4); - assert!( - redacted - .iter() - .any(|(k, v)| k == "Authorization" && v == "***REDACTED***") - ); - assert!( - redacted - .iter() - .any(|(k, v)| k == "X-API-Key" && v == "***REDACTED***") - ); - assert!( - redacted - .iter() - .any(|(k, v)| k == "X-Secret-Token" && v == "***REDACTED***") - ); - assert!( - redacted - .iter() - .any(|(k, v)| k == "Content-Type" && v == "application/json") - ); -} - -#[test] -fn redact_headers_does_not_alter_original() { - let headers = vec![("Authorization".into(), "Bearer real-token".into())]; - let _ = HttpRequestTool::redact_headers_for_display(&headers); - assert_eq!(headers[0].1, "Bearer real-token"); -} - -#[test] -fn only_explicitly_safe_response_header_values_are_displayed() { - for safe in [ - "content-type", - "content-length", - "retry-after", - "x-ratelimit-remaining", - ] { - assert!(HttpRequestTool::is_safe_response_header(safe), "{safe}"); - assert!(HttpRequestTool::is_safe_response_header( - &safe.to_ascii_uppercase() - )); - } - - for sensitive in [ - "set-cookie", - "www-authenticate", - "proxy-authenticate", - "authentication-info", - "x-api-key", - "x-service-token", - ] { - assert!( - !HttpRequestTool::is_safe_response_header(sensitive), - "{sensitive} must be redacted" - ); - } -} - -#[test] -fn redirect_policy_is_none() { - let tool = test_tool(vec!["example.com"]); - assert_eq!(tool.name(), "http_request"); -} - -#[test] -fn supervised_http_request_is_external_effect_for_approval_gate() { - let tool = test_tool(vec!["example.com"]); - assert_eq!(tool.permission_level(), PermissionLevel::Write); - assert!(tool.external_effect_with_args(&json!({ - "url": "https://example.com/api", - "method": "POST", - "headers": { "Authorization": "Bearer token" }, - "body": "{}" - }))); -} - -#[test] -fn readonly_http_request_is_not_external_effect_because_execute_blocks() { - let security = TestNetGate::with(AutonomyLevel::ReadOnly, 100); - let tool = HttpRequestTool::new( - security, - vec!["example.com".into()], - 1_000_000, - 30, - DEFAULT_LIMITS, - ); - assert!(!tool.external_effect_with_args(&json!({ - "url": "https://example.com/api", - "method": "GET" - }))); -} - -#[tokio::test] -async fn a_request_discloses_body_and_header_presence_to_the_gate() { - // A public IPv4 literal skips DNS; the disallowed method fails after - // disclosure, so nothing is ever contacted. - let gate = TestNetGate::supervised(); - let tool = HttpRequestTool::new( - gate.clone(), - vec!["8.8.8.8".into()], - 1_000_000, - 30, - DEFAULT_LIMITS, - ); - let result = tool - .execute(json!({ - "url": "https://8.8.8.8/x", - "method": "TRACE", - "headers": {"Authorization": "Bearer t"}, - "body": "{}" - })) - .await - .unwrap(); - assert!(result.is_error); - assert!(result.output().contains("Unsupported HTTP method")); - assert_eq!(gate.disclosed(), vec![("8.8.8.8".to_string(), true, true)]); -} - -#[tokio::test] -async fn a_bare_request_discloses_neither_body_nor_headers() { - let gate = TestNetGate::supervised(); - let tool = HttpRequestTool::new( - gate.clone(), - vec!["8.8.8.8".into()], - 1_000_000, - 30, - DEFAULT_LIMITS, - ); - let _ = tool - .execute(json!({"url": "https://8.8.8.8/x", "method": "TRACE"})) - .await - .unwrap(); - assert_eq!( - gate.disclosed(), - vec![("8.8.8.8".to_string(), false, false)] - ); -} - -#[test] -fn the_host_of_an_unparseable_url_is_unknown() { - assert_eq!(super::super::gate::host_of("not a url"), "unknown"); - assert_eq!( - super::super::gate::host_of("https://api.example.com/v1"), - "api.example.com" - ); -} - -/// Serves one canned HTTP response per accepted connection, in order, and -/// records each request's raw head. Returns the bound address. -async fn serve( - responses: Vec, -) -> (std::net::SocketAddr, Arc>>) { - use tokio::io::{AsyncReadExt, AsyncWriteExt}; - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - let seen = Arc::new(std::sync::Mutex::new(Vec::new())); - let log = Arc::clone(&seen); - tokio::spawn(async move { - for response in responses { - let (mut socket, _) = listener.accept().await.unwrap(); - let mut buf = vec![0u8; 8192]; - let n = socket.read(&mut buf).await.unwrap(); - log.lock() - .unwrap() - .push(String::from_utf8_lossy(&buf[..n]).to_string()); - socket.write_all(response.as_bytes()).await.unwrap(); - let _ = socket.shutdown().await; - } - }); - (addr, seen) -} - -#[derive(Debug)] -struct RecordingHook { - settled: Arc>>, - fail: bool, -} - -#[async_trait] -impl PaymentHook for RecordingHook { - async fn pay( - &self, - _url: &str, - response_headers: &reqwest::header::HeaderMap, - ) -> Result { - if self.fail { - return Err("x402 payment failed: no wallet".into()); - } - assert!(response_headers.get("PAYMENT-REQUIRED").is_some()); - let settled = Arc::clone(&self.settled); - Ok(PaymentAttempt { - headers: vec![("PAYMENT-SIGNATURE".into(), "sig".into())], - settle: Box::new(move |outcome| settled.lock().unwrap().push(outcome)), - }) - } -} - -const PAYMENT_REQUIRED: &str = "HTTP/1.1 402 Payment Required\r\nPAYMENT-REQUIRED: abc\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"; - -// Loopback is refused by the SSRF guard, so these drive the private request -// path directly rather than `execute`. -#[tokio::test] -async fn a_402_is_retried_once_with_the_hooks_headers_and_settled() { - let ok = "HTTP/1.1 200 OK\r\nPAYMENT-RESPONSE: resp\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok"; - let (addr, seen) = serve(vec![PAYMENT_REQUIRED.to_string(), ok.to_string()]).await; - let settled = Arc::new(std::sync::Mutex::new(Vec::new())); - let hook: Arc = Arc::new(RecordingHook { - settled: Arc::clone(&settled), - fail: false, - }); - let tool = test_tool(vec![]); - let url = format!("http://{addr}/paid"); - let target = pinned_to_local(&url); - let first = tool - .execute_request( - &target, - reqwest::Method::GET, - vec![("X-A".into(), "1".into())], - None, - ) - .await - .unwrap(); - assert_eq!(first.status(), reqwest::StatusCode::PAYMENT_REQUIRED); - - let paid = tool - .handle_payment_required( - &hook, - first, - &target, - reqwest::Method::GET, - vec![("X-A".into(), "1".into())], - None, - ) - .await - .unwrap(); - assert!(paid.status().is_success()); - - let requests = seen.lock().unwrap().clone(); - assert_eq!(requests.len(), 2); - assert!( - !requests[0] - .to_ascii_lowercase() - .contains("payment-signature") - ); - assert!( - requests[1] - .to_ascii_lowercase() - .contains("payment-signature: sig") - ); - assert!(requests[1].to_ascii_lowercase().contains("x-a: 1")); - assert_eq!( - *settled.lock().unwrap(), - vec![PaymentOutcome { - status: 200, - success: true, - payment_response: Some("resp".into()), - }] - ); -} - -#[tokio::test] -async fn a_hook_failure_is_returned_as_the_error_without_a_retry() { - let (addr, seen) = serve(vec![PAYMENT_REQUIRED.to_string()]).await; - let hook: Arc = Arc::new(RecordingHook { - settled: Arc::default(), - fail: true, - }); - let tool = test_tool(vec![]); - let url = format!("http://{addr}/paid"); - let target = pinned_to_local(&url); - let first = tool - .execute_request(&target, reqwest::Method::GET, vec![], None) - .await - .unwrap(); - let err = tool - .handle_payment_required(&hook, first, &target, reqwest::Method::GET, vec![], None) - .await - .unwrap_err(); - assert_eq!(err, "x402 payment failed: no wallet"); - assert_eq!(seen.lock().unwrap().len(), 1); -} - -#[tokio::test] -async fn a_body_is_sent_and_a_success_response_is_formatted_with_redacted_cookies() { - let ok = "HTTP/1.1 200 OK\r\nSet-Cookie: session=abc\r\nWWW-Authenticate: Bearer secret-challenge\r\nX-Api-Key: api-secret\r\nX-Service-Token: service-secret\r\nX-Other: 1\r\nContent-Length: 5\r\nConnection: close\r\n\r\nhello"; - let (addr, seen) = serve(vec![ok.to_string()]).await; - let tool = test_tool(vec![]); - let response = tool - .execute_request( - &pinned_to_local(&format!("http://{addr}/post")), - reqwest::Method::POST, - vec![], - Some("payload"), - ) - .await - .unwrap(); - let result = tool.format_response(response).await.unwrap(); - assert!(!result.is_error); - let text = result.text(); - assert!(text.contains("hello"), "{text}"); - assert!(text.contains("set-cookie: ***REDACTED***"), "{text}"); - assert!(!text.contains("session=abc"), "{text}"); - for secret in ["secret-challenge", "api-secret", "service-secret"] { - assert!(!text.contains(secret), "response leaked {secret}: {text}"); - } - for header in ["www-authenticate", "x-api-key", "x-service-token"] { - assert!( - text.to_ascii_lowercase() - .contains(&format!("{header}: ***redacted***")), - "{text}" - ); - } - assert!(seen.lock().unwrap()[0].ends_with("payload")); -} - -#[tokio::test] -async fn a_non_success_response_is_reported_as_an_error() { - // The failure now carries the whole response rather than just its code. - // - // It used to answer `HTTP 500` —, and in a measured case `HTTP 403`, - // twenty characters, after building the status line, headers and body and - // then dropping them. The discarded body was the only thing that said what - // was wrong: GitHub's 403 names the missing `User-Agent` header outright. - let body = "Request forbidden by administrative rules. \ - Please make sure your request has a User-Agent header"; - let (addr, _) = serve(vec![format!( - "HTTP/1.1 403 Forbidden\r\nContent-Length: {}\r\nX-RateLimit-Remaining: 59\r\nConnection: close\r\n\r\n{}", - body.len(), - body - )]) - .await; - let tool = test_tool(vec![]); - let response = tool - .execute_request( - &pinned_to_local(&format!("http://{addr}/")), - reqwest::Method::GET, - vec![], - None, - ) - .await - .unwrap(); - let result = tool.format_response(response).await.unwrap(); - assert!(result.is_error, "a 403 is still a failure"); - let text = result.text(); - // Anchored: a caller classifying the failure reads the status from the head - // of the text, and an unreadable one gets guessed at. - assert!(text.starts_with("Status: 403"), "{text}"); - // The reason the server gave. - assert!(text.contains("User-Agent header"), "{text}"); - // Header *values*, not the name printed twice. `x-ratelimit-remaining` is - // exactly what a caller wants on the failures this block renders. - let lower = text.to_ascii_lowercase(); - assert!(lower.contains("x-ratelimit-remaining: 59"), "{text}"); -} - -#[tokio::test] -async fn an_unreadable_body_is_reported_inline() { - // Promise more bytes than are sent, then close: reading the body fails. - let (addr, _) = serve(vec![ - "HTTP/1.1 200 OK\r\nContent-Length: 100\r\nConnection: close\r\n\r\nshort".to_string(), - ]) - .await; - let tool = test_tool(vec![]); - let response = tool - .execute_request( - &pinned_to_local(&format!("http://{addr}/")), - reqwest::Method::GET, - vec![], - None, - ) - .await - .unwrap(); - let result = tool.format_response(response).await.unwrap(); - assert!(result.text().contains("Failed to read response body")); -} - -#[tokio::test] -async fn a_missing_url_is_an_error() { - let tool = test_tool(vec![]); - let err = tool.execute(json!({})).await.unwrap_err(); - assert!(err.to_string().contains("Missing 'url'")); -} - -#[test] -fn a_payment_hook_can_be_installed_and_attempts_debug_without_headers() { - let hook: Arc = Arc::new(RecordingHook { - settled: Arc::default(), - fail: false, - }); - let tool = test_tool(vec![]).with_payment_hook(hook); - assert!(tool.payment.is_some()); - let attempt = PaymentAttempt { - headers: vec![("PAYMENT-SIGNATURE".into(), "secret".into())], - settle: Box::new(|_| {}), - }; - let shown = format!("{attempt:?}"); - assert!(shown.contains("PaymentAttempt") && !shown.contains("secret")); -} - -#[test] -fn the_test_gate_builds_a_client_with_the_requested_timeouts() { - let gate = TestNetGate::supervised(); - let _client = gate.timeout_client("svc", 5, 2); -} - -/// Both network tools must name themselves on the wire. -/// -/// Not cosmetic: GitHub's REST API answers 403 to a request with no -/// `User-Agent`, so every `api.github.com` call through these tools failed -/// until this was set. `serve` records the raw request, which is the only way -/// to assert an outgoing header actually left the process. -#[tokio::test] -async fn an_outgoing_request_carries_a_user_agent() -> anyhow::Result<()> { - let (addr, seen) = serve(vec![ - "HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok".to_string(), - ]) - .await; - let tool = test_tool(vec![]); - let _ = tool - .execute_request( - &pinned_to_local(&format!("http://{addr}/")), - reqwest::Method::GET, - vec![], - None, - ) - .await?; - - let request = seen - .lock() - .map_err(|error| anyhow::anyhow!("request log mutex poisoned: {error}"))?[0] - .clone(); - let lower = request.to_ascii_lowercase(); - assert!( - lower.contains("user-agent:"), - "no User-Agent was sent:\n{request}" - ); - assert!( - lower.contains("user-agent: tinytools/"), - "the header must identify this crate:\n{request}" - ); - Ok(()) -} - -/// The connection goes to the vetted address, not to whatever the host name -/// resolves to a second time: the name below does not resolve at all, and the -/// request still reaches the pinned socket with the name in `Host`. -#[tokio::test] -async fn the_request_connects_to_the_pinned_address_and_keeps_the_host_name() { - let ok = "HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok"; - let (addr, seen) = serve(vec![ok.to_string()]).await; - let tool = test_tool(vec![]); - let target = crate::url_guard::ValidatedUrl { - url: format!("http://pinned.invalid:{}/x", addr.port()), - host: "pinned.invalid".to_string(), - addrs: vec![addr], - }; - let response = tool - .execute_request(&target, reqwest::Method::GET, vec![], None) - .await - .unwrap(); - assert_eq!(response.status(), reqwest::StatusCode::OK); - let request = seen.lock().unwrap().join(""); - assert!( - request - .to_ascii_lowercase() - .contains(&format!("host: pinned.invalid:{}", addr.port())), - "{request}" - ); -} diff --git a/crates/tinytools-std/src/network/mod.rs b/crates/tinytools-std/src/network/mod.rs deleted file mode 100644 index e9fae3c6..00000000 --- a/crates/tinytools-std/src/network/mod.rs +++ /dev/null @@ -1,50 +0,0 @@ -//! Network tools: `http_request`, `web_fetch`, `curl` and `pushover`. -//! -//! Every tool takes an `Arc`: the tool performs the I/O, the -//! host's gate decides whether it may (autonomy, action budget, approval, -//! privacy mode) and how the process-wide proxy is applied. The gate trait -//! ([`NetGate`]) is the whole seam; nothing here knows what an autonomy level, -//! an approval prompt or an egress descriptor is. URL allowlisting and SSRF -//! checks are [`crate::url_guard`]'s. -//! -//! Two more seams keep host-specific behavior out of the tools: a -//! [`PaymentHook`] answers a `402 Payment Required` for `http_request`, and an -//! [`HtmlExtractor`] turns pages into Markdown for `web_fetch`. - -// 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::unused_async, - clippy::needless_pass_by_value, - clippy::doc_markdown, - clippy::unnecessary_wraps -)] - -mod curl; -mod gate; -mod http_request; -mod pushover; -pub mod web_fetch; - -#[cfg(test)] -#[path = "contract_tests.rs"] -mod contract_test; -#[cfg(test)] -mod test_support; - -pub use curl::CurlTool; -pub use gate::{Destination, DestinationFuture, HttpLimits, NetGate}; -pub use http_request::{HttpRequestTool, PaymentAttempt, PaymentHook, PaymentOutcome}; -pub use pushover::PushoverTool; -pub use web_fetch::{AsyncHtmlExtractor, HtmlExtractor, WebFetchTool}; diff --git a/crates/tinytools-std/src/network/pushover.rs b/crates/tinytools-std/src/network/pushover.rs deleted file mode 100644 index defd9bf2..00000000 --- a/crates/tinytools-std/src/network/pushover.rs +++ /dev/null @@ -1,217 +0,0 @@ -use super::gate::NetGate; -use async_trait::async_trait; -use serde_json::json; -use std::path::PathBuf; -use std::sync::Arc; -use tinytools::{Tool, ToolResult}; - -const PUSHOVER_API_URL: &str = "https://api.pushover.net/1/messages.json"; -const PUSHOVER_REQUEST_TIMEOUT_SECS: u64 = 15; - -/// Sends a Pushover notification using credentials from the workspace `.env`. -#[derive(Debug)] -pub struct PushoverTool { - gate: Arc, - workspace_dir: PathBuf, -} - -impl PushoverTool { - /// A `pushover` tool over `gate`, reading `/.env`. - pub fn new(gate: Arc, workspace_dir: PathBuf) -> Self { - Self { - gate, - workspace_dir, - } - } - - fn parse_env_value(raw: &str) -> String { - let raw = raw.trim(); - - let unquoted = if raw.len() >= 2 - && ((raw.starts_with('"') && raw.ends_with('"')) - || (raw.starts_with('\'') && raw.ends_with('\''))) - { - &raw[1..raw.len() - 1] - } else { - raw - }; - - // Keep support for inline comments in unquoted values: - // KEY=value # comment - unquoted.split_once(" #").map_or_else( - || unquoted.trim().to_string(), - |(value, _)| value.trim().to_string(), - ) - } - - async fn get_credentials(&self) -> anyhow::Result<(String, String)> { - let env_path = self.workspace_dir.join(".env"); - let content = tokio::fs::read_to_string(&env_path) - .await - .map_err(|e| anyhow::anyhow!("Failed to read {}: {}", env_path.display(), e))?; - - let mut token = None; - let mut user_key = None; - - for line in content.lines() { - let line = line.trim(); - if line.starts_with('#') || line.is_empty() { - continue; - } - let line = line.strip_prefix("export ").map_or(line, str::trim); - if let Some((key, value)) = line.split_once('=') { - let key = key.trim(); - let value = Self::parse_env_value(value); - - if key.eq_ignore_ascii_case("PUSHOVER_TOKEN") { - token = Some(value); - } else if key.eq_ignore_ascii_case("PUSHOVER_USER_KEY") { - user_key = Some(value); - } - } - } - - let token = token.ok_or_else(|| anyhow::anyhow!("PUSHOVER_TOKEN not found in .env"))?; - let user_key = - user_key.ok_or_else(|| anyhow::anyhow!("PUSHOVER_USER_KEY not found in .env"))?; - - Ok((token, user_key)) - } -} - -#[async_trait] -impl Tool for PushoverTool { - fn name(&self) -> &'static str { - "pushover" - } - - fn description(&self) -> &'static str { - "Send a Pushover notification to your device. Requires PUSHOVER_TOKEN and PUSHOVER_USER_KEY in .env file." - } - - fn parameters_schema(&self) -> serde_json::Value { - json!({ - "type": "object", - "properties": { - "message": { - "type": "string", - "description": "The notification message to send" - }, - "title": { - "type": "string", - "description": "Optional notification title" - }, - "priority": { - "type": "integer", - "description": "Message priority: -2 (lowest/silent), -1 (low/no sound), 0 (normal), 1 (high), 2 (emergency/repeating)" - }, - "sound": { - "type": "string", - "description": "Notification sound override (e.g., 'pushover', 'bike', 'bugle', 'cashregister', etc.)" - } - }, - "required": ["message"] - }) - } - - // Intentionally NOT marked external_effect=true in v1. - // - // `execute()` below already enforces local policy via - // `gate.can_act()` (read-only autonomy block) and - // `gate.record_action()` (rate limit). The gate runs - // BEFORE `execute()`, so flipping external_effect would prompt - // the user even for calls that the policy will immediately - // reject — a dead-end approval flow. Leaving this tool at the - // trait default (false) means the local policy stays the - // single approval surface until the gate learns to consult - // policy before parking. - // - // Follow-up #1339-v3: thread the SecurityPolicy into the gate - // pre-check so external_effect can stay true here without - // generating ghost approvals. - - async fn execute(&self, args: serde_json::Value) -> anyhow::Result { - if !self.gate.can_act() { - return Ok(ToolResult::error( - "[policy-blocked] Action blocked: autonomy is read-only", - )); - } - - if !self.gate.record_action() { - return Ok(ToolResult::error("Action blocked: rate limit exceeded")); - } - - let message = args - .get("message") - .and_then(|v| v.as_str()) - .map(str::trim) - .filter(|v| !v.is_empty()) - .ok_or_else(|| anyhow::anyhow!("Missing 'message' parameter"))? - .to_string(); - - let title = args.get("title").and_then(|v| v.as_str()).map(String::from); - - let priority = match args.get("priority").and_then(serde_json::Value::as_i64) { - Some(value) if (-2..=2).contains(&value) => Some(value), - Some(value) => { - return Ok(ToolResult::error(format!( - "Invalid 'priority': {value}. Expected integer in range -2..=2" - ))); - } - None => None, - }; - - let sound = args.get("sound").and_then(|v| v.as_str()).map(String::from); - - let (token, user_key) = self.get_credentials().await?; - - let mut form = reqwest::multipart::Form::new() - .text("token", token) - .text("user", user_key) - .text("message", message); - - if let Some(title) = title { - form = form.text("title", title); - } - - if let Some(priority) = priority { - form = form.text("priority", priority.to_string()); - } - - if let Some(sound) = sound { - form = form.text("sound", sound); - } - - let client = self - .gate - .timeout_client("tool.pushover", PUSHOVER_REQUEST_TIMEOUT_SECS, 10); - let response = client.post(PUSHOVER_API_URL).multipart(form).send().await?; - - let status = response.status(); - let body = response.text().await.unwrap_or_default(); - - if !status.is_success() { - return Ok(ToolResult::error(format!( - "Pushover API returned status {status}" - ))); - } - - let api_status = serde_json::from_str::(&body) - .ok() - .and_then(|json| json.get("status").and_then(serde_json::Value::as_i64)); - - if api_status == Some(1) { - Ok(ToolResult::success(format!( - "Pushover notification sent successfully. Response: {body}" - ))) - } else { - Ok(ToolResult::error( - "Pushover API returned an application-level error", - )) - } - } -} - -#[cfg(test)] -#[path = "pushover_tests.rs"] -mod tests; diff --git a/crates/tinytools-std/src/network/pushover_tests.rs b/crates/tinytools-std/src/network/pushover_tests.rs deleted file mode 100644 index 66c4e6b1..00000000 --- a/crates/tinytools-std/src/network/pushover_tests.rs +++ /dev/null @@ -1,209 +0,0 @@ -use super::*; -use crate::network::test_support::{AutonomyLevel, TestNetGate}; -use std::fs; -use tempfile::TempDir; - -fn test_security(level: AutonomyLevel, max_actions_per_hour: usize) -> Arc { - TestNetGate::with(level, max_actions_per_hour) -} - -#[test] -fn pushover_tool_name() { - let tool = PushoverTool::new( - test_security(AutonomyLevel::Full, 100), - PathBuf::from("/tmp"), - ); - assert_eq!(tool.name(), "pushover"); -} - -#[test] -fn pushover_tool_description() { - let tool = PushoverTool::new( - test_security(AutonomyLevel::Full, 100), - PathBuf::from("/tmp"), - ); - assert_ne!(tool.description().len(), 0); -} - -#[test] -fn pushover_tool_has_parameters_schema() { - let tool = PushoverTool::new( - test_security(AutonomyLevel::Full, 100), - PathBuf::from("/tmp"), - ); - let schema = tool.parameters_schema(); - assert_eq!(schema["type"], "object"); - assert!(schema["properties"].get("message").is_some()); -} - -#[test] -fn pushover_tool_requires_message() { - let tool = PushoverTool::new( - test_security(AutonomyLevel::Full, 100), - PathBuf::from("/tmp"), - ); - let schema = tool.parameters_schema(); - let required = schema["required"].as_array().unwrap(); - assert!(required.contains(&serde_json::Value::String("message".to_string()))); -} - -#[tokio::test] -async fn credentials_parsed_from_env_file() { - let tmp = TempDir::new().unwrap(); - let env_path = tmp.path().join(".env"); - fs::write( - &env_path, - "PUSHOVER_TOKEN=testtoken123\nPUSHOVER_USER_KEY=userkey456\n", - ) - .unwrap(); - - let tool = PushoverTool::new( - test_security(AutonomyLevel::Full, 100), - tmp.path().to_path_buf(), - ); - let result = tool.get_credentials().await; - - assert!(result.is_ok()); - let (token, user_key) = result.unwrap(); - assert_eq!(token, "testtoken123"); - assert_eq!(user_key, "userkey456"); -} - -#[tokio::test] -async fn credentials_fail_without_env_file() { - let tmp = TempDir::new().unwrap(); - let tool = PushoverTool::new( - test_security(AutonomyLevel::Full, 100), - tmp.path().to_path_buf(), - ); - let result = tool.get_credentials().await; - - assert!(result.is_err()); -} - -#[tokio::test] -async fn credentials_fail_without_token() { - let tmp = TempDir::new().unwrap(); - let env_path = tmp.path().join(".env"); - fs::write(&env_path, "PUSHOVER_USER_KEY=userkey456\n").unwrap(); - - let tool = PushoverTool::new( - test_security(AutonomyLevel::Full, 100), - tmp.path().to_path_buf(), - ); - let result = tool.get_credentials().await; - - assert!(result.is_err()); -} - -#[tokio::test] -async fn credentials_fail_without_user_key() { - let tmp = TempDir::new().unwrap(); - let env_path = tmp.path().join(".env"); - fs::write(&env_path, "PUSHOVER_TOKEN=testtoken123\n").unwrap(); - - let tool = PushoverTool::new( - test_security(AutonomyLevel::Full, 100), - tmp.path().to_path_buf(), - ); - let result = tool.get_credentials().await; - - assert!(result.is_err()); -} - -#[tokio::test] -async fn credentials_ignore_comments() { - let tmp = TempDir::new().unwrap(); - let env_path = tmp.path().join(".env"); - fs::write(&env_path, "# This is a comment\nPUSHOVER_TOKEN=realtoken\n# Another comment\nPUSHOVER_USER_KEY=realuser\n").unwrap(); - - let tool = PushoverTool::new( - test_security(AutonomyLevel::Full, 100), - tmp.path().to_path_buf(), - ); - let result = tool.get_credentials().await; - - assert!(result.is_ok()); - let (token, user_key) = result.unwrap(); - assert_eq!(token, "realtoken"); - assert_eq!(user_key, "realuser"); -} - -#[test] -fn pushover_tool_supports_priority() { - let tool = PushoverTool::new( - test_security(AutonomyLevel::Full, 100), - PathBuf::from("/tmp"), - ); - let schema = tool.parameters_schema(); - assert!(schema["properties"].get("priority").is_some()); -} - -#[test] -fn pushover_tool_supports_sound() { - let tool = PushoverTool::new( - test_security(AutonomyLevel::Full, 100), - PathBuf::from("/tmp"), - ); - let schema = tool.parameters_schema(); - assert!(schema["properties"].get("sound").is_some()); -} - -#[tokio::test] -async fn credentials_support_export_and_quoted_values() { - let tmp = TempDir::new().unwrap(); - let env_path = tmp.path().join(".env"); - fs::write( - &env_path, - "export PUSHOVER_TOKEN=\"quotedtoken\"\nPUSHOVER_USER_KEY='quoteduser'\n", - ) - .unwrap(); - - let tool = PushoverTool::new( - test_security(AutonomyLevel::Full, 100), - tmp.path().to_path_buf(), - ); - let result = tool.get_credentials().await; - - assert!(result.is_ok()); - let (token, user_key) = result.unwrap(); - assert_eq!(token, "quotedtoken"); - assert_eq!(user_key, "quoteduser"); -} - -#[tokio::test] -async fn execute_blocks_readonly_mode() { - let tool = PushoverTool::new( - test_security(AutonomyLevel::ReadOnly, 100), - PathBuf::from("/tmp"), - ); - - let result = tool.execute(json!({"message": "hello"})).await.unwrap(); - assert!(result.is_error); - assert!(result.output().contains("read-only")); -} - -#[tokio::test] -async fn execute_blocks_rate_limit() { - let tool = PushoverTool::new(test_security(AutonomyLevel::Full, 0), PathBuf::from("/tmp")); - - let result = tool.execute(json!({"message": "hello"})).await.unwrap(); - assert!(result.is_error); - assert!(result.output().contains("rate limit")); -} - -#[tokio::test] -async fn execute_rejects_priority_out_of_range() { - let tool = PushoverTool::new( - test_security(AutonomyLevel::Full, 100), - PathBuf::from("/tmp"), - ); - - let result = tool - .execute(json!({"message": "hello", "priority": 5})) - .await - .unwrap(); - - assert!(result.is_error); - assert!(result.output().contains("-2..=2")); -} diff --git a/crates/tinytools-std/src/network/test_support.rs b/crates/tinytools-std/src/network/test_support.rs deleted file mode 100644 index 33ead40b..00000000 --- a/crates/tinytools-std/src/network/test_support.rs +++ /dev/null @@ -1,176 +0,0 @@ -//! A small, self-contained [`NetGate`] for the network tools' unit tests. -//! -//! It models just enough of a host policy to exercise the tools: an autonomy -//! tier, an action budget, an approval flag, a local-only switch and a record -//! of what the tool disclosed. 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::sync::atomic::{AtomicUsize, Ordering}; -use std::sync::{Arc, Mutex}; - -use super::gate::{HttpLimits, NetGate}; -use super::web_fetch::HtmlExtractor; - -/// 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 may run. - ReadOnly, - /// Outbound requests ask for approval. - Supervised, - /// Outbound requests run unprompted. - Full, -} - -/// The defaults the tests hand to constructors that take [`HttpLimits`]. -pub const DEFAULT_LIMITS: HttpLimits = HttpLimits { - max_response_size: 1_000_000, - timeout_secs: 30, -}; - -/// A fake host policy. -#[derive(Debug)] -pub struct TestNetGate { - autonomy: AutonomyLevel, - max_actions: usize, - used: AtomicUsize, - local_only: bool, - disclosed: Mutex>, -} - -impl TestNetGate { - /// Supervised gate with an effectively unlimited budget. - pub fn supervised() -> Arc { - Self::with(AutonomyLevel::Supervised, 1_000_000) - } - - /// Gate with an explicit tier and action budget. - pub fn with(autonomy: AutonomyLevel, max_actions: usize) -> Arc { - Arc::new(Self { - autonomy, - max_actions, - used: AtomicUsize::new(0), - local_only: false, - disclosed: Mutex::new(Vec::new()), - }) - } - - /// Supervised gate whose privacy mode refuses every outbound request. - pub fn local_only() -> Arc { - Arc::new(Self { - autonomy: AutonomyLevel::Supervised, - max_actions: 1_000_000, - used: AtomicUsize::new(0), - local_only: true, - disclosed: Mutex::new(Vec::new()), - }) - } - - /// Everything the tool has disclosed so far: `(host, has_body, has_headers)`. - pub fn disclosed(&self) -> Vec<(String, bool, bool)> { - self.disclosed.lock().unwrap().clone() - } -} - -impl NetGate for TestNetGate { - fn can_act(&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 network_needs_approval(&self) -> bool { - self.autonomy == AutonomyLevel::Supervised - } - - fn local_only_block(&self, host: &str) -> Option { - self.local_only.then(|| { - format!("{POLICY_BLOCKED_MARKER} Local-only privacy mode blocks contacting {host}.") - }) - } - - fn disclose(&self, host: &str, has_body: bool, has_headers: bool) { - self.disclosed - .lock() - .unwrap() - .push((host.to_string(), has_body, has_headers)); - } - - fn prepare_client( - &self, - _service: &str, - builder: reqwest::ClientBuilder, - ) -> reqwest::ClientBuilder { - builder - } - - fn timeout_client( - &self, - _service: &str, - timeout_secs: u64, - connect_timeout_secs: u64, - ) -> reqwest::Client { - reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(timeout_secs)) - .connect_timeout(std::time::Duration::from_secs(connect_timeout_secs)) - .build() - .unwrap_or_default() - } -} - -/// An [`HtmlExtractor`] that recognises ` bool { - let head = body.trim_start().to_ascii_lowercase(); - head.starts_with(" String { - let mut out = String::new(); - let mut in_tag = false; - for ch in html.chars() { - match ch { - '<' => in_tag = true, - '>' => in_tag = false, - c if !in_tag => out.push(c), - _ => {} - } - } - out - } -} - -/// A [`ValidatedUrl`] for a loopback test server: the host is the IP literal in -/// `url`, pinned to that one socket address, as the SSRF guard would return it -/// for a public literal. -pub fn pinned_to_local(url: &str) -> crate::url_guard::ValidatedUrl { - let parsed = reqwest::Url::parse(url).expect("test URL parses"); - let host = parsed.host_str().expect("test URL has a host").to_string(); - let ip: std::net::IpAddr = host.parse().expect("test URL host is an IP literal"); - let port = parsed.port_or_known_default().expect("test URL has a port"); - crate::url_guard::ValidatedUrl { - url: url.to_string(), - host, - addrs: vec![std::net::SocketAddr::new(ip, port)], - } -} diff --git a/crates/tinytools-std/src/network/web_fetch.rs b/crates/tinytools-std/src/network/web_fetch.rs deleted file mode 100644 index ba45a465..00000000 --- a/crates/tinytools-std/src/network/web_fetch.rs +++ /dev/null @@ -1,674 +0,0 @@ -//! `web_fetch` — fetch a URL and return its text body. -//! -//! Coding-harness baseline tool (issue #1205). Distinct from -//! `http_request` (full method/header surface) and `curl` (writes to -//! disk). `web_fetch` is the single-purpose "GET and read" primitive -//! the agent reaches for when researching: returns the response body -//! as text, capped, with a tiny preamble (status + final URL). -//! -//! A 4xx/5xx response is a failed fetch: it comes back as an error result -//! (`is_error`) naming the status and a short body excerpt, so a host that -//! budgets or retries on tool errors sees blocked and rate-limited pages for -//! what they are. 3xx responses are not followed and stay successful reports. - -use super::gate::{HttpLimits, NetGate, USER_AGENT, host_of}; -use crate::url_guard::{ValidatedUrl, normalize_allowed_domains}; - -use super::gate::validate_destination; -use async_trait::async_trait; -use serde_json::json; -use std::sync::Arc; -use std::time::Duration; -use tinytools::{PermissionLevel, Tool, ToolResult}; - -/// How `web_fetch` turns an HTML page into something a model should read. -/// -/// Content transforms are somebody else's specialty (the host wires in -/// whichever engine it has), so the tool asks instead of owning one. -pub trait HtmlExtractor: std::fmt::Debug + Send + Sync { - /// Whether `body`, which arrived with no usable `Content-Type`, is HTML. - fn looks_like_html(&self, body: &str) -> bool; - - /// Convert an HTML document to Markdown (links kept, scripts dropped). - fn to_markdown(&self, html: &str) -> String; -} - -/// An asynchronous, fallible HTML transform supplied by the host. -/// -/// Use this interface when extraction runs in a separate service or module. -/// Failures propagate to the fetch caller; the tool does not retry locally. -#[async_trait] -pub trait AsyncHtmlExtractor: std::fmt::Debug + Send + Sync { - /// Whether a response without a usable content type contains HTML. - /// - /// # Errors - /// Returns the host's detection failure. - async fn looks_like_html(&self, body: &str) -> anyhow::Result; - - /// Convert HTML to Markdown, preserving links and dropping scripts. - /// - /// # Errors - /// Returns the host's extraction failure. - async fn to_markdown(&self, html: &str) -> anyhow::Result; -} - -#[derive(Debug)] -enum HtmlProvider { - Local(Arc), - Async { - extractor: Arc, - timeout: Duration, - }, -} - -impl HtmlProvider { - async fn looks_like_html(&self, body: &str) -> anyhow::Result { - match self { - Self::Local(html) => Ok(html.looks_like_html(body)), - Self::Async { extractor, timeout } => { - tokio::time::timeout(*timeout, extractor.looks_like_html(body)) - .await - .map_err(|_| anyhow::anyhow!("HTML detection timed out"))? - } - } - } - - async fn to_markdown(&self, body: &str) -> anyhow::Result { - match self { - Self::Local(html) => Ok(html.to_markdown(body)), - Self::Async { extractor, timeout } => { - tokio::time::timeout(*timeout, extractor.to_markdown(body)) - .await - .map_err(|_| anyhow::anyhow!("HTML extraction timed out"))? - } - } - } -} - -/// Fetches a URL and returns its text body. -#[derive(Debug)] -pub struct WebFetchTool { - gate: Arc, - allowed_domains: Vec, - max_bytes: usize, - timeout_secs: u64, - html: HtmlProvider, - extra_properties: Vec<(String, serde_json::Value)>, -} - -impl WebFetchTool { - /// A `web_fetch` tool over `gate`. - /// - /// Both `None` and `Some(0)` for the limits mean "use `defaults`". - pub fn new( - gate: Arc, - allowed_domains: Vec, - max_bytes: Option, - timeout_secs: Option, - defaults: HttpLimits, - html: Arc, - ) -> Self { - Self::with_provider( - gate, - allowed_domains, - max_bytes, - timeout_secs, - defaults, - HtmlProvider::Local(html), - ) - } - - /// A `web_fetch` tool with asynchronous HTML detection and extraction. - /// - /// Limits and raw-response handling match [`Self::new`]. Extraction errors - /// propagate without invoking another provider. Both asynchronous detection - /// and conversion are bounded by the configured request timeout. - #[must_use] - pub fn new_async( - gate: Arc, - allowed_domains: Vec, - max_bytes: Option, - timeout_secs: Option, - defaults: HttpLimits, - html: Arc, - ) -> Self { - let resolved_timeout = match timeout_secs { - Some(0) => { - log::warn!( - "[tool.web_fetch] coercing invalid limit field=timeout_secs from=0 to={} \ - (stale/invalid config — see migration 5→6)", - defaults.timeout_secs - ); - defaults.timeout_secs - } - Some(seconds) => seconds, - None => defaults.timeout_secs, - }; - Self::with_provider( - gate, - allowed_domains, - max_bytes, - Some(resolved_timeout), - defaults, - HtmlProvider::Async { - extractor: html, - timeout: Duration::from_secs(resolved_timeout), - }, - ) - } - - fn with_provider( - gate: Arc, - allowed_domains: Vec, - max_bytes: Option, - timeout_secs: Option, - defaults: HttpLimits, - html: HtmlProvider, - ) -> Self { - // Treat both `None` and `Some(0)` as "use default": callers wire these - // from `[http_request]`, and a 0-byte cap truncates every body to - // nothing while a 0-second timeout fails every request instantly. - // Stale-zero configs are repaired on load (migration 5→6); this clamp - // is the always-on guard at the point of use. The fallbacks come from - // the host's `defaults` so the tool shares one source with the schema + - // migration (no cross-layer drift). `Some(0)` is a genuine - // misconfiguration, so log it (grep-friendly, no payload); a bare - // `None` is a normal "use default" call and stays quiet. - let max_bytes = match max_bytes { - Some(0) => { - log::warn!( - "[tool.web_fetch] coercing invalid limit field=max_bytes \ - from=0 to={} (stale/invalid config — see migration 5→6)", - defaults.max_response_size - ); - defaults.max_response_size - } - Some(n) => n, - None => defaults.max_response_size, - }; - let timeout_secs = match timeout_secs { - Some(0) => { - log::warn!( - "[tool.web_fetch] coercing invalid limit field=timeout_secs \ - from=0 to={} (stale/invalid config — see migration 5→6)", - defaults.timeout_secs - ); - defaults.timeout_secs - } - Some(n) => n, - None => defaults.timeout_secs, - }; - Self { - gate, - allowed_domains: normalize_allowed_domains(allowed_domains), - max_bytes, - timeout_secs, - html, - extra_properties: Vec::new(), - } - } - - /// Add a property to the advertised parameter schema (not `required`). - /// - /// The host uses this to opt the tool into arguments it interprets itself - /// before the call reaches the tool. - #[must_use] - pub fn with_schema_property(mut self, name: &str, schema: serde_json::Value) -> Self { - self.extra_properties.push((name.to_string(), schema)); - self - } -} - -#[async_trait] -impl Tool for WebFetchTool { - fn name(&self) -> &'static str { - "web_fetch" - } - - fn description(&self) -> &'static str { - "GET a URL and read the page. HTML returns as Markdown (links kept, \ - scripts dropped); `raw: true` for the body as sent. For POST or \ - custom headers use `http_request`." - } - - fn parameters_schema(&self) -> serde_json::Value { - let mut schema = json!({ - "type": "object", - "properties": { - "url": { "type": "string", "description": "Absolute http(s) URL." }, - "max_bytes": { - "type": "integer", - "description": "Cap the returned text at this many bytes \ - (default 1_000_000). Bounds the OUTPUT — the extracted \ - markdown, or the raw body with raw:true — never the markup \ - the extractor reads, so lowering it cannot cost you content \ - the page actually had.", - "minimum": 1 - }, - "raw": { - "type": "boolean", - "description": "Return the body as sent." - } - }, - "required": ["url"] - }); - if let Some(properties) = schema["properties"].as_object_mut() { - for (name, property) in &self.extra_properties { - properties.insert(name.clone(), property.clone()); - } - } - schema - } - - fn permission_level(&self) -> PermissionLevel { - PermissionLevel::ReadOnly - } - - /// Idempotent GET — safe to fan out across parallel `web_fetch` - /// calls. Targets that throttle aggressively are the user's - /// concern; we don't try to second-guess at the tool layer. - fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool { - true - } - - /// How much of a page reaches the model in one result. - /// - /// Extraction does most of the work — after HTML→Markdown a long - /// documentation page is usually a few thousand chars — so this - /// bites only on genuinely large documents. What it no longer does - /// is throw the remainder away: `ToolOutputMiddleware` spills the - /// full extracted page to an artifact and returns the `file_read` - /// call that pages it, which is what this comment used to - /// recommend while the cap itself disabled the affordance. - /// - /// 24k rather than the old 50k because the point of reference - /// moved: 50k was a bound on raw markup, this is clean Markdown. - /// Hermes budgets 15,000 chars of extracted text for the same job; - /// Codex caps every tool result at ~10,000 tokens. - fn max_result_size_chars(&self) -> Option { - Some(24_000) - } - - async fn execute(&self, args: serde_json::Value) -> anyhow::Result { - let raw_url = args - .get("url") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("Missing 'url' parameter"))?; - let max_bytes = args - .get("max_bytes") - .and_then(serde_json::Value::as_u64) - .map_or(self.max_bytes, |n| (n as usize).max(1)); - let raw_requested = args - .get("raw") - .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", - )); - } - - // Local-only enforcement (privacy epic S7, #4441): refuse the fetch under - // LocalOnly before URL validation / DNS. The post-validation - // `emit_external_transfer` below stays the S2 disclosure point. - { - let host = host_of(raw_url); - if let Some(msg) = self.gate.local_only_block(&host) { - return Ok(ToolResult::error(msg)); - } - } - - let target = match validate_destination( - &*self.gate, - raw_url, - &self.allowed_domains, - "GET", - &[], - None, - ) - .await - { - Ok(u) => u, - Err(e) => return Ok(ToolResult::error(format!("URL rejected: {e}"))), - }; - let url = target.url.clone(); - - // Egress spine (privacy epic S2, #4436): disclose the fetch destination - // before contacting the host. - self.gate.disclose(&host_of(&url), false, false); - - self.fetch_validated(&target, max_bytes, raw_requested) - .await - } -} - -impl WebFetchTool { - /// Issue the GET for a URL that already passed the gate and the SSRF - /// guard, and render the response for the model. - async fn fetch_validated( - &self, - target: &ValidatedUrl, - max_bytes: usize, - raw_requested: bool, - ) -> anyhow::Result { - // Disable automatic redirect following: reqwest follows up to 10 - // redirects by default, and a redirect target may be on a host - // outside the allowed-domains list. We surface 3xx responses to - // the caller so they can decide whether to refetch the new URL. - let client = match reqwest::Client::builder() - .timeout(Duration::from_secs(self.timeout_secs)) - .user_agent(USER_AGENT) - .redirect(reqwest::redirect::Policy::none()) - // Connect only to the vetted addresses; the URL keeps its host name. - .resolve_to_addrs(&target.host, &target.addrs) - .build() - { - Ok(c) => c, - Err(e) => return Ok(ToolResult::error(format!("Failed to build client: {e}"))), - }; - - let url = target.url.as_str(); - let resp = match client.get(url).send().await { - Ok(r) => r, - Err(e) => return Ok(ToolResult::error(format!("Request failed: {e}"))), - }; - let status = resp.status(); - let final_url = resp.url().to_string(); - let location = resp - .headers() - .get(reqwest::header::LOCATION) - .and_then(|v| v.to_str().ok()) - .map(str::to_string); - let content_type = resp - .headers() - .get(reqwest::header::CONTENT_TYPE) - .and_then(|v| v.to_str().ok()) - .map(str::to_string); - let retry_after = resp - .headers() - .get(reqwest::header::RETRY_AFTER) - .and_then(|v| v.to_str().ok()) - .map(str::to_string); - let body = match resp.text().await { - Ok(b) => b, - Err(e) => return Ok(ToolResult::error(format!("Failed to read body: {e}"))), - }; - - if let Some(loc) = &location - && status.is_redirection() - { - return Ok(ToolResult::success(format!( - "status={} url={} location={loc}\n[redirect not followed — re-call web_fetch with the location URL if it's an allowed domain]", - status.as_u16(), - final_url - ))); - } - - // A 4xx/5xx is a failed fetch, not a page: returning it as a success - // let a blocked or rate-limited site count as research done. (3xx is - // handled above and stays a successful "not followed" report.) - if status.is_client_error() || status.is_server_error() { - let host = host_of(&final_url); - log::debug!( - "[tool.web_fetch] http error status={} host={host} retry_after_present={}", - status.as_u16(), - retry_after.is_some() - ); - let excerpt = - error_body_excerpt(&self.html, &body, content_type.as_deref(), raw_requested) - .await?; - return Ok(ToolResult::error(http_error_message( - status, - &host, - retry_after.as_deref(), - &excerpt, - ))); - } - - let downloaded = body.len(); - - // Markdown by default. A page's prose is a small fraction of its - // bytes; handing the raw document to the model (and to the payload - // summarizer behind it) is how one research turn came to cost - // 1,083,069 input tokens. The host's `HtmlExtractor` owns - // content transforms. - let converted = - !raw_requested && is_html(&self.html, &body, content_type.as_deref()).await?; - let rendered = render_body(&self.html, body, converted, max_bytes).await?; - - let extracted = rendered.extracted; - let mut header = format!("status={} url={final_url}", status.as_u16()); - if converted { - header.push_str(" content=markdown"); - } - // `output_capped_at`, not the old `download_capped_at`: nothing here - // ever capped a download — `resp.text()` above materialises the whole - // body regardless — and naming it that sent a reader looking in the - // wrong place for the content that went missing. - if rendered.output_capped { - header.push_str(&format!(" output_capped_at={max_bytes}B")); - } - append_markup_truncation_header(&mut header, &rendered); - if converted && extracted < downloaded { - header.push_str(&format!(" extracted={extracted}B_of_{downloaded}B")); - } - header.push('\n'); - let content = rendered.content; - - // Full extracted content. Bounding it — the head/tail window, the - // spill to an artifact and the paging handle — belongs to - // `ToolOutputMiddleware`, which applies one rule to every tool. - // Codex enforces exactly this invariant at a single chokepoint - // (`context_manager/history.rs`), which is why a tool there cannot - // leak an unbounded payload however it misbehaves. - Ok(ToolResult::success(format!("{header}{content}"))) - } -} - -/// How much of an error response's body is quoted back to the model. -const ERROR_EXCERPT_CHARS: usize = 300; - -/// The model-facing text for a 4xx/5xx response: what happened, why it -/// matters, and what to do next, plus a short excerpt of the body. -fn http_error_message( - status: reqwest::StatusCode, - host: &str, - retry_after: Option<&str>, - excerpt: &str, -) -> String { - let code = status.as_u16(); - let reason = status.canonical_reason().unwrap_or("Unknown Status"); - let mut msg = format!("HTTP {code} {reason} from {host}; "); - match code { - 429 => { - msg.push_str("the site is rate limiting requests."); - if let Some(wait) = retry_after.map(str::trim).filter(|w| !w.is_empty()) { - msg.push_str(&format!(" Retry-After: {wait}.")); - } - msg.push_str(" Try another source, or retry later."); - } - 401 | 403 => msg.push_str("the site refused the request. Try another source."), - 404 | 410 => { - msg.push_str( - "the page does not exist at this URL. Check the URL or try another source.", - ); - } - 500..=599 => { - msg.push_str( - "the server failed to handle the request. Retry later or try another source.", - ); - } - _ => msg.push_str("the server rejected the request. Try another source."), - } - if !excerpt.is_empty() { - msg.push_str("\nResponse excerpt: "); - msg.push_str(excerpt); - } - msg -} - -/// A short, single-line, text-only excerpt of an error response body. -/// -/// HTML goes through the host extractor (unless the caller asked for `raw`) -/// so the model reads the page's words rather than its markup. -async fn error_body_excerpt( - extractor: &HtmlProvider, - body: &str, - content_type: Option<&str>, - raw_requested: bool, -) -> anyhow::Result { - let (bounded_body, _) = cap_extractor_input(body); - let text = if !raw_requested && is_html(extractor, bounded_body, content_type).await? { - extractor.to_markdown(bounded_body).await? - } else { - body.to_string() - }; - let collapsed = text.split_whitespace().collect::>().join(" "); - Ok(match collapsed.char_indices().nth(ERROR_EXCERPT_CHARS) { - Some((cut, _)) => format!("{}...", &collapsed[..cut]), - None => collapsed, - }) -} - -/// Raw markup handed to the HTML extractor, at most. -/// -/// Not a byte budget — the caller's `max_bytes` owns that, applied to the -/// output. This exists only so a pathological document cannot cost unbounded -/// CPU in `to_markdown`, a cost the old pre-truncation ordering hid by -/// accident. 8 MiB is ~19x the largest real page measured here (a 434 KB -/// client-rendered spreadsheet) and ~8x the default `max_response_size`, so no -/// realistic page reaches it. -const EXTRACTOR_INPUT_CEILING: usize = 8 * 1024 * 1024; - -/// A body turned into what the caller reads. -struct RenderedBody { - /// The text to return, bounded by the caller's `max_bytes`. - content: String, - /// Length *before* that bound, so the header's ratio describes the - /// extraction rather than the truncation. - extracted: usize, - /// Whether `content` was cut to fit `max_bytes`. - output_capped: bool, - /// Whether the markup was cut before the extractor saw it, which only - /// happens past [`EXTRACTOR_INPUT_CEILING`]. - markup_truncated: bool, -} - -/// Add the extractor input ceiling to a fetch header when markup was cut. -fn append_markup_truncation_header(header: &mut String, rendered: &RenderedBody) { - if rendered.markup_truncated { - header.push_str(&format!(" markup_truncated_at={EXTRACTOR_INPUT_CEILING}B")); - } -} - -/// Convert, **then** bound. -/// -/// The order is the whole of this function. It used to be the other way round, -/// and the cap was therefore destroying the thing it was meant to measure: a -/// 432,864-byte client-rendered page fetched with `max_bytes: 50000` was cut at -/// byte 50,000 — mid-tag, mid-DOM — and the readability pass, handed that -/// wreckage, recovered only the ``. The result was -/// `extracted=48B_of_432864B`, reported as `status=200`. The same URL with no -/// `max_bytes` yields `extracted=37243B_of_433638B`: the real document. -/// Identical tool, identical extractor, 776x the content, one parameter. -/// -/// So a caller setting a sensible cost bound silently lost the page, and the -/// knob it would then reach for — raising the cap — was the right knob turned -/// too timidly, which is the worst case for learning anything from the failure. -/// -/// Bounding the output is also what the schema promises, and it costs no -/// memory: the caller has already materialised the whole body. Only the -/// extractor's input needs a ceiling, and that is for CPU. -async fn render_body( - html: &HtmlProvider, - body: String, - converted: bool, - max_bytes: usize, -) -> anyhow::Result<RenderedBody> { - let (markup_truncated, body) = if converted { - let (body, truncated) = cap_extractor_input(&body); - (truncated, body.to_string()) - } else { - (false, body) - }; - let full = if converted { - html.to_markdown(&body).await? - } else { - body - }; - let extracted = full.len(); - let (content, output_capped) = if extracted > max_bytes { - let cut = floor_char_boundary(&full, max_bytes); - (full[..cut].to_string(), true) - } else { - (full, false) - }; - Ok(RenderedBody { - content, - extracted, - output_capped, - markup_truncated, - }) -} - -/// Bound every body before it crosses into a host supplied extractor. -fn cap_extractor_input(body: &str) -> (&str, bool) { - if body.len() > EXTRACTOR_INPUT_CEILING { - let cut = floor_char_boundary(body, EXTRACTOR_INPUT_CEILING); - (&body[..cut], true) - } else { - (body, false) - } -} - -/// The largest index at or below `index` that is a char boundary of `s`. -fn floor_char_boundary(s: &str, index: usize) -> usize { - if index >= s.len() { - return s.len(); - } - let mut end = index; - while end > 0 && !s.is_char_boundary(end) { - end -= 1; - } - end -} - -/// Is this HTML? The server's own `Content-Type` is authoritative when it -/// says so; otherwise fall back to the host's content detection, which -/// already distinguishes HTML from JSON, diffs and code. -async fn is_html( - extractor: &HtmlProvider, - body: &str, - content_type: Option<&str>, -) -> anyhow::Result<bool> { - if let Some(ct) = content_type { - let ct = ct.to_ascii_lowercase(); - let mime = ct.split(';').next().unwrap_or("").trim().to_string(); - // An explicit non-HTML type is a statement, not a guess: a JSON API - // that happens to embed markup must come back verbatim. - if !mime.is_empty() && mime != "text/html" && mime != "application/xhtml+xml" { - return Ok(false); - } - if !mime.is_empty() { - return Ok(true); - } - } - let (body, _) = cap_extractor_input(body); - extractor.looks_like_html(body).await -} - -/// The HTTP status of a `web_fetch` error result. This remains available from -/// the producer module, while the single parser lives in `tinytools::status` -/// so lightweight hosts can consume the result contract without depending on -/// `tinytools-std`. -pub use tinytools::status::web_fetch_status as status_of; - -#[cfg(test)] -#[path = "web_fetch_tests.rs"] -mod tests; diff --git a/crates/tinytools-std/src/network/web_fetch_tests.rs b/crates/tinytools-std/src/network/web_fetch_tests.rs deleted file mode 100644 index 685dc1e2..00000000 --- a/crates/tinytools-std/src/network/web_fetch_tests.rs +++ /dev/null @@ -1,816 +0,0 @@ -#![allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)] - -use super::*; -use crate::network::test_support::{DEFAULT_LIMITS, TestHtml, TestNetGate, pinned_to_local}; -use std::sync::Mutex; - -fn test_security() -> Arc<TestNetGate> { - TestNetGate::supervised() -} - -fn fetch( - gate: Arc<TestNetGate>, - allowed: Vec<String>, - max: Option<usize>, - timeout: Option<u64>, -) -> WebFetchTool { - WebFetchTool::new( - gate, - allowed, - max, - timeout, - DEFAULT_LIMITS, - Arc::new(TestHtml), - ) -} - -#[test] -fn web_fetch_name_and_schema() { - let tool = fetch(test_security(), vec!["example.com".into()], None, None); - assert_eq!(tool.name(), "web_fetch"); - let schema = tool.parameters_schema(); - assert!(schema["properties"]["url"].is_object()); - assert!( - schema["required"] - .as_array() - .unwrap() - .contains(&json!("url")) - ); -} - -#[test] -fn a_host_can_opt_the_schema_into_extra_optional_arguments() { - let tool = fetch(test_security(), vec!["example.com".into()], None, None).with_schema_property( - "summary_focus", - json!({"type": "string", "description": "What you need."}), - ); - let schema = tool.parameters_schema(); - assert_eq!(schema["properties"]["summary_focus"]["type"], "string"); - assert!( - !schema["required"] - .as_array() - .unwrap() - .contains(&json!("summary_focus")) - ); - // Without the opt-in the schema is exactly the base one. - let plain = fetch(test_security(), vec![], None, None).parameters_schema(); - assert!(plain["properties"].get("summary_focus").is_none()); -} - -#[test] -fn zero_and_none_limits_fall_back_to_defaults() { - // Callers wire these from `[http_request]`; a stale `Some(0)` is a - // 0-byte cap (empty bodies) and a 0-second timeout (instant failure). - // Both `None` and `Some(0)` must coerce to the shared schema defaults. - let defaults = DEFAULT_LIMITS; - let from_zero = fetch( - test_security(), - vec!["example.com".into()], - Some(0), - Some(0), - ); - assert_eq!(from_zero.max_bytes, defaults.max_response_size); - assert_eq!(from_zero.timeout_secs, defaults.timeout_secs); - assert_ne!(from_zero.timeout_secs, 0); - assert_ne!(from_zero.max_bytes, 0); - - let from_none = fetch(test_security(), vec!["example.com".into()], None, None); - assert_eq!(from_none.max_bytes, defaults.max_response_size); - assert_eq!(from_none.timeout_secs, defaults.timeout_secs); -} - -#[test] -fn nonzero_limits_are_preserved() { - let tool = fetch( - test_security(), - vec!["example.com".into()], - Some(4096), - Some(15), - ); - assert_eq!(tool.max_bytes, 4096); - assert_eq!(tool.timeout_secs, 15); -} - -#[tokio::test] -async fn web_fetch_rejects_disallowed_domain() { - let tool = fetch(test_security(), vec!["example.com".into()], None, None); - let result = tool - .execute(json!({ "url": "https://evil.test/path" })) - .await - .unwrap(); - assert!(result.is_error); - assert!(result.output().contains("URL rejected")); -} - -#[tokio::test] -async fn web_fetch_rejects_invalid_url() { - let tool = fetch(test_security(), vec!["example.com".into()], None, None); - let result = tool.execute(json!({ "url": "not-a-url" })).await.unwrap(); - assert!(result.is_error); -} - -#[tokio::test] -async fn web_fetch_blocked_under_local_only_privacy_mode() { - // Privacy epic S7 (#4441): under LocalOnly the fetch is refused with a - // `[policy-blocked]` result before any URL validation / network. - let tool = fetch( - TestNetGate::local_only(), - vec!["example.com".into()], - None, - None, - ); - let result = tool - .execute(json!({ "url": "https://example.com/data" })) - .await - .unwrap(); - assert!(result.is_error); - assert!( - result.output().contains("[policy-blocked]"), - "got: {}", - result.output() - ); - assert!( - result.output().contains("Local-only"), - "got: {}", - result.output() - ); -} - -#[tokio::test] -async fn test_web_fetch_truncation_utf8() { - // Mock body with multi-byte char exactly at budget - let body = "Hello 🦀 World"; // 🦀 is at index 6-9 - let max_bytes = 8; - // Should truncate at index 6 - let cut = floor_char_boundary(body, max_bytes); - assert_eq!(cut, 6); - assert_eq!(&body[..cut], "Hello "); -} - -// --- content extraction ---------------------------------------------------- - -async fn html(body: &str, content_type: Option<&str>) -> bool { - is_html(&HtmlProvider::Local(Arc::new(TestHtml)), body, content_type) - .await - .unwrap() -} - -#[tokio::test] -async fn an_explicit_html_content_type_selects_markdown_conversion() { - assert!(html("<p>hi</p>", Some("text/html; charset=utf-8")).await); - assert!(html("<p>hi</p>", Some("application/xhtml+xml")).await); -} - -#[tokio::test] -async fn an_explicit_non_html_content_type_is_taken_at_its_word() { - // A JSON API that happens to quote markup must come back verbatim — - // the server said what it sent, so we don't second-guess it by sniffing. - let body = r#"{"html": "<div><p>one</p><span>two</span><a href="/x">two</a></div>"}"#; - assert!(!html(body, Some("application/json")).await); - assert!(!html("<p>x</p>", Some("text/plain")).await); -} - -#[tokio::test] -async fn a_missing_content_type_falls_back_to_content_detection() { - assert!(html("<!DOCTYPE html><html><body><p>hi</p></body></html>", None).await); - assert!(!html("# Just a README\n\nSome prose.\n", None).await); -} - -#[tokio::test] -async fn an_empty_content_type_does_not_veto_detection() { - assert!(html("<!DOCTYPE html><html><body>x</body></html>", Some("")).await); -} - -#[test] -fn the_schema_offers_the_raw_escape_hatch() { - let tool = fetch(test_security(), vec![], None, None); - let schema = tool.parameters_schema(); - assert!( - schema["properties"].get("raw").is_some(), - "a caller must be able to opt out of conversion: {schema}" - ); - assert!( - tool.description().contains("Markdown"), - "the model needs to know what it will get back: {}", - tool.description() - ); -} - -#[test] -fn the_declared_cap_is_sized_for_extracted_markdown_not_raw_markup() { - let tool = fetch(test_security(), vec![], None, None); - let cap = tool - .max_result_size_chars() - .expect("web_fetch declares a cap"); - assert!( - (8_000..=32_000).contains(&cap), - "cap should sit in the same range as Hermes (15k chars) and Codex \ - (~10k tokens) budget for one result, got {cap}" - ); -} - -#[tokio::test] -async fn html_is_converted_through_the_host_extractor_only_when_it_is_html() { - let body = "<!DOCTYPE html><html><body><p>hi</p></body></html>"; - assert!(html(body, None).await); - assert_eq!(TestHtml.to_markdown(body), "hi"); -} - -#[tokio::test] -async fn execute_blocks_when_rate_limited() { - let tool = fetch( - TestNetGate::with(crate::network::test_support::AutonomyLevel::Supervised, 0), - vec!["example.com".into()], - None, - None, - ); - let result = tool - .execute(json!({ "url": "https://example.com/data" })) - .await - .unwrap(); - assert!(result.is_error); - assert!(result.output().contains("Rate limit exceeded")); -} - -// --- HTTP error statuses --------------------------------------------------- -// -// Loopback is refused by the SSRF guard, so these drive `fetch_validated` -// (everything after validation) against a one-shot local server. - -/// Serves one canned response and returns the base URL. -async fn serve_once(response: &str) -> String { - use tokio::io::{AsyncReadExt, AsyncWriteExt}; - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - let response = response.to_string(); - tokio::spawn(async move { - let (mut socket, _) = listener.accept().await.unwrap(); - let mut buf = vec![0u8; 8192]; - let _ = socket.read(&mut buf).await.unwrap(); - socket.write_all(response.as_bytes()).await.unwrap(); - let _ = socket.shutdown().await; - }); - format!("http://{addr}/page") -} - -#[tokio::test] -async fn an_outgoing_request_carries_a_user_agent() { - use tokio::io::{AsyncReadExt, AsyncWriteExt}; - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - let seen = Arc::new(std::sync::Mutex::new(String::new())); - let request_log = Arc::clone(&seen); - let server = tokio::spawn(async move { - let (mut socket, _) = listener.accept().await.unwrap(); - let mut request = Vec::new(); - let mut buf = [0u8; 1024]; - while !request.windows(4).any(|window| window == b"\r\n\r\n") { - let n = socket.read(&mut buf).await.unwrap(); - assert!( - n != 0, - "peer closed before sending the complete HTTP headers" - ); - request.extend_from_slice(&buf[..n]); - } - *request_log.lock().unwrap() = String::from_utf8_lossy(&request).to_string(); - socket - .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok") - .await - .unwrap(); - }); - - let tool = fetch(test_security(), vec![], None, None); - let result = tool - .fetch_validated( - &pinned_to_local(&format!("http://{addr}/")), - 1_000_000, - false, - ) - .await - .unwrap(); - assert!(!result.is_error, "got: {}", result.output()); - server.await.unwrap(); - - let request = seen.lock().unwrap().to_ascii_lowercase(); - let user_agent = request - .lines() - .find_map(|line| line.strip_prefix("user-agent: ")); - assert_eq!( - user_agent, - Some(concat!("tinytools/", env!("CARGO_PKG_VERSION"))) - ); -} - -fn http_response(status_line: &str, headers: &str, body: &str) -> String { - format!( - "HTTP/1.1 {status_line}\r\n{headers}Content-Length: {}\r\nConnection: close\r\n\r\n{body}", - body.len() - ) -} - -async fn fetch_canned(response: String) -> ToolResult { - let url = serve_once(&response).await; - fetch(test_security(), vec![], None, None) - .fetch_validated(&pinned_to_local(&url), 1_000_000, false) - .await - .unwrap() -} - -#[tokio::test] -async fn a_200_is_a_successful_result_with_the_status_header() { - let result = fetch_canned(http_response( - "200 OK", - "Content-Type: text/plain\r\n", - "hello", - )) - .await; - assert!(!result.is_error, "got: {}", result.output()); - assert!(result.output().starts_with("status=200 url=")); - assert!(result.output().ends_with("hello")); -} - -#[tokio::test] -async fn a_403_is_an_error_naming_the_status_and_suggesting_another_source() { - let result = fetch_canned(http_response( - "403 Forbidden", - "Content-Type: text/plain\r\n", - "Access denied by bot protection", - )) - .await; - assert!(result.is_error, "got: {}", result.output()); - let out = result.output(); - assert!( - out.contains("HTTP 403 Forbidden from 127.0.0.1"), - "got: {out}" - ); - assert!(out.contains("refused the request"), "got: {out}"); - assert!(out.contains("another source"), "got: {out}"); - assert!( - out.contains("Access denied by bot protection"), - "got: {out}" - ); -} - -#[tokio::test] -async fn a_429_is_an_error_that_reports_rate_limiting_and_retry_after() { - let result = fetch_canned(http_response( - "429 Too Many Requests", - "Retry-After: 120\r\n", - "", - )) - .await; - assert!(result.is_error, "got: {}", result.output()); - let out = result.output(); - assert!(out.contains("HTTP 429 Too Many Requests"), "got: {out}"); - assert!(out.contains("rate limit"), "got: {out}"); - assert!(out.contains("Retry-After: 120"), "got: {out}"); -} - -#[tokio::test] -async fn a_429_without_retry_after_still_reports_rate_limiting() { - let result = fetch_canned(http_response("429 Too Many Requests", "", "")).await; - assert!(result.is_error); - assert!(result.output().contains("rate limit")); - assert!(!result.output().contains("Retry-After")); -} - -#[tokio::test] -async fn a_404_is_an_error() { - let result = fetch_canned(http_response( - "404 Not Found", - "Content-Type: text/html\r\n", - "<!DOCTYPE html><html><body><p>No such page</p></body></html>", - )) - .await; - assert!(result.is_error, "got: {}", result.output()); - let out = result.output(); - assert!(out.contains("HTTP 404 Not Found"), "got: {out}"); - // The excerpt is the page's text, not its markup. - assert!(out.contains("No such page"), "got: {out}"); - assert!(!out.contains("<p>"), "got: {out}"); -} - -#[tokio::test] -async fn a_5xx_is_an_error() { - let result = fetch_canned(http_response("503 Service Unavailable", "", "")).await; - assert!(result.is_error); - assert!(result.output().contains("HTTP 503 Service Unavailable")); -} - -#[tokio::test] -async fn an_error_body_excerpt_is_short() { - let long = "x".repeat(5_000); - let result = fetch_canned(http_response( - "500 Internal Server Error", - "Content-Type: text/plain\r\n", - &long, - )) - .await; - assert!(result.is_error); - assert!( - result.output().len() < 1_000, - "excerpt must be bounded, got {} bytes", - result.output().len() - ); -} - -#[tokio::test] -async fn a_redirect_is_still_reported_as_a_successful_result() { - let result = fetch_canned(http_response( - "301 Moved Permanently", - "Location: https://example.com/new\r\n", - "", - )) - .await; - assert!(!result.is_error, "got: {}", result.output()); - assert!(result.output().contains("status=301")); - assert!(result.output().contains("location=https://example.com/new")); -} - -// --- The cap bounds the output, not the extractor's input ------------------ -// -// `TestHtml::to_markdown` strips tags, so a document whose prose sits past the -// cap offset is the shape that discriminates: cutting the markup first loses -// the prose outright, cutting the rendered text keeps it. - -/// Markup whose readable text sits behind `filler` bytes of attribute. -fn page_with_prose_after(filler: usize) -> String { - format!( - "<!DOCTYPE html><html><head><title>T\ -

the prose that matters

", - "f".repeat(filler) - ) -} - -#[tokio::test] -async fn the_cap_applies_to_the_extracted_text_not_the_markup() { - // The regression this function exists for. A 432,864-byte page fetched at - // `max_bytes: 50000` used to come back as `extracted=48B_of_432864B` — the - // whole document reduced to its `` — because the markup was cut - // mid-DOM before the extractor ever saw it. - let body = page_with_prose_after(4_000); - assert!(body.len() > 1_000, "the prose must sit past the cap"); - - let rendered = render_body(&HtmlProvider::Local(Arc::new(TestHtml)), body, true, 1_000) - .await - .unwrap(); - assert!( - rendered.content.contains("the prose that matters"), - "cutting the markup first would have lost this: {:?}", - rendered.content - ); -} - -#[test] -fn cutting_the_markup_first_really_does_destroy_the_extraction() { - // The counterfactual, so the test above cannot pass vacuously: the old - // ordering, performed by hand, loses the prose from the same document. - let body = page_with_prose_after(4_000); - let truncated = &body[..1_000]; - assert!( - !TestHtml - .to_markdown(truncated) - .contains("the prose that matters"), - "a pre-extraction cut is what destroyed the content" - ); -} - -#[tokio::test] -async fn the_reported_length_is_the_extraction_not_the_truncation() { - // `extracted` is pre-cap on purpose: the header's `extracted=XB_of_YB` - // ratio describes how much of the document the extractor found, which is - // the number that reveals a collapse. Measuring post-cap would report the - // cap back to the caller as if it were the page. - let body = page_with_prose_after(4_000); - let full = TestHtml.to_markdown(&body); - let rendered = render_body(&HtmlProvider::Local(Arc::new(TestHtml)), body, true, 4) - .await - .unwrap(); - assert_eq!(rendered.extracted, full.len()); - assert!(rendered.output_capped); - assert!(rendered.content.len() <= 4); -} - -#[tokio::test] -async fn an_output_within_the_cap_is_returned_whole_and_unflagged() { - let body = page_with_prose_after(16); - let rendered = render_body( - &HtmlProvider::Local(Arc::new(TestHtml)), - body, - true, - 1_000_000, - ) - .await - .unwrap(); - assert!(!rendered.output_capped); - assert!(!rendered.markup_truncated); - assert!(rendered.content.contains("the prose that matters")); -} - -#[tokio::test] -async fn markup_truncated_input_is_reported_in_the_fetch_header() { - // Drive the body across the extractor's independent input ceiling. The - // large attribute keeps extracted text small, while proving that the - // extractor input itself was bounded and reported to the caller. - let body = format!( - "<!DOCTYPE html><html><body><div data-pad=\"{}\"></div><p>visible</p></body></html>", - "x".repeat(EXTRACTOR_INPUT_CEILING) - ); - assert!(body.len() > EXTRACTOR_INPUT_CEILING); - let rendered = render_body(&HtmlProvider::Local(Arc::new(TestHtml)), body, true, 1_000) - .await - .unwrap(); - assert!(rendered.markup_truncated); - let mut output = "status=200 url=https://example.com content=markdown".to_string(); - append_markup_truncation_header(&mut output, &rendered); - assert!( - output.contains(&format!("markup_truncated_at={EXTRACTOR_INPUT_CEILING}B")), - "header should disclose the extractor input ceiling: {output}" - ); -} - -#[tokio::test] -async fn raw_output_is_bounded_by_the_same_cap() { - // With `raw: true` there is no extraction, so the cap applies to the body - // itself — the one case where cutting the input and cutting the output are - // the same act. - let rendered = render_body( - &HtmlProvider::Local(Arc::new(TestHtml)), - "abcdefghij".to_string(), - false, - 4, - ) - .await - .unwrap(); - assert_eq!(rendered.content, "abcd"); - assert!(rendered.output_capped); - assert_eq!(rendered.extracted, 10); -} - -#[test] -fn the_markup_ceiling_is_far_above_any_real_page() { - // Sized against the measured worst case, not picked round. A ceiling near - // the old default would reintroduce the bug for ordinary documents. - const { - assert!(EXTRACTOR_INPUT_CEILING >= 8 * 1024 * 1024); - assert!(EXTRACTOR_INPUT_CEILING > 433_638 * 10); - } -} - -#[test] -fn the_schema_says_which_side_of_the_extractor_it_bounds() { - // The description is a caller's only account of what the knob does, and - // the old wording ("Truncate body at this many bytes") is what made - // lowering it look free. - let tool = fetch(test_security(), vec![], None, None); - let schema = tool.parameters_schema(); - let desc = schema["properties"]["max_bytes"]["description"] - .as_str() - .expect("max_bytes documents itself"); - assert!(desc.contains("OUTPUT"), "{desc}"); - assert!(desc.contains("never the markup"), "{desc}"); -} - -#[derive(Debug)] -struct RemoteHtml { - fail_detection: bool, - fail_extraction: bool, -} - -#[async_trait] -impl AsyncHtmlExtractor for RemoteHtml { - async fn looks_like_html(&self, body: &str) -> anyhow::Result<bool> { - tokio::task::yield_now().await; - anyhow::ensure!(!self.fail_detection, "remote detection unavailable"); - Ok(TestHtml.looks_like_html(body)) - } - - async fn to_markdown(&self, body: &str) -> anyhow::Result<String> { - tokio::task::yield_now().await; - anyhow::ensure!(!self.fail_extraction, "remote extraction unavailable"); - Ok(TestHtml.to_markdown(body)) - } -} - -fn remote_fetch(fail_detection: bool, fail_extraction: bool) -> WebFetchTool { - WebFetchTool::new_async( - test_security(), - vec![], - None, - None, - DEFAULT_LIMITS, - Arc::new(RemoteHtml { - fail_detection, - fail_extraction, - }), - ) -} - -#[tokio::test] -async fn remote_extraction_reads_before_applying_the_output_cap() { - let tool = remote_fetch(false, false); - let body = page_with_prose_after(4_000); - let url = serve_once(&http_response("200 OK", "", &body)).await; - let result = tool - .fetch_validated(&pinned_to_local(&url), 1_000, false) - .await - .unwrap(); - assert!(result.output().contains("the prose that matters")); - assert!(result.output().contains("content=markdown")); -} - -#[tokio::test] -async fn remote_detection_failure_propagates_without_returning_markup() { - let tool = remote_fetch(true, false); - let url = serve_once(&http_response("200 OK", "", "<html>private</html>")).await; - let error = tool - .fetch_validated(&pinned_to_local(&url), 1_000, false) - .await - .unwrap_err(); - assert_eq!(error.to_string(), "remote detection unavailable"); -} - -#[tokio::test] -async fn remote_extraction_failure_propagates_for_success_and_error_responses() { - let tool = remote_fetch(false, true); - for status in ["200 OK", "403 Forbidden"] { - let url = serve_once(&http_response( - status, - "Content-Type: text/html\r\n", - "<p>private</p>", - )) - .await; - let error = tool - .fetch_validated(&pinned_to_local(&url), 1_000, false) - .await - .unwrap_err(); - assert_eq!(error.to_string(), "remote extraction unavailable"); - } -} - -#[tokio::test] -async fn raw_and_explicit_non_html_responses_do_not_call_the_remote_provider() { - let tool = remote_fetch(true, true); - for (headers, raw) in [("Content-Type: application/json\r\n", false), ("", true)] { - let url = serve_once(&http_response("200 OK", headers, "<p>raw</p>")).await; - let result = tool - .fetch_validated(&pinned_to_local(&url), 1_000, raw) - .await - .unwrap(); - assert!(result.output().ends_with("<p>raw</p>")); - assert!(!result.output().contains("content=markdown")); - } -} - -#[derive(Debug)] -struct PendingHtml; - -#[async_trait] -impl AsyncHtmlExtractor for PendingHtml { - async fn looks_like_html(&self, _body: &str) -> anyhow::Result<bool> { - std::future::pending().await - } - - async fn to_markdown(&self, _body: &str) -> anyhow::Result<String> { - std::future::pending().await - } -} - -#[tokio::test(start_paused = true)] -async fn pending_remote_detection_and_extraction_are_bounded() { - let tool = WebFetchTool::new_async( - test_security(), - vec![], - None, - Some(2), - DEFAULT_LIMITS, - Arc::new(PendingHtml), - ); - let error = is_html(&tool.html, "<html>body</html>", None) - .await - .unwrap_err(); - assert_eq!(error.to_string(), "HTML detection timed out"); - let error = render_body(&tool.html, "<p>body</p>".into(), true, 1_000) - .await - .err() - .unwrap(); - assert_eq!(error.to_string(), "HTML extraction timed out"); - // Error-response excerpts are governed by the same provider deadline. - let error = error_body_excerpt(&tool.html, "<p>body</p>", Some("text/html"), false) - .await - .unwrap_err(); - assert_eq!(error.to_string(), "HTML extraction timed out"); -} - -#[derive(Debug)] -struct InputLengthHtml(Arc<Mutex<Vec<usize>>>); - -#[async_trait] -impl AsyncHtmlExtractor for InputLengthHtml { - async fn looks_like_html(&self, body: &str) -> anyhow::Result<bool> { - self.0.lock().unwrap().push(body.len()); - Ok(true) - } - - async fn to_markdown(&self, body: &str) -> anyhow::Result<String> { - self.0.lock().unwrap().push(body.len()); - Ok(String::new()) - } -} - -#[tokio::test] -async fn every_remote_extractor_call_receives_at_most_the_input_ceiling() { - let lengths = Arc::new(Mutex::new(Vec::new())); - let extractor = HtmlProvider::Async { - extractor: Arc::new(InputLengthHtml(lengths.clone())), - timeout: Duration::from_secs(1), - }; - let body = "x".repeat(EXTRACTOR_INPUT_CEILING + 1); - - // Missing Content-Type takes the remote detection path. - assert!(is_html(&extractor, &body, None).await.unwrap()); - // Error bodies can call detection and extraction directly. - error_body_excerpt(&extractor, &body, None, false) - .await - .unwrap(); - // An explicit HTML error response skips detection but still caps extraction. - error_body_excerpt(&extractor, &body, Some("text/html"), false) - .await - .unwrap(); - - assert_eq!( - *lengths.lock().unwrap(), - vec![ - EXTRACTOR_INPUT_CEILING, - EXTRACTOR_INPUT_CEILING, - EXTRACTOR_INPUT_CEILING, - EXTRACTOR_INPUT_CEILING, - ] - ); -} - -#[test] -fn remote_provider_timeout_uses_the_same_zero_and_none_defaults_as_http() { - for configured in [Some(0), None, Some(7)] { - let tool = WebFetchTool::new_async( - test_security(), - vec![], - None, - configured, - DEFAULT_LIMITS, - Arc::new(PendingHtml), - ); - let HtmlProvider::Async { timeout, .. } = tool.html else { - panic!("expected remote provider") - }; - assert_eq!(timeout, Duration::from_secs(tool.timeout_secs)); - } -} - -#[test] -fn status_of_reads_the_leading_web_fetch_error_shape() { - assert_eq!( - status_of("HTTP 403 Forbidden from example.com; try another source\nbody"), - Some(403) - ); - assert_eq!( - status_of(" HTTP 503 Service Unavailable from a.b; retry later"), - Some(503) - ); -} - -#[test] -fn status_of_rejects_quoted_bare_or_non_error_statuses() { - assert_eq!(status_of("403 Forbidden"), None); - assert_eq!(status_of("HTTP 403"), None); - assert_eq!(status_of("HTTP 200 OK from example.com; fine"), None); - assert_eq!(status_of("HTTP 99 x from h; g"), None); - assert_eq!(status_of("HTTP 404 Not Found from two words; g"), None); - assert_eq!( - status_of("error\nHTTP 403 Forbidden from example.com; quoted"), - None - ); -} - -/// As for `http_request`: the fetch connects to the vetted address and keeps -/// the host name, rather than resolving the name again. -#[tokio::test] -async fn the_fetch_connects_to_the_pinned_address() { - let url = serve_once(&http_response( - "200 OK", - "Content-Type: text/plain\r\n", - "pinned", - )) - .await; - let port = reqwest::Url::parse(&url).unwrap().port().unwrap(); - let target = ValidatedUrl { - url: format!("http://pinned.invalid:{port}/page"), - host: "pinned.invalid".to_string(), - addrs: vec![format!("127.0.0.1:{port}").parse().unwrap()], - }; - let result = fetch(test_security(), vec![], None, None) - .fetch_validated(&target, 1_000_000, false) - .await - .unwrap(); - assert!(!result.is_error, "{result:?}"); -} diff --git a/crates/tinytools-std/src/timeout/command_environment.rs b/crates/tinytools-std/src/timeout/command_environment.rs deleted file mode 100644 index 92bcb262..00000000 --- a/crates/tinytools-std/src/timeout/command_environment.rs +++ /dev/null @@ -1,70 +0,0 @@ -//! Child-process environment carried by one dispatch task. - -use std::{collections::BTreeMap, future::Future, sync::Arc}; - -tokio::task_local! { static ACTIVE: CommandEnvironment; } - -/// A complete, turn-owned environment for builtin tool subprocesses. -/// This never mutates the host process environment. Host-spawned tasks must -/// explicitly inherit its scope. Interpreter pools cannot accept scoped work. -#[derive(Clone, Debug)] -pub struct CommandEnvironment(Arc<BTreeMap<String, String>>); - -impl CommandEnvironment { - /// Own the exact variables permitted in child processes. - #[must_use] - pub fn new(env: impl IntoIterator<Item = (String, String)>) -> Self { - Self(Arc::new(env.into_iter().collect())) - } - /// Whether this task requires its own child environment. - #[must_use] - pub fn is_active() -> bool { - ACTIVE.try_with(|_| ()).is_ok() - } - /// Run a future using this environment for builtin subprocesses. - pub async fn scope<F: Future>(&self, future: F) -> F::Output { - ACTIVE.scope(self.clone(), future).await - } - /// Read a functional child variable from the scoped map, falling back to - /// the process only when no map was supplied. - #[must_use] - pub fn var_os(name: &str) -> Option<std::ffi::OsString> { - ACTIVE - .try_with(|environment| environment.0.get(name).map(Into::into)) - .unwrap_or_else(|_| std::env::var_os(name)) - } - /// UTF-8 counterpart used by managed interpreter command builders. - pub fn var(name: &str) -> Result<String, std::env::VarError> { - ACTIVE - .try_with(|environment| { - environment - .0 - .get(name) - .cloned() - .ok_or(std::env::VarError::NotPresent) - }) - .unwrap_or_else(|_| std::env::var(name)) - } - pub(super) fn apply(command: &mut tokio::process::Command) { - let _ = ACTIVE.try_with(|environment| { - // Explicit runtime/security additions (managed PATH, scratch TMP, - // Git policy) remain authoritative over the base environment. - let explicit: Vec<_> = command - .as_std() - .get_envs() - .map(|(key, value)| (key.to_owned(), value.map(ToOwned::to_owned))) - .collect(); - command.env_clear().envs(environment.0.iter()); - for (key, value) in explicit { - match value { - Some(value) => { - command.env(key, value); - } - None => { - command.env_remove(key); - } - } - } - }); - } -} diff --git a/crates/tinytools-std/src/timeout/group_exit.rs b/crates/tinytools-std/src/timeout/group_exit.rs deleted file mode 100644 index a30cc7e1..00000000 --- a/crates/tinytools-std/src/timeout/group_exit.rs +++ /dev/null @@ -1,59 +0,0 @@ -//! Wait for signalled Linux process-group members to stop executing. -//! -//! The caller keeps the group leader unreaped during this check, reserving -//! its PID and preventing a new process group from reusing the identifier. - -/// Wait until the reserved group contains no executing Linux processes. -#[cfg(target_os = "linux")] -pub(super) async fn wait(pid: u32) -> std::io::Result<()> { - loop { - let active = tokio::task::spawn_blocking(move || active_members(pid)) - .await - .map_err(std::io::Error::other)??; - if !active { - return Ok(()); - } - tokio::time::sleep(std::time::Duration::from_millis(1)).await; - } -} - -/// Other platforms retain their existing direct-child acknowledgement. -#[cfg(not(target_os = "linux"))] -pub(super) async fn wait(_: u32) -> std::io::Result<()> { - Ok(()) -} - -/// Inspect kernel process states without reaping the reserved group leader. -#[cfg(target_os = "linux")] -fn active_members(group: u32) -> std::io::Result<bool> { - for entry in std::fs::read_dir("/proc")? { - let entry = entry?; - if entry.file_name().to_string_lossy().parse::<u32>().is_err() { - continue; - } - // Process entries can disappear or become unreadable between - // enumeration and reading. - if let Ok(stat) = std::fs::read_to_string(entry.path().join("stat")) { - if is_active_member(&stat, group) { - return Ok(true); - } - } - } - Ok(false) -} - -/// Zombies and dead tasks cannot execute further side effects. -#[cfg(target_os = "linux")] -pub(super) fn is_active_member(stat: &str, group: u32) -> bool { - let Some((_, fields)) = stat.rsplit_once(") ") else { - return false; - }; - let mut fields = fields.split_whitespace(); - let state = fields.next(); - let process_group = fields.nth(1).and_then(|value| value.parse::<u32>().ok()); - process_group == Some(group) && !matches!(state, Some("Z" | "X" | "x")) -} - -#[cfg(all(test, target_os = "linux"))] -#[path = "group_exit_tests.rs"] -mod tests; diff --git a/crates/tinytools-std/src/timeout/group_exit_tests.rs b/crates/tinytools-std/src/timeout/group_exit_tests.rs deleted file mode 100644 index 9b20c5bf..00000000 --- a/crates/tinytools-std/src/timeout/group_exit_tests.rs +++ /dev/null @@ -1,44 +0,0 @@ -#![allow(clippy::unwrap_used, clippy::expect_used)] -use super::*; - -#[test] -fn exited_members_do_not_block_cleanup_but_running_members_do() { - for state in ["R", "S", "D", "T", "I"] { - assert!(is_active_member( - &format!("12 (name with ) spaces) {state} 1 42 42"), - 42 - )); - } - for state in ["Z", "X", "x"] { - assert!(!is_active_member( - &format!("12 (sleep) {state} 1 42 42"), - 42 - )); - } - assert!(!is_active_member("12 (sleep) R 1 43 43", 42)); - assert!(!is_active_member("malformed", 42)); -} - -#[tokio::test] -async fn wait_returns_immediately_for_a_group_with_no_members() { - // PID 0x7fff_fff0 is above any pid_max, so no process group matches. - wait(0x7fff_fff0).await.unwrap(); - assert!(!active_members(0x7fff_fff0).unwrap()); -} - -#[tokio::test] -async fn wait_polls_until_the_group_has_stopped() { - let mut cmd = tokio::process::Command::new("sh"); - cmd.arg("-c").arg("sleep 0.3"); - cmd.process_group(0); - let mut child = cmd.spawn().unwrap(); - let pid = child.id().unwrap(); - assert!(active_members(pid).unwrap()); - // The group leader stays unreaped, so `wait` only returns once the - // member has exited (zombie) after polling at least once. - tokio::time::timeout(std::time::Duration::from_secs(10), wait(pid)) - .await - .expect("group stops") - .unwrap(); - child.wait().await.unwrap(); -} diff --git a/crates/tinytools-std/src/timeout/mod.rs b/crates/tinytools-std/src/timeout/mod.rs deleted file mode 100644 index d7d595f4..00000000 --- a/crates/tinytools-std/src/timeout/mod.rs +++ /dev/null @@ -1,39 +0,0 @@ -//! Tool-execution timeouts and child-process management. -//! -//! - [`settings`]: bounds, resolution and the process-global inherited timeout. -//! The host reads its config and env, then calls [`configure`]. -//! - [`output_or_kill`] / [`output_unbounded`] / [`output_with_input`]: capture a -//! command's output, killing its whole process group on a deadline or drop. -//! - [`ProcessCleanup`]: lets an embedder await reaping after cancelling a turn; -//! [`CleanupHold`] extends that wait to work a host tool hands off to its own task. -//! - [`CommandEnvironment`]: a turn-owned child environment, never the host's. - -#![allow( - clippy::missing_errors_doc, - clippy::ignored_unit_patterns, - clippy::struct_field_names, - clippy::collapsible_if -)] - -mod command_environment; -mod group_exit; -mod output; -mod process_cleanup; -mod settings; - -pub use command_environment::CommandEnvironment; -pub use output::{ - kill_process_group, output_or_kill, output_unbounded, output_with_input, own_process_group, - set_task_wrapper, -}; -pub use process_cleanup::{CleanupHold, ProcessCleanup}; -pub use settings::{ - DEFAULT_TIMEOUT_SECS, MAX_TIMEOUT_SECS, MIN_TIMEOUT_SECS, ResolvedTimeout, - SANDBOX_UNBOUNDED_CAP_SECS, TOOL_TIMEOUT_GRACE_SECS, TimeoutSettings, configure, - explicit_call_timeout_duration, explicit_call_timeout_secs, override_from, - parse_tool_timeout_secs, resolve_effective, settings, -}; - -#[cfg(test)] -#[path = "mod_tests.rs"] -mod tests; diff --git a/crates/tinytools-std/src/timeout/mod_tests.rs b/crates/tinytools-std/src/timeout/mod_tests.rs deleted file mode 100644 index d5bc1a80..00000000 --- a/crates/tinytools-std/src/timeout/mod_tests.rs +++ /dev/null @@ -1,424 +0,0 @@ -#![allow( - clippy::expect_used, - clippy::panic, - clippy::unwrap_used, - clippy::cast_sign_loss -)] -use super::*; -use std::time::Duration; -use tinytools::ToolTimeout; - -fn sh(script: &str) -> tokio::process::Command { - let mut cmd = tokio::process::Command::new("sh"); - cmd.arg("-c").arg(script); - cmd -} - -#[cfg(unix)] -fn process_exists(pid: i32) -> bool { - rustix::process::Pid::from_raw(pid) - .is_some_and(|pid| rustix::process::test_kill_process(pid).is_ok()) -} - -#[test] -fn configure_pushes_the_inherited_timeout_into_the_global() { - let global = configure(TimeoutSettings::with_inherited_secs(77)); - assert_eq!(global.inherited_timeout(), Duration::from_secs(77)); - configure(TimeoutSettings::with_inherited_secs(88)); - assert_eq!(settings().inherited_timeout(), Duration::from_secs(88)); - assert_eq!( - settings().resolve(ToolTimeout::Inherit).deadline, - Some(Duration::from_secs(88)) - ); - assert_eq!(settings().resolve(ToolTimeout::Unbounded).deadline, None); -} - -#[test] -fn default_when_env_missing() { - assert_eq!(parse_tool_timeout_secs(None), DEFAULT_TIMEOUT_SECS); -} - -#[test] -fn default_when_value_not_numeric() { - assert_eq!( - parse_tool_timeout_secs(Some("not-a-number")), - DEFAULT_TIMEOUT_SECS - ); - assert_eq!(parse_tool_timeout_secs(Some("")), DEFAULT_TIMEOUT_SECS); - assert_eq!(parse_tool_timeout_secs(Some("12x")), DEFAULT_TIMEOUT_SECS); -} - -#[test] -fn default_when_value_zero() { - // 0 seconds would disable the timeout — reject and fall back. - assert_eq!(parse_tool_timeout_secs(Some("0")), DEFAULT_TIMEOUT_SECS); -} - -#[test] -fn default_when_value_above_max() { - assert_eq!(parse_tool_timeout_secs(Some("3601")), DEFAULT_TIMEOUT_SECS); - assert_eq!( - parse_tool_timeout_secs(Some("99999999999")), - DEFAULT_TIMEOUT_SECS - ); -} - -#[test] -fn default_when_value_negative_or_signed() { - // Negative values fail u64 parse and fall back to default. - assert_eq!(parse_tool_timeout_secs(Some("-5")), DEFAULT_TIMEOUT_SECS); -} - -#[test] -fn accepts_valid_values_at_boundaries() { - assert_eq!(parse_tool_timeout_secs(Some("1")), MIN_TIMEOUT_SECS); - assert_eq!(parse_tool_timeout_secs(Some("3600")), MAX_TIMEOUT_SECS); -} - -#[test] -fn accepts_valid_midrange_value() { - assert_eq!(parse_tool_timeout_secs(Some("300")), 300); -} - -#[test] -fn env_override_takes_precedence_over_config() { - // When the env var holds a valid value it wins over the config value. - assert_eq!(resolve_effective(300, Some("600")), 600); -} - -#[test] -fn config_value_used_when_env_absent_or_invalid() { - // No env → config drives the effective value (bounded). - assert_eq!(resolve_effective(300, None), 300); - // Present-but-invalid env (non-numeric / 0 / out of range) is ignored, - // so the config value still applies. - assert_eq!(resolve_effective(300, Some("nonsense")), 300); - assert_eq!(resolve_effective(300, Some("0")), 300); - assert_eq!(resolve_effective(300, Some("4000")), 300); -} - -#[test] -fn config_value_is_bounded() { - // An out-of-range config value falls back to the default rather than - // being applied verbatim. - assert_eq!(resolve_effective(0, None), DEFAULT_TIMEOUT_SECS); - assert_eq!(resolve_effective(99_999, None), DEFAULT_TIMEOUT_SECS); -} - -#[test] -fn explicit_call_timeout_unbounded_when_absent_or_disabled() { - // No request, or an explicit 0, means "run unbounded" (None). - assert_eq!(explicit_call_timeout_secs(None, MAX_TIMEOUT_SECS), None); - assert_eq!(explicit_call_timeout_secs(Some(0), MAX_TIMEOUT_SECS), None); -} - -#[test] -fn explicit_call_timeout_enforces_and_clamps_request() { - // An in-range request is enforced verbatim. - assert_eq!( - explicit_call_timeout_secs(Some(900), MAX_TIMEOUT_SECS), - Some(900) - ); - // Below the floor clamps up to MIN. - assert_eq!( - explicit_call_timeout_secs(Some(0), MAX_TIMEOUT_SECS), - None, - "0 disables rather than clamping to MIN" - ); - // Above the cap clamps down to the cap. - assert_eq!( - explicit_call_timeout_secs(Some(99_999), MAX_TIMEOUT_SECS), - Some(MAX_TIMEOUT_SECS) - ); - // A tool with a tighter own ceiling (node/npm at 1800) clamps to it. - assert_eq!(explicit_call_timeout_secs(Some(99_999), 1800), Some(1800)); - assert_eq!(explicit_call_timeout_secs(Some(600), 1800), Some(600)); -} - -#[test] -fn explicit_call_timeout_duration_matches_secs() { - assert_eq!( - explicit_call_timeout_duration(Some(900), MAX_TIMEOUT_SECS), - Some(Duration::from_secs(900)) - ); - assert_eq!(explicit_call_timeout_duration(None, MAX_TIMEOUT_SECS), None); -} - -#[test] -fn override_from_rejects_invalid() { - assert_eq!(override_from(None), None); - assert_eq!(override_from(Some("")), None); - assert_eq!(override_from(Some("0")), None); - assert_eq!(override_from(Some("abc")), None); - assert_eq!(override_from(Some("3601")), None); - assert_eq!(override_from(Some("120")), Some(120)); -} - -/// Table of `(policy, inherited config secs, expected (deadline_ms, budget_secs))`. -/// Pins the deadline resolution. -#[test] -fn resolve_tool_deadline_table() { - use tinytools::ToolTimeout; - let ms = |m: u64| Some(Duration::from_millis(m)); - let cases: Vec<(ToolTimeout, u64, (Option<Duration>, u64))> = vec![ - // Inherit: the global config value, no grace. - (ToolTimeout::Inherit, 120, (ms(120_000), 120)), - (ToolTimeout::Inherit, 1, (ms(1_000), 1)), - (ToolTimeout::Inherit, 3600, (ms(3_600_000), 3600)), - // Unbounded: no deadline, zero budget. - (ToolTimeout::Unbounded, 120, (None, 0)), - // Millis: rounded UP to whole seconds, clamped, +5s grace on deadline. - (ToolTimeout::Millis(0), 120, (ms(6_000), 1)), - (ToolTimeout::Millis(1), 120, (ms(6_000), 1)), - (ToolTimeout::Millis(1_000), 120, (ms(6_000), 1)), - (ToolTimeout::Millis(1_001), 120, (ms(7_000), 2)), - (ToolTimeout::Millis(1_500), 120, (ms(7_000), 2)), - (ToolTimeout::Millis(30_000), 120, (ms(35_000), 30)), - (ToolTimeout::Millis(3_600_000), 120, (ms(3_605_000), 3600)), - (ToolTimeout::Millis(3_600_001), 120, (ms(3_605_000), 3600)), - (ToolTimeout::Millis(u64::MAX), 120, (ms(3_605_000), 3600)), - ]; - for (policy, inherited_secs, expected) in cases { - // Resolve against the pure helper so the process-global stays untouched. - let r = TimeoutSettings::with_inherited_secs(inherited_secs).resolve(policy); - let got = (r.deadline, r.budget_ms / 1000); - assert_eq!( - got, expected, - "policy {policy:?} inherited {inherited_secs}s" - ); - } -} - -/// Env / config precedence table for the effective inherited timeout. -#[test] -fn resolve_effective_table() { - let cases: &[(u64, Option<&str>, u64)] = &[ - (300, None, 300), - (300, Some("600"), 600), - (300, Some("1"), 1), - (300, Some("3600"), 3600), - (300, Some("3601"), 300), - (300, Some("0"), 300), - (300, Some("-1"), 300), - (300, Some(""), 300), - (300, Some("abc"), 300), - (0, None, DEFAULT_TIMEOUT_SECS), - (0, Some("45"), 45), - (3601, None, DEFAULT_TIMEOUT_SECS), - (u64::MAX, None, DEFAULT_TIMEOUT_SECS), - (1, None, 1), - (3600, None, 3600), - ]; - for &(config, env, expected) in cases { - assert_eq!( - resolve_effective(config, env), - expected, - "config {config} env {env:?}" - ); - } -} - -/// The deadline must take the command's whole process group with it. A shell -/// pipeline is grandchildren of the tool's child, and a `grep -rl … /` the tool -/// had reported as killed ran on for half an hour at a full core. -#[cfg(unix)] -#[tokio::test] -async fn a_timed_out_command_takes_its_whole_process_group_with_it() { - let dir = tempfile::tempdir() - .unwrap() - .keep() - .join(format!("oh-pgkill-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let pidfile = dir.join("grandchild.pid"); - // `sleep 30 &` is a grandchild in the shell's own process group; `wait` - // keeps the shell alive past the deadline. - let mut cmd = sh(&format!("sleep 30 & echo $! > {}; wait", pidfile.display())); - let result = output_or_kill(&mut cmd, Duration::from_millis(700)).await; - assert!(result.is_err(), "the deadline must fire on a 30s sleep"); - - let grandchild: i32 = loop { - if let Ok(text) = std::fs::read_to_string(&pidfile) { - if let Ok(pid) = text.trim().parse() { - break pid; - } - } - tokio::time::sleep(Duration::from_millis(20)).await; - }; - let mut gone = false; - for _ in 0..100 { - // kill(pid, 0) succeeds while the process (or its zombie) exists. - if !process_exists(grandchild) { - gone = true; - break; - } - // A container's PID 1 may leave a killed orphan unreaped. It cannot - // execute side effects; use the same kernel-state rule as cleanup. - #[cfg(target_os = "linux")] - if let Ok(stat) = std::fs::read_to_string(format!("/proc/{grandchild}/stat")) { - let group = rustix::process::Pid::from_raw(grandchild) - .and_then(|pid| rustix::process::getpgid(Some(pid)).ok()); - if let Some(group) = group.map(|g| g.as_raw_nonzero().get() as u32) { - if !group_exit::is_active_member(&stat, group) { - gone = true; - break; - } - } - } - tokio::time::sleep(Duration::from_millis(20)).await; - } - let _ = std::fs::remove_dir_all(&dir); - assert!(gone, "grandchild {grandchild} survived the deadline"); -} - -/// Without a deadline the child still dies with the handle: a cancelled tool -/// future must not leave the command running either. -#[cfg(unix)] -#[tokio::test] -async fn a_shell_family_child_dies_with_a_dropped_future() { - let dir = tempfile::tempdir() - .unwrap() - .keep() - .join(format!("oh-dropkill-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let pidfile = dir.join("child.pid"); - let mut cmd = sh(&format!("echo $$ > {}; sleep 30", pidfile.display())); - // `timeout` takes the future by value and drops it when the deadline - // passes -- exactly the abandoned-future path a cancelled tool call takes. - let _ = tokio::time::timeout(Duration::from_millis(300), output_unbounded(&mut cmd)).await; - let shell: i32 = loop { - if let Ok(text) = std::fs::read_to_string(&pidfile) { - if let Ok(pid) = text.trim().parse() { - break pid; - } - } - tokio::time::sleep(Duration::from_millis(20)).await; - }; - let mut gone = false; - for _ in 0..100 { - if !process_exists(shell) { - gone = true; - break; - } - tokio::time::sleep(Duration::from_millis(20)).await; - } - let _ = std::fs::remove_dir_all(&dir); - assert!(gone, "shell {shell} survived its dropped future"); -} - -/// A reaped scope: after the turn future is dropped, `ProcessCleanup::wait` -/// returns only once the sleeping child group is gone. -#[cfg(target_os = "linux")] -#[tokio::test] -async fn process_cleanup_waits_for_a_cancelled_sleeping_group() { - let cleanup = ProcessCleanup::default(); - let turn = { - let cleanup = cleanup.clone(); - tokio::spawn(async move { - cleanup - .scope(async { - let mut cmd = sh("sleep 30 & wait"); - let _ = output_unbounded(&mut cmd).await; - }) - .await; - }) - }; - tokio::time::sleep(Duration::from_millis(300)).await; - turn.abort(); - let _ = turn.await; - tokio::time::timeout(Duration::from_secs(10), cleanup.wait()) - .await - .expect("cleanup completes once the group is reaped"); -} - -#[tokio::test] -async fn command_environment_replaces_the_child_environment() { - let env = CommandEnvironment::new([ - ("PATH".to_string(), "/usr/bin:/bin".to_string()), - ("ONLY_THIS".to_string(), "1".to_string()), - ]); - let out = env - .scope(async { - let mut cmd = sh("echo ${ONLY_THIS:-missing}-${HOME:-nohome}"); - output_unbounded(&mut cmd).await.unwrap() - }) - .await; - assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "1-nohome"); -} - -#[tokio::test] -async fn command_environment_scopes_variable_lookup() { - assert!(!CommandEnvironment::is_active()); - let env = CommandEnvironment::new([("SCOPED_ONLY".to_string(), "yes".to_string())]); - env.scope(async { - assert!(CommandEnvironment::is_active()); - assert_eq!( - CommandEnvironment::var_os("SCOPED_ONLY").unwrap(), - std::ffi::OsString::from("yes") - ); - assert_eq!(CommandEnvironment::var("SCOPED_ONLY").unwrap(), "yes"); - assert!(CommandEnvironment::var_os("PATH").is_none()); - assert!(CommandEnvironment::var("PATH").is_err()); - }) - .await; - assert!(CommandEnvironment::var_os("PATH").is_some()); - assert!(CommandEnvironment::var("PATH").is_ok()); -} - -#[tokio::test] -async fn explicit_command_env_overrides_and_removals_survive_the_scoped_base() { - let env = CommandEnvironment::new([ - ("PATH".to_string(), "/usr/bin:/bin".to_string()), - ("BASE".to_string(), "base".to_string()), - ("DROP".to_string(), "x".to_string()), - ]); - let out = env - .scope(async { - let mut cmd = sh("echo $BASE-$EXTRA-${DROP:-dropped}"); - cmd.env("BASE", "override") - .env("EXTRA", "e") - .env_remove("DROP"); - output_unbounded(&mut cmd).await.unwrap() - }) - .await; - assert_eq!( - String::from_utf8_lossy(&out.stdout).trim(), - "override-e-dropped" - ); -} - -#[tokio::test] -async fn process_cleanup_reports_active_only_inside_its_scope() { - assert!(!ProcessCleanup::is_active()); - let cleanup = ProcessCleanup::default(); - cleanup - .scope(async { - assert!(ProcessCleanup::is_active()); - }) - .await; - cleanup.wait().await; -} - -/// A hold taken inside a turn's scope keeps `wait` pending until dropped, so -/// detached host work can finish its own cleanup before cancellation is -/// acknowledged. A hold taken outside any scope registers nothing. -#[tokio::test] -async fn a_cleanup_hold_keeps_wait_pending_until_dropped() { - let cleanup = ProcessCleanup::default(); - let hold = cleanup.scope(async { ProcessCleanup::hold() }).await; - assert!( - tokio::time::timeout(Duration::from_millis(50), cleanup.wait()) - .await - .is_err(), - "wait must not return while the hold is alive" - ); - drop(hold); - tokio::time::timeout(Duration::from_secs(1), cleanup.wait()) - .await - .expect("wait returns once the hold is dropped"); - - let unscoped = ProcessCleanup::default(); - let _outside = ProcessCleanup::hold(); - tokio::time::timeout(Duration::from_secs(1), unscoped.wait()) - .await - .expect("a hold outside any scope registers nothing"); -} diff --git a/crates/tinytools-std/src/timeout/output.rs b/crates/tinytools-std/src/timeout/output.rs deleted file mode 100644 index ef31b521..00000000 --- a/crates/tinytools-std/src/timeout/output.rs +++ /dev/null @@ -1,227 +0,0 @@ -//! Child-process management: bounded capture, process-group kill, reaping. - -use std::future::Future; -use std::pin::Pin; -use std::sync::OnceLock; -use std::time::Duration; - -use super::command_environment::CommandEnvironment; -use super::group_exit; -use super::process_cleanup::Reaped; - -type BoxTask = Pin<Box<dyn Future<Output = std::io::Result<std::process::Output>> + Send>>; - -static TASK_WRAPPER: OnceLock<fn(BoxTask) -> BoxTask> = OnceLock::new(); - -/// Register a wrapper applied to every internal waiter task before it is -/// spawned, so a host can carry its ambient task-locals (request context, -/// identity) into it. First registration wins; returns whether it took effect. -pub fn set_task_wrapper(wrapper: fn(BoxTask) -> BoxTask) -> bool { - TASK_WRAPPER.set(wrapper).is_ok() -} - -fn spawn_wrapped( - fut: impl Future<Output = std::io::Result<std::process::Output>> + Send + 'static, -) -> tokio::task::JoinHandle<std::io::Result<std::process::Output>> { - let fut: BoxTask = Box::pin(fut); - tokio::spawn(match TASK_WRAPPER.get() { - Some(wrap) => wrap(fut), - None => fut, - }) -} - -/// Run `cmd` to completion with its output captured, or kill it -- and every -/// process it started -- when `deadline` passes. -/// -/// `tokio::time::timeout(deadline, cmd.output())` only abandons the future. -/// Without `kill_on_drop` the child is not even signalled, and a shell's -/// pipeline (`grep -r … | head`) is a set of grandchildren that no kill of the -/// direct child reaches anyway. One `grep -rl … /` the shell tool had reported -/// as "timed out after 600s and was killed" ran on for half an hour at a full -/// core inside a two-CPU container, starving the agent that had started it. -/// -/// So the child is spawned as the leader of its own process group and the -/// whole group is signalled when the deadline fires. The result has the same -/// shape as `timeout(deadline, cmd.output())`, so a caller's match arms do not -/// change. Stdio is set the way `output()` sets it: stdin closed, both output -/// streams captured. -pub async fn output_or_kill( - cmd: &mut tokio::process::Command, - deadline: Duration, -) -> Result<std::io::Result<std::process::Output>, tokio::time::error::Elapsed> { - tokio::time::timeout(deadline, output_unbounded(cmd)).await -} - -/// Capture a command with no deadline, killing its process group if the -/// future is dropped. The child is reaped by an owned waiter even when the -/// caller cancels; a scoped [`super::ProcessCleanup`] can await that waiter. -pub async fn output_unbounded( - cmd: &mut tokio::process::Command, -) -> std::io::Result<std::process::Output> { - output_with_input(cmd, None).await -} - -/// Capture a command, optionally supplying stdin, in the current cleanup scope. -pub async fn output_with_input( - cmd: &mut tokio::process::Command, - input: Option<Vec<u8>>, -) -> std::io::Result<std::process::Output> { - use std::process::Stdio; - cmd.stdin(if input.is_some() { - Stdio::piped() - } else { - Stdio::null() - }) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .kill_on_drop(true); - CommandEnvironment::apply(cmd); - own_process_group(cmd.as_std_mut()); - let mut child = cmd.spawn()?; - let stdin = child.stdin.take(); - let reaped = Reaped::register(); - let (cancel, cancellation) = tokio::sync::watch::channel(false); - let waiter = spawn_wrapped(async move { - let _reaped = reaped; - let write_input = async move { - if let (Some(mut stdin), Some(input)) = (stdin, input) { - use tokio::io::AsyncWriteExt; - // An early child exit closes stdin. Its exit status/output is - // authoritative; a broken pipe must not hide it. - let _ = stdin.write_all(&input).await; - } - }; - let (_, output) = tokio::join!(write_input, collect_command_output(child, cancellation)); - output - }); - let _cancel_on_drop = CancelOnDrop(cancel); - waiter.await.map_err(std::io::Error::other)? -} - -// The caller signals only the owned waiter. It never retains a PID after -// that waiter reaps the child, so late future drops cannot kill a reused PID. -struct CancelOnDrop(tokio::sync::watch::Sender<bool>); - -impl Drop for CancelOnDrop { - fn drop(&mut self) { - self.0.send_replace(true); - } -} - -struct CommandGroup(Option<u32>); - -impl CommandGroup { - fn kill(&self) { - if let Some(pid) = self.0 { - kill_process_group(pid); - } - } -} - -impl Drop for CommandGroup { - fn drop(&mut self) { - self.kill(); - } -} - -async fn collect_command_output( - mut child: tokio::process::Child, - mut cancellation: tokio::sync::watch::Receiver<bool>, -) -> std::io::Result<std::process::Output> { - use tokio::io::AsyncReadExt; - - let mut group = CommandGroup(child.id()); - let mut stdout = child - .stdout - .take() - .ok_or_else(|| std::io::Error::other("command stdout is not piped"))?; - let mut stderr = child - .stderr - .take() - .ok_or_else(|| std::io::Error::other("command stderr is not piped"))?; - let mut stdout_bytes = Vec::new(); - let mut stderr_bytes = Vec::new(); - { - let drain = async { - tokio::try_join!( - stdout.read_to_end(&mut stdout_bytes), - stderr.read_to_end(&mut stderr_bytes), - ) - }; - tokio::pin!(drain); - tokio::select! { - biased; - _ = async { let _ = cancellation.wait_for(|cancelled| *cancelled).await; } => { - // The leader has not been reaped, even if it already exited. - // Its PID cannot be reused while signalling this group. - group.kill(); - child.start_kill()?; - if let Ok(result) = tokio::time::timeout(Duration::from_secs(2), drain).await { - result?; - } - } - result = &mut drain => { result?; } - } - } - let status = tokio::select! { - biased; - _ = async { let _ = cancellation.wait_for(|cancelled| *cancelled).await; } => { - group.kill(); - child.start_kill()?; - // Pipe EOF and the shell's exit can precede a descendant's exit. - // Keep the leader unreaped while checking the reserved group. - let stopped = match group.0 { - Some(pid) => group_exit::wait(pid).await, - None => Ok(()), - }; - let status = child.wait().await?; - // Disarm before propagating a check error after reaping: its PID - // could now be reused, including while unwinding this function. - group.0 = None; - stopped?; - status - } - result = child.wait() => result?, - }; - // No await between reaping and disarming. Only this waiter owns the PID. - group.0 = None; - Ok(std::process::Output { - status, - stdout: stdout_bytes, - stderr: stderr_bytes, - }) -} - -/// Make `cmd` the leader of a new process group when it is spawned, so that -/// [`kill_process_group`] can reach everything it starts. A no-op off Unix. -pub fn own_process_group(cmd: &mut std::process::Command) { - #[cfg(unix)] - { - use std::os::unix::process::CommandExt; - cmd.process_group(0); - } - #[cfg(not(unix))] - { - let _ = cmd; - } -} - -/// Send SIGKILL to the process group led by `pid` -- a child spawned via -/// [`own_process_group`] and everything it started. Off Unix the direct child -/// is what `kill_on_drop` reaches and no group exists to signal. -pub fn kill_process_group(pid: u32) { - #[cfg(unix)] - { - use rustix::process::{Pid, Signal}; - let Some(pid) = i32::try_from(pid).ok().and_then(Pid::from_raw) else { - return; - }; - // A signal to a process group this process created; the kernel - // validates the target. A failure (group already gone) is expected. - let _ = rustix::process::kill_process_group(pid, Signal::KILL); - } - #[cfg(not(unix))] - { - let _ = pid; - } -} diff --git a/crates/tinytools-std/src/timeout/process_cleanup.rs b/crates/tinytools-std/src/timeout/process_cleanup.rs deleted file mode 100644 index 845f11c9..00000000 --- a/crates/tinytools-std/src/timeout/process_cleanup.rs +++ /dev/null @@ -1,94 +0,0 @@ -//! Track command reaping for an embedder that awaits turn cancellation. - -use std::future::Future; -use std::sync::{Arc, Mutex}; - -use tokio::sync::watch; - -tokio::task_local! { - static ACTIVE: Vec<ProcessCleanup>; -} - -/// Command waiters belonging to one turn. Clone before scoping the turn; -/// after dropping the turn future, [`wait`](Self::wait) awaits their reaping. -/// Tasks spawned by a host tool must explicitly inherit this scope. -#[derive(Clone, Default, Debug)] -pub struct ProcessCleanup(Arc<Mutex<Vec<watch::Receiver<bool>>>>); - -impl ProcessCleanup { - /// Whether the current task must acknowledge owned subprocess cleanup. - /// Interpreter pools have no per-job cancellation acknowledgement and - /// therefore cannot accept work from this scope. - #[must_use] - pub fn is_active() -> bool { - ACTIVE.try_with(|_| ()).is_ok() - } - - /// Run a future with command waiters registered to this turn. - pub async fn scope<T>(&self, future: impl Future<Output = T>) -> T { - let mut scopes = ACTIVE.try_with(Clone::clone).unwrap_or_default(); - scopes.push(self.clone()); - ACTIVE.scope(scopes, future).await - } - - /// Register work that the current turn's [`wait`](Self::wait) must outlast - /// until the returned hold is dropped: the explicit inheritance a host tool - /// needs when it hands a command to a task or service of its own. Take it - /// inside the scope (before spawning), move it into the detached work, and - /// drop it once that work has stopped. Outside any scope it holds nothing. - pub fn hold() -> CleanupHold { - CleanupHold { - _reaped: Reaped::register(), - } - } - - /// Wait for every registered command to exit and its output pipes to close. - /// Call only after the scoped future has finished or been dropped, so no - /// further commands can be registered by it. - pub async fn wait(&self) { - let waiters = self - .0 - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .clone(); - for mut waiter in waiters { - let _ = waiter.wait_for(|done| *done).await; - } - } -} - -/// Keeps the turn's cleanup waiting while alive. See [`ProcessCleanup::hold`]. -#[must_use = "the cleanup stops waiting as soon as the hold is dropped"] -pub struct CleanupHold { - _reaped: Reaped, -} - -impl std::fmt::Debug for CleanupHold { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("CleanupHold").finish_non_exhaustive() - } -} - -pub(super) struct Reaped(watch::Sender<bool>); - -impl Reaped { - pub(super) fn register() -> Self { - let (done, waiter) = watch::channel(false); - let _ = ACTIVE.try_with(|scopes| { - for cleanup in scopes { - cleanup - .0 - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .push(waiter.clone()); - } - }); - Self(done) - } -} - -impl Drop for Reaped { - fn drop(&mut self) { - self.0.send_replace(true); - } -} diff --git a/crates/tinytools-std/src/timeout/settings.rs b/crates/tinytools-std/src/timeout/settings.rs deleted file mode 100644 index f5df16b4..00000000 --- a/crates/tinytools-std/src/timeout/settings.rs +++ /dev/null @@ -1,164 +0,0 @@ -//! Wall-clock bounds for tool execution: pure resolution, no host policy. -//! -//! The host decides where the number comes from (config, an operator env var) -//! and pushes it in through [`configure`]; this module only bounds, resolves -//! and stores it. The inherited value lives in an atomic so a later push -//! reaches every reader on its next call. - -use std::sync::OnceLock; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::time::Duration; - -use tinytools::ToolTimeout; - -/// Default tool-execution timeout in seconds when nothing else is configured. -pub const DEFAULT_TIMEOUT_SECS: u64 = 120; -/// Smallest accepted timeout. `0` would disable the timeout entirely, so it is -/// rejected and falls back to the default. -pub const MIN_TIMEOUT_SECS: u64 = 1; -/// Largest accepted timeout (1 hour). -pub const MAX_TIMEOUT_SECS: u64 = 3600; -/// Effective-unbounded cap (24h) for sandbox backends, which require a finite -/// deadline. -pub const SANDBOX_UNBOUNDED_CAP_SECS: u64 = 86_400; -/// Slack added on top of an explicit per-call budget before the hard deadline -/// fires, so a tool finishing right at its budget is not killed by jitter. -pub const TOOL_TIMEOUT_GRACE_SECS: u64 = 5; - -/// A resolved per-call deadline. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct ResolvedTimeout { - /// Hard deadline, or `None` to run unbounded. - pub deadline: Option<Duration>, - /// The user-facing budget in milliseconds (`0` when unbounded). - pub budget_ms: u64, -} - -/// Bounds plus the inherited timeout, with the inherited value atomic. -#[derive(Debug)] -pub struct TimeoutSettings { - inherited_ms: AtomicU64, - min_ms: u64, - max_ms: u64, - grace_ms: u64, -} - -impl TimeoutSettings { - /// Settings with explicit bounds, all in milliseconds. - #[must_use] - pub fn new(inherited_ms: u64, min_ms: u64, max_ms: u64, grace_ms: u64) -> Self { - Self { - inherited_ms: AtomicU64::new(inherited_ms), - min_ms, - max_ms, - grace_ms, - } - } - - /// Settings with the crate's default bounds, inheriting `inherited_secs`. - #[must_use] - pub fn with_inherited_secs(inherited_secs: u64) -> Self { - Self::new( - inherited_secs.saturating_mul(1000), - MIN_TIMEOUT_SECS * 1000, - MAX_TIMEOUT_SECS * 1000, - TOOL_TIMEOUT_GRACE_SECS * 1000, - ) - } - - /// The timeout `Inherit` tools get. - #[must_use] - pub fn inherited_timeout(&self) -> Duration { - Duration::from_millis(self.inherited_ms.load(Ordering::Relaxed)) - } - - /// Replace the inherited timeout; readers see it on their next call. - pub fn set_inherited_timeout_ms(&self, ms: u64) { - self.inherited_ms.store(ms, Ordering::Relaxed); - } - - /// Resolve a tool's policy. An explicit millisecond request is rounded up - /// to whole seconds, clamped to the bounds, and padded with the grace. - #[must_use] - pub fn resolve(&self, policy: ToolTimeout) -> ResolvedTimeout { - match policy { - ToolTimeout::Inherit => { - let ms = self.inherited_ms.load(Ordering::Relaxed); - ResolvedTimeout { - deadline: Some(Duration::from_millis(ms)), - budget_ms: ms, - } - } - ToolTimeout::Unbounded => ResolvedTimeout { - deadline: None, - budget_ms: 0, - }, - ToolTimeout::Millis(req) => { - let rounded = req.saturating_add(999) / 1000 * 1000; - let budget = rounded.clamp(self.min_ms, self.max_ms); - ResolvedTimeout { - deadline: Some(Duration::from_millis(budget.saturating_add(self.grace_ms))), - budget_ms: budget, - } - } - } - } -} - -static SETTINGS: OnceLock<TimeoutSettings> = OnceLock::new(); - -/// Install the process-global settings. The first call installs `settings`; -/// later calls only push its inherited timeout into the installed one, so -/// handles obtained earlier stay live. Returns the global. -pub fn configure(settings: TimeoutSettings) -> &'static TimeoutSettings { - let ms = settings.inherited_ms.load(Ordering::Relaxed); - let global = SETTINGS.get_or_init(|| settings); - global.set_inherited_timeout_ms(ms); - log::debug!("[tool_timeout] configured inherited timeout {ms}ms"); - global -} - -/// The process-global settings, seeded with [`DEFAULT_TIMEOUT_SECS`] when the -/// host never called [`configure`]. -#[must_use] -pub fn settings() -> &'static TimeoutSettings { - SETTINGS.get_or_init(|| TimeoutSettings::with_inherited_secs(DEFAULT_TIMEOUT_SECS)) -} - -/// Parse a raw value into a bounded timeout; anything missing, non-numeric or -/// outside `MIN_TIMEOUT_SECS..=MAX_TIMEOUT_SECS` yields [`DEFAULT_TIMEOUT_SECS`]. -#[must_use] -pub fn parse_tool_timeout_secs(raw: Option<&str>) -> u64 { - override_from(raw).unwrap_or(DEFAULT_TIMEOUT_SECS) -} - -/// A raw operator override, if it is a valid in-range number of seconds. -#[must_use] -pub fn override_from(raw: Option<&str>) -> Option<u64> { - raw.and_then(|s| s.parse::<u64>().ok()) - .filter(|&n| (MIN_TIMEOUT_SECS..=MAX_TIMEOUT_SECS).contains(&n)) -} - -/// Env override wins when valid; otherwise the (bounded) config value applies. -#[must_use] -pub fn resolve_effective(config_secs: u64, env_raw: Option<&str>) -> u64 { - override_from(env_raw) - .unwrap_or_else(|| parse_tool_timeout_secs(Some(&config_secs.to_string()))) -} - -/// Resolve an explicit per-call request for an otherwise unbounded tool. -/// `None`/`Some(0)` run unbounded; otherwise clamp to `MIN..=cap`. -#[must_use] -pub fn explicit_call_timeout_secs(requested: Option<u64>, cap: u64) -> Option<u64> { - let cap = cap.clamp(MIN_TIMEOUT_SECS, MAX_TIMEOUT_SECS); - match requested { - None | Some(0) => None, - Some(n) => Some(n.clamp(MIN_TIMEOUT_SECS, cap)), - } -} - -/// [`explicit_call_timeout_secs`] as a [`Duration`]. -#[must_use] -pub fn explicit_call_timeout_duration(requested: Option<u64>, cap: u64) -> Option<Duration> { - explicit_call_timeout_secs(requested, cap).map(Duration::from_secs) -} diff --git a/crates/tinytools-std/src/url_guard/README.md b/crates/tinytools-std/src/url_guard/README.md deleted file mode 100644 index 81c41b8a..00000000 --- a/crates/tinytools-std/src/url_guard/README.md +++ /dev/null @@ -1,33 +0,0 @@ -# URL guard - -This module provides the URL policy shared by outbound network tools. It -accepts HTTP and HTTPS URLs, applies an optional domain allowlist, rejects -local and non-global IP destinations, and checks DNS answers for rebinding. - -## Public surface - -- `validate_url` checks syntax, host policy, and local-address rules. -- `validate_url_with_dns_check` also resolves the host and returns a - `ValidatedUrl` containing the vetted socket addresses. -- `normalize_allowed_domains`, `normalize_domain`, and - `host_matches_allowlist` support host configuration. -- `is_private_or_local_host`, `is_non_global_v4`, and `is_non_global_v6` - expose the address classification used by the guard. - -## Security and operational constraints - -Callers making outbound requests should use `validate_url_with_dns_check` and -pin the connection to every address in `ValidatedUrl::addrs`. Resolving the -hostname again in an HTTP client reopens the DNS-rebinding gap. Keep the URL's -hostname for TLS SNI and the Host header. Validate every redirect destination -before following it. - -The guard rejects loopback, private, link-local, multicast, documentation, -shared-address, local names, IPv4-mapped and IPv4-compatible IPv6, private -IPv4 destinations embedded in NAT64 and 6to4 addresses, private Teredo client -addresses, and NAT64 translation prefixes. A Teredo server address is not the -IPv4 destination represented by the endpoint. -Its lexical checks reject userinfo, backslashes, percent-encoded hosts, and -IPv6 URL literals because downstream URL parsers can interpret those forms -differently. This crate supplies no HTTP transport, so connection pinning and -redirect policy remain the caller's responsibility. diff --git a/crates/tinytools-std/src/url_guard/mod.rs b/crates/tinytools-std/src/url_guard/mod.rs deleted file mode 100644 index caa4579c..00000000 --- a/crates/tinytools-std/src/url_guard/mod.rs +++ /dev/null @@ -1,496 +0,0 @@ -//! Shared URL validation + SSRF guards for outbound network tools. -//! -//! Used by `http_request`, `curl`, and any future tool that takes a -//! user-supplied URL. Two allowlist modes: -//! -//! - **Open allowlist** (`allowed_domains` is empty): any public non-private -//! host is permitted. All SSRF guards still apply (loopback / RFC1918 / -//! link-local / multicast / documentation / shared-address / -//! IPv4-mapped and transition IPv6, `localhost` / `*.localhost` / `*.local`). -//! - **Strict allowlist** (`allowed_domains` is non-empty): only the listed -//! domains and their subdomains are permitted. -//! -//! Both modes enforce: http(s) only, no whitespace, no userinfo, no IPv6 hosts, -//! no backslash anywhere, and no percent-encoding in the host — the last two -//! because a WHATWG parser would read them as a different host than the one -//! checked here. -//! -//! **Alternate IP notations** (octal, hex, decimal): Rust's `IpAddr::parse` -//! rejects them so they are treated as plain hostnames. In strict-allowlist -//! mode they are rejected by the domain check. In open-allowlist mode they -//! pass `validate_url` but are caught by `validate_url_with_dns_check` -//! because they fail real-world DNS resolution. -//! -//! ## DNS Rebinding -//! -//! Hostname validation alone is insufficient: an attacker can register a -//! domain that alternates DNS responses between a public IP (passing the -//! allowlist) and a private IP (e.g. 127.0.0.1). -//! [`validate_url_with_dns_check`] resolves the hostname, vets every -//! resolved IP, and returns them in a [`ValidatedUrl`]. That closes the gap -//! **only if the caller connects to [`ValidatedUrl::addrs`]** — for example -//! via `reqwest::ClientBuilder::resolve_to_addrs` — rather than letting its -//! HTTP client resolve the hostname a second time. This crate carries no -//! HTTP client, so the pinning is the caller's responsibility. The network -//! tools in [`crate::network`] do pin: they connect only to the vetted -//! addresses, which are either the ones this module resolved or the ones the -//! host's [`NetGate::authorize_destination`](crate::network::NetGate::authorize_destination) -//! authorized. - -use std::future::Future; -use std::net::{IpAddr, SocketAddr, ToSocketAddrs}; - -/// Validate a URL against the allowlist + SSRF rules. Returns the -/// original URL on success. -/// -/// # Errors -/// -/// Fails when the URL is empty, contains whitespace or a backslash, is not -/// `http(s)`, has percent-encoding in its host, names a local/private host, -/// or (in strict mode) is outside the allowlist. -pub fn validate_url(raw_url: &str, allowed_domains: &[String]) -> anyhow::Result<String> { - let url = raw_url.trim(); - - if url.is_empty() { - anyhow::bail!("URL cannot be empty"); - } - - if url.chars().any(char::is_whitespace) { - anyhow::bail!("URL cannot contain whitespace"); - } - - if !url.starts_with("http://") && !url.starts_with("https://") { - anyhow::bail!("Only http:// and https:// URLs are allowed"); - } - - reject_backslash(url)?; - - let host = extract_host(url)?; - - if is_private_or_local_host(&host) { - log::debug!( - "[url_guard] ssrf block: host={host} mode={}", - if allowed_domains.is_empty() { - "open" - } else { - "strict" - } - ); - anyhow::bail!("Blocked local/private host: {host}"); - } - - // Empty allowed_domains = open mode: any public non-private host is - // permitted (same as ["*"]). This ensures the http_request tool works - // out of the box regardless of whether the user configured an explicit - // domain list, and keeps web-fetch consistent across routing paths. - // A non-empty list = strict mode: only listed domains pass. (#2700) - if !allowed_domains.is_empty() && !host_matches_allowlist(&host, allowed_domains) { - log::debug!( - "[url_guard] strict-allowlist rejection: host={host} allowed={allowed_domains:?}" - ); - anyhow::bail!( - "I'm not allowed to open '{host}' — it isn't in your allowed websites. \ - Add it (or turn on \"Allow all sites\") under \ - Settings → Advanced → Search engine → Allowed websites, then ask me again." - ); - } - - log::debug!( - "[url_guard] validate_url ok: host={host} mode={}", - if allowed_domains.is_empty() { - "open" - } else { - "strict" - } - ); - - Ok(url.to_string()) -} - -/// A URL that passed [`validate_url_with_dns_check`], together with the -/// exact socket addresses that were vetted. -/// -/// The addresses are the point: DNS can answer differently the next time it -/// is asked, so a client that re-resolves `host` may connect somewhere that -/// was never checked. Pin the connection to [`addrs`](Self::addrs) instead — -/// for example with `reqwest::ClientBuilder::resolve_to_addrs(&host, &addrs)` -/// — and keep `url` unchanged so TLS SNI and the `Host` header still name -/// `host`. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ValidatedUrl { - /// The validated URL, trimmed, otherwise exactly as supplied. - pub url: String, - /// The lowercase host the URL names (a hostname or an IP literal). - pub host: String, - /// Every address `host` resolved to, each paired with the URL's port; - /// all are public. For an IP-literal host this is that single address. - pub addrs: Vec<SocketAddr>, -} - -impl ValidatedUrl { - /// The validated URL, retaining its hostname as the request authority. - #[must_use] - pub fn url(&self) -> &str { - &self.url - } - - /// Vetted connection destinations to pin the HTTP client to. - #[must_use] - pub fn addresses(&self) -> &[SocketAddr] { - &self.addrs - } -} - -/// Like [`validate_url`] but also resolves the hostname via DNS and -/// verifies that none of the resolved IPs are private/local. -/// -/// This only defends against DNS rebinding — an attacker's domain answering -/// with a public IP here and 127.0.0.1 at request time — when the caller -/// connects to the returned [`ValidatedUrl::addrs`] rather than resolving -/// the hostname again. Callers should use this function instead of -/// `validate_url` in all paths that make outbound HTTP requests, and pin -/// the connection as described on [`ValidatedUrl`]. -/// -/// # Errors -/// -/// Everything [`validate_url`] rejects, plus DNS failure, an empty answer, or -/// any resolved address that is private/local. -pub async fn validate_url_with_dns_check( - raw_url: &str, - allowed_domains: &[String], -) -> anyhow::Result<ValidatedUrl> { - validate_url_with_dns_check_with_resolver(raw_url, allowed_domains, resolve_host_ips).await -} - -async fn validate_url_with_dns_check_with_resolver<F, Fut>( - raw_url: &str, - allowed_domains: &[String], - resolver: F, -) -> anyhow::Result<ValidatedUrl> -where - F: FnOnce(String, u16) -> Fut, - Fut: Future<Output = anyhow::Result<Vec<IpAddr>>>, -{ - let url = validate_url(raw_url, allowed_domains)?; - - let host = extract_host(&url)?; - - let port = extract_port(&url)?; - - // If the host is already a valid IP literal, `is_private_or_local_host` - // has already checked it above. We only need DNS resolution for hostnames. - if let Ok(ip) = host.parse::<IpAddr>() { - return Ok(ValidatedUrl { - url, - host, - addrs: vec![SocketAddr::new(ip, port)], - }); - } - - log::debug!("[url_guard] resolving DNS for host={host} port={port}"); - let addrs = resolver(host.clone(), port).await?; - - if addrs.is_empty() { - anyhow::bail!("DNS resolution returned no addresses for '{host}'"); - } - - log::debug!("[url_guard] DNS resolved host={host} addrs={}", addrs.len()); - - for addr in &addrs { - let ip_str = addr.to_string(); - if is_private_or_local_host(&ip_str) { - log::debug!("[url_guard] DNS rebinding blocked host={host} resolved_ip={ip_str}"); - anyhow::bail!( - "DNS rebinding blocked: '{host}' resolved to private/local address {ip_str}" - ); - } - } - - Ok(ValidatedUrl { - url, - host, - addrs: addrs - .into_iter() - .map(|ip| SocketAddr::new(ip, port)) - .collect(), - }) -} - -async fn resolve_host_ips(host: String, port: u16) -> anyhow::Result<Vec<IpAddr>> { - let log_host = host.clone(); - tokio::task::spawn_blocking(move || { - (host.as_str(), port) - .to_socket_addrs() - .map_err(|e| { - log::debug!("[url_guard] DNS resolution failed host={host} port={port} error={e}"); - anyhow::anyhow!("DNS resolution failed for '{host}': {e}") - }) - .map(|iter| iter.map(|addr| addr.ip()).collect()) - }) - .await - .map_err(|e| { - log::debug!("[url_guard] DNS resolution task failed host={log_host} port={port} error={e}"); - anyhow::anyhow!("DNS resolution task failed for '{log_host}': {e}") - })? -} - -#[must_use] -/// Normalise an allowlist: strip scheme/path, lowercase, drop invalid entries and -/// duplicates. An empty result means open mode. -pub fn normalize_allowed_domains(domains: Vec<String>) -> Vec<String> { - if domains.is_empty() { - return Vec::new(); - } - let mut normalized = domains - .into_iter() - .filter_map(|d| normalize_domain(&d)) - .collect::<Vec<_>>(); - normalized.sort_unstable(); - normalized.dedup(); - if normalized.is_empty() { - // All entries were malformed (whitespace-only, scheme-only, etc.) and - // filtered out. Returning empty would silently enter open mode; instead - // return a sentinel that keeps the tool in strict mode and rejects every - // URL — fail-closed on misconfiguration. (#2738) - log::warn!( - "[url_guard] all configured allowed_domains entries are invalid — \ - treating as misconfigured allowlist (fail-closed)" - ); - return vec!["<misconfigured-allowlist>".to_string()]; - } - normalized -} - -/// Normalise one allowlist entry to a bare lowercase host, or `None` if invalid. -pub fn normalize_domain(raw: &str) -> Option<String> { - let mut d = raw.trim().to_lowercase(); - if d.is_empty() { - return None; - } - - if let Some(stripped) = d.strip_prefix("https://") { - d = stripped.to_string(); - } else if let Some(stripped) = d.strip_prefix("http://") { - d = stripped.to_string(); - } - - if let Some((host, _)) = d.split_once('/') { - d = host.to_string(); - } - - d = d.trim_start_matches('.').trim_end_matches('.').to_string(); - - if let Some((host, _)) = d.split_once(':') { - d = host.to_string(); - } - - if d.is_empty() || d.chars().any(char::is_whitespace) { - return None; - } - - Some(d) -} - -/// Refuse a URL containing `\` anywhere. WHATWG URL parsers (browsers, -/// `reqwest`'s `url` crate) treat `\` as a path separator in `http(s)` URLs, -/// so `http://127.0.0.1\.example.com/` names `127.0.0.1` to a real client -/// while a naive split on `/` would see a subdomain of `example.com`. -fn reject_backslash(url: &str) -> anyhow::Result<()> { - if url.contains('\\') { - anyhow::bail!("URL cannot contain a backslash"); - } - Ok(()) -} - -/// Split an `http(s)` URL into whether it is plain `http` and its authority -/// (`host[:port]`), refusing inputs a WHATWG parser would read differently. -fn split_authority(url: &str) -> anyhow::Result<(bool, &str)> { - let (is_http, rest) = if let Some(rest) = url.strip_prefix("http://") { - (true, rest) - } else if let Some(rest) = url.strip_prefix("https://") { - (false, rest) - } else { - anyhow::bail!("Only http:// and https:// URLs are allowed"); - }; - - reject_backslash(url)?; - - let authority = rest - .split(['/', '?', '#']) - .next() - .ok_or_else(|| anyhow::anyhow!("Invalid URL"))?; - - // WHATWG percent-decodes the host, so `%31%32%37.0.0.1` is 127.0.0.1 on - // the wire while the literal text matches neither the SSRF checks nor - // the allowlist. - if authority.contains('%') { - anyhow::bail!("URL host cannot contain percent-encoded characters"); - } - - if authority.starts_with('[') { - anyhow::bail!("IPv6 hosts are not supported in http_request"); - } - - Ok((is_http, authority)) -} - -/// Extract the host part of an `http(s)` URL. -/// -/// # Errors -/// -/// Fails on a missing/empty host, userinfo, an IPv6 literal, a backslash -/// anywhere in the URL, or percent-encoding in the authority. -pub fn extract_host(url: &str) -> anyhow::Result<String> { - let (_, authority) = split_authority(url)?; - - if authority.is_empty() { - anyhow::bail!("URL must include a host"); - } - - if authority.contains('@') { - anyhow::bail!("URL userinfo is not allowed"); - } - - let host = authority - .split(':') - .next() - .unwrap_or_default() - .trim() - .trim_end_matches('.') - .to_lowercase(); - - if host.is_empty() { - anyhow::bail!("URL must include a valid host"); - } - - Ok(host) -} - -/// Extract the explicit or scheme-default port of an `http(s)` URL. -/// -/// # Errors -/// -/// Fails when the URL has no valid port, is an IPv6 literal, contains a -/// backslash, or has percent-encoding in the authority. -pub fn extract_port(url: &str) -> anyhow::Result<u16> { - let (is_http, authority) = split_authority(url)?; - - if let Some((_, port)) = authority.rsplit_once(':') { - if port.is_empty() || !port.chars().all(|ch| ch.is_ascii_digit()) { - anyhow::bail!("URL port must be numeric"); - } - return port - .parse::<u16>() - .map_err(|_| anyhow::anyhow!("URL port is out of range")); - } - - Ok(if is_http { 80 } else { 443 }) -} - -#[must_use] -/// Whether `host` equals, or is a subdomain of, an allowlist entry. -pub fn host_matches_allowlist(host: &str, allowed_domains: &[String]) -> bool { - allowed_domains.iter().any(|domain| { - // `"*"` is the explicit allow-all wildcard (the "Allow all sites" - // toggle), mirroring the browser tool. Local/private hosts are still - // rejected upstream by `is_private_or_local_host`, so a wildcard only - // opens *public* hosts, never the loopback/RFC1918 SSRF surface. - domain == "*" - || host == domain - || host - .strip_suffix(domain) - .is_some_and(|prefix| prefix.ends_with('.')) - }) -} - -#[must_use] -/// Whether `host` is a local name or resolves lexically to a non-global address. -pub fn is_private_or_local_host(host: &str) -> bool { - let unbracketed = host - .strip_prefix('[') - .and_then(|h| h.strip_suffix(']')) - .unwrap_or(host); - let bare = unbracketed.strip_suffix('.').unwrap_or(unbracketed); - - let lower = bare.to_ascii_lowercase(); - - let has_local_tld = lower - .rsplit('.') - .next() - .is_some_and(|label| label == "local"); - - if lower == "localhost" || lower.ends_with(".localhost") || has_local_tld { - return true; - } - - if let Ok(ip) = bare.parse::<std::net::IpAddr>() { - return match ip { - std::net::IpAddr::V4(v4) => is_non_global_v4(v4), - std::net::IpAddr::V6(v6) => is_non_global_v6(v6), - }; - } - - false -} - -#[must_use] -/// Whether an IPv4 address is non-global (loopback, private, link-local, ...). -pub fn is_non_global_v4(v4: std::net::Ipv4Addr) -> bool { - let [a, b, c, _] = v4.octets(); - v4.is_loopback() - || v4.is_private() - || v4.is_link_local() - || v4.is_unspecified() - || v4.is_broadcast() - || v4.is_multicast() - || (a == 100 && (64..=127).contains(&b)) - || a >= 240 - || (a == 192 && b == 0 && c == 0) - || (a == 192 && b == 88 && c == 99) - || (a == 198 && b == 51 && c == 100) - || (a == 203 && b == 0 && c == 113) - || (a == 198 && (18..=19).contains(&b)) - // 0.0.0.0/8 — "this network" (RFC 1122 §3.2.1.3). `is_unspecified()` only - // covers 0.0.0.0 itself, but the whole /8 routes to the local host on - // Linux. Carried over from the `ops_install` copy this replaces. - || a == 0 -} - -/// Whether an IPv6 address is non-global, including private IPv4 destinations -/// embedded in transition addresses. -pub fn is_non_global_v6(v6: std::net::Ipv6Addr) -> bool { - let segs = v6.segments(); - let embedded_v4 = |hi: u16, lo: u16| { - let [first, second] = hi.to_be_bytes(); - let [third, fourth] = lo.to_be_bytes(); - is_non_global_v4(std::net::Ipv4Addr::new(first, second, third, fourth)) - }; - v6.is_loopback() - || v6.is_unspecified() - || v6.is_multicast() - || (segs[0] & 0xfe00) == 0xfc00 - || (segs[0] & 0xffc0) == 0xfe80 - || (segs[0] == 0x2001 && segs[1] == 0x0db8) - || (segs[0] == 0x0100 && segs[1] == 0 && segs[2] == 0 && segs[3] <= 1) - || (segs[0] == 0x2001 && segs[1] == 0x0002 && segs[2] == 0) - // Local-use translation (RFC 8215) and the well-known NAT64 prefix - // can embed addresses that translate to private IPv4 destinations. - || (segs[0] == 0x0064 && segs[1] == 0xff9b && segs[2] == 1) - || (segs[0] == 0x0064 - && segs[1] == 0xff9b - && segs[2..6] == [0; 4] - && embedded_v4(segs[6], segs[7])) - // 6to4 carries the destination IPv4 address immediately after 2002::/16. - || (segs[0] == 0x2002 && embedded_v4(segs[1], segs[2])) - // Teredo carries a server IPv4 address, but the destination is the - // client's XOR-obfuscated IPv4 address in the last two segments. - || (segs[..2] == [0x2001, 0] && embedded_v4(!segs[6], !segs[7])) - || (segs[0] & 0xfff0) == 0x3ff0 - || segs[0] == 0x5f00 - // `to_ipv4` covers both mapped and deprecated compatible addresses. - || v6.to_ipv4().is_some_and(is_non_global_v4) -} - -#[cfg(test)] -#[path = "mod_tests.rs"] -mod test; diff --git a/crates/tinytools-std/src/url_guard/mod_tests.rs b/crates/tinytools-std/src/url_guard/mod_tests.rs deleted file mode 100644 index ec2c8200..00000000 --- a/crates/tinytools-std/src/url_guard/mod_tests.rs +++ /dev/null @@ -1,770 +0,0 @@ -//! Unit tests for URL validation, SSRF host classification, and the DNS -//! check's vetted addresses. - -use super::*; - -/// Turns an expected rejection into its message, and an unexpected success -/// into a test failure, without `unwrap_err`. -trait Rejection { - fn rejection(self) -> anyhow::Result<String>; -} - -impl<T: std::fmt::Debug> Rejection for anyhow::Result<T> { - fn rejection(self) -> anyhow::Result<String> { - match self { - Ok(value) => anyhow::bail!("expected a rejection, got {value:?}"), - Err(err) => Ok(err.to_string()), - } - } -} - -#[test] -fn normalize_domain_strips_scheme_path_and_case() { - let got = normalize_domain(" HTTPS://Docs.Example.com/path "); - assert_eq!(got.as_deref(), Some("docs.example.com")); -} - -#[test] -fn normalizes_http_domains_and_rejects_empty_hosts() { - assert_eq!( - normalize_domain("http://Example.com:8080/path"), - Some("example.com".into()) - ); - assert_eq!(normalize_domain("https://"), None); - assert!(extract_host("http:///path").is_err()); - assert!(extract_host("http://:80/path").is_err()); -} - -#[test] -fn rejects_malformed_ports() { - assert!(extract_port("http://example.com:abc").is_err()); - assert!(extract_port("http://example.com:65536").is_err()); -} - -#[tokio::test] -async fn system_dns_resolves_numeric_loopback_without_external_network() -> anyhow::Result<()> { - let resolved = super::resolve_host_ips("127.0.0.1".to_string(), 80).await?; - assert_eq!(resolved, vec!["127.0.0.1".parse::<std::net::IpAddr>()?]); - assert!(super::resolve_host_ips(String::new(), 80).await.is_err()); - Ok(()) -} - -#[test] -fn normalize_allowed_domains_deduplicates() { - let got = normalize_allowed_domains(vec![ - "example.com".into(), - "EXAMPLE.COM".into(), - "https://example.com/".into(), - ]); - assert_eq!(got, vec!["example.com".to_string()]); -} - -#[test] -fn validate_accepts_exact_domain() -> anyhow::Result<()> { - let allow = vec!["example.com".to_string()]; - let got = validate_url("https://example.com/docs", &allow)?; - assert_eq!(got, "https://example.com/docs"); - Ok(()) -} - -#[test] -fn validate_accepts_http() { - let allow = vec!["example.com".to_string()]; - assert!(validate_url("http://example.com", &allow).is_ok()); -} - -#[test] -fn validate_accepts_subdomain() { - let allow = vec!["example.com".to_string()]; - assert!(validate_url("https://api.example.com/v1", &allow).is_ok()); -} - -#[test] -fn validate_rejects_allowlist_miss() -> anyhow::Result<()> { - let allow = vec!["example.com".to_string()]; - let err = validate_url("https://google.com", &allow).rejection()?; - assert!(err.contains("allowed websites")); - Ok(()) -} - -#[test] -fn validate_wildcard_allows_any_public_host() { - let allow = vec!["*".to_string()]; - assert!(validate_url("https://example.com/docs", &allow).is_ok()); - assert!(validate_url("https://www.cnbc.com/markets", &allow).is_ok()); - assert!(validate_url("https://sub.deep.example.org", &allow).is_ok()); -} - -#[test] -fn validate_wildcard_still_blocks_local_and_private() -> anyhow::Result<()> { - // "Allow all sites" must NOT defeat the SSRF guard. - let allow = vec!["*".to_string()]; - assert!( - validate_url("https://localhost:8080", &allow) - .rejection()? - .contains("local/private") - ); - assert!( - validate_url("https://192.168.1.5", &allow) - .rejection()? - .contains("local/private") - ); - Ok(()) -} - -#[test] -fn validate_rejects_localhost() -> anyhow::Result<()> { - let allow = vec!["localhost".to_string()]; - let err = validate_url("https://localhost:8080", &allow).rejection()?; - assert!(err.contains("local/private")); - Ok(()) -} - -#[test] -fn validate_rejects_private_ipv4() -> anyhow::Result<()> { - let allow = vec!["192.168.1.5".to_string()]; - let err = validate_url("https://192.168.1.5", &allow).rejection()?; - assert!(err.contains("local/private")); - Ok(()) -} - -#[test] -fn validate_rejects_whitespace() -> anyhow::Result<()> { - let allow = vec!["example.com".to_string()]; - let err = validate_url("https://example.com/hello world", &allow).rejection()?; - assert!(err.contains("whitespace")); - Ok(()) -} - -#[test] -fn validate_rejects_userinfo() -> anyhow::Result<()> { - let allow = vec!["example.com".to_string()]; - let err = validate_url("https://user@example.com", &allow).rejection()?; - assert!(err.contains("userinfo")); - Ok(()) -} - -// Empty allowed_domains = open mode: any public host is permitted. -// This keeps web-fetch working when no domain list is configured and -// makes behaviour consistent between default and external-LLM routing. -// (#2700) -#[test] -fn validate_empty_allowlist_allows_public_host() { - assert!(validate_url("https://example.com", &[]).is_ok()); - assert!(validate_url("https://www.cnbc.com/markets", &[]).is_ok()); -} - -#[test] -fn validate_empty_allowlist_still_blocks_private_hosts() -> anyhow::Result<()> { - let err = validate_url("https://192.168.1.5", &[]).rejection()?; - assert!(err.contains("local/private")); - - let err = validate_url("https://localhost", &[]).rejection()?; - assert!(err.contains("local/private")); - Ok(()) -} - -// ── normalize_allowed_domains: fail-closed on malformed-only input ── - -#[test] -fn normalize_all_invalid_entries_stays_fail_closed() { - // A non-empty list that fully normalizes to nothing must NOT produce - // an empty slice (which would silently enter open mode). (#2738) - let got = normalize_allowed_domains(vec![" ".into(), "https://".into()]); - assert!( - !got.is_empty(), - "normalized result must be non-empty to stay in strict mode" - ); - // The sentinel must not match any real public host. - assert!( - !host_matches_allowlist("example.com", &got), - "sentinel must not grant access to real hosts" - ); - assert!( - !host_matches_allowlist("api.example.com", &got), - "sentinel must not grant access to subdomains" - ); -} - -#[test] -fn normalize_empty_input_stays_empty_for_open_mode() { - // Explicitly empty input should return empty (open mode is intentional). - assert_eq!(normalize_allowed_domains(vec![]).len(), 0); -} - -#[tokio::test] -async fn dns_check_with_empty_allowlist_allows_public_resolved_host() -> anyhow::Result<()> { - // Open mode (empty allowlist) must still pass DNS check for public IPs. - let got = validate_url_with_dns_check_with_resolver( - "https://example.com", - &[], - |host, port| async move { - assert_eq!(host, "example.com"); - assert_eq!(port, 443); - Ok(vec!["93.184.216.34".parse()?]) - }, - ) - .await?; - assert_eq!(got.url, "https://example.com"); - Ok(()) -} - -#[tokio::test] -async fn dns_check_with_empty_allowlist_blocks_private_resolved_ip() -> anyhow::Result<()> { - // Even in open mode, DNS rebinding to a private IP must be blocked. - let err = validate_url_with_dns_check_with_resolver("https://example.com", &[], |_, _| async { - Ok(vec!["10.0.0.1".parse()?]) - }) - .await - .rejection()?; - assert!(err.contains("DNS rebinding blocked")); - Ok(()) -} - -#[tokio::test] -async fn dns_check_resolver_failure_is_a_refusal_not_a_pass_through() -> anyhow::Result<()> { - // A resolver error (NXDOMAIN, network down, timeout) must refuse the - // fetch, not fall back to treating the host as unresolved-and-therefore- - // allowed. - let err = validate_url_with_dns_check_with_resolver( - "https://this-host-does-not-exist.invalid", - &[], - |host, _port| async move { anyhow::bail!("DNS resolution failed for '{host}': NXDOMAIN") }, - ) - .await - .rejection()?; - assert!(err.contains("DNS resolution failed")); - Ok(()) -} - -#[tokio::test] -async fn dns_check_resolver_returning_no_addresses_is_a_refusal() -> anyhow::Result<()> { - // A resolver that answers with zero addresses (some stub resolvers do - // this instead of erroring) must not be treated as "no IPs to check, - // therefore allowed". - let err = validate_url_with_dns_check_with_resolver("https://example.com", &[], |_, _| async { - Ok(Vec::new()) - }) - .await - .rejection()?; - assert!(err.contains("DNS resolution returned no addresses")); - Ok(()) -} - -#[test] -fn validate_rejects_ftp_scheme() -> anyhow::Result<()> { - let allow = vec!["example.com".to_string()]; - let err = validate_url("ftp://example.com", &allow).rejection()?; - assert!(err.contains("http://") || err.contains("https://")); - Ok(()) -} - -#[test] -fn validate_rejects_empty_url() -> anyhow::Result<()> { - let allow = vec!["example.com".to_string()]; - let err = validate_url("", &allow).rejection()?; - assert!(err.contains("empty")); - Ok(()) -} - -#[test] -fn validate_rejects_ipv6_host() -> anyhow::Result<()> { - let allow = vec!["example.com".to_string()]; - let err = validate_url("http://[::1]:8080/path", &allow).rejection()?; - assert!(err.contains("IPv6")); - Ok(()) -} - -#[test] -fn blocks_multicast_ipv4() { - assert!(is_private_or_local_host("224.0.0.1")); - assert!(is_private_or_local_host("239.255.255.255")); -} - -#[test] -fn blocks_broadcast() { - assert!(is_private_or_local_host("255.255.255.255")); -} - -#[test] -fn blocks_reserved_ipv4() { - assert!(is_private_or_local_host("240.0.0.1")); - assert!(is_private_or_local_host("250.1.2.3")); -} - -#[test] -fn blocks_documentation_ranges() { - // TEST-NET-1 is globally routable in this policy; only TEST-NET-2 and - // TEST-NET-3 are classified as non-global here. - assert!(!is_private_or_local_host("192.0.2.1")); - assert!(is_private_or_local_host("198.51.100.1")); - assert!(is_private_or_local_host("203.0.113.1")); -} - -#[test] -fn blocks_benchmarking_range() { - assert!(is_private_or_local_host("198.18.0.1")); - assert!(is_private_or_local_host("198.19.255.255")); -} - -#[test] -fn blocks_ipv6_localhost() { - assert!(is_private_or_local_host("::1")); - assert!(is_private_or_local_host("[::1]")); -} - -#[test] -fn blocks_ipv6_multicast() { - assert!(is_private_or_local_host("ff02::1")); -} - -#[test] -fn blocks_ipv6_link_local() { - assert!(is_private_or_local_host("fe80::1")); -} - -#[test] -fn blocks_ipv6_unique_local() { - assert!(is_private_or_local_host("fd00::1")); -} - -#[test] -fn blocks_ipv4_mapped_ipv6() { - assert!(is_private_or_local_host("::ffff:127.0.0.1")); - assert!(is_private_or_local_host("::ffff:192.168.1.1")); - assert!(is_private_or_local_host("::ffff:10.0.0.1")); -} - -#[test] -fn allows_public_ipv4() { - assert!(!is_private_or_local_host("8.8.8.8")); - assert!(!is_private_or_local_host("1.1.1.1")); - assert!(!is_private_or_local_host("93.184.216.34")); -} - -#[test] -fn blocks_ipv6_documentation_range() { - assert!(is_private_or_local_host("2001:db8::1")); -} - -#[test] -fn blocks_nat64_translation_prefixes() { - assert!(is_private_or_local_host("64:ff9b:1::7f00:1")); - assert!(is_private_or_local_host("64:ff9b::7f00:1")); - assert!(!is_private_or_local_host("64:ff9b::808:808")); - assert!(!is_private_or_local_host("2001:4860:4860::8888")); -} - -#[test] -fn classifies_well_known_nat64_embedded_addresses() { - assert!(is_private_or_local_host("64:ff9b::a00:1")); - assert!(is_private_or_local_host("64:ff9b::7f00:1")); - assert!(is_private_or_local_host("64:ff9b::c633:6401")); - assert!(!is_private_or_local_host("64:ff9b::808:808")); - // This is outside the exact /96 translation prefix. - assert!(!is_private_or_local_host("64:ff9b:0:0:0:1:a00:1")); -} - -#[test] -fn classifies_6to4_embedded_destinations() { - assert!(is_private_or_local_host("2002:a00:1::")); - assert!(is_private_or_local_host("2002:7f00:1::")); - assert!(!is_private_or_local_host("2002:808:808::")); -} - -#[test] -fn classifies_teredo_client_address_without_rejecting_server_address() { - // The last two segments are the client's IPv4 address with every bit inverted. - assert!(is_private_or_local_host("2001:0:808:808:0:0:f5ff:fffe")); - // The server is not the IPv4 destination represented by this endpoint. - assert!(!is_private_or_local_host("2001:0:a00:1:0:0:f7f7:f7f7")); - assert!(!is_private_or_local_host("2001:0:808:808:0:0:fefe:fefe")); -} - -#[test] -fn classifies_mapped_and_compatible_ipv4_addresses() { - assert!(is_private_or_local_host("::ffff:10.0.0.1")); - assert!(!is_private_or_local_host("::ffff:8.8.8.8")); - assert!(is_private_or_local_host("::10.0.0.1")); - assert!(!is_private_or_local_host("::8.8.8.8")); -} - -#[tokio::test] -async fn dns_check_rejects_private_ipv4_inside_transition_address() -> anyhow::Result<()> { - let err = validate_url_with_dns_check_with_resolver("https://example.com", &[], |_, _| async { - Ok(vec!["2002:a00:1::".parse()?]) - }) - .await - .rejection()?; - assert!(err.contains("DNS rebinding blocked")); - - let allowed = - validate_url_with_dns_check_with_resolver("https://example.com", &[], |_, _| async { - Ok(vec!["2002:808:808::".parse()?]) - }) - .await?; - assert_eq!(allowed.addrs[0].ip().to_string(), "2002:808:808::"); - Ok(()) -} - -#[test] -fn allows_public_ipv6() { - assert!(!is_private_or_local_host("2607:f8b0:4004:800::200e")); -} - -#[test] -fn blocks_shared_address_space() { - assert!(is_private_or_local_host("100.64.0.1")); - assert!(is_private_or_local_host("100.127.255.255")); - assert!(!is_private_or_local_host("100.63.0.1")); - assert!(!is_private_or_local_host("100.128.0.1")); -} - -#[test] -fn ssrf_blocks_loopback_127_range() { - assert!(is_private_or_local_host("127.0.0.1")); - assert!(is_private_or_local_host("127.0.0.2")); - assert!(is_private_or_local_host("127.255.255.255")); -} - -#[test] -fn ssrf_blocks_rfc1918_10_range() { - assert!(is_private_or_local_host("10.0.0.1")); - assert!(is_private_or_local_host("10.255.255.255")); -} - -#[test] -fn ssrf_blocks_rfc1918_172_range() { - assert!(is_private_or_local_host("172.16.0.1")); - assert!(is_private_or_local_host("172.31.255.255")); -} - -#[test] -fn ssrf_blocks_unspecified_address() { - assert!(is_private_or_local_host("0.0.0.0")); -} - -#[test] -fn ssrf_blocks_dot_localhost_subdomain() { - assert!(is_private_or_local_host("evil.localhost")); - assert!(is_private_or_local_host("a.b.localhost")); -} - -#[test] -fn ssrf_blocks_dot_local_tld() { - assert!(is_private_or_local_host("service.local")); -} - -#[test] -fn ssrf_ipv6_unspecified() { - assert!(is_private_or_local_host("::")); -} - -// ── Defense-in-depth: alternate IP notations rejected by allowlist -// -// Rust's IpAddr::parse() rejects octal, hex, decimal, and -// zero-padded notations. They fall through as hostnames and get -// rejected by the allowlist instead. These tests pin that -// behaviour so a parser change can't silently re-open SSRF. - -#[test] -fn ssrf_octal_loopback_not_parsed_as_ip() { - assert!(!is_private_or_local_host("0177.0.0.1")); -} - -#[test] -fn ssrf_hex_loopback_not_parsed_as_ip() { - assert!(!is_private_or_local_host("0x7f000001")); -} - -#[test] -fn ssrf_decimal_loopback_not_parsed_as_ip() { - assert!(!is_private_or_local_host("2130706433")); -} - -#[test] -fn ssrf_zero_padded_loopback_not_parsed_as_ip() { - assert!(!is_private_or_local_host("127.000.000.001")); -} - -#[test] -fn ssrf_alternate_notations_rejected_by_validate_url() -> anyhow::Result<()> { - let allow = vec!["example.com".to_string()]; - for notation in [ - "http://0177.0.0.1", - "http://0x7f000001", - "http://2130706433", - "http://127.000.000.001", - ] { - let err = validate_url(notation, &allow).rejection()?; - assert!( - err.contains("allowed websites"), - "Expected allowlist rejection for {notation}, got: {err}" - ); - } - Ok(()) -} - -// ── DNS rebinding protection ───────────────────────────────── - -#[tokio::test] -async fn dns_check_blocks_localhost_resolution() -> anyhow::Result<()> { - // "localhost" resolves to 127.0.0.1 on most systems. Even if - // someone adds it to the allowlist, the DNS check should block it. - let allow = vec!["localhost".to_string()]; - // validate_url itself already blocks "localhost" via the hostname check, - // but validate_url_with_dns_check should also catch it. - let err = validate_url_with_dns_check("https://localhost", &allow) - .await - .rejection()?; - assert!( - err.contains("local/private") || err.contains("rebinding"), - "Expected SSRF block for localhost, got: {err}" - ); - Ok(()) -} - -#[tokio::test] -async fn dns_check_passes_for_public_resolved_ip() -> anyhow::Result<()> { - let allow = vec!["example.com".to_string()]; - let got = validate_url_with_dns_check_with_resolver( - "https://example.com", - &allow, - |host, port| async move { - assert_eq!(host, "example.com"); - assert_eq!(port, 443); - Ok(vec!["93.184.216.34".parse()?]) - }, - ) - .await?; - assert_eq!(got.url, "https://example.com"); - Ok(()) -} - -#[tokio::test] -async fn dns_check_blocks_private_resolved_ip() -> anyhow::Result<()> { - let allow = vec!["example.com".to_string()]; - let err = - validate_url_with_dns_check_with_resolver("https://example.com", &allow, |_, _| async { - Ok(vec!["127.0.0.1".parse()?]) - }) - .await - .rejection()?; - assert!(err.contains("DNS rebinding blocked")); - Ok(()) -} - -#[tokio::test] -async fn dns_check_uses_explicit_port_for_resolution() -> anyhow::Result<()> { - let allow = vec!["api.example.com".to_string()]; - let got = validate_url_with_dns_check_with_resolver( - "http://api.example.com:8080/status", - &allow, - |host, port| async move { - assert_eq!(host, "api.example.com"); - assert_eq!(port, 8080); - Ok(vec!["93.184.216.34".parse()?]) - }, - ) - .await?; - assert_eq!(got.url, "http://api.example.com:8080/status"); - Ok(()) -} - -#[tokio::test] -async fn dns_check_returns_resolver_failure() -> anyhow::Result<()> { - let allow = vec!["example.com".to_string()]; - let err = validate_url_with_dns_check_with_resolver( - "https://example.com", - &allow, - |host, _| async move { - anyhow::bail!("DNS resolution failed for '{host}': resolver unavailable") - }, - ) - .await - .rejection()?; - assert!(err.contains("DNS resolution failed")); - Ok(()) -} - -#[tokio::test] -async fn dns_check_rejects_ip_literal_private() -> anyhow::Result<()> { - let allow = vec!["10.0.0.1".to_string()]; - let err = validate_url_with_dns_check("https://10.0.0.1", &allow) - .await - .rejection()?; - assert!(err.contains("local/private")); - Ok(()) -} - -#[test] -fn wildcard_allows_any_host() { - let any = vec!["*".to_string()]; - assert!(host_matches_allowlist("docs.rs", &any)); - assert!(host_matches_allowlist("api.github.com", &any)); - assert!(host_matches_allowlist("whatever.example.org", &any)); -} - -#[tokio::test] -async fn wildcard_still_blocks_private_hosts() -> anyhow::Result<()> { - // `*` opens public hosts only — SSRF block on private/local hosts stays. - let any = vec!["*".to_string()]; - let err = validate_url_with_dns_check("https://127.0.0.1", &any) - .await - .rejection()?; - assert!(err.contains("local/private"), "got: {err}"); - Ok(()) -} - -#[test] -fn exported_ssrf_predicates_classify_non_global_ips_accurately() -> anyhow::Result<()> { - use std::net::{Ipv4Addr, Ipv6Addr}; - - // IPv4 Non-global checks - assert!(is_non_global_v4(Ipv4Addr::LOCALHOST)); - assert!(is_non_global_v4(Ipv4Addr::new(10, 0, 0, 1))); - assert!(is_non_global_v4(Ipv4Addr::new(172, 16, 0, 1))); - assert!(is_non_global_v4(Ipv4Addr::new(192, 168, 1, 1))); - assert!(is_non_global_v4(Ipv4Addr::new(169, 254, 1, 1))); - assert!(is_non_global_v4(Ipv4Addr::new(100, 64, 0, 1))); // CGNAT - assert!(is_non_global_v4(Ipv4Addr::new(240, 0, 0, 1))); // Class E - assert!(!is_non_global_v4(Ipv4Addr::new(192, 0, 2, 1))); // TEST-NET-1 is globally routable in this policy - assert!(is_non_global_v4(Ipv4Addr::new(198, 51, 100, 1))); // TEST-NET-2 - assert!(is_non_global_v4(Ipv4Addr::new(203, 0, 113, 1))); // TEST-NET-3 - assert!(is_non_global_v4(Ipv4Addr::new(192, 88, 99, 1))); // 6to4 anycast - assert!(is_non_global_v4(Ipv4Addr::UNSPECIFIED)); // 0.0.0.0/8 - assert!(is_non_global_v4(Ipv4Addr::new(0, 1, 2, 3))); // 0.0.0.0/8 - - // IPv4 Global public IPs - assert!(!is_non_global_v4(Ipv4Addr::new(8, 8, 8, 8))); - assert!(!is_non_global_v4(Ipv4Addr::new(1, 1, 1, 1))); - assert!(!is_non_global_v4(Ipv4Addr::new(140, 82, 121, 4))); - // Non-TEST-NET IPs in adjacent /24 blocks should not be classified as non-global - assert!(!is_non_global_v4(Ipv4Addr::new(198, 51, 1, 1))); - assert!(!is_non_global_v4(Ipv4Addr::new(203, 0, 1, 1))); - assert!(!is_non_global_v4(Ipv4Addr::new(192, 88, 98, 1))); - - // IPv6 Non-global checks - assert!(is_non_global_v6(Ipv6Addr::LOCALHOST)); - assert!(is_non_global_v6(Ipv6Addr::UNSPECIFIED)); - assert!(is_non_global_v6("fc00::1".parse()?)); - assert!(is_non_global_v6("fe80::1".parse()?)); - assert!(is_non_global_v6("2001:db8::1".parse()?)); - assert!(is_non_global_v6("100::1".parse()?)); - assert!(is_non_global_v6("100:0:0:1::1".parse()?)); - assert!(is_non_global_v6("2001:2::1".parse()?)); - assert!(is_non_global_v6("3fff::1".parse()?)); - assert!(is_non_global_v6("5f00::1".parse()?)); - - // IPv6 Global public IPs - assert!(!is_non_global_v6("2606:4700:4700::1111".parse()?)); - assert!(!is_non_global_v6("101::1".parse()?)); - assert!(!is_non_global_v6("100:0:0:2::1".parse()?)); - assert!(!is_non_global_v6("2001:3::1".parse()?)); - assert!(!is_non_global_v6("4000::1".parse()?)); - assert!(!is_non_global_v6("5f01::1".parse()?)); - - // Host helper checks (including ASCII case-insensitivity and trailing dot) - assert!(is_private_or_local_host("localhost")); - assert!(is_private_or_local_host("LOCALHOST")); - assert!(is_private_or_local_host("localhost.")); - assert!(is_private_or_local_host("my-service.localhost")); - assert!(is_private_or_local_host("MY-SERVICE.LOCALHOST")); - assert!(is_private_or_local_host("device.local")); - assert!(is_private_or_local_host("DEVICE.LOCAL")); - assert!(is_private_or_local_host("device.local.")); - assert!(is_private_or_local_host("127.0.0.1")); - assert!(is_private_or_local_host("[::1]")); - assert!(!is_private_or_local_host("github.com")); - assert!(!is_private_or_local_host("api.openai.com")); - Ok(()) -} - -// ── WHATWG parser differentials ───────────────────────────── - -#[test] -fn validate_rejects_backslash_authority_smuggling() -> anyhow::Result<()> { - // A WHATWG parser treats `\` as `/` for http(s), so a real client - // connects to 127.0.0.1 while a naive split sees `*.example.com`. - let allow = vec!["example.com".to_string()]; - let smuggled = "http://127.0.0.1\\.example.com/"; - let err = validate_url(smuggled, &allow).rejection()?; - assert!(err.contains("backslash"), "got: {err}"); - let err = validate_url(smuggled, &[]).rejection()?; - assert!(err.contains("backslash"), "got: {err}"); - Ok(()) -} - -#[test] -fn validate_rejects_backslash_anywhere() -> anyhow::Result<()> { - let err = validate_url("https://example.com/a\\b", &[]).rejection()?; - assert!(err.contains("backslash"), "got: {err}"); - Ok(()) -} - -#[test] -fn extract_host_and_port_reject_backslash() { - let smuggled = "http://127.0.0.1\\.example.com:8080/"; - assert!(extract_host(smuggled).is_err()); - assert!(extract_port(smuggled).is_err()); -} - -#[test] -fn validate_rejects_percent_encoded_host() -> anyhow::Result<()> { - // WHATWG percent-decodes the host, so this is 127.0.0.1 on the wire. - let err = validate_url("http://%31%32%37.0.0.1/", &[]).rejection()?; - assert!(err.contains("percent-encoded"), "got: {err}"); - let allow = vec!["example.com".to_string()]; - let err = validate_url("http://evil%2eexample.com/", &allow).rejection()?; - assert!(err.contains("percent-encoded"), "got: {err}"); - Ok(()) -} - -#[test] -fn extract_host_and_port_reject_percent_encoded_authority() { - assert!(extract_host("http://%31%32%37.0.0.1/").is_err()); - assert!(extract_port("http://example.com:%38%30/").is_err()); -} - -#[test] -fn validate_allows_percent_encoding_outside_the_authority() -> anyhow::Result<()> { - let got = validate_url("https://example.com/search?q=a%20b#x%2F", &[])?; - assert_eq!(got, "https://example.com/search?q=a%20b#x%2F"); - Ok(()) -} - -#[tokio::test] -async fn dns_check_returns_exactly_the_vetted_addresses() -> anyhow::Result<()> { - let got = validate_url_with_dns_check_with_resolver( - "https://API.example.com:8443/v1", - &[], - |_, _| async { - Ok(vec![ - "93.184.216.34".parse()?, - "2606:4700:4700::1111".parse()?, - ]) - }, - ) - .await?; - assert_eq!( - got, - ValidatedUrl { - url: "https://API.example.com:8443/v1".to_string(), - host: "api.example.com".to_string(), - addrs: vec![ - "93.184.216.34:8443".parse()?, - "[2606:4700:4700::1111]:8443".parse()?, - ], - } - ); - Ok(()) -} - -#[tokio::test] -async fn dns_check_pins_an_ip_literal_host_to_itself() -> anyhow::Result<()> { - // IP literals skip DNS entirely, so this stays network-free. - let got = validate_url_with_dns_check("http://93.184.216.34/page", &[]).await?; - assert_eq!(got.host, "93.184.216.34"); - assert_eq!(got.addrs, vec!["93.184.216.34:80".parse()?]); - Ok(()) -} From 384b7e8c5c16231af37c4defc7c0b580b5275aea Mon Sep 17 00:00:00 2001 From: Steven Enamakel <enamakel@tinyhumans.ai> Date: Thu, 22 Oct 2026 21:48:40 +0000 Subject: [PATCH 2/4] docs: drop references to the removed tinytools-std crate Update the workspace guidance, the web_fetch status module docs, and the host-adoption map to stop pointing at `tinytools-std`, which no longer exists. The shared tool definitions and call protocols now live in this workspace while concrete tools and host-independent building blocks belong in their owning host or module repository. Auto-committed-on: cuttlefish Co-authored-by: Medulla <medulla@tinyhumans.ai> --- AGENTS.md | 7 +++---- crates/tinytools/src/status/web_fetch.rs | 6 +++--- docs/host-adoption/72-host-adoption-map.md | 5 ++--- 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7fbdd4a1..986fc360 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,10 +61,9 @@ is sufficient, whether a timeout applies, whether an external effect needs 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, 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`. +This workspace contains shared tool definitions and call protocols only. +Concrete tools and host-independent building blocks belong in their owning +host or module repository. Add a crate by creating `crates/<name>/` — `members = ["crates/*"]` picks it up by existing. Inherit `version`, `edition`, `rust-version`, `license`, and diff --git a/crates/tinytools/src/status/web_fetch.rs b/crates/tinytools/src/status/web_fetch.rs index d20c9e9d..b2ff3c11 100644 --- a/crates/tinytools/src/status/web_fetch.rs +++ b/crates/tinytools/src/status/web_fetch.rs @@ -1,8 +1,8 @@ //! The status shape emitted by the standard `web_fetch` tool. //! -//! This parser and its disposition live in the lightweight `tinytools` crate so -//! a host can use the producer's exact result contract without depending on -//! `tinytools-std` (which depends on `tinytools`). +//! This parser and its disposition live in `tinytools` so a host can use the +//! producer's exact result contract without a separate tool-implementation +//! crate. /// The safe recovery meaning of a structured HTTP failure from a fetched site. #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/docs/host-adoption/72-host-adoption-map.md b/docs/host-adoption/72-host-adoption-map.md index 518ad118..cab1552c 100644 --- a/docs/host-adoption/72-host-adoption-map.md +++ b/docs/host-adoption/72-host-adoption-map.md @@ -23,6 +23,5 @@ fetched-site disposition policy with the `tinytools::status` API: host's separate host-per-URL recovery scope; that remains a small URL projection (`web_fetch` + `url` argument → parsed URL host). -The status parser is `tinytools::status::web_fetch_status`. The standard tool -continues to expose `tinytools_std::network::web_fetch::status_of` as an alias -to that same parser, so the producer and host share one accepted shape. +The status parser is `tinytools::status::web_fetch_status`, which the producer +and host share as one accepted shape. From 34956944c08f305c158e45a1a41fcb6c90984bf0 Mon Sep 17 00:00:00 2001 From: Steven Enamakel <enamakel@tinyhumans.ai> Date: Thu, 22 Oct 2026 21:49:11 +0000 Subject: [PATCH 3/4] chore(deps): prune unused dependencies from lockfile Regenerate Cargo.lock after removing dependencies that are no longer referenced by the workspace, dropping transitive crates such as atomic-waker, base64, and the crypto stack. This shrinks the lockfile and keeps it in sync with the current manifest. Auto-committed-on: cuttlefish Co-authored-by: Medulla <medulla@tinyhumans.ai> --- Cargo.lock | 1640 +++------------------------------------------------- 1 file changed, 96 insertions(+), 1544 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index bc8303ce..f71dfe6e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -29,196 +29,94 @@ dependencies = [ ] [[package]] -name = "atomic-waker" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" - -[[package]] -name = "base64" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" - -[[package]] -name = "base64" -version = "0.23.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" - -[[package]] -name = "bitflags" -version = "2.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" - -[[package]] -name = "block-buffer" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" -dependencies = [ - "hybrid-array", -] - -[[package]] -name = "bumpalo" -version = "3.20.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" - -[[package]] -name = "bytes" -version = "1.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" - -[[package]] -name = "cc" -version = "1.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040" -dependencies = [ - "find-msvc-tools", - "shlex", -] - -[[package]] -name = "cfg-if" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" - -[[package]] -name = "cfg_aliases" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" - -[[package]] -name = "chacha20" -version = "0.10.2" +name = "itoa" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" -dependencies = [ - "cfg-if", - "cpufeatures", - "rand_core", -] +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] -name = "const-oid" -version = "0.10.2" +name = "memchr" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] -name = "cpufeatures" -version = "0.3.1" +name = "pin-project-lite" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" -dependencies = [ - "libc", -] +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] -name = "crypto-common" -version = "0.2.2" +name = "proc-macro2" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ - "hybrid-array", + "unicode-ident", ] [[package]] -name = "digest" -version = "0.11.3" +name = "quote" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ - "block-buffer", - "const-oid", - "crypto-common", + "proc-macro2", ] [[package]] -name = "displaydoc" -version = "0.2.7" +name = "regex" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" dependencies = [ - "proc-macro2", - "quote", - "syn", + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", ] [[package]] -name = "errno" -version = "0.3.14" +name = "regex-automata" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ - "libc", - "windows-sys 0.61.2", + "aho-corasick", + "memchr", + "regex-syntax", ] [[package]] -name = "fastrand" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" - -[[package]] -name = "find-msvc-tools" -version = "0.1.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" - -[[package]] -name = "form_urlencoded" -version = "1.2.2" +name = "regex-syntax" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" -dependencies = [ - "percent-encoding", -] +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] -name = "fs2" -version = "0.4.3" +name = "serde" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ - "libc", - "winapi", + "serde_core", + "serde_derive", ] [[package]] -name = "futures-channel" -version = "0.3.34" +name = "serde_core" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ - "futures-core", + "serde_derive", ] [[package]] -name = "futures-core" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" - -[[package]] -name = "futures-io" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" - -[[package]] -name = "futures-macro" -version = "0.3.34" +name = "serde_derive" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", @@ -226,1450 +124,104 @@ dependencies = [ ] [[package]] -name = "futures-sink" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" - -[[package]] -name = "futures-task" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" - -[[package]] -name = "futures-util" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" -dependencies = [ - "futures-core", - "futures-io", - "futures-macro", - "futures-sink", - "futures-task", - "memchr", - "pin-project-lite", - "slab", -] - -[[package]] -name = "getrandom" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "wasi", - "wasm-bindgen", -] - -[[package]] -name = "getrandom" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "r-efi", - "rand_core", - "wasm-bindgen", -] - -[[package]] -name = "glob" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" - -[[package]] -name = "http" -version = "1.5.0" +name = "serde_json" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ - "bytes", "itoa", + "memchr", + "serde", + "serde_core", + "zmij", ] [[package]] -name = "http-body" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" -dependencies = [ - "bytes", - "http", -] - -[[package]] -name = "http-body-util" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "pin-project-lite", -] - -[[package]] -name = "httparse" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" - -[[package]] -name = "hybrid-array" -version = "0.4.15" +name = "syn" +version = "3.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" dependencies = [ - "typenum", + "proc-macro2", + "quote", + "unicode-ident", ] [[package]] -name = "hyper" -version = "1.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +name = "tinytools" +version = "0.6.0" dependencies = [ - "atomic-waker", - "bytes", - "futures-channel", - "futures-core", - "http", - "http-body", - "httparse", - "itoa", - "pin-project-lite", - "smallvec", + "anyhow", + "async-trait", + "serde", + "serde_json", "tokio", - "want", ] [[package]] -name = "hyper-rustls" -version = "0.27.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +name = "tinytools-agent" +version = "0.6.0" dependencies = [ - "http", - "hyper", - "hyper-util", - "rustls", - "tokio", - "tokio-rustls", - "tower-service", - "webpki-roots", + "regex", + "serde", + "serde_json", + "tinytools", + "tracing", ] [[package]] -name = "hyper-util" -version = "0.1.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +name = "tinytools-jev" +version = "0.6.0" dependencies = [ - "base64 0.23.1", - "bytes", - "futures-channel", - "futures-util", - "http", - "http-body", - "httparse", - "hyper", - "ipnet", - "libc", - "percent-encoding", - "pin-project-lite", - "socket2", + "async-trait", + "tinytools", "tokio", - "tower-service", "tracing", ] [[package]] -name = "icu_collections" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" -dependencies = [ - "displaydoc", - "potential_utf", - "utf8_iter", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_locale_core" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" -dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_normalizer" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" -dependencies = [ - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", -] - -[[package]] -name = "icu_normalizer_data" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" - -[[package]] -name = "icu_properties" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" -dependencies = [ - "displaydoc", - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" - -[[package]] -name = "icu_provider" -version = "2.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" -dependencies = [ - "displaydoc", - "icu_locale_core", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", -] - -[[package]] -name = "idna" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" -dependencies = [ - "idna_adapter", - "smallvec", - "utf8_iter", -] - -[[package]] -name = "idna_adapter" -version = "1.2.2" +name = "tokio" +version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ - "icu_normalizer", - "icu_properties", + "pin-project-lite", + "tokio-macros", ] [[package]] -name = "ipnet" -version = "2.12.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" - -[[package]] -name = "itoa" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" - -[[package]] -name = "js-sys" -version = "0.3.106" +name = "tokio-macros" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ - "cfg-if", - "futures-util", - "wasm-bindgen", + "proc-macro2", + "quote", + "syn", ] [[package]] -name = "libc" -version = "0.2.189" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" - -[[package]] -name = "linux-raw-sys" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" - -[[package]] -name = "litemap" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" - -[[package]] -name = "lock_api" -version = "0.4.14" +name = "tracing" +version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ - "scopeguard", + "pin-project-lite", + "tracing-core", ] [[package]] -name = "log" -version = "0.4.34" +name = "tracing-core" +version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" [[package]] -name = "lru-slab" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" - -[[package]] -name = "memchr" -version = "2.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" - -[[package]] -name = "mime" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" - -[[package]] -name = "mime_guess" -version = "2.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" -dependencies = [ - "mime", - "unicase", -] - -[[package]] -name = "mio" -version = "1.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" -dependencies = [ - "libc", - "wasi", - "windows-sys 0.61.2", -] - -[[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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-link", -] - -[[package]] -name = "percent-encoding" -version = "2.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" - -[[package]] -name = "pin-project-lite" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" - -[[package]] -name = "potential_utf" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" -dependencies = [ - "zerovec", -] - -[[package]] -name = "proc-macro2" -version = "1.0.107" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quinn" -version = "0.11.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" -dependencies = [ - "bytes", - "cfg_aliases", - "pin-project-lite", - "quinn-proto", - "quinn-udp", - "rustc-hash", - "rustls", - "socket2", - "thiserror", - "tokio", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-proto" -version = "0.11.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" -dependencies = [ - "bytes", - "getrandom 0.4.3", - "lru-slab", - "rand", - "rand_pcg", - "ring", - "rustc-hash", - "rustls", - "rustls-pki-types", - "slab", - "thiserror", - "tinyvec", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-udp" -version = "0.5.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016" -dependencies = [ - "cfg_aliases", - "libc", - "once_cell", - "socket2", - "tracing", - "windows-sys 0.61.2", -] - -[[package]] -name = "quote" -version = "1.0.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" -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 = "rand" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" -dependencies = [ - "chacha20", - "getrandom 0.4.3", - "rand_core", -] - -[[package]] -name = "rand_core" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" - -[[package]] -name = "rand_pcg" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" -dependencies = [ - "rand_core", -] - -[[package]] -name = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags", -] - -[[package]] -name = "regex" -version = "1.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" -dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "regex-automata" -version = "0.4.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", -] - -[[package]] -name = "regex-syntax" -version = "0.8.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" - -[[package]] -name = "reqwest" -version = "0.12.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" -dependencies = [ - "base64 0.22.1", - "bytes", - "futures-core", - "futures-util", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-rustls", - "hyper-util", - "js-sys", - "log", - "mime_guess", - "percent-encoding", - "pin-project-lite", - "quinn", - "rustls", - "rustls-pki-types", - "serde", - "serde_json", - "serde_urlencoded", - "sync_wrapper", - "tokio", - "tokio-rustls", - "tokio-util", - "tower", - "tower-http", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "wasm-streams", - "web-sys", - "webpki-roots", -] - -[[package]] -name = "ring" -version = "0.17.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" -dependencies = [ - "cc", - "cfg-if", - "getrandom 0.2.17", - "libc", - "untrusted", - "windows-sys 0.52.0", -] - -[[package]] -name = "rustc-hash" -version = "2.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" - -[[package]] -name = "rustix" -version = "1.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" -dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys", - "windows-sys 0.61.2", -] - -[[package]] -name = "rustls" -version = "0.23.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" -dependencies = [ - "once_cell", - "ring", - "rustls-pki-types", - "rustls-webpki", - "subtle", - "zeroize", -] - -[[package]] -name = "rustls-pki-types" -version = "1.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" -dependencies = [ - "web-time", - "zeroize", -] - -[[package]] -name = "rustls-webpki" -version = "0.103.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" -dependencies = [ - "ring", - "rustls-pki-types", - "untrusted", -] - -[[package]] -name = "rustversion" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" - -[[package]] -name = "ryu" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" - -[[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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - -[[package]] -name = "serde" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_core" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "serde_json" -version = "1.0.151" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "serde_urlencoded" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" -dependencies = [ - "form_urlencoded", - "itoa", - "ryu", - "serde", -] - -[[package]] -name = "sha2" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - -[[package]] -name = "shlex" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" - -[[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 = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - -[[package]] -name = "smallvec" -version = "1.16.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" - -[[package]] -name = "socket2" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "stable_deref_trait" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" - -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - -[[package]] -name = "syn" -version = "3.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "sync_wrapper" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" -dependencies = [ - "futures-core", -] - -[[package]] -name = "synstructure" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tempfile" -version = "3.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" -dependencies = [ - "fastrand", - "getrandom 0.4.3", - "once_cell", - "rustix", - "windows-sys 0.61.2", -] - -[[package]] -name = "thiserror" -version = "2.0.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" -dependencies = [ - "thiserror-impl", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tinystr" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" -dependencies = [ - "displaydoc", - "zerovec", -] - -[[package]] -name = "tinytools" -version = "0.6.0" -dependencies = [ - "anyhow", - "async-trait", - "serde", - "serde_json", - "tokio", -] - -[[package]] -name = "tinytools-agent" -version = "0.6.0" -dependencies = [ - "regex", - "serde", - "serde_json", - "tinytools", - "tracing", -] - -[[package]] -name = "tinytools-jev" -version = "0.6.0" -dependencies = [ - "async-trait", - "tinytools", - "tokio", - "tracing", -] - -[[package]] -name = "tinytools-std" -version = "0.6.0" -dependencies = [ - "anyhow", - "async-trait", - "base64 0.23.1", - "fs2", - "futures-util", - "glob", - "libc", - "log", - "parking_lot", - "regex", - "reqwest", - "rustix", - "serde_json", - "sha2", - "tempfile", - "tinytools", - "tokio", - "tracing", - "walkdir", -] - -[[package]] -name = "tinyvec" -version = "1.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" - -[[package]] -name = "tokio" -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", - "socket2", - "tokio-macros", - "windows-sys 0.61.2", -] - -[[package]] -name = "tokio-macros" -version = "2.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tokio-rustls" -version = "0.26.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" -dependencies = [ - "rustls", - "tokio", -] - -[[package]] -name = "tokio-util" -version = "0.7.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" -dependencies = [ - "bytes", - "futures-core", - "futures-sink", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "tower" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" -dependencies = [ - "futures-core", - "futures-util", - "pin-project-lite", - "sync_wrapper", - "tokio", - "tower-layer", - "tower-service", -] - -[[package]] -name = "tower-http" -version = "0.6.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" -dependencies = [ - "bitflags", - "bytes", - "futures-util", - "http", - "http-body", - "pin-project-lite", - "tower", - "tower-layer", - "tower-service", - "url", -] - -[[package]] -name = "tower-layer" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" - -[[package]] -name = "tower-service" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" - -[[package]] -name = "tracing" -version = "0.1.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" -dependencies = [ - "pin-project-lite", - "tracing-core", -] - -[[package]] -name = "tracing-core" -version = "0.1.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" -dependencies = [ - "once_cell", -] - -[[package]] -name = "try-lock" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" - -[[package]] -name = "typenum" -version = "1.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" - -[[package]] -name = "unicase" -version = "2.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" - -[[package]] -name = "unicode-ident" -version = "1.0.24" +name = "unicode-ident" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" -[[package]] -name = "untrusted" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" - -[[package]] -name = "url" -version = "2.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" -dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", - "serde", -] - -[[package]] -name = "utf8_iter" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" - -[[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 = "want" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" -dependencies = [ - "try-lock", -] - -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - -[[package]] -name = "wasm-bindgen" -version = "0.2.129" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-futures" -version = "0.4.79" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" -dependencies = [ - "js-sys", - "tokio", - "wasm-bindgen", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.129" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.129" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.129" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "wasm-streams" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" -dependencies = [ - "futures-util", - "js-sys", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - -[[package]] -name = "web-sys" -version = "0.3.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "web-time" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "webpki-roots" -version = "1.0.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" -dependencies = [ - "rustls-pki-types", -] - -[[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 0.61.2", -] - -[[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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-sys" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" -dependencies = [ - "windows-targets", -] - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-targets" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" -dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - -[[package]] -name = "windows_i686_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" - -[[package]] -name = "writeable" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" - -[[package]] -name = "yoke" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" -dependencies = [ - "stable_deref_trait", - "yoke-derive", - "zerofrom", -] - -[[package]] -name = "yoke-derive" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - -[[package]] -name = "zerofrom" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" -dependencies = [ - "zerofrom-derive", -] - -[[package]] -name = "zerofrom-derive" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - -[[package]] -name = "zeroize" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" - -[[package]] -name = "zerotrie" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", -] - -[[package]] -name = "zerovec" -version = "0.11.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" -dependencies = [ - "yoke", - "zerofrom", - "zerovec-derive", -] - -[[package]] -name = "zerovec-derive" -version = "0.11.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "zmij" version = "1.0.23" From 71c941b023d06db477a3c37e0f59f42ce14996c7 Mon Sep 17 00:00:00 2001 From: Steven Enamakel <enamakel@tinyhumans.ai> Date: Fri, 23 Oct 2026 04:31:44 +0000 Subject: [PATCH 4/4] docs: mark tinytools-std plans and specs as historical Add historical and superseded notes to the collapsed-tools, web-fetch, and fsgate documents, recording that the tinytools-std work was removed in tinytools#99 and its helper implementations are now owned by tinyagents-harness-tools. The status lines were adjusted so these documents no longer claim to describe APIs currently implemented by tinytools. Auto-committed-on: cuttlefish Co-authored-by: Medulla <medulla@tinyhumans.ai> --- docs/plans/collapsed-tools-and-standard-helpers.md | 5 +++++ docs/plans/web-fetch-async-html-extraction.md | 4 ++++ docs/specs/collapsed-tools-and-standard-helpers.md | 5 +++++ docs/specs/fsgate-batch-path-check.md | 7 ++++++- docs/specs/web-fetch-async-html-extraction.md | 6 +++++- 5 files changed, 25 insertions(+), 2 deletions(-) diff --git a/docs/plans/collapsed-tools-and-standard-helpers.md b/docs/plans/collapsed-tools-and-standard-helpers.md index 23259aa1..a553bf69 100644 --- a/docs/plans/collapsed-tools-and-standard-helpers.md +++ b/docs/plans/collapsed-tools-and-standard-helpers.md @@ -4,6 +4,11 @@ Specification: [Collapsed tools and standard helpers](../specs/collapsed-tools-a **Status:** Complete +**Historical plan:** The `tinytools-std` work in this plan was removed in +tinytools#99. Its former helper implementations are now owned by +`tinyagents-harness-tools`. Do not follow the deleted `crates/tinytools-std` +paths; this plan records the completed historical work. + ## Goal and assumptions Implement the public contracts in the linked specification across `tinytools` diff --git a/docs/plans/web-fetch-async-html-extraction.md b/docs/plans/web-fetch-async-html-extraction.md index 7b5e00b3..c4dab8c0 100644 --- a/docs/plans/web-fetch-async-html-extraction.md +++ b/docs/plans/web-fetch-async-html-extraction.md @@ -1,5 +1,9 @@ # Implement asynchronous HTML extraction in `web_fetch` +**Historical plan:** The implementation moved to `tinyagents-harness-tools` +when `tinytools-std` was removed in tinytools#99. This plan records completed +work and does not describe current ownership in `tinytools`. + Spec: [Asynchronous HTML extraction in `web_fetch`](../specs/web-fetch-async-html-extraction.md). ## Steps diff --git a/docs/specs/collapsed-tools-and-standard-helpers.md b/docs/specs/collapsed-tools-and-standard-helpers.md index ee8b5c5b..db7168f4 100644 --- a/docs/specs/collapsed-tools-and-standard-helpers.md +++ b/docs/specs/collapsed-tools-and-standard-helpers.md @@ -3,6 +3,11 @@ **Status:** Implemented **Owner:** tinytools maintainers +**Scope note:** The standard-helper portion of this specification was +superseded when `tinytools-std` was removed in tinytools#99. Its former helper +implementations are owned by `tinyagents-harness-tools`; the collapsed-tool +contracts remain in `tinytools`. + ## Problem Hosts need to expose related tools as one action-dispatched tool without losing diff --git a/docs/specs/fsgate-batch-path-check.md b/docs/specs/fsgate-batch-path-check.md index c8e4a802..dc8caac2 100644 --- a/docs/specs/fsgate-batch-path-check.md +++ b/docs/specs/fsgate-batch-path-check.md @@ -1,8 +1,13 @@ # FsGate batch path check +**Status:** Superseded by the removal of `tinytools-std` in tinytools#99. The +former filesystem helper implementation is now owned by +`tinyagents-harness-tools`; this document is historical and does not describe +an API implemented by `tinytools`. + ## Status and owner -Implemented. Owner: tinytools-std (`filesystem`). +Historical implementation: `tinytools-std` (`filesystem`). ## Problem diff --git a/docs/specs/web-fetch-async-html-extraction.md b/docs/specs/web-fetch-async-html-extraction.md index aa6ac2ed..f1b71077 100644 --- a/docs/specs/web-fetch-async-html-extraction.md +++ b/docs/specs/web-fetch-async-html-extraction.md @@ -1,8 +1,12 @@ # Asynchronous HTML extraction in `web_fetch` +**Status:** Superseded by the removal of `tinytools-std` in tinytools#99. The +current implementation is owned by `tinyagents-harness-tools`; this document +records historical behavior and does not describe a `tinytools` API. + ## Status -Accepted behavior, introduced with `AsyncHtmlExtractor` and +Historical accepted behavior, introduced with `AsyncHtmlExtractor` and `WebFetchTool::new_async`. ## Context