diff --git a/Cargo.lock b/Cargo.lock index 5af3e5a..1def83f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -40,6 +40,16 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + [[package]] name = "itoa" version = "1.0.18" @@ -52,6 +62,12 @@ 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 = "lock_api" version = "0.4.14" @@ -158,6 +174,19 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[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", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -226,7 +255,7 @@ dependencies = [ [[package]] name = "tinytools" -version = "0.4.1" +version = "0.5.0" dependencies = [ "anyhow", "async-trait", @@ -237,7 +266,7 @@ dependencies = [ [[package]] name = "tinytools-agent" -version = "0.4.1" +version = "0.5.0" dependencies = [ "regex", "serde", @@ -248,7 +277,7 @@ dependencies = [ [[package]] name = "tinytools-jev" -version = "0.4.1" +version = "0.5.0" dependencies = [ "async-trait", "tinytools", @@ -258,12 +287,13 @@ dependencies = [ [[package]] name = "tinytools-std" -version = "0.4.1" +version = "0.5.0" dependencies = [ "anyhow", "async-trait", "log", "parking_lot", + "rustix", "serde_json", "tinytools", "tokio", @@ -319,6 +349,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[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 = "zmij" version = "1.0.23" diff --git a/Cargo.toml b/Cargo.toml index 52d074c..8f919f5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,7 @@ exclude = ["worktrees"] # true`, so the version the release workflow bumps is written in exactly one # place and every crate moves together. [workspace.package] -version = "0.4.1" +version = "0.5.0" edition = "2024" rust-version = "1.88" license = "GPL-3.0-only" diff --git a/coverage.log b/coverage.log new file mode 100644 index 0000000..e69de29 diff --git a/crates/tinytools-agent/Cargo.toml b/crates/tinytools-agent/Cargo.toml index a332958..d74f1f2 100644 --- a/crates/tinytools-agent/Cargo.toml +++ b/crates/tinytools-agent/Cargo.toml @@ -14,7 +14,7 @@ readme = "README.md" regex = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } -tinytools = { path = "../tinytools", version = "0.4.1" } +tinytools = { path = "../tinytools", version = "0.5.0" } tracing = { workspace = true, optional = true } [features] diff --git a/crates/tinytools-jev/Cargo.toml b/crates/tinytools-jev/Cargo.toml index dc46ed4..9bf9e19 100644 --- a/crates/tinytools-jev/Cargo.toml +++ b/crates/tinytools-jev/Cargo.toml @@ -12,7 +12,7 @@ readme = "README.md" [dependencies] async-trait = { workspace = true } -tinytools = { path = "../tinytools", version = "0.4.1" } +tinytools = { path = "../tinytools", version = "0.5.0" } tracing = { workspace = true, optional = true } [dev-dependencies] diff --git a/crates/tinytools-std/Cargo.toml b/crates/tinytools-std/Cargo.toml index c48e0cb..7ef3d9d 100644 --- a/crates/tinytools-std/Cargo.toml +++ b/crates/tinytools-std/Cargo.toml @@ -13,10 +13,15 @@ publish = false [dependencies] anyhow = { workspace = true } async-trait = { workspace = true } +# 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" +# Safe effective-credential executable checks for Unix PATH candidates. +rustix = { version = "1", default-features = false, features = ["fs"] } serde_json = { workspace = true } -tinytools = { path = "../tinytools", version = "0.4.1" } +tinytools = { path = "../tinytools", version = "0.5.0" } +# `spawn_blocking` keeps synchronous system DNS resolution off async executor threads. tokio = { version = "1", default-features = false, features = ["rt", "sync"] } tracing = { workspace = true } diff --git a/crates/tinytools-std/README.md b/crates/tinytools-std/README.md index 1ba76b7..b818eda 100644 --- a/crates/tinytools-std/README.md +++ b/crates/tinytools-std/README.md @@ -4,8 +4,8 @@ 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)`). | -| `url_guard` | URL validation with SSRF checks; DNS validation returns addresses callers must pin for the connection. | +| `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. | | `detect_tools` | `find_on_path` and the read-only `detect_tools` tool. | No enforcement of host policy lives here; the crate only supplies mechanisms. diff --git a/crates/tinytools-std/src/detect_tools/mod.rs b/crates/tinytools-std/src/detect_tools/mod.rs index 48f7dee..aa040b0 100644 --- a/crates/tinytools-std/src/detect_tools/mod.rs +++ b/crates/tinytools-std/src/detect_tools/mod.rs @@ -34,50 +34,76 @@ impl Default for DetectToolsTool { } } +/// 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 exts: Vec = { - #[cfg(windows)] - { - std::env::var("PATHEXT") - .unwrap_or_else(|_| ".EXE;.CMD;.BAT".to_string()) - .split(';') - .map(std::string::ToString::to_string) - .collect() - } - #[cfg(not(windows))] - { - vec![String::new()] - } - }; + 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 ext in &exts { - let candidate = dir.join(format!("{name}{ext}")); - if candidate.is_file() { - // On Unix a plain `is_file()` can match a non-executable file and - // falsely report the tool as available; require the exec bit. - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let is_exec = std::fs::metadata(&candidate) - .is_ok_and(|m| m.permissions().mode() & 0o111 != 0); - if is_exec { - return Some(candidate); - } - } - #[cfg(not(unix))] - { - return Some(candidate); - } + 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 { diff --git a/crates/tinytools-std/src/detect_tools/test.rs b/crates/tinytools-std/src/detect_tools/test.rs index bf279ea..f88a399 100644 --- a/crates/tinytools-std/src/detect_tools/test.rs +++ b/crates/tinytools-std/src/detect_tools/test.rs @@ -1,7 +1,26 @@ -#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)] +//! 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(); @@ -10,9 +29,9 @@ fn name_and_permission() { } #[test] -fn default_tool_exposes_read_only_probe_schema() { +fn default_and_metadata_contracts_are_available() { let tool = ::default(); - assert!(!tool.description().is_empty()); + assert!(tool.description().contains("PATH")); assert_eq!( tool.parameters_schema()["properties"]["tools"]["type"], "array" @@ -20,40 +39,87 @@ fn default_tool_exposes_read_only_probe_schema() { } #[tokio::test] -async fn empty_or_malformed_tool_list_uses_the_default_catalog() { - for args in [json!({}), json!({"tools": [1, null]})] { - let result = DetectToolsTool::new().execute(args).await.unwrap(); - let payload: serde_json::Value = serde_json::from_str(&result.output()).unwrap(); - assert_eq!(payload["probed"], super::DEFAULT_CANDIDATES.len()); - } +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() { +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 - .unwrap(); + .await?; assert!(!result.is_error); - let payload: serde_json::Value = serde_json::from_str(&result.output()).unwrap(); + let payload: serde_json::Value = serde_json::from_str(&result.output())?; assert_eq!(payload["probed"], 1); - assert_eq!(payload["available"].as_array().unwrap().len(), 0); + assert_eq!(payload["available"], json!([])); assert_eq!( - payload["missing"].as_array().unwrap()[0], - "definitely_not_a_real_binary_xyz_123" + payload["missing"], + json!(["definitely_not_a_real_binary_xyz_123"]) ); + Ok(()) } #[tokio::test] -async fn available_plus_missing_equals_probed() { +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 - .unwrap(); - let payload: serde_json::Value = serde_json::from_str(&result.output()).unwrap(); - let avail = payload["available"].as_array().unwrap().len(); - let miss = payload["missing"].as_array().unwrap().len(); + .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/mod.rs b/crates/tinytools-std/src/file_state/mod.rs index df24ea1..35a14b6 100644 --- a/crates/tinytools-std/src/file_state/mod.rs +++ b/crates/tinytools-std/src/file_state/mod.rs @@ -3,9 +3,14 @@ //! 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 per-path write stamps so that write -//! tools can detect the conflict and return a model-facing error -//! requiring the agent to re-read. +//! 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). diff --git a/crates/tinytools-std/src/file_state/ops.rs b/crates/tinytools-std/src/file_state/ops.rs index b0d3266..da098ca 100644 --- a/crates/tinytools-std/src/file_state/ops.rs +++ b/crates/tinytools-std/src/file_state/ops.rs @@ -5,7 +5,7 @@ use std::sync::{Arc, OnceLock}; use std::time::{Instant, SystemTime}; use tokio::sync::{Mutex, OwnedMutexGuard}; -use super::types::{FileStateCoordinator, ReadStamp, WriteStamp}; +use super::types::{FileStateCoordinator, ReadStamp, writers_after_read}; // ── Singleton ──────────────────────────────────────────────────────────── @@ -31,23 +31,59 @@ pub fn try_global() -> Option> { // ── Read tracking ──────────────────────────────────────────────────────── -/// Record that `agent_id` read `resolved_path` at the given mtime. -pub fn record_read(agent_id: &str, resolved_path: PathBuf, mtime: SystemTime, partial: bool) { +/// 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 }; - tracing::trace!( - agent = agent_id, - path = %resolved_path.display(), - partial, - "[file_state] record_read" - ); - coord.reads.write().insert( - (agent_id.to_string(), resolved_path), - ReadStamp { - mtime, - timestamp: Instant::now(), + 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 ─────────────────────────────────────────────────────── @@ -55,29 +91,34 @@ pub fn record_read(agent_id: &str, resolved_path: PathBuf, mtime: SystemTime, pa /// Record that `agent_id` wrote `resolved_path`. pub fn record_write(agent_id: &str, resolved_path: PathBuf) { let Some(coord) = try_global() else { return }; - tracing::trace!( - agent = agent_id, - path = %resolved_path.display(), - "[file_state] record_write" - ); - let now = Instant::now(); - coord.writes.write().insert( - resolved_path.clone(), - WriteStamp { - writer: agent_id.to_string(), - timestamp: now, - }, - ); - // Also update this agent's own read stamp so its own subsequent - // writes don't trigger self-staleness. - coord.reads.write().insert( - (agent_id.to_string(), resolved_path), - ReadStamp { - mtime: SystemTime::now(), - timestamp: now, - partial: false, - }, - ); + 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 ───────────────────────────────────────────────────── @@ -86,22 +127,27 @@ pub fn record_write(agent_id: &str, resolved_path: PathBuf) { /// 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: &PathBuf) -> Option { - let coord = try_global()?; - let reads = coord.reads.read(); - let writes = coord.writes.read(); - let read_key = (agent_id.to_string(), resolved_path.clone()); - let read_stamp = reads.get(&read_key)?; - let ws = writes.get(resolved_path)?; - if ws.writer != agent_id && ws.timestamp > read_stamp.timestamp { +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 '{}' after your last read. \ - Re-read the file before editing.", - ws.writer + "Stale read: file '{display_path}' was modified by agent '{latest}' after your last \ + read. Re-read the file before editing." )) - } else { - None } } @@ -147,24 +193,20 @@ pub async fn acquire_path_lock(resolved_path: &Path) -> Option Vec { - let Some(coord) = try_global() else { - return Vec::new(); - }; - let reads = coord.reads.read(); - let writes = coord.writes.read(); - let mut stale = Vec::new(); - for ((agent_id, path), read_stamp) in reads.iter() { - if agent_id != parent_agent_id { - continue; - } - if let Some(ws) = writes.get(path) - && child_agent_ids.contains(&ws.writer) - && ws.timestamp > read_stamp.timestamp - { - stale.push(path.clone()); - } + 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) + }) } - stale.sort(); - stale.dedup(); - stale } diff --git a/crates/tinytools-std/src/file_state/test/agent_context.rs b/crates/tinytools-std/src/file_state/test/agent_context.rs index 4bf301e..de922a0 100644 --- a/crates/tinytools-std/src/file_state/test/agent_context.rs +++ b/crates/tinytools-std/src/file_state/test/agent_context.rs @@ -1,3 +1,5 @@ +//! 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] diff --git a/crates/tinytools-std/src/file_state/test/mod.rs b/crates/tinytools-std/src/file_state/test/mod.rs index 4621a9b..6adf096 100644 --- a/crates/tinytools-std/src/file_state/test/mod.rs +++ b/crates/tinytools-std/src/file_state/test/mod.rs @@ -1,4 +1,5 @@ -#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)] +//! 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 index f879afe..6d43751 100644 --- a/crates/tinytools-std/src/file_state/test/ops.rs +++ b/crates/tinytools-std/src/file_state/test/ops.rs @@ -1,6 +1,7 @@ -use crate::file_state::types::WriteStamp; +//! Tests for read/write tracking, staleness checks, and path locks. + use crate::file_state::{FileStateCoordinator, ReadStamp}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::{Duration, Instant, SystemTime}; use tokio::sync::Mutex; @@ -21,9 +22,12 @@ fn record_and_check_no_staleness() { partial: false, }, ); - let reads = coord.reads.read(); - let rs = reads.get(&("agent-a".to_string(), path.clone())).unwrap(); - assert!(!rs.partial); + 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()); } @@ -31,23 +35,15 @@ fn record_and_check_no_staleness() { fn detect_sibling_write_staleness() { let coord = fresh_coordinator(); let path = PathBuf::from("/tmp/test/b.txt"); - let read_time = Instant::now(); - coord.reads.write().insert( - ("agent-a".to_string(), path.clone()), - ReadStamp { - mtime: SystemTime::now(), - timestamp: read_time, - partial: false, - }, - ); - std::thread::sleep(Duration::from_millis(5)); - coord.writes.write().insert( + coord.record_read( + "agent-a", path.clone(), - WriteStamp { - writer: "agent-b".to_string(), - timestamp: Instant::now(), - }, + 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]); } @@ -56,25 +52,18 @@ fn detect_sibling_write_staleness() { fn own_write_does_not_trigger_staleness() { let coord = fresh_coordinator(); let path = PathBuf::from("/tmp/test/c.txt"); - let now = Instant::now(); - coord.reads.write().insert( - ("agent-a".to_string(), path.clone()), - ReadStamp { - mtime: SystemTime::now(), - timestamp: now, - partial: false, - }, - ); - std::thread::sleep(Duration::from_millis(5)); - coord.writes.write().insert( + coord.record_read( + "agent-a", path.clone(), - WriteStamp { - writer: "agent-a".to_string(), - timestamp: Instant::now(), - }, + 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!(stale.is_empty()); + assert_eq!(coord.check_stale_read("agent-a", &path), None); } #[test] @@ -89,34 +78,37 @@ fn partial_read_detected() { partial: true, }, ); - let reads = coord.reads.read(); - let rs = reads.get(&("agent-a".to_string(), path.clone())).unwrap(); - assert!(rs.partial); + 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"); - let parent_read_time = Instant::now(); - coord.reads.write().insert( - ("parent".to_string(), path.clone()), - ReadStamp { - mtime: SystemTime::now(), - timestamp: parent_read_time, - partial: false, - }, + coord.record_read( + "parent", + path.clone(), + SystemTime::now(), + false, + Instant::now(), ); std::thread::sleep(Duration::from_millis(5)); - coord.writes.write().insert( - path.clone(), - WriteStamp { - writer: "child-1".to_string(), - timestamp: Instant::now(), - }, + 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!( + coord + .parent_stale_files("parent", &["someone-else".to_string()]) + .is_empty() ); - let stale = coord.stale_reads_for_parent("parent"); - assert_eq!(stale, vec![path]); } #[test] @@ -124,24 +116,11 @@ 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.writes.write().insert( - p1.clone(), - WriteStamp { - writer: "child-1".to_string(), - timestamp: Instant::now(), - }, - ); - coord.writes.write().insert( - p2.clone(), - WriteStamp { - writer: "child-2".to_string(), - timestamp: Instant::now(), - }, - ); + 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!(result.contains_key("child-1")); - assert_eq!(result["child-1"], vec![p1]); + assert_eq!(result.get("child-1"), Some(&vec![p1])); } #[tokio::test] @@ -163,7 +142,7 @@ async fn path_lock_serialises_access() { } #[tokio::test] -async fn global_api_tracks_reads_writes_and_locks() { +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, @@ -174,16 +153,29 @@ async fn global_api_tracks_reads_writes_and_locks() { assert!(try_global().is_some()); let path = PathBuf::from("/tmp/test/global-flow.txt"); - record_read("reader", path.clone(), SystemTime::now(), true); + 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); + 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).expect("stale after sibling write"); + 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()]), @@ -193,4 +185,132 @@ async fn global_api_tracks_reads_writes_and_locks() { 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!(coord.stale_reads_for_parent("parent").is_empty()); + assert!( + coord + .parent_stale_files("parent", &["child-1".to_string()]) + .is_empty() + ); + 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 index 56a527e..28a4fa9 100644 --- a/crates/tinytools-std/src/file_state/types.rs +++ b/crates/tinytools-std/src/file_state/types.rs @@ -12,20 +12,16 @@ use tokio::sync::Mutex; pub struct ReadStamp { /// Filesystem mtime at the moment of the read. pub mtime: SystemTime, - /// Monotonic clock timestamp of the read. + /// 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, } -/// Per-path write metadata. -#[derive(Debug, Clone)] -pub(crate) struct WriteStamp { - /// Agent identity that performed the write. - pub writer: String, - /// Monotonic clock timestamp of the write. - pub timestamp: Instant, -} +/// 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`. @@ -35,8 +31,10 @@ pub struct FileStateCoordinator { /// Key: `(agent_id, canonical_path)`. pub(crate) reads: RwLock>, - /// Per-resolved-path write stamp (last writer wins). - pub(crate) writes: 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`). @@ -61,39 +59,64 @@ impl FileStateCoordinator { } /// Return the set of resolved paths that `parent_agent_id` has read - /// but were subsequently written by a different agent. + /// 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::new(); - for ((agent_id, path), read_stamp) in reads.iter() { - if agent_id != parent_agent_id { - continue; - } - if let Some(ws) = writes.get(path) - && ws.writer != parent_agent_id - && ws.timestamp > read_stamp.timestamp - { - stale.push(path.clone()); - } - } + 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.dedup(); stale } - /// Collect all paths written by agents in the given set. + /// 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, ws) in writes.iter() { - if agent_ids.contains(&ws.writer) { + for (path, writers) in writes.iter() { + for agent_id in agent_ids.iter().filter(|id| writers.contains_key(*id)) { result - .entry(ws.writer.clone()) + .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/lib.rs b/crates/tinytools-std/src/lib.rs index 99d6913..8d7ea62 100644 --- a/crates/tinytools-std/src/lib.rs +++ b/crates/tinytools-std/src/lib.rs @@ -6,8 +6,26 @@ //! //! - [`file_state`] — cross-agent read/write stamps and per-path locks, so a //! sibling agent's edit is noticed before a stale overwrite. -//! - [`url_guard`] — URL validation with SSRF and DNS-rebinding checks. +//! - [`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. +//! +//! # 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; diff --git a/crates/tinytools-std/src/url_guard/README.md b/crates/tinytools-std/src/url_guard/README.md new file mode 100644 index 0000000..29f68bb --- /dev/null +++ b/crates/tinytools-std/src/url_guard/README.md @@ -0,0 +1,30 @@ +# 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 IPv6, and NAT64 translation prefixes. +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 index 7a1ee52..33c62df 100644 --- a/crates/tinytools-std/src/url_guard/mod.rs +++ b/crates/tinytools-std/src/url_guard/mod.rs @@ -10,7 +10,10 @@ //! - **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. +//! 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 @@ -18,49 +21,29 @@ //! pass `validate_url` but are caught by `validate_url_with_dns_check` //! because they fail real-world DNS resolution. //! -//! ## DNS Rebinding Protection +//! ## 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). To close this gap, -//! callers should use [`validate_url_with_dns_check`] which resolves the -//! hostname and re-validates the resolved IPs before the request is made. +//! 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. use std::future::Future; use std::net::{IpAddr, SocketAddr, ToSocketAddrs}; -/// A URL and the public socket addresses that passed the DNS SSRF check. -/// -/// An HTTP client must connect to one of [`Self::addresses`] while retaining -/// [`Self::url`] as the request authority (for Host and TLS SNI). Resolving -/// [`Self::url`] again in the client would reopen the DNS-rebinding window. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct ValidatedUrl { - url: String, - addresses: Vec, -} - -impl ValidatedUrl { - /// The original validated URL, including its hostname for HTTP authority. - #[must_use] - pub fn url(&self) -> &str { - &self.url - } - - /// Public addresses safe to use as the connection destination. - #[must_use] - pub fn addresses(&self) -> &[SocketAddr] { - &self.addresses - } -} - /// Validate a URL against the allowlist + SSRF rules. Returns the /// original URL on success. /// /// # Errors /// -/// Fails when the URL is empty, contains whitespace, is not `http(s)`, names a -/// local/private host, or (in strict mode) is outside the allowlist. +/// 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 { let url = raw_url.trim(); @@ -76,6 +59,8 @@ pub fn validate_url(raw_url: &str, allowed_domains: &[String]) -> anyhow::Result anyhow::bail!("Only http:// and https:// URLs are allowed"); } + reject_backslash(url)?; + let host = extract_host(url)?; if is_private_or_local_host(&host) { @@ -118,14 +103,49 @@ pub fn validate_url(raw_url: &str, allowed_domains: &[String]) -> anyhow::Result 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, +} + +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. The returned -/// addresses let the caller pin the actual connection and defend against DNS -/// rebinding between validation and request time. +/// verifies that none of the resolved IPs are private/local. /// -/// Callers should use this function instead of `validate_url` in all -/// paths that make outbound HTTP requests, and connect to one of the returned -/// addresses while preserving the URL hostname as the HTTP authority. +/// 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 /// @@ -151,15 +171,18 @@ where 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. - let port = extract_port(&url)?; if let Ok(ip) = host.parse::() { return Ok(ValidatedUrl { url, - addresses: vec![SocketAddr::new(ip, port)], + 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?; @@ -181,7 +204,8 @@ where Ok(ValidatedUrl { url, - addresses: addrs + host, + addrs: addrs .into_iter() .map(|ip| SocketAddr::new(ip, port)) .collect(), @@ -263,22 +287,58 @@ pub fn normalize_domain(raw: &str) -> Option { Some(d) } -/// Extract the host part of an `http(s)` URL. -/// -/// # Errors -/// -/// Fails on a missing/empty host, userinfo, or an IPv6 literal. -pub fn extract_host(url: &str) -> anyhow::Result { - let rest = url - .strip_prefix("http://") - .or_else(|| url.strip_prefix("https://")) - .ok_or_else(|| anyhow::anyhow!("Only http:// and https:// URLs are allowed"))?; +/// 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 { + let (_, authority) = split_authority(url)?; + if authority.is_empty() { anyhow::bail!("URL must include a host"); } @@ -287,10 +347,6 @@ pub fn extract_host(url: &str) -> anyhow::Result { anyhow::bail!("URL userinfo is not allowed"); } - if authority.starts_with('[') { - anyhow::bail!("IPv6 hosts are not supported in http_request"); - } - let host = authority .split(':') .next() @@ -310,22 +366,10 @@ pub fn extract_host(url: &str) -> anyhow::Result { /// /// # Errors /// -/// Fails when the URL has no valid port. +/// 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 { - let is_http = url.starts_with("http://"); - let rest = url - .strip_prefix("http://") - .or_else(|| url.strip_prefix("https://")) - .ok_or_else(|| anyhow::anyhow!("Only http:// and https:// URLs are allowed"))?; - - let authority = rest - .split(['/', '?', '#']) - .next() - .ok_or_else(|| anyhow::anyhow!("Invalid URL"))?; - - if authority.starts_with('[') { - anyhow::bail!("IPv6 hosts are not supported in http_request"); - } + 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()) { @@ -411,6 +455,12 @@ pub fn is_non_global_v4(v4: std::net::Ipv4Addr) -> bool { /// Whether an IPv6 address is non-global (loopback, ULA, link-local, mapped, ...). pub fn is_non_global_v6(v6: std::net::Ipv6Addr) -> bool { let segs = v6.segments(); + let well_known_nat64_v4 = + (segs[0] == 0x0064 && segs[1] == 0xff9b && segs[2] == 0 && segs[3] == 0).then(|| { + let [first, second] = segs[6].to_be_bytes(); + let [third, fourth] = segs[7].to_be_bytes(); + std::net::Ipv4Addr::new(first, second, third, fourth) + }); v6.is_loopback() || v6.is_unspecified() || v6.is_multicast() @@ -419,6 +469,10 @@ pub fn is_non_global_v6(v6: std::net::Ipv6Addr) -> bool { || (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) + || well_known_nat64_v4.is_some_and(is_non_global_v4) || (segs[0] & 0xfff0) == 0x3ff0 || segs[0] == 0x5f00 || v6.to_ipv4_mapped().is_some_and(is_non_global_v4) diff --git a/crates/tinytools-std/src/url_guard/test.rs b/crates/tinytools-std/src/url_guard/test.rs index cfeec25..1a16101 100644 --- a/crates/tinytools-std/src/url_guard/test.rs +++ b/crates/tinytools-std/src/url_guard/test.rs @@ -1,11 +1,52 @@ -#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)] +//! 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; +} + +impl Rejection for anyhow::Result { + fn rejection(self) -> anyhow::Result { + 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 ").unwrap(); - assert_eq!(got, "docs.example.com"); + 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::()?]); + assert!(super::resolve_host_ips(String::new(), 80).await.is_err()); + Ok(()) } #[test] @@ -19,10 +60,11 @@ fn normalize_allowed_domains_deduplicates() { } #[test] -fn validate_accepts_exact_domain() { +fn validate_accepts_exact_domain() -> anyhow::Result<()> { let allow = vec!["example.com".to_string()]; - let got = validate_url("https://example.com/docs", &allow).unwrap(); + let got = validate_url("https://example.com/docs", &allow)?; assert_eq!(got, "https://example.com/docs"); + Ok(()) } #[test] @@ -38,12 +80,11 @@ fn validate_accepts_subdomain() { } #[test] -fn validate_rejects_allowlist_miss() { +fn validate_rejects_allowlist_miss() -> anyhow::Result<()> { let allow = vec!["example.com".to_string()]; - let err = validate_url("https://google.com", &allow) - .unwrap_err() - .to_string(); + let err = validate_url("https://google.com", &allow).rejection()?; assert!(err.contains("allowed websites")); + Ok(()) } #[test] @@ -55,57 +96,52 @@ fn validate_wildcard_allows_any_public_host() { } #[test] -fn validate_wildcard_still_blocks_local_and_private() { +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) - .unwrap_err() - .to_string() + .rejection()? .contains("local/private") ); assert!( validate_url("https://192.168.1.5", &allow) - .unwrap_err() - .to_string() + .rejection()? .contains("local/private") ); + Ok(()) } #[test] -fn validate_rejects_localhost() { +fn validate_rejects_localhost() -> anyhow::Result<()> { let allow = vec!["localhost".to_string()]; - let err = validate_url("https://localhost:8080", &allow) - .unwrap_err() - .to_string(); + let err = validate_url("https://localhost:8080", &allow).rejection()?; assert!(err.contains("local/private")); + Ok(()) } #[test] -fn validate_rejects_private_ipv4() { +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) - .unwrap_err() - .to_string(); + let err = validate_url("https://192.168.1.5", &allow).rejection()?; assert!(err.contains("local/private")); + Ok(()) } #[test] -fn validate_rejects_whitespace() { +fn validate_rejects_whitespace() -> anyhow::Result<()> { let allow = vec!["example.com".to_string()]; - let err = validate_url("https://example.com/hello world", &allow) - .unwrap_err() - .to_string(); + let err = validate_url("https://example.com/hello world", &allow).rejection()?; assert!(err.contains("whitespace")); + Ok(()) } #[test] -fn validate_rejects_userinfo() { +fn validate_rejects_userinfo() -> anyhow::Result<()> { let allow = vec!["example.com".to_string()]; - let err = validate_url("https://user@example.com", &allow) - .unwrap_err() - .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. @@ -119,16 +155,13 @@ fn validate_empty_allowlist_allows_public_host() { } #[test] -fn validate_empty_allowlist_still_blocks_private_hosts() { - let err = validate_url("https://192.168.1.5", &[]) - .unwrap_err() - .to_string(); +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", &[]) - .unwrap_err() - .to_string(); + let err = validate_url("https://localhost", &[]).rejection()?; assert!(err.contains("local/private")); + Ok(()) } // ── normalize_allowed_domains: fail-closed on malformed-only input ── @@ -159,31 +192,8 @@ fn normalize_empty_input_stays_empty_for_open_mode() { assert!(normalize_allowed_domains(vec![]).is_empty()); } -#[test] -fn normalization_discards_invalid_domains_and_strips_ports() { - assert_eq!( - normalize_domain("https://.Example.com:8443/path").as_deref(), - Some("example.com") - ); - assert_eq!(normalize_domain(" "), None); - assert_eq!(normalize_domain("bad domain"), None); - assert_eq!(normalize_domain("http://"), None); -} - -#[test] -fn host_and_port_parsing_reject_malformed_authorities() { - assert!(extract_host("https:///path").is_err()); - assert!(extract_host("https://:80/path").is_err()); - assert_eq!(extract_port("http://example.com").unwrap(), 80); - assert_eq!(extract_port("https://example.com").unwrap(), 443); - assert_eq!(extract_port("https://example.com:8443/path").unwrap(), 8443); - assert!(extract_port("https://example.com:nope").is_err()); - assert!(extract_port("https://example.com:65536").is_err()); - assert!(extract_port("https://[::1]:443").is_err()); -} - #[tokio::test] -async fn dns_check_with_empty_allowlist_allows_public_resolved_host() { +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", @@ -191,49 +201,28 @@ async fn dns_check_with_empty_allowlist_allows_public_resolved_host() { |host, port| async move { assert_eq!(host, "example.com"); assert_eq!(port, 443); - Ok(vec!["93.184.216.34".parse().unwrap()]) + Ok(vec!["93.184.216.34".parse()?]) }, ) - .await - .unwrap(); - assert_eq!(got.url(), "https://example.com"); - assert_eq!(got.addresses(), &["93.184.216.34:443".parse().unwrap()]); -} - -#[tokio::test] -async fn dns_check_skips_resolution_for_a_public_ip_literal() { - let got = validate_url_with_dns_check_with_resolver("https://8.8.8.8", &[], |_, _| async { - panic!("IP literals should not be resolved") - }) - .await - .unwrap(); - assert_eq!(got.url(), "https://8.8.8.8"); - assert_eq!(got.addresses(), &["8.8.8.8:443".parse().unwrap()]); + .await?; + assert_eq!(got.url, "https://example.com"); + Ok(()) } #[tokio::test] -async fn system_resolver_accepts_a_numeric_loopback_without_network_access() { - let addresses = resolve_host_ips("127.0.0.1".to_string(), 80).await.unwrap(); - assert_eq!( - addresses, - vec!["127.0.0.1".parse::().unwrap()] - ); -} - -#[tokio::test] -async fn dns_check_with_empty_allowlist_blocks_private_resolved_ip() { +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().unwrap()]) + Ok(vec!["10.0.0.1".parse()?]) }) .await - .unwrap_err() - .to_string(); + .rejection()?; assert!(err.contains("DNS rebinding blocked")); + Ok(()) } #[tokio::test] -async fn dns_check_resolver_failure_is_a_refusal_not_a_pass_through() { +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. @@ -243,13 +232,13 @@ async fn dns_check_resolver_failure_is_a_refusal_not_a_pass_through() { |host, _port| async move { anyhow::bail!("DNS resolution failed for '{host}': NXDOMAIN") }, ) .await - .unwrap_err() - .to_string(); + .rejection()?; assert!(err.contains("DNS resolution failed")); + Ok(()) } #[tokio::test] -async fn dns_check_resolver_returning_no_addresses_is_a_refusal() { +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". @@ -257,34 +246,33 @@ async fn dns_check_resolver_returning_no_addresses_is_a_refusal() { Ok(Vec::new()) }) .await - .unwrap_err() - .to_string(); + .rejection()?; assert!(err.contains("DNS resolution returned no addresses")); + Ok(()) } #[test] -fn validate_rejects_ftp_scheme() { +fn validate_rejects_ftp_scheme() -> anyhow::Result<()> { let allow = vec!["example.com".to_string()]; - let err = validate_url("ftp://example.com", &allow) - .unwrap_err() - .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() { +fn validate_rejects_empty_url() -> anyhow::Result<()> { let allow = vec!["example.com".to_string()]; - let err = validate_url("", &allow).unwrap_err().to_string(); + let err = validate_url("", &allow).rejection()?; assert!(err.contains("empty")); + Ok(()) } #[test] -fn validate_rejects_ipv6_host() { +fn validate_rejects_ipv6_host() -> anyhow::Result<()> { let allow = vec!["example.com".to_string()]; - let err = validate_url("http://[::1]:8080/path", &allow) - .unwrap_err() - .to_string(); + let err = validate_url("http://[::1]:8080/path", &allow).rejection()?; assert!(err.contains("IPv6")); + Ok(()) } #[test] @@ -359,6 +347,14 @@ 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 allows_public_ipv6() { assert!(!is_private_or_local_host("2607:f8b0:4004:800::200e")); @@ -440,7 +436,7 @@ fn ssrf_zero_padded_loopback_not_parsed_as_ip() { } #[test] -fn ssrf_alternate_notations_rejected_by_validate_url() { +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", @@ -448,18 +444,19 @@ fn ssrf_alternate_notations_rejected_by_validate_url() { "http://2130706433", "http://127.000.000.001", ] { - let err = validate_url(notation, &allow).unwrap_err().to_string(); + 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() { +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()]; @@ -467,16 +464,16 @@ async fn dns_check_blocks_localhost_resolution() { // but validate_url_with_dns_check should also catch it. let err = validate_url_with_dns_check("https://localhost", &allow) .await - .unwrap_err() - .to_string(); + .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() { +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", @@ -484,30 +481,29 @@ async fn dns_check_passes_for_public_resolved_ip() { |host, port| async move { assert_eq!(host, "example.com"); assert_eq!(port, 443); - Ok(vec!["93.184.216.34".parse().unwrap()]) + Ok(vec!["93.184.216.34".parse()?]) }, ) - .await - .unwrap(); - assert_eq!(got.url(), "https://example.com"); - assert_eq!(got.addresses(), &["93.184.216.34:443".parse().unwrap()]); + .await?; + assert_eq!(got.url, "https://example.com"); + Ok(()) } #[tokio::test] -async fn dns_check_blocks_private_resolved_ip() { +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().unwrap()]) + Ok(vec!["127.0.0.1".parse()?]) }) .await - .unwrap_err() - .to_string(); + .rejection()?; assert!(err.contains("DNS rebinding blocked")); + Ok(()) } #[tokio::test] -async fn dns_check_uses_explicit_port_for_resolution() { +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", @@ -515,17 +511,16 @@ async fn dns_check_uses_explicit_port_for_resolution() { |host, port| async move { assert_eq!(host, "api.example.com"); assert_eq!(port, 8080); - Ok(vec!["93.184.216.34".parse().unwrap()]) + Ok(vec!["93.184.216.34".parse()?]) }, ) - .await - .unwrap(); - assert_eq!(got.url(), "http://api.example.com:8080/status"); - assert_eq!(got.addresses(), &["93.184.216.34:8080".parse().unwrap()]); + .await?; + assert_eq!(got.url, "http://api.example.com:8080/status"); + Ok(()) } #[tokio::test] -async fn dns_check_returns_resolver_failure() { +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", @@ -535,19 +530,19 @@ async fn dns_check_returns_resolver_failure() { }, ) .await - .unwrap_err() - .to_string(); + .rejection()?; assert!(err.contains("DNS resolution failed")); + Ok(()) } #[tokio::test] -async fn dns_check_rejects_ip_literal_private() { +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 - .unwrap_err() - .to_string(); + .rejection()?; assert!(err.contains("local/private")); + Ok(()) } #[test] @@ -559,18 +554,18 @@ fn wildcard_allows_any_host() { } #[tokio::test] -async fn wildcard_still_blocks_private_hosts() { +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 - .unwrap_err() - .to_string(); + .rejection()?; assert!(err.contains("local/private"), "got: {err}"); + Ok(()) } #[test] -fn exported_ssrf_predicates_classify_non_global_ips_accurately() { +fn exported_ssrf_predicates_classify_non_global_ips_accurately() -> anyhow::Result<()> { use std::net::{Ipv4Addr, Ipv6Addr}; // IPv4 Non-global checks @@ -600,22 +595,22 @@ fn exported_ssrf_predicates_classify_non_global_ips_accurately() { // 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().unwrap())); - assert!(is_non_global_v6("fe80::1".parse().unwrap())); - assert!(is_non_global_v6("2001:db8::1".parse().unwrap())); - assert!(is_non_global_v6("100::1".parse().unwrap())); - assert!(is_non_global_v6("100:0:0:1::1".parse().unwrap())); - assert!(is_non_global_v6("2001:2::1".parse().unwrap())); - assert!(is_non_global_v6("3fff::1".parse().unwrap())); - assert!(is_non_global_v6("5f00::1".parse().unwrap())); + 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().unwrap())); - assert!(!is_non_global_v6("101::1".parse().unwrap())); - assert!(!is_non_global_v6("100:0:0:2::1".parse().unwrap())); - assert!(!is_non_global_v6("2001:3::1".parse().unwrap())); - assert!(!is_non_global_v6("4000::1".parse().unwrap())); - assert!(!is_non_global_v6("5f01::1".parse().unwrap())); + 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")); @@ -630,4 +625,94 @@ fn exported_ssrf_predicates_classify_non_global_ips_accurately() { 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(()) } diff --git a/crates/tinytools/src/collapse/README.md b/crates/tinytools/src/collapse/README.md new file mode 100644 index 0000000..905ac13 --- /dev/null +++ b/crates/tinytools/src/collapse/README.md @@ -0,0 +1,31 @@ +# Collapsed actions + +This module combines a family of related tools behind one action-dispatched +tool. It derives the combined parameter schema from the member tools, validates +that action names and schemas are safe to combine, and provides helpers for +dispatching classification decisions to the selected member. + +## Public surface + +- [`CollapsedAction`](types.rs) pairs a stable action name with its tool. +- [`validate_actions`](mod.rs) rejects empty families, duplicate action names, + and member schemas that use the reserved `action` property. +- [`merge_action_schemas`](mod.rs) unions member properties and namespaces + member-local `$defs` and draft-07 `definitions` so local references remain + valid after merging. +- Permission and external-effect helpers expose the static minimum/maximum + classifications and select the member-specific classification for a call. +- [`CollapseError`](types.rs) describes invalid action families. + +## Schema and classification constraints + +The merged schema requires only the `action` discriminator. A union cannot +express that a property is required for one action but optional for another, so +the selected member remains responsible for validating its own arguments. +Conflicting definitions for a shared property are preserved as `anyOf` +alternatives. Member-local definition names include the action in the merged +namespace to prevent cross-member collisions. + +The module describes classifications; it does not enforce permissions or run +tools. The host must use the argument-aware classification helpers at its +enforcement point and dispatch execution to the matching member. diff --git a/crates/tinytools/src/collapse/mod.rs b/crates/tinytools/src/collapse/mod.rs index 4216a0b..e4a8568 100644 --- a/crates/tinytools/src/collapse/mod.rs +++ b/crates/tinytools/src/collapse/mod.rs @@ -21,40 +21,69 @@ //! drift is silent: the model is told about a parameter the implementation //! ignores, or not told about one it needs. [`merge_action_schemas`] derives //! it from the same `parameters_schema()` the members serve. -//! 2. **Permission is per action, and the argument-free answer is the -//! strictest.** [`Tool::permission_level`] has no arguments, so a collapsed -//! tool cannot answer it honestly; it returns the strictest level any member -//! requires, and [`Tool::permission_level_with_args`] gives the exact one -//! once the action is known. A caller that ignores the arguments therefore -//! over-restricts rather than under-restricts. +//! 2. **Classification is per action.** The argument-free answers follow the +//! [`Tool`] contract for a multi-action tool: [`Tool::permission_level`] is +//! the *minimum* any member requires ([`minimum_permission`]), so a caller +//! who may run the read-only half is not statically shut out of the whole +//! tool, and [`Tool::external_effect`] is `true` for any non-empty family +//! ([`any_external_effect`]), since a member may classify per call. The enforcement points are the +//! argument-aware variants, and those delegate to the member the call +//! selects — [`permission_for_args`] and [`external_effect_for_action`] — so +//! a member that classifies per call keeps doing so behind the collapse. +//! A call whose action resolves to no member falls back to the strictest +//! answer, even though it will fail before any member runs. //! -//! A collapsed tool uses [`external_effect_for_action`] at the host's -//! argument-aware approval point. Its argument-less declaration remains a -//! conservative summary via [`any_external_effect`]. +//! Call [`validate_actions`] once when building the collapsed tool: it rejects +//! an empty family, a duplicated action name, and a member that declares the +//! reserved `action` parameter. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashSet}; use serde_json::{Map, Value, json}; -use crate::{PermissionLevel, Tool}; +use crate::PermissionLevel; +// Only named in the docs: the module's contract is stated against it. +#[cfg(doc)] +use crate::Tool; -/// One member of a collapsed family: the action name the model passes, and the -/// tool that serves it. -#[derive(Clone, Copy)] -pub struct CollapsedAction<'a> { - /// The `action` value the model passes to select this member. - pub action: &'static str, - /// The member tool that serves the action. - pub tool: &'a dyn Tool, -} +mod types; + +pub use types::{CollapseError, CollapsedAction}; + +/// The parameter a collapsed tool reserves to select the member. +const ACTION_KEY: &str = "action"; -impl std::fmt::Debug for CollapsedAction<'_> { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("CollapsedAction") - .field("action", &self.action) - .field("tool", &self.tool.name()) - .finish() +/// Check that `actions` can be served as one collapsed tool. +/// +/// # Errors +/// +/// Returns [`CollapseError::Empty`] when `actions` is empty, +/// [`CollapseError::DuplicateAction`] when two members share an action name, +/// and [`CollapseError::ReservedProperty`] when a member's schema declares a +/// property named `action`. +pub fn validate_actions(actions: &[CollapsedAction<'_>]) -> Result<(), CollapseError> { + if actions.is_empty() { + return Err(CollapseError::Empty); + } + let mut seen = HashSet::new(); + for entry in actions { + if !seen.insert(entry.action) { + return Err(CollapseError::DuplicateAction { + action: entry.action.to_string(), + }); + } + let schema = entry.tool.parameters_schema(); + if schema + .get("properties") + .and_then(Value::as_object) + .is_some_and(|props| props.contains_key(ACTION_KEY)) + { + return Err(CollapseError::ReservedProperty { + action: entry.action.to_string(), + }); + } } + Ok(()) } /// Build the collapsed `parameters_schema` from the members' own schemas. @@ -65,6 +94,14 @@ impl std::fmt::Debug for CollapsedAction<'_> { /// and `todo` already use — so the model can tell which fields apply to the /// action it picked. /// +/// When members declare the same property with different schemas, neither is +/// dropped: the merged property is an `anyOf` over the distinct definitions, +/// so no action's constraints are lost and member order does not matter. +/// Definitions that differ only in their `description` count as the same. +/// +/// The `action` discriminator always wins over a member property of the same +/// name; [`validate_actions`] reports such a member as an error. +/// /// Nothing is `required` beyond `action`. A union cannot express "required for /// this action only", and marking a field required because one action needs it /// would make every other action's call invalid. The members already validate @@ -72,59 +109,74 @@ impl std::fmt::Debug for CollapsedAction<'_> { /// where it can be specific rather than in a schema that has to be vague. #[must_use] pub fn merge_action_schemas(actions: &[CollapsedAction<'_>]) -> Value { - let mut properties: BTreeMap> = BTreeMap::new(); + // Every distinct definition of each property, in first-seen order. + let mut definitions: BTreeMap, Value)>> = BTreeMap::new(); + let mut merged_defs = Map::new(); // Track which actions mentioned each property so a shared field reads as // shared rather than as belonging to whichever action happened to be first. let mut owners: BTreeMap> = BTreeMap::new(); for entry in actions { let schema = entry.tool.parameters_schema(); - let Some(props) = schema.get("properties").and_then(Value::as_object) else { - continue; - }; - for (name, spec) in props { - owners.entry(name.clone()).or_default().push(entry.action); - properties - .entry(name.clone()) - .or_default() - .push((entry.action, spec.clone())); + if let Some(props) = schema.get("properties").and_then(Value::as_object) { + for (name, spec) in props { + if name == ACTION_KEY { + continue; + } + owners.entry(name.clone()).or_default().push(entry.action); + let known = definitions.entry(name.clone()).or_default(); + let mut property = spec.clone(); + rewrite_local_refs(&mut property, entry.action); + if let Some((owners, _)) = known + .iter_mut() + .find(|(_, existing)| same_definition(existing, &property)) + { + owners.push(entry.action); + } else { + known.push((vec![entry.action], property)); + } + } + } + for defs_key in ["$defs", "definitions"] { + if let Some(defs) = schema.get(defs_key).and_then(Value::as_object) { + for (name, definition) in defs { + let mut definition = definition.clone(); + rewrite_local_refs(&mut definition, entry.action); + merged_defs.insert(namespace_definition(entry.action, name), definition); + } + } } } + let mut properties: BTreeMap = definitions + .into_iter() + .map(|(name, specs)| (name, merge_property_definitions(specs))) + .collect(); + // Rewrite each description to name its actions. Done in a second pass so // the prefix can list every owner, which the first pass does not yet know. - let mut merged_properties = BTreeMap::new(); - for (name, specs) in properties { - let owned_by = owners.get(&name).map_or(&[][..], Vec::as_slice); + for (name, spec) in &mut properties { + let Some(object) = spec.as_object_mut() else { + continue; + }; + let owned_by = owners.get(name).map_or(&[][..], Vec::as_slice); // A property every action takes needs no prefix — saying so would be // noise on every line. - let needs_prefix = owned_by.len() != actions.len() && !owned_by.is_empty(); - let prefix = owned_by.join("/"); - let mut alternatives = Vec::new(); - for (action, mut spec) in specs { - if needs_prefix && let Some(object) = spec.as_object_mut() { - let existing = object - .get("description") - .and_then(Value::as_str) - .unwrap_or_default(); - let described = if existing.is_empty() { - format!("{action}: {prefix}") - } else { - format!("{action}: {existing}") - }; - object.insert("description".to_string(), Value::String(described)); - } - if !alternatives.contains(&spec) { - alternatives.push(spec); - } + if owned_by.len() == actions.len() || owned_by.is_empty() { + continue; } - let merged = match alternatives.as_slice() { - [only] => only.clone(), - many => json!({"anyOf": many}), + let prefix = owned_by.join("/"); + let existing = object + .get("description") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + let described = if existing.is_empty() { + prefix + } else { + format!("{prefix}: {existing}") }; - // `spec` entries are deduplicated by schema; action ownership in the - // descriptions above tells the model which alternative applies. - merged_properties.insert(name, merged); + object.insert("description".to_string(), Value::String(described)); } let enum_values: Vec = actions @@ -133,29 +185,198 @@ pub fn merge_action_schemas(actions: &[CollapsedAction<'_>]) -> Value { .collect(); let mut merged = Map::new(); + for (name, spec) in properties { + merged.insert(name, spec); + } + // Inserted last so nothing a member declares can replace it. merged.insert( - "action".to_string(), + ACTION_KEY.to_string(), json!({ "type": "string", "enum": enum_values, "description": "Which operation to run." }), ); - for (name, spec) in merged_properties { - merged.insert(name, spec); - } - json!({ + let mut result = json!({ "type": "object", "properties": Value::Object(merged), - "required": ["action"] - }) + "required": [ACTION_KEY] + }); + if !merged_defs.is_empty() { + result["$defs"] = Value::Object(merged_defs); + } + result +} + +/// Merge distinct schema definitions, keeping each conflicting definition's +/// action ownership visible to callers of the combined schema. +fn merge_property_definitions(mut specs: Vec<(Vec<&str>, Value)>) -> Value { + if specs.len() == 1 { + return specs.remove(0).1; + } + let alternatives = specs + .into_iter() + .map(|(owners, mut spec)| { + if let Some(object) = spec.as_object_mut() { + let prefix = owners.join("/"); + let existing = object + .get("description") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + let description = if existing.is_empty() { + prefix + } else { + format!("{prefix}: {existing}") + }; + object.insert("description".to_string(), Value::String(description)); + } + spec + }) + .collect::>(); + json!({ "anyOf": alternatives }) +} + +/// Namespace member-local JSON Schema definitions so merged properties keep +/// resolving their references without collisions between actions. +fn namespace_definition(action: &str, name: &str) -> String { + format!("a{}_{}d{}_{}", action.len(), action, name.len(), name) +} + +/// Rewrite references only where JSON Schema expects a subschema. Values +/// inside `const`, `enum`, `default`, and `examples` are instance data. +fn rewrite_local_refs(value: &mut Value, action: &str) { + match value { + Value::Object(object) => { + if let Some(Value::String(reference)) = object.get_mut("$ref") + && let Some(pointer) = reference + .strip_prefix("#/$defs/") + .or_else(|| reference.strip_prefix("#/definitions/")) + && let (token, suffix) = pointer.split_once('/').unwrap_or((pointer, "")) + && let Some(name) = decode_pointer_token(token) + { + let namespaced = namespace_definition(action, &name); + let suffix = if suffix.is_empty() { + String::new() + } else { + format!("/{suffix}") + }; + *reference = format!("#/$defs/{}{suffix}", encode_pointer_token(&namespaced)); + } + for key in [ + "$defs", + "definitions", + "properties", + "patternProperties", + "dependentSchemas", + ] { + if let Some(Value::Object(schemas)) = object.get_mut(key) { + for schema in schemas.values_mut() { + rewrite_local_refs(schema, action); + } + } + } + // Draft-07 dependencies can be schemas or property-name lists. + // Only schema-valued entries contain references to rewrite. + if let Some(Value::Object(dependencies)) = object.get_mut("dependencies") { + for dependency in dependencies.values_mut() { + if dependency.is_object() { + rewrite_local_refs(dependency, action); + } + } + } + for key in [ + "additionalProperties", + "additionalItems", + "unevaluatedProperties", + "propertyNames", + "items", + "contains", + "not", + "if", + "then", + "else", + "unevaluatedItems", + "prefixItems", + "contentSchema", + ] { + if let Some(schema) = object.get_mut(key) { + rewrite_local_refs(schema, action); + } + } + for key in ["allOf", "anyOf", "oneOf"] { + if let Some(Value::Array(schemas)) = object.get_mut(key) { + for schema in schemas { + rewrite_local_refs(schema, action); + } + } + } + } + Value::Array(values) => values + .iter_mut() + .for_each(|schema| rewrite_local_refs(schema, action)), + _ => {} + } +} + +/// Decode one JSON Pointer token, leaving malformed escape sequences intact. +fn decode_pointer_token(token: &str) -> Option { + let mut decoded = String::with_capacity(token.len()); + let mut chars = token.chars(); + while let Some(ch) = chars.next() { + if ch != '~' { + decoded.push(ch); + continue; + } + match chars.next()? { + '0' => decoded.push('~'), + '1' => decoded.push('/'), + _ => return None, + } + } + Some(decoded) +} + +/// Escape a definition name for its JSON Pointer token. +fn encode_pointer_token(token: &str) -> String { + token.replace('~', "~0").replace('/', "~1") +} + +/// Whether two property schemas constrain the same thing, ignoring the +/// human-facing `description`. +fn same_definition(a: &Value, b: &Value) -> bool { + match (a.as_object(), b.as_object()) { + (Some(a), Some(b)) => { + let strip = |object: &Map| { + let mut object = object.clone(); + object.remove("description"); + object + }; + strip(a) == strip(b) + } + _ => a == b, + } +} + +/// The least privilege any member requires. +/// +/// The answer for the argument-free [`Tool::permission_level`], which the +/// [`Tool`] contract defines as the minimum over a multi-action tool's actions +/// so a caller entitled to the read-only half is not statically blocked. The +/// exact per-call level comes from [`permission_for_args`]. +#[must_use] +pub fn minimum_permission(actions: &[CollapsedAction<'_>]) -> PermissionLevel { + actions + .iter() + .map(|entry| entry.tool.permission_level()) + .min_by_key(|level| permission_rank(*level)) + .unwrap_or(PermissionLevel::None) } /// The strictest permission level any member requires. /// -/// Used for the argument-free [`Tool::permission_level`], which cannot know -/// which action is coming. Over-restricting is the only safe direction. +/// The fallback [`permission_for_args`] uses when the call selects no member. #[must_use] pub fn strictest_permission(actions: &[CollapsedAction<'_>]) -> PermissionLevel { actions @@ -165,34 +386,60 @@ pub fn strictest_permission(actions: &[CollapsedAction<'_>]) -> PermissionLevel .unwrap_or(PermissionLevel::None) } -/// Conservative argument-less answer: `true` whenever the family has a member. +/// The answer for [`Tool::permission_level_with_args`]: the selected member's +/// own argument-aware level, asked with the dispatch key stripped. /// -/// The trait has no argument-less way to detect argument-dependent effects, so -/// a collapsed family cannot safely claim `false` here. Hosts should use -/// [`external_effect_for_action`] to get the exact per-call answer. +/// A call whose `action` is missing or unknown gets +/// [`strictest_permission`] — over-restricting is the only safe direction when +/// the member is not known. +#[must_use] +pub fn permission_for_args(actions: &[CollapsedAction<'_>], args: &Value) -> PermissionLevel { + match selected(actions, args) { + Some(entry) => entry + .tool + .permission_level_with_args(&args_without_action(args)), + None => strictest_permission(actions), + } +} + +/// `true` for any non-empty family, whatever the members' static answers. +/// +/// The conservative argument-free answer: `true` whenever the family has a +/// member, because this form cannot inspect call arguments. #[must_use] pub fn any_external_effect(actions: &[CollapsedAction<'_>]) -> bool { - // The static answer has no arguments to discriminate with. Conservatively - // require approval whenever there is a member; the per-action helper below - // gives the precise answer at the actual call boundary. !actions.is_empty() } -/// Resolve whether the selected member has an external effect for these args. -/// -/// Hosts must call this at the approval gate for collapsed tools, passing the -/// original model arguments so the member's argument-aware declaration runs. +/// Resolve the selected member's external-effect classification for a call. +/// Missing or unknown actions use the conservative [`any_external_effect`]. #[must_use] pub fn external_effect_for_action(actions: &[CollapsedAction<'_>], args: &Value) -> bool { - let Some(action) = args.get("action").and_then(Value::as_str) else { - return any_external_effect(actions); - }; - let Some(member) = resolve(actions, action) else { - return any_external_effect(actions); - }; - member - .tool - .external_effect_with_args(&args_without_action(args)) + external_effect_for_args(actions, args) +} + +/// The answer for [`Tool::external_effect_with_args`]: the selected member's +/// own argument-aware answer, asked with the dispatch key stripped. +/// +/// A call whose `action` is missing or unknown gets [`any_external_effect`]; +/// such a call fails before any member runs. +#[must_use] +pub fn external_effect_for_args(actions: &[CollapsedAction<'_>], args: &Value) -> bool { + match selected(actions, args) { + Some(entry) => entry + .tool + .external_effect_with_args(&args_without_action(args)), + None => any_external_effect(actions), + } +} + +/// The member a call's `action` argument selects, if any. +fn selected<'a>( + actions: &'a [CollapsedAction<'a>], + args: &Value, +) -> Option<&'a CollapsedAction<'a>> { + let action = args.get(ACTION_KEY).and_then(Value::as_str)?; + resolve(actions, action) } /// Order the permission levels from least to most privileged. @@ -249,7 +496,7 @@ pub fn args_without_action(args: &Value) -> Value { match args.as_object() { Some(object) => { let mut cloned = object.clone(); - cloned.remove("action"); + cloned.remove(ACTION_KEY); Value::Object(cloned) } None => args.clone(), diff --git a/crates/tinytools/src/collapse/test.rs b/crates/tinytools/src/collapse/test.rs index 63f903d..1ba81cd 100644 --- a/crates/tinytools/src/collapse/test.rs +++ b/crates/tinytools/src/collapse/test.rs @@ -3,7 +3,7 @@ #![allow(clippy::unwrap_used, clippy::unnecessary_literal_bound)] use super::*; -use crate::ToolResult; +use crate::{Tool, ToolResult}; use async_trait::async_trait; struct Stub { @@ -30,9 +30,6 @@ impl Tool for Stub { fn external_effect(&self) -> bool { self.external } - fn external_effect_with_args(&self, args: &Value) -> bool { - self.external || args["outbound"] == true - } async fn execute(&self, _args: Value) -> anyhow::Result { Ok(ToolResult::success("ok")) } @@ -191,125 +188,527 @@ fn external_effect_is_true_when_any_member_has_one() { } #[test] -fn action_effect_resolution_uses_member_arguments_and_static_fallback_is_safe() { - let clean = stub("c", json!({}), PermissionLevel::ReadOnly, false); +fn the_dispatch_key_does_not_reach_the_member() { + // Several members set `additionalProperties: false`. + let args = json!({"action": "runs", "job_id": "j1"}); + assert_eq!(args_without_action(&args), json!({"job_id": "j1"})); +} + +#[test] +fn an_unknown_action_names_the_valid_ones() { + let a = stub("a", json!({}), PermissionLevel::ReadOnly, false); + let actions = vec![CollapsedAction { + action: "add", + tool: &a, + }]; + assert_eq!( + unknown_action_message(&actions, Some("addd")), + "unknown action 'addd' (expected add)" + ); + assert_eq!( + unknown_action_message(&actions, None), + "missing required field `action` (expected add)" + ); +} + +/// A member that classifies per call: `force` raises it to `Write` and `send` +/// makes it effectful. It answers `Dangerous` if the dispatch key reaches it, +/// so a test can tell the key was stripped first. +struct ArgAware; + +#[async_trait] +impl Tool for ArgAware { + fn name(&self) -> &str { + "arg_aware" + } + fn description(&self) -> &str { + "classifies per call" + } + fn parameters_schema(&self) -> Value { + json!({"type": "object", "properties": {"force": {"type": "boolean"}}}) + } + fn permission_level_with_args(&self, args: &Value) -> PermissionLevel { + if args.get("action").is_some() { + PermissionLevel::Dangerous + } else if args["force"] == json!(true) { + PermissionLevel::Write + } else { + PermissionLevel::ReadOnly + } + } + fn external_effect_with_args(&self, args: &Value) -> bool { + args["send"] == json!(true) + } + async fn execute(&self, _args: Value) -> anyhow::Result { + Ok(ToolResult::success("ok")) + } +} + +#[test] +fn the_argument_free_permission_is_the_least_member() { + // The `Tool` contract: a multi-action tool must not be statically hidden + // from a caller entitled to its read-only half. + let read = stub("r", json!({}), PermissionLevel::ReadOnly, false); + let execute = stub("x", json!({}), PermissionLevel::Execute, false); + let actions = [ + CollapsedAction { + action: "x", + tool: &execute, + }, + CollapsedAction { + action: "r", + tool: &read, + }, + ]; + assert_eq!(minimum_permission(&actions), PermissionLevel::ReadOnly); + assert_eq!(minimum_permission(&[]), PermissionLevel::None); +} + +#[test] +fn the_per_call_permission_is_the_selected_members_own_answer() { + let aware = ArgAware; + let execute = stub("x", json!({}), PermissionLevel::Execute, false); + let actions = [ + CollapsedAction { + action: "aware", + tool: &aware, + }, + CollapsedAction { + action: "x", + tool: &execute, + }, + ]; + assert_eq!( + permission_for_args(&actions, &json!({"action": "aware"})), + PermissionLevel::ReadOnly + ); + assert_eq!( + permission_for_args(&actions, &json!({"action": "aware", "force": true})), + PermissionLevel::Write + ); + assert_eq!( + permission_for_args(&actions, &json!({"action": "x"})), + PermissionLevel::Execute + ); +} + +#[test] +fn a_call_selecting_no_member_gets_the_strictest_permission() { + let aware = ArgAware; + let execute = stub("x", json!({}), PermissionLevel::Execute, false); + let actions = [ + CollapsedAction { + action: "aware", + tool: &aware, + }, + CollapsedAction { + action: "x", + tool: &execute, + }, + ]; + assert_eq!( + permission_for_args(&actions, &json!({"action": "nope"})), + PermissionLevel::Execute + ); + assert_eq!( + permission_for_args(&actions, &json!({})), + PermissionLevel::Execute + ); +} + +#[test] +fn the_per_call_external_effect_reaches_a_member_that_classifies_per_call() { + // `ArgAware` leaves the argument-free `external_effect` at `false`, so an + // aggregate of static answers would wave an effectful call past the gate. + let aware = ArgAware; let actions = [CollapsedAction { - action: "c", - tool: &clean, + action: "aware", + tool: &aware, }]; - assert!(!external_effect_for_action( + assert!(any_external_effect(&actions)); + assert!(external_effect_for_args( &actions, - &json!({"action": "c"}) + &json!({"action": "aware", "send": true}) )); - assert!(external_effect_for_action( + assert!(!external_effect_for_args( &actions, - &json!({"action": "c", "outbound": true}) + &json!({"action": "aware"}) )); - assert!(external_effect_for_action( +} + +#[test] +fn a_call_selecting_no_member_gets_the_aggregate_external_effect() { + let dirty = stub("d", json!({}), PermissionLevel::ReadOnly, true); + let actions = [CollapsedAction { + action: "d", + tool: &dirty, + }]; + assert!(external_effect_for_args( &actions, - &json!({"action": "unknown"}) + &json!({"action": "nope"}) )); - assert!(any_external_effect(&actions)); - assert!(!any_external_effect(&[])); } #[test] -fn shared_property_types_are_exposed_as_schema_alternatives() { - let text = stub( - "text", - json!({"properties": {"value": {"type": "string"}}}), - PermissionLevel::None, +fn conflicting_definitions_of_a_shared_property_are_all_kept() { + let a = stub( + "a", + json!({"type": "object", "properties": {"id": {"type": "string"}}}), + PermissionLevel::ReadOnly, false, ); - let number = stub( - "number", - json!({"properties": {"value": {"type": "number"}}}), - PermissionLevel::None, + let b = stub( + "b", + json!({"type": "object", "properties": {"id": {"type": "integer", "minimum": 1}}}), + PermissionLevel::ReadOnly, + false, + ); + let forward = [ + CollapsedAction { + action: "a", + tool: &a, + }, + CollapsedAction { + action: "b", + tool: &b, + }, + ]; + let reversed = [forward[1], forward[0]]; + let merged = merge_action_schemas(&forward); + let alternatives = merged["properties"]["id"]["anyOf"].as_array().unwrap(); + assert_eq!(alternatives.len(), 2); + assert!(alternatives.contains(&json!({"type": "string", "description": "a"}))); + assert!(alternatives.contains(&json!({"type": "integer", "minimum": 1, "description": "b"}))); + // Neither member's constraints depend on which came first. + let reordered = merge_action_schemas(&reversed); + let reordered_alternatives = reordered["properties"]["id"]["anyOf"].as_array().unwrap(); + assert_eq!(reordered_alternatives.len(), 2); + assert!(reordered_alternatives.contains(&json!({"type": "string", "description": "a"}))); +} + +#[test] +fn definitions_differing_only_in_description_are_one_property() { + let a = stub( + "a", + json!({"type": "object", "properties": {"id": {"type": "string", "description": "A."}}}), + PermissionLevel::ReadOnly, + false, + ); + let b = stub( + "b", + json!({"type": "object", "properties": {"id": {"type": "string", "description": "B."}}}), + PermissionLevel::ReadOnly, false, ); let actions = [ CollapsedAction { - action: "text", - tool: &text, + action: "a", + tool: &a, }, CollapsedAction { - action: "number", - tool: &number, + action: "b", + tool: &b, }, ]; - let schema = merge_action_schemas(&actions); - let alternatives = schema["properties"]["value"]["anyOf"] - .as_array() - .unwrap() - .iter() - .map(|schema| schema["type"].as_str().unwrap()) - .collect::>(); + let merged = merge_action_schemas(&actions); + assert!(merged["properties"]["id"].get("anyOf").is_none()); + assert_eq!(merged["properties"]["id"]["type"], json!("string")); +} + +#[test] +fn a_member_property_named_action_cannot_replace_the_discriminator() { + let clash = stub( + "clash", + json!({"type": "object", "properties": {"action": {"type": "integer"}}}), + PermissionLevel::ReadOnly, + false, + ); + let actions = [CollapsedAction { + action: "clash", + tool: &clash, + }]; + let merged = merge_action_schemas(&actions); + assert_eq!(merged["properties"]["action"]["type"], json!("string")); + assert_eq!(merged["properties"]["action"]["enum"], json!(["clash"])); +} + +#[test] +fn member_definitions_are_preserved_and_namespaced() { + let first = stub( + "first", + json!({"type":"object", "properties":{"options":{"$ref":"#/$defs/Options"}}, "$defs":{"Options":{"type":"string"}}}), + PermissionLevel::ReadOnly, + false, + ); + let second = stub( + "second", + json!({"type":"object", "properties":{"options":{"$ref":"#/$defs/Options"}}, "$defs":{"Options":{"type":"integer"}}}), + PermissionLevel::ReadOnly, + false, + ); + let merged = merge_action_schemas(&[ + CollapsedAction { + action: "first", + tool: &first, + }, + CollapsedAction { + action: "second", + tool: &second, + }, + ]); + let first_definition = namespace_definition("first", "Options"); + let second_definition = namespace_definition("second", "Options"); + assert_eq!( + merged["properties"]["options"]["anyOf"][0]["$ref"], + format!("#/$defs/{first_definition}") + ); assert_eq!( - alternatives, - std::collections::BTreeSet::from(["number", "string"]) + merged["properties"]["options"]["anyOf"][1]["$ref"], + format!("#/$defs/{second_definition}") ); + assert_eq!(merged["$defs"][first_definition]["type"], "string"); + assert_eq!(merged["$defs"][second_definition]["type"], "integer"); } #[test] -fn the_dispatch_key_does_not_reach_the_member() { - // Several members set `additionalProperties: false`. - let args = json!({"action": "runs", "job_id": "j1"}); - assert_eq!(args_without_action(&args), json!({"job_id": "j1"})); +fn nested_definition_refs_keep_their_pointer_suffix() { + let member = stub( + "read", + json!({ + "properties": {"options": {"$ref": "#/$defs/Options/properties/id"}}, + "$defs": {"Options": {"properties": {"id": {"type": "string"}}}} + }), + PermissionLevel::ReadOnly, + false, + ); + let merged = merge_action_schemas(&[CollapsedAction { + action: "read", + tool: &member, + }]); + let definition = namespace_definition("read", "Options"); + assert_eq!( + merged["properties"]["options"]["$ref"], + format!("#/$defs/{definition}/properties/id") + ); } #[test] -fn an_unknown_action_names_the_valid_ones() { - let a = stub("a", json!({}), PermissionLevel::ReadOnly, false); +fn draft_07_definitions_are_namespaced_and_rewritten() { + let tool = stub( + "read", + json!({"properties":{"options":{"$ref":"#/definitions/Options"}}, "definitions":{"Options":{"type":"string"}}}), + PermissionLevel::ReadOnly, + false, + ); let actions = vec![CollapsedAction { - action: "add", - tool: &a, + action: "read", + tool: &tool, }]; + let merged = merge_action_schemas(&actions); + let definition = namespace_definition("read", "Options"); assert_eq!( - unknown_action_message(&actions, Some("addd")), - "unknown action 'addd' (expected add)" + merged["properties"]["options"]["$ref"], + format!("#/$defs/{definition}") + ); + assert_eq!(merged["$defs"][definition]["type"], "string"); +} + +#[test] +fn refs_inside_prefix_items_are_rewritten() { + let tool = stub( + "read", + json!({"properties":{"tuple":{"prefixItems":[{"$ref":"#/$defs/Item"}]}}, "$defs":{"Item":{"type":"string"}}}), + PermissionLevel::ReadOnly, + false, ); + let actions = vec![CollapsedAction { + action: "read", + tool: &tool, + }]; + let merged = merge_action_schemas(&actions); + let definition = namespace_definition("read", "Item"); assert_eq!( - unknown_action_message(&actions, None), - "missing required field `action` (expected add)" + merged["properties"]["tuple"]["prefixItems"][0]["$ref"], + format!("#/$defs/{definition}") ); } #[test] -fn resolves_actions_and_formats_debug_without_exposing_the_tool() { - let a = stub("a", json!({}), PermissionLevel::Dangerous, false); - let actions = [CollapsedAction { - action: "add", - tool: &a, +fn draft_07_additional_items_and_schema_dependencies_rewrite_refs() { + let tool = stub( + "read", + json!({ + "properties": { + "tuple": { + "items": [{"type": "string"}], + "additionalItems": {"$ref": "#/definitions/Extra"}, + "dependencies": { + "other": {"$ref": "#/$defs/Extra"}, + "legacy": ["name"] + } + } + }, + "definitions": {"Extra": {"type": "integer"}} + }), + PermissionLevel::ReadOnly, + false, + ); + let actions = vec![CollapsedAction { + action: "read", + tool: &tool, }]; - assert!(resolve(&actions, "add").is_some()); - assert!(resolve(&actions, "missing").is_none()); - assert!(format!("{:?}", actions[0]).contains("tool: \"a\"")); - assert_eq!(strictest_permission(&actions), PermissionLevel::Dangerous); + + let merged = merge_action_schemas(&actions); + let definition = namespace_definition("read", "Extra"); + let tuple = &merged["properties"]["tuple"]; + assert_eq!( + tuple["additionalItems"]["$ref"], + format!("#/$defs/{definition}") + ); + assert_eq!( + tuple["dependencies"]["other"]["$ref"], + format!("#/$defs/{definition}") + ); + assert_eq!(tuple["dependencies"]["legacy"], json!(["name"])); } #[test] -fn schema_merge_skips_missing_properties_and_non_object_property_specs() { - let absent = stub("absent", json!({}), PermissionLevel::None, false); - let malformed = stub( - "malformed", - json!({"properties": {"ignored": null}}), - PermissionLevel::None, +fn member_definition_namespaces_cannot_collide() { + let read_file = stub( + "read_file", + json!({"properties":{"first":{"$ref":"#/$defs/Options"}}, "$defs":{"Options":{"type":"string"}}}), + PermissionLevel::ReadOnly, false, ); + let read = stub( + "read", + json!({"properties":{"second":{"$ref":"#/$defs/file_Options"}}, "$defs":{"file_Options":{"type":"integer"}}}), + PermissionLevel::ReadOnly, + false, + ); + let merged = merge_action_schemas(&[ + CollapsedAction { + action: "read_file", + tool: &read_file, + }, + CollapsedAction { + action: "read", + tool: &read, + }, + ]); + let first_name = namespace_definition("read_file", "Options"); + let second_name = namespace_definition("read", "file_Options"); + assert_ne!(first_name, second_name); + assert_eq!( + merged["properties"]["first"]["$ref"], + format!("#/$defs/{first_name}") + ); + assert_eq!( + merged["properties"]["second"]["$ref"], + format!("#/$defs/{second_name}") + ); + assert_eq!(merged["$defs"][first_name]["type"], "string"); + assert_eq!(merged["$defs"][second_name]["type"], "integer"); +} + +#[test] +fn local_ref_like_instance_data_is_not_rewritten() { + let edit = stub( + "edit", + json!({"properties":{"value":{"const":{"$ref":"#/$defs/Options"}}}, "$defs":{"Options":{"type":"string"}}}), + PermissionLevel::ReadOnly, + false, + ); + let merged = merge_action_schemas(&[CollapsedAction { + action: "edit", + tool: &edit, + }]); + assert_eq!( + merged["properties"]["value"]["const"]["$ref"], + "#/$defs/Options" + ); +} + +#[test] +fn validation_accepts_a_well_formed_family() { + let a = stub("a", json!({}), PermissionLevel::ReadOnly, false); + let b = stub("b", json!({}), PermissionLevel::Write, false); + let actions = [ + CollapsedAction { + action: "a", + tool: &a, + }, + CollapsedAction { + action: "b", + tool: &b, + }, + ]; + assert_eq!(validate_actions(&actions), Ok(())); +} + +#[test] +fn validation_rejects_an_empty_family() { + assert_eq!(validate_actions(&[]), Err(CollapseError::Empty)); + assert_eq!( + CollapseError::Empty.to_string(), + "a collapsed tool needs at least one action" + ); +} + +#[test] +fn validation_rejects_a_duplicated_action() { + let a = stub("a", json!({}), PermissionLevel::ReadOnly, false); let actions = [ CollapsedAction { - action: "absent", - tool: &absent, + action: "a", + tool: &a, }, CollapsedAction { - action: "malformed", - tool: &malformed, + action: "a", + tool: &a, }, ]; + let err = validate_actions(&actions).unwrap_err(); + assert_eq!( + err, + CollapseError::DuplicateAction { + action: "a".to_string() + } + ); + assert_eq!(err.to_string(), "action 'a' is declared more than once"); +} + +#[test] +fn validation_rejects_a_member_declaring_the_reserved_property() { + let clash = stub( + "clash", + json!({"type": "object", "properties": {"action": {"type": "string"}}}), + PermissionLevel::ReadOnly, + false, + ); + let actions = [CollapsedAction { + action: "clash", + tool: &clash, + }]; + let err = validate_actions(&actions).unwrap_err(); + assert_eq!( + err, + CollapseError::ReservedProperty { + action: "clash".to_string() + } + ); + assert!(err.to_string().contains("reserved for dispatch")); +} + +#[test] +fn the_debug_form_names_the_member_tool() { + let a = stub("member", json!({}), PermissionLevel::ReadOnly, false); + let entry = CollapsedAction { + action: "a", + tool: &a, + }; assert_eq!( - merge_action_schemas(&actions)["properties"]["ignored"], - Value::Null + format!("{entry:?}"), + r#"CollapsedAction { action: "a", tool: "member" }"# ); - assert_eq!(strictest_permission(&[]), PermissionLevel::None); - assert_eq!(args_without_action(&json!([1, 2])), json!([1, 2])); } diff --git a/crates/tinytools/src/collapse/types.rs b/crates/tinytools/src/collapse/types.rs new file mode 100644 index 0000000..ddd0d64 --- /dev/null +++ b/crates/tinytools/src/collapse/types.rs @@ -0,0 +1,66 @@ +//! Types for the action-collapse building blocks. + +use std::fmt; + +use crate::Tool; + +/// One member of a collapsed family: the action name the model passes, and the +/// tool that serves it. +#[derive(Clone, Copy)] +pub struct CollapsedAction<'a> { + /// The `action` value the model passes to select this member. + pub action: &'static str, + /// The member tool that serves the action. + pub tool: &'a dyn Tool, +} + +impl fmt::Debug for CollapsedAction<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("CollapsedAction") + .field("action", &self.action) + .field("tool", &self.tool.name()) + .finish() + } +} + +/// Why a set of [`CollapsedAction`]s cannot be served as one tool. +/// +/// Returned by [`super::validate_actions`], which a host calls once when it +/// builds the collapsed tool rather than on every request. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub enum CollapseError { + /// No members were given, so there is no action to dispatch to. + Empty, + /// Two members answer to the same action name; dispatch could only ever + /// reach the first. + DuplicateAction { + /// The action name declared more than once. + action: String, + }, + /// A member declares a parameter named `action`, the key the collapsed + /// tool reserves for dispatch. The member could never receive it, because + /// [`super::args_without_action`] strips it before forwarding. + ReservedProperty { + /// The action whose member declares the reserved property. + action: String, + }, +} + +impl fmt::Display for CollapseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Empty => f.write_str("a collapsed tool needs at least one action"), + Self::DuplicateAction { action } => { + write!(f, "action '{action}' is declared more than once") + } + Self::ReservedProperty { action } => write!( + f, + "the member serving '{action}' declares a parameter named `action`, \ + which is reserved for dispatch" + ), + } + } +} + +impl std::error::Error for CollapseError {} diff --git a/crates/tinytools/src/deferral/test.rs b/crates/tinytools/src/deferral/test.rs index 7d4dec1..7ac3043 100644 --- a/crates/tinytools/src/deferral/test.rs +++ b/crates/tinytools/src/deferral/test.rs @@ -67,11 +67,11 @@ fn deferred_tool_names_lists_every_deferred_registration() { } #[tokio::test] -async fn fake_tool_implements_the_tool_contract() { - let tool = Fake("sample", ToolExposure::Direct); - assert_eq!(tool.name(), "sample"); - assert_eq!(tool.description(), "fake"); - assert_eq!(tool.parameters_schema()["type"], "object"); - assert_eq!(tool.exposure(), ToolExposure::Direct); - assert_eq!(tool.execute(json!({})).await.unwrap().output(), "ok"); +async fn fake_tools_implement_the_public_tool_contract() -> anyhow::Result<()> { + let registered = tools(); + let fake = ®istered[0]; + assert_eq!(fake.description(), "fake"); + assert_eq!(fake.parameters_schema(), json!({"type": "object"})); + assert_eq!(fake.execute(json!({})).await?.output(), "ok"); + Ok(()) } diff --git a/crates/tinytools/src/lib.rs b/crates/tinytools/src/lib.rs index 139b11c..955f8aa 100644 --- a/crates/tinytools/src/lib.rs +++ b/crates/tinytools/src/lib.rs @@ -122,8 +122,9 @@ pub use call::{ }; pub use classification::{ToolCategory, ToolScope}; pub use collapse::{ - CollapsedAction, any_external_effect, args_without_action, external_effect_for_action, - merge_action_schemas, resolve, strictest_permission, unknown_action_message, + CollapseError, CollapsedAction, any_external_effect, args_without_action, + external_effect_for_action, external_effect_for_args, merge_action_schemas, minimum_permission, + permission_for_args, resolve, strictest_permission, unknown_action_message, validate_actions, }; pub use command_output::{command_failure, render_command_failure, sandbox_exit_code}; pub use context::ToolRunContext; diff --git a/docs/plans/README.md b/docs/plans/README.md index 0a5db16..797e80a 100644 --- a/docs/plans/README.md +++ b/docs/plans/README.md @@ -20,3 +20,5 @@ code snippets when they remove ambiguity, but do not paste entire future files into the plan. See [`example-retry-policy.md`](example-retry-policy.md) for a test-first sample. + +Completed plan: [collapsed tools and standard helpers](collapsed-tools-and-standard-helpers.md). diff --git a/docs/plans/collapsed-tools-and-standard-helpers.md b/docs/plans/collapsed-tools-and-standard-helpers.md new file mode 100644 index 0000000..23259aa --- /dev/null +++ b/docs/plans/collapsed-tools-and-standard-helpers.md @@ -0,0 +1,46 @@ +# Collapsed tools and standard helpers implementation plan + +Specification: [Collapsed tools and standard helpers](../specs/collapsed-tools-and-standard-helpers.md) + +**Status:** Complete + +## Goal and assumptions + +Implement the public contracts in the linked specification across `tinytools` +and `tinytools-std`. The contracts are host-facing helpers; enforcement and +network/file operations remain in consuming hosts. + +## Ordered tasks + +1. **Define collapsed action contracts** in + `crates/tinytools/src/collapse/`. Add tests first for invalid action sets, + property unions, namespaced references, and per-call classification; then + implement validation, schema merging, conservative static answers, and + selected-member delegation. Update exports and module documentation. +2. **Define PATH discovery behavior** in + `crates/tinytools-std/src/detect_tools/`. Add tests for candidate names and + executable checks before implementing platform-aware discovery. Document + the public entry point. +3. **Define file-state coordination** in + `crates/tinytools-std/src/file_state/`. Add tests for reads captured before + I/O, stale reads during concurrent writes, and attribution retained for + multiple writers before updating the tracking implementation. +4. **Define URL validation behavior** in + `crates/tinytools-std/src/url_guard/`. Add rejection/acceptance tests for + authority parsing, DNS results, and vetted addresses before updating the + validator. Document caller requirements for connection pinning and + redirects. +5. **Verify the workspace** with formatting, Clippy, build, tests, and rustdoc. + +## Completion checklist + +- [x] Each behavior change has focused regression coverage. +- [x] Public exports and module/crate documentation describe the contracts. +- [x] Draft-07 schema references under `additionalItems` and object-valued + `dependencies` are rewritten; dependency name arrays remain untouched. +- [x] The well-known NAT64 prefix permits public embedded IPv4 addresses and + rejects non-global embedded IPv4 addresses. +- [x] `cargo fmt --all -- --check` passes. +- [x] `cargo clippy --all-targets --all-features -- -D warnings` passes. +- [x] `cargo build --all-targets --all-features` passes. +- [x] `cargo test --all-features` passes. diff --git a/docs/specs/README.md b/docs/specs/README.md index a8286ae..40ee9c4 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -21,3 +21,5 @@ After the specification is accepted, create a linked implementation plan in the contract; production code still belongs under `src/`. See [`example-retry-policy.md`](example-retry-policy.md) for a complete sample. + +Implemented host-facing helper contracts: [collapsed tools and standard helpers](collapsed-tools-and-standard-helpers.md). diff --git a/docs/specs/collapsed-tools-and-standard-helpers.md b/docs/specs/collapsed-tools-and-standard-helpers.md new file mode 100644 index 0000000..ee8b5c5 --- /dev/null +++ b/docs/specs/collapsed-tools-and-standard-helpers.md @@ -0,0 +1,74 @@ +# Collapsed tools and standard helpers + +**Status:** Implemented +**Owner:** tinytools maintainers + +## Problem + +Hosts need to expose related tools as one action-dispatched tool without losing +the members' parameter schemas or per-call permission and effect decisions. +Standard helpers also need consistent contracts for PATH discovery, concurrent +file-state tracking, and URL validation before a host performs network I/O. + +## Goals and non-goals + +Goals are to define the public contracts for action collapse and the +`tinytools-std` helpers changed alongside it. This includes action validation +and schema merging, executable discovery on supported platforms, per-agent +read/write staleness tracking, and URL authority/DNS validation results. + +These helpers describe data and classifications. They do not execute tools, +enforce permissions, perform HTTP requests, pin connections, or replace host +coordination around file mutation. + +## Proposed behavior + +- A collapsed family is non-empty, has unique action names, and reserves the + `action` parameter for dispatch. Its merged schema preserves member + properties and namespaces definitions and local references. Static + permission is the minimum across members; static external-effect + classification is conservative for any non-empty family. Argument-aware + classification delegates to the selected member after removing `action`. +- PATH discovery returns executable candidates, accounting for `PATHEXT` on + Windows and execute access on Unix. +- File-state records reads before I/O and retains write timestamps for + staleness checks as well as every writer's path attribution. +- URL validation rejects malformed or ambiguous authorities, resolves the + host, rejects non-global destinations (including translated IPv4), and + returns the validated URL, host, and vetted socket addresses for the host to + use when connecting. + +## Invariants and constraints + +- The vocabulary crate remains independent of harnesses, transports, runtimes, + and native libraries. +- A schema merge must not introduce dangling references when namespacing + definitions. Draft-07 `additionalItems` and schema-valued `dependencies` + contain schemas; array-valued dependencies are property-name lists. +- Unknown actions receive conservative classifications and fail before a + member executes. +- URL validation is not an end-to-end SSRF guarantee: callers must connect to + vetted addresses, preserve the hostname for TLS and Host, and revalidate + redirects. +- File-state tracking coordinates cooperating callers and does not authenticate + agent identities or lock files by itself. + +## Acceptance criteria + +- Invalid action families are rejected, and merged schemas retain namespaced + definitions with all local references rewritten. +- Per-call classification reaches only the selected member with the dispatch + key removed. +- PATH lookup tests cover platform-specific candidate and executable behavior. +- File-state tests cover concurrent reads/writes, stale reads, and attribution + for multiple writers of one path. +- URL tests reject private and local destinations while allowing public IPv4 + addresses synthesized through the well-known NAT64 prefix. +- Public APIs and their host-side operational constraints are documented. + +## Open questions + +None for the implemented contract. Hosts remain responsible for the +connection-pinning and locking sequences described above. + +Implementation sequence: [collapsed-tools-and-standard-helpers plan](../plans/collapsed-tools-and-standard-helpers.md).