From 997df3f720c2c0606d439e6b9dd710240263ca19 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 20:45:30 +0300 Subject: [PATCH 01/26] chore(vendor): add tinytools submodule Add the tinytools repository as a vendor submodule tracking its main branch for shared tooling. Auto-committed-on: dragonfly Co-authored-by: Medulla --- .gitmodules | 4 ++++ vendor/tinytools | 1 + 2 files changed, 5 insertions(+) create mode 160000 vendor/tinytools diff --git a/.gitmodules b/.gitmodules index a01a23c2..2ea42b7c 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,7 @@ [submodule "wiki"] path = wiki url = https://github.com/tinyhumansai/tinyagents.wiki.git +[submodule "vendor/tinytools"] + path = vendor/tinytools + url = https://github.com/tinyhumansai/tinytools.git + branch = main diff --git a/vendor/tinytools b/vendor/tinytools new file mode 160000 index 00000000..d5c9bdba --- /dev/null +++ b/vendor/tinytools @@ -0,0 +1 @@ +Subproject commit d5c9bdbab70af43e663785b9d5dd241a72215dba From f444d94324605ce5b3c5d5dbf27f5c1e2cdb9b07 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 20:53:56 +0300 Subject: [PATCH 02/26] chore(tinytools): update vendored dependency Update the tinytools submodule to a newer revision to incorporate its latest changes. Auto-committed-on: dragonfly Co-authored-by: Medulla --- vendor/tinytools | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/tinytools b/vendor/tinytools index d5c9bdba..f4167844 160000 --- a/vendor/tinytools +++ b/vendor/tinytools @@ -1 +1 @@ -Subproject commit d5c9bdbab70af43e663785b9d5dd241a72215dba +Subproject commit f41678447c265776fdf60d98bbf3d6d3082df4ee From 25bf74bbea7c7198143f9a98bed1d16d0e438939 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 20:54:36 +0300 Subject: [PATCH 03/26] chore(deps): add tinytools vocabulary dependency Add the path dependency so the crate can share the tool trait with hosts while keeping execution context types in the harness. This remains path-only until tinytools is published. Auto-committed-on: dragonfly Co-authored-by: Medulla --- Cargo.toml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/Cargo.toml b/Cargo.toml index 088c121a..d7bd1512 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,6 +11,18 @@ categories = ["asynchronous", "api-bindings"] [dependencies] async-trait = "0.1" +# The tool vocabulary. This crate re-exports it rather than declaring its own, +# so `tinyagents::harness::tool::Tool` and a host's `tinytools::Tool` are the +# *same* trait — a host implements a tool once and both sides accept it. +# +# The edge points one way: `tinytools` must never depend on this crate. That is +# why `ToolExecutionContext` stays here and reaches tools through +# `tinytools::ToolRunContext`, implemented below in `harness::tool::types`, +# rather than being named in the vocabulary. +# +# Path-only for now, which is what stops this crate being published. Publishing +# `tinytools` is the prerequisite; see that repository's AGENTS.md. +tinytools = { path = "vendor/tinytools/crates/tinytools" } # Cheap, reference-counted byte buffers. Used only on the *internal* SSE # byte-stream seam (`harness::providers::openai::sse::SseState`) so each # network chunk from `reqwest::Response::bytes_stream` is forwarded without a From b7416908abfd32e7a82a1353c910d39aaa3fa7be Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 20:54:47 +0300 Subject: [PATCH 04/26] refactor(tool): centralize tool name rendering helpers Re-export tool name and context detail rendering from `tinytools` instead of maintaining duplicate implementations in the harness. This keeps rendering behavior consistent across hosts and avoids divergent prefix handling. Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/harness/tool/mod.rs | 151 +++------------------------------------- 1 file changed, 8 insertions(+), 143 deletions(-) diff --git a/src/harness/tool/mod.rs b/src/harness/tool/mod.rs index 6f69aa3d..da967a6e 100644 --- a/src/harness/tool/mod.rs +++ b/src/harness/tool/mod.rs @@ -25,6 +25,14 @@ use serde_json::Value; use crate::error::{Result, TinyAgentsError}; pub use error_policy::{ToolErrorPolicy, is_control_flow_error}; +// Rendering a tool call for a human is not harness-specific, and two copies of +// the prefix list is how one of them silently stops stripping a prefix the +// other does. The definitions live in `tinytools` so a host that never links +// this crate still renders a tool name the same way. +pub use tinytools::{ + ContextDetailOptions, context_detail_from_args, context_detail_from_args_with, + humanize_tool_name, +}; pub use injected::{project_injected_arguments, strip_injected_arguments}; pub use prompt::*; pub use schema::*; @@ -272,149 +280,6 @@ impl ToolPolicy { } } -/// Derives a title-cased human-readable label from a raw tool name. -/// -/// Common machine prefixes are stripped, and `snake_case` / `kebab-case` names -/// become spaced labels. Degenerate names fall back to the original input so -/// callers never receive an empty label unless the input itself was empty. -pub fn humanize_tool_name(name: &str) -> String { - let trimmed = name - .strip_prefix("composio_") - .or_else(|| name.strip_prefix("mcp_")) - .unwrap_or(name); - - let mut out = String::with_capacity(trimmed.len()); - let mut capitalize = true; - for ch in trimmed.chars() { - if ch == '_' || ch == '-' { - if !out.is_empty() && !out.ends_with(' ') { - out.push(' '); - } - capitalize = true; - } else if capitalize { - out.extend(ch.to_uppercase()); - capitalize = false; - } else { - out.push(ch); - } - } - - let label = out.trim(); - if label.is_empty() { - name.to_string() - } else { - label.to_string() - } -} - -/// How a context detail is trimmed for display. -/// -/// Exists because the cap and the ellipsis are **presentation**, and a host -/// that renders tool activity in its own timeline has already picked both. The -/// key-scanning rule underneath is what is actually shared; forcing a host to -/// re-implement the whole function to change one character is how two copies of -/// it end up in a codebase. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct ContextDetailOptions { - /// Maximum rendered length, in characters, including the ellipsis. - pub max_chars: usize, - /// Appended when the value is trimmed. - pub ellipsis: &'static str, -} - -impl Default for ContextDetailOptions { - fn default() -> Self { - Self { - max_chars: 80, - ellipsis: "...", - } - } -} - -/// Extracts a compact human-facing detail from common tool argument keys. -/// -/// The first recognized scalar value wins, using keys that usually identify the -/// resource being acted on (`path`, `query`, `to`, `url`, and similar). Returns -/// `None` for non-object args, empty values, and complex values. -/// -/// Uses [`ContextDetailOptions::default`]; see -/// [`context_detail_from_args_with`] to choose the cap and ellipsis. -pub fn context_detail_from_args(args: &Value) -> Option { - context_detail_from_args_with(args, ContextDetailOptions::default()) -} - -/// [`context_detail_from_args`] with explicit trimming. -pub fn context_detail_from_args_with( - args: &Value, - options: ContextDetailOptions, -) -> Option { - const CONTEXT_KEYS: &[&str] = &[ - "to", - "recipient", - "recipient_email", - "to_email", - "email", - "query", - "q", - "search", - "search_query", - "url", - "file_path", - "path", - "command", - "cmd", - "subject", - "title", - "channel", - "channel_id", - "repo", - "repository", - "name", - "id", - ]; - - let obj = args.as_object()?; - for key in CONTEXT_KEYS { - let Some(value) = obj.get(*key) else { - continue; - }; - if let Some(rendered) = render_context_value(value, options) { - return Some(rendered); - } - } - None -} - -fn render_context_value(value: &Value, options: ContextDetailOptions) -> Option { - let raw = match value { - Value::String(s) => s.trim().to_string(), - Value::Number(n) => n.to_string(), - Value::Bool(b) => b.to_string(), - Value::Array(items) => items - .iter() - .filter_map(Value::as_str) - .collect::>() - .join(", "), - _ => String::new(), - }; - let raw = raw.split_whitespace().collect::>().join(" "); - if raw.is_empty() { - return None; - } - if raw.chars().count() > options.max_chars { - // Clamp the ellipsis itself to max_chars first: an ellipsis longer than - // the cap (a misconfigured caller) would otherwise survive - // `saturating_sub`'s zero and still get appended in full, pushing the - // rendered value past `max_chars`. - let ellipsis: String = options.ellipsis.chars().take(options.max_chars).collect(); - let keep = options.max_chars.saturating_sub(ellipsis.chars().count()); - let truncated: String = raw.chars().take(keep).collect(); - Some(format!("{truncated}{ellipsis}")) - } else { - Some(raw) - } -} - impl ToolRegistry { /// Creates an empty registry. pub fn new() -> Self { From b9b6045466695a682c2450bee0c86844cf3a19f7 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 20:55:42 +0300 Subject: [PATCH 05/26] feat(harness): expose tool execution context to tinytools Implement the tinytools context trait for the harness execution context, allowing tools to access workspace, policy, thread, and output limit information without depending on harness internals. Auto-committed-on: dragonfly Co-authored-by: Medulla --- Cargo.lock | 17 +++++++++++++++++ src/harness/tool/types.rs | 29 +++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 7bbd5dbe..20e7d686 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -26,6 +26,12 @@ dependencies = [ "libc", ] +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + [[package]] name = "async-trait" version = "0.1.92" @@ -1341,6 +1347,7 @@ dependencies = [ "sha2", "tempfile", "thiserror", + "tinytools", "tokio", "tracing", ] @@ -1355,6 +1362,16 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinytools" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "serde", + "serde_json", +] + [[package]] name = "tinyvec" version = "1.11.0" diff --git a/src/harness/tool/types.rs b/src/harness/tool/types.rs index 34f82db3..55bf2a90 100644 --- a/src/harness/tool/types.rs +++ b/src/harness/tool/types.rs @@ -229,6 +229,35 @@ impl ToolExecutionContext { } } +/// Lets a tool read this context without depending on the harness. +/// +/// A tool is written against `tinytools`, which cannot name this type: this +/// crate depends on `tinytools`, so an edge back would be a cycle. The +/// vocabulary therefore declares a narrow trait and this crate implements it, +/// which is what lets a host hand a live [`ToolExecutionContext`] to a tool +/// that has never heard of the harness. +/// +/// Only the facts a tool actually reads are exposed. The run id, event sink, +/// cancellation token and streaming flag stay harness-internal — a tool that +/// wanted them would be reaching into the run rather than doing its job. +impl tinytools::ToolRunContext for ToolExecutionContext { + fn workspace_root(&self) -> Option<&std::path::Path> { + self.workspace.as_ref().map(|w| w.root.as_path()) + } + + fn workspace_policy_id(&self) -> Option<&str> { + self.workspace.as_ref().map(|w| w.policy_id.as_str()) + } + + fn thread_id(&self) -> Option<&str> { + self.thread_id.as_ref().map(ThreadId::as_str) + } + + fn max_turn_output_tokens(&self) -> Option { + self.max_turn_output_tokens + } +} + /// How strictly a tool must be sandboxed when it executes. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] From 744020a6366a70db50b2a240d12f7abb80004c2e Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 20:56:33 +0300 Subject: [PATCH 06/26] feat: take the tool vocabulary from tinytools instead of declaring it Vendors tinytools at vendor/tinytools and depends on it, so the harness and a host application name the same tool types rather than structural twins with hand-written conversions between them. Two changes fall out: humanize_tool_name / context_detail_from_args and ContextDetailOptions move down into tinytools and are re-exported here. Naming a tool for a human is not harness work, and the duplicated prefix list was already carrying a comment warning that two copies is how one silently stops stripping a prefix the other does. ToolExecutionContext gains a tinytools::ToolRunContext impl. tinytools cannot name this type -- this crate depends on it, so an edge back would be a cycle -- and a tool still needs its isolated workspace root. The trait exposes only what a tool reads: the workspace root and policy id, the thread id, and the turn output budget. The run id, event sink, cancellation token and streaming flag stay internal, because a tool that wanted them would be reaching into the run rather than doing its job. Co-authored-by: Medulla --- src/harness/tool/mod.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/harness/tool/mod.rs b/src/harness/tool/mod.rs index da967a6e..7e60ef7e 100644 --- a/src/harness/tool/mod.rs +++ b/src/harness/tool/mod.rs @@ -29,15 +29,15 @@ pub use error_policy::{ToolErrorPolicy, is_control_flow_error}; // the prefix list is how one of them silently stops stripping a prefix the // other does. The definitions live in `tinytools` so a host that never links // this crate still renders a tool name the same way. -pub use tinytools::{ - ContextDetailOptions, context_detail_from_args, context_detail_from_args_with, - humanize_tool_name, -}; pub use injected::{project_injected_arguments, strip_injected_arguments}; pub use prompt::*; pub use schema::*; pub use schema_prepare::*; pub use timeout::*; +pub use tinytools::{ + ContextDetailOptions, context_detail_from_args, context_detail_from_args_with, + humanize_tool_name, +}; pub use types::*; impl ToolSchema { From 5c003c66c49ec5ddf7b546b6156ccfaafa7abf67 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 21:01:04 +0300 Subject: [PATCH 07/26] refactor(workspace): use tinytools workspace descriptor Move workspace descriptors and sandbox modes into tinytools so they share the tool vocabulary and types. Keep harness-specific violation reporting in a free enforcement function while preserving fail-closed path validation. Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/harness/tool/types.rs | 16 +--- src/harness/workspace/mod.rs | 1 + src/harness/workspace/policy.rs | 147 ++++++++------------------------ src/harness/workspace/types.rs | 30 ++----- 4 files changed, 44 insertions(+), 150 deletions(-) diff --git a/src/harness/tool/types.rs b/src/harness/tool/types.rs index 55bf2a90..28170f7d 100644 --- a/src/harness/tool/types.rs +++ b/src/harness/tool/types.rs @@ -258,19 +258,9 @@ impl tinytools::ToolRunContext for ToolExecutionContext { } } -/// How strictly a tool must be sandboxed when it executes. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum SandboxMode { - /// Inherit whatever the run's execution environment provides (the default). - #[default] - Inherit, - /// The tool is safe to run without any sandbox. - Disabled, - /// The tool must run inside an isolated execution environment; policy - /// enforcement fails closed if no sandbox is available. - Required, -} +// `SandboxMode` rides on `WorkspaceDescriptor`, which is `tinytools`' type, so +// the mode has to be the same type on both sides of that field. +pub use tinytools::SandboxMode; /// How a tool is allowed to reach the caller's workspace / filesystem root. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] diff --git a/src/harness/workspace/mod.rs b/src/harness/workspace/mod.rs index 51c11668..10090aa3 100644 --- a/src/harness/workspace/mod.rs +++ b/src/harness/workspace/mod.rs @@ -14,6 +14,7 @@ mod policy; mod types; pub use git::*; +pub use policy::enforce_workspace_path; pub use types::*; use std::path::PathBuf; diff --git a/src/harness/workspace/policy.rs b/src/harness/workspace/policy.rs index 1822732c..76747027 100644 --- a/src/harness/workspace/policy.rs +++ b/src/harness/workspace/policy.rs @@ -1,121 +1,42 @@ -//! Path-gating policy for [`WorkspaceDescriptor`]: the `allows`/`enforce` -//! checks and the lexical path-normalization helpers they rely on. +//! The fail-closed path gate for a [`WorkspaceDescriptor`]. //! -//! Split out of `workspace/types.rs`; kept separate from the plain type -//! definitions because this is where the fail-closed security guarantee -//! actually lives. +//! The descriptor and its lexical `allows` check live in `tinytools`, which +//! owns the tool vocabulary. What stays here is the half that needs this +//! crate: emitting a [`WorkspaceViolation`][crate::harness::events::AgentEvent::WorkspaceViolation] +//! and returning this crate's error type. It is a free function rather than an +//! inherent method because the descriptor is now a foreign type. -use std::path::{Path, PathBuf}; +use std::path::Path; -use crate::Result; -use crate::harness::tool::SandboxMode; -use crate::harness::workspace::types::WorkspaceDescriptor; - -impl WorkspaceDescriptor { - /// Creates a descriptor rooted at `root` with no extra trusted roots. - pub fn new(root: impl Into) -> Self { - Self { - root: root.into(), - trusted_roots: Vec::new(), - policy_id: String::new(), - sandbox: SandboxMode::Inherit, - } - } - - /// Adds a trusted root the tool may also touch. - pub fn with_trusted_root(mut self, root: impl Into) -> Self { - self.trusted_roots.push(root.into()); - self - } - - /// Sets the audit policy identity. - pub fn with_policy_id(mut self, id: impl Into) -> Self { - self.policy_id = id.into(); - self - } - - /// Sets the sandbox mode. - pub fn with_sandbox(mut self, sandbox: SandboxMode) -> Self { - self.sandbox = sandbox; - self - } +use tinytools::WorkspaceDescriptor; - /// Returns `true` when `path` is contained within the root or any trusted - /// root. - /// - /// Comparison is lexical (after normalizing `.`/`..` components) so it does - /// not require the path to exist; it is a policy gate, not a canonicalizing - /// filesystem call. Relative candidates and roots are first anchored to the - /// current working directory so a relative path cannot use leading `..` - /// components to spoof re-entry into a same-named sibling of the root. If - /// the current directory cannot be read, the gate fails closed (`false`). - pub fn allows(&self, path: &Path) -> bool { - let Some(candidate) = anchored_normalize(path) else { - return false; - }; - std::iter::once(&self.root) - .chain(self.trusted_roots.iter()) - .filter_map(|root| anchored_normalize(root)) - .any(|root| candidate.starts_with(&root)) - } - - /// Fail-closed path gate to call *before* a tool touches `path`: when the - /// path is outside every allowed root, emits an - /// [`AgentEvent::WorkspaceViolation`][crate::harness::events::AgentEvent::WorkspaceViolation] - /// on `events` and returns a [`TinyAgentsError::Validation`] so the caller - /// blocks the operation. Returns `Ok(())` when the path is allowed. - pub fn enforce(&self, path: &Path, events: &crate::harness::events::EventSink) -> Result<()> { - if self.allows(path) { - return Ok(()); - } - let rendered = path.display().to_string(); - events.emit(crate::harness::events::AgentEvent::WorkspaceViolation { - path: rendered.clone(), - }); - Err(crate::error::TinyAgentsError::Validation(format!( - "path `{rendered}` is outside the allowed workspace roots" - ))) - } -} - -/// Anchors `path` to an absolute base (the current working directory when -/// relative) and lexically normalizes it. Returns `None` when a relative path -/// cannot be anchored because the current directory is unavailable, so callers -/// fail closed. -fn anchored_normalize(path: &Path) -> Option { - let absolute = if path.is_absolute() { - path.to_path_buf() - } else { - std::env::current_dir().ok()?.join(path) - }; - Some(normalize(&absolute)) -} +use crate::Result; +use crate::harness::events::{AgentEvent, EventSink}; -/// Lexically normalizes a path by resolving `.` and `..` components without -/// touching the filesystem. +/// Fail-closed path gate to call *before* a tool touches `path`. +/// +/// When the path is outside every allowed root, emits a +/// [`AgentEvent::WorkspaceViolation`] on `events` and returns a validation +/// error so the caller blocks the operation. Returns `Ok(())` when the path is +/// allowed. +/// +/// # Errors /// -/// A `..` only pops a preceding *named* segment; a `..` that would escape the -/// accumulated prefix (leading or after another `..`) is preserved rather than -/// discarded. Dropping such components would let a relative path like -/// `ws/../../ws/secret` collapse back onto `ws` and spoof re-entry into a -/// same-named sibling directory outside the workspace. -fn normalize(path: &Path) -> PathBuf { - use std::path::Component; - let mut out = PathBuf::new(); - for component in path.components() { - match component { - Component::ParentDir => match out.components().next_back() { - Some(Component::Normal(_)) => { - out.pop(); - } - Some(Component::RootDir | Component::Prefix(_)) => { - // At a filesystem root; `..` cannot go higher. - } - _ => out.push(Component::ParentDir), - }, - Component::CurDir => {} - other => out.push(other.as_os_str()), - } +/// Returns [`TinyAgentsError::Validation`][crate::error::TinyAgentsError::Validation] +/// when `path` lies outside the descriptor's root and trusted roots. +pub fn enforce_workspace_path( + workspace: &WorkspaceDescriptor, + path: &Path, + events: &EventSink, +) -> Result<()> { + if workspace.allows(path) { + return Ok(()); } - out + let rendered = path.display().to_string(); + events.emit(AgentEvent::WorkspaceViolation { + path: rendered.clone(), + }); + Err(crate::error::TinyAgentsError::Validation(format!( + "path `{rendered}` is outside the allowed workspace roots" + ))) } diff --git a/src/harness/workspace/types.rs b/src/harness/workspace/types.rs index e6a1f1d2..59397bea 100644 --- a/src/harness/workspace/types.rs +++ b/src/harness/workspace/types.rs @@ -7,34 +7,16 @@ //! worktrees/sandboxes. TinyAgents does not own any concrete policy; it owns the //! interface so parallel agents can be isolated consistently. -use std::path::PathBuf; - use async_trait::async_trait; -use serde::{Deserialize, Serialize}; use crate::Result; -use crate::harness::tool::SandboxMode; -/// Describes the isolated execution environment a tool is allowed to operate in. -/// -/// A tool discovers its allowed root from this descriptor (via -/// [`ToolExecutionContext::workspace`][crate::harness::tool::ToolExecutionContext::workspace]) -/// instead of reaching for an application global, and a policy engine can call -/// [`allows`](Self::allows) to block unsafe paths before execution. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct WorkspaceDescriptor { - /// The primary root the agent/tool may read and write under. - pub root: PathBuf, - /// Additional roots the tool is explicitly trusted to touch. - #[serde(default)] - pub trusted_roots: Vec, - /// Identity of the policy that produced this descriptor (for audit). - #[serde(default)] - pub policy_id: String, - /// How strictly the environment is sandboxed. - #[serde(default)] - pub sandbox: SandboxMode, -} +// The descriptor is tool vocabulary — it tells a tool which filesystem root it +// may touch — so it is defined in `tinytools` alongside the trait that reads +// it, and re-exported here at its historical path. `WorkspaceIsolation` stays: +// preparing and tearing down a worktree is harness work, and it returns this +// crate's `Result`. +pub use tinytools::WorkspaceDescriptor; /// Prepares and tears down per-agent execution environments. /// From 86bfc915a2ad131e90c581079f0524ed793ce8f9 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 21:01:27 +0300 Subject: [PATCH 08/26] fix(harness): expose the workspace descriptor in tool context ToolExecutionContext now returns the complete workspace descriptor through the updated tinytools context interface, preserving access to workspace root and policy data. Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/harness/tool/types.rs | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/harness/tool/types.rs b/src/harness/tool/types.rs index 28170f7d..41fdbaa7 100644 --- a/src/harness/tool/types.rs +++ b/src/harness/tool/types.rs @@ -240,13 +240,12 @@ impl ToolExecutionContext { /// Only the facts a tool actually reads are exposed. The run id, event sink, /// cancellation token and streaming flag stay harness-internal — a tool that /// wanted them would be reaching into the run rather than doing its job. +/// +/// `workspace` needs no conversion: [`WorkspaceDescriptor`] is `tinytools`' +/// type, re-exported by this crate, so the field is already the right one. impl tinytools::ToolRunContext for ToolExecutionContext { - fn workspace_root(&self) -> Option<&std::path::Path> { - self.workspace.as_ref().map(|w| w.root.as_path()) - } - - fn workspace_policy_id(&self) -> Option<&str> { - self.workspace.as_ref().map(|w| w.policy_id.as_str()) + fn workspace(&self) -> Option<&tinytools::WorkspaceDescriptor> { + self.workspace.as_ref() } fn thread_id(&self) -> Option<&str> { From 73689c430fab2838d4f5391a1fdc8633f31658a4 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 21:01:40 +0300 Subject: [PATCH 09/26] test(workspace): use path enforcement helper Update workspace enforcement coverage to call the shared helper while preserving assertions for allowed and blocked paths. Auto-committed-on: dragonfly Co-authored-by: Medulla --- src/harness/workspace/test.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/harness/workspace/test.rs b/src/harness/workspace/test.rs index 95709b8c..8782c8a8 100644 --- a/src/harness/workspace/test.rs +++ b/src/harness/workspace/test.rs @@ -90,13 +90,11 @@ fn enforce_blocks_unsafe_paths_and_emits_violation() { let ws = WorkspaceDescriptor::new("/work/agent-a"); // Allowed path passes silently with no event. - ws.enforce(Path::new("/work/agent-a/out.txt"), &events) - .unwrap(); + enforce_workspace_path(&ws, Path::new("/work/agent-a/out.txt"), &events).unwrap(); assert!(recorder.is_empty()); // Unsafe path fails closed and emits a violation. - let err = ws - .enforce(Path::new("/etc/passwd"), &events) + let err = enforce_workspace_path(&ws, Path::new("/etc/passwd"), &events) .expect_err("path outside root must be blocked"); assert!(err.to_string().contains("outside the allowed workspace")); assert_eq!(recorder.events()[0].event.kind(), "workspace.violation"); From aa4678b83f5ca00f4621a5abb772e4facc3ffb59 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 21:02:13 +0300 Subject: [PATCH 10/26] refactor: take WorkspaceDescriptor and SandboxMode from tinytools Both are tool vocabulary -- the descriptor's own docs describe it as telling a tool which filesystem root it may touch -- so they move down beside the ToolRunContext trait that reads them, and are re-exported here at their historical paths. No caller outside this crate changes. WorkspaceIsolation stays: preparing and tearing down a worktree is harness work and returns this crate's Result. So does the fail-closed path gate, which needs an EventSink and this crate's error type; it becomes the free function enforce_workspace_path, because an inherent method on a now-foreign type is not allowed. Its only two callers were this crate's own tests. Co-authored-by: Medulla --- vendor/tinytools | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/tinytools b/vendor/tinytools index f4167844..a057831d 160000 --- a/vendor/tinytools +++ b/vendor/tinytools @@ -1 +1 @@ -Subproject commit f41678447c265776fdf60d98bbf3d6d3082df4ee +Subproject commit a057831dab464845890e43707ea6cb18de912f38 From 5ceb81e8877b8157a8908c65dbc5ea3684a8ddb7 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 22:00:39 +0300 Subject: [PATCH 11/26] chore(vendor): bump tinytools for the CI gate fix Co-authored-by: Medulla --- vendor/tinytools | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/tinytools b/vendor/tinytools index a057831d..b8f3f8a3 160000 --- a/vendor/tinytools +++ b/vendor/tinytools @@ -1 +1 @@ -Subproject commit a057831dab464845890e43707ea6cb18de912f38 +Subproject commit b8f3f8a3a9726d233bbca64dafd53b6d8c456e4f From 8d036daf85ffb68accb2d3f54c7fa4c524ee8d9a Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 23:04:02 +0300 Subject: [PATCH 12/26] ci: check out submodules vendor/tinytools is a path dependency, so cargo cannot resolve the manifest without it. CI failed at 'Updating crates.io index' with 'failed to read vendor/tinytools/crates/tinytools/Cargo.toml'. Co-authored-by: Medulla --- .github/workflows/ci.yml | 5 +++++ .github/workflows/release.yml | 2 ++ 2 files changed, 7 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 85498448..4ef69c40 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,6 +21,11 @@ jobs: # This job executes repo code (cargo build/test); don't persist the # token in git config. persist-credentials: false + # `vendor/tinytools` is a path dependency of this crate, so cargo + # cannot even resolve the manifest without it. Without this the build + # fails at `Updating crates.io index` with "failed to read + # vendor/tinytools/crates/tinytools/Cargo.toml". + submodules: recursive - uses: dtolnay/rust-toolchain@stable with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5ee1bfcd..902917f4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,6 +31,8 @@ jobs: - uses: actions/checkout@v7 with: fetch-depth: 0 + # Required to resolve the `vendor/tinytools` path dependency. + submodules: recursive - uses: dtolnay/rust-toolchain@stable with: From 1393dcf57b837a557d243651cef7ab31a0191a44 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 23:07:23 +0300 Subject: [PATCH 13/26] test: update workspace and registry e2e coverage Improve end-to-end coverage for workspace and registry behavior to verify these integration paths continue working together. Auto-committed-on: dragonfly Co-authored-by: Medulla --- tests/e2e_workspace_and_registry.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e_workspace_and_registry.rs b/tests/e2e_workspace_and_registry.rs index 5ee51309..bb8421f8 100644 --- a/tests/e2e_workspace_and_registry.rs +++ b/tests/e2e_workspace_and_registry.rs @@ -25,7 +25,7 @@ use tinyagents::harness::tool::{ SandboxMode, Tool, ToolCall, ToolExecutionContext, ToolResult, ToolSchema, }; use tinyagents::harness::usage::Usage; -use tinyagents::harness::workspace::{cleanup_workspace, prepare_workspace}; +use tinyagents::harness::workspace::{cleanup_workspace, enforce_workspace_path, prepare_workspace}; use tinyagents::language::Blueprint; use tinyagents::{ CapabilityRegistry, ComponentKind, DiagnosticSeverity, RegistrySnapshot, SharedRootWorkspace, From 10c74db8fc854fa80c8d0cd2cd7c5c4de876b9c7 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 23:07:39 +0300 Subject: [PATCH 14/26] test: update workspace and registry end-to-end coverage Expand end-to-end tests for workspace and registry behavior to improve coverage of their integration. Auto-committed-on: dragonfly Co-authored-by: Medulla --- tests/e2e_workspace_and_registry.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/e2e_workspace_and_registry.rs b/tests/e2e_workspace_and_registry.rs index bb8421f8..ff8004ce 100644 --- a/tests/e2e_workspace_and_registry.rs +++ b/tests/e2e_workspace_and_registry.rs @@ -202,8 +202,7 @@ impl Tool<()> for WorkspaceEnforcingTool { .expect("the run was configured with a workspace"); let root = ws.root.display().to_string(); // Enforcing an out-of-root path fails closed and emits a violation event. - let blocked = ws - .enforce(Path::new("/etc/shadow"), &context.events) + let blocked = enforce_workspace_path(&ws, Path::new("/etc/shadow"), &context.events) .is_err(); Ok(ToolResult::text( call.id, From d6122ff9b4dca092455cdf5fc9810e9acf25568a Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 23:08:25 +0300 Subject: [PATCH 15/26] style(tests): format workspace test code Apply consistent Rust formatting to workspace imports and the path enforcement assertion without changing test behavior. Auto-committed-on: dragonfly Co-authored-by: Medulla --- tests/e2e_workspace_and_registry.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/e2e_workspace_and_registry.rs b/tests/e2e_workspace_and_registry.rs index ff8004ce..fbdbfe57 100644 --- a/tests/e2e_workspace_and_registry.rs +++ b/tests/e2e_workspace_and_registry.rs @@ -25,7 +25,9 @@ use tinyagents::harness::tool::{ SandboxMode, Tool, ToolCall, ToolExecutionContext, ToolResult, ToolSchema, }; use tinyagents::harness::usage::Usage; -use tinyagents::harness::workspace::{cleanup_workspace, enforce_workspace_path, prepare_workspace}; +use tinyagents::harness::workspace::{ + cleanup_workspace, enforce_workspace_path, prepare_workspace, +}; use tinyagents::language::Blueprint; use tinyagents::{ CapabilityRegistry, ComponentKind, DiagnosticSeverity, RegistrySnapshot, SharedRootWorkspace, @@ -202,8 +204,8 @@ impl Tool<()> for WorkspaceEnforcingTool { .expect("the run was configured with a workspace"); let root = ws.root.display().to_string(); // Enforcing an out-of-root path fails closed and emits a violation event. - let blocked = enforce_workspace_path(&ws, Path::new("/etc/shadow"), &context.events) - .is_err(); + let blocked = + enforce_workspace_path(&ws, Path::new("/etc/shadow"), &context.events).is_err(); Ok(ToolResult::text( call.id, call.name, From 190a88164cd318cff19c2c9a7648db8ac0decab1 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 23:11:47 +0300 Subject: [PATCH 16/26] chore(vendor): update tinytools dependency Update the vendored tinytools submodule to a newer upstream revision. Auto-committed-on: dragonfly Co-authored-by: Medulla --- vendor/tinytools | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/tinytools b/vendor/tinytools index b8f3f8a3..c64e9bdb 160000 --- a/vendor/tinytools +++ b/vendor/tinytools @@ -1 +1 @@ -Subproject commit b8f3f8a3a9726d233bbca64dafd53b6d8c456e4f +Subproject commit c64e9bdbda32cc3704be7aa7a0d7fa8e41b879b9 From c0101720a10f4384a0d3ee59c1eef12b10b4d2e1 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 23:12:46 +0300 Subject: [PATCH 17/26] docs(deps): clarify tinytools tool interoperability Update dependency comments to explain the separation between host-facing tool vocabulary and the harness-owned tool trait. Document how execution context interoperability provides shared workspace metadata without creating a reverse dependency. Auto-committed-on: dragonfly Co-authored-by: Medulla --- Cargo.toml | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index d7bd1512..da79f4f2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,14 +11,20 @@ categories = ["asynchronous", "api-bindings"] [dependencies] async-trait = "0.1" -# The tool vocabulary. This crate re-exports it rather than declaring its own, -# so `tinyagents::harness::tool::Tool` and a host's `tinytools::Tool` are the -# *same* trait — a host implements a tool once and both sides accept it. +# The tool vocabulary. This crate re-exports the host-facing naming/context +# helpers (`humanize_tool_name`, `context_detail_from_args`, `WorkspaceDescriptor`, +# `SandboxMode`, …) rather than declaring its own copies, so a host and this +# harness read a tool's workspace root and display name identically. # -# The edge points one way: `tinytools` must never depend on this crate. That is -# why `ToolExecutionContext` stays here and reaches tools through -# `tinytools::ToolRunContext`, implemented below in `harness::tool::types`, -# rather than being named in the vocabulary. +# `harness::tool::Tool` itself stays a distinct, harness-owned trait — +# it is generic over application `State` and its signature carries this +# crate's own model-facing dialect types (`ToolCall` / `ToolResult` / +# `ToolPolicy` / `ToolTimeout`), which are a deliberately separate concern from +# `tinytools::Tool`'s host-facing shape. The interop point is +# `ToolExecutionContext` implementing `tinytools::ToolRunContext` (below in +# `harness::tool::types`), which lets a tool read its workspace descriptor +# without this crate naming `ToolExecutionContext` inside `tinytools` (the +# edge points one way: `tinytools` must never depend on this crate). # # Path-only for now, which is what stops this crate being published. Publishing # `tinytools` is the prerequisite; see that repository's AGENTS.md. From 0cc56deadc73683ef29687348e243eb767668ab9 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 23:12:59 +0300 Subject: [PATCH 18/26] docs(harness): update workspace documentation Clarify the workspace harness documentation to better explain its usage and behavior. Auto-committed-on: dragonfly Co-authored-by: Medulla --- docs/modules/harness/workspace.md | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/docs/modules/harness/workspace.md b/docs/modules/harness/workspace.md index 655f72a1..716ead5f 100644 --- a/docs/modules/harness/workspace.md +++ b/docs/modules/harness/workspace.md @@ -112,17 +112,22 @@ assert_eq!(kinds, vec!["workspace.prepared", "workspace.cleanup"]); ## Fail-closed path enforcement -Before a tool touches a path, call `WorkspaceDescriptor::enforce(path, &events)`. +Before a tool touches a path, call `enforce_workspace_path(&ws, path, &events)`. It is a fail-closed gate: an allowed path returns `Ok(())` silently; a path outside every allowed root emits `AgentEvent::WorkspaceViolation { path }` and returns `TinyAgentsError::Validation`, so the caller blocks the operation. +`WorkspaceDescriptor` is now `tinytools`' type — its lexical `allows()` check +moved there with it — so the event-emitting half of the old `enforce()` method +is a free function here instead of an inherent method on a foreign type. + ```rust +use tinyagents::harness::workspace::enforce_workspace_path; + let ws = WorkspaceDescriptor::new("/work/agent-a"); -ws.enforce(std::path::Path::new("/work/agent-a/out.txt"), &events)?; // allowed, no event +enforce_workspace_path(&ws, std::path::Path::new("/work/agent-a/out.txt"), &events)?; // allowed, no event -let err = ws - .enforce(std::path::Path::new("/etc/passwd"), &events) +let err = enforce_workspace_path(&ws, std::path::Path::new("/etc/passwd"), &events) .expect_err("path outside root must be blocked"); assert!(err.to_string().contains("outside the allowed workspace")); // A `workspace.violation` event was emitted for audit. From d0e0226f586ef967807b196b7ad48b1515ad7562 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 23:23:20 +0300 Subject: [PATCH 19/26] chore(vendor): update tinytools submodule Update tinytools to a newer revision to incorporate its latest changes. Auto-committed-on: dragonfly Co-authored-by: Medulla --- vendor/tinytools | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/tinytools b/vendor/tinytools index c64e9bdb..8db59ae8 160000 --- a/vendor/tinytools +++ b/vendor/tinytools @@ -1 +1 @@ -Subproject commit c64e9bdbda32cc3704be7aa7a0d7fa8e41b879b9 +Subproject commit 8db59ae8049bf06289824223e3e5287fac1c8301 From 954b3dcf58038e8c11d674976fb779f1c72c6af4 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 23:26:23 +0300 Subject: [PATCH 20/26] chore(vendor): update tinytools submodule Update the tinytools dependency to a newer revision to incorporate its latest changes. Auto-committed-on: dragonfly Co-authored-by: Medulla --- vendor/tinytools | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/tinytools b/vendor/tinytools index 8db59ae8..47db6d45 160000 --- a/vendor/tinytools +++ b/vendor/tinytools @@ -1 +1 @@ -Subproject commit 8db59ae8049bf06289824223e3e5287fac1c8301 +Subproject commit 47db6d45b7691c6d4ab6f3d2b5967e631865376d From 2332573856d004672c3a80f5d271a8b63d916017 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 23:35:42 +0300 Subject: [PATCH 21/26] chore(vendor): update tinytools submodule Update the tinytools dependency to a newer revision to incorporate its latest changes. Auto-committed-on: dragonfly Co-authored-by: Medulla --- vendor/tinytools | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/tinytools b/vendor/tinytools index 47db6d45..f8992007 160000 --- a/vendor/tinytools +++ b/vendor/tinytools @@ -1 +1 @@ -Subproject commit 47db6d45b7691c6d4ab6f3d2b5967e631865376d +Subproject commit f899200759f92febb34aa122bdb68ada372b0652 From 67ca1c78ce9a706aea95b9223c67566efcb0f76f Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 23:55:29 +0300 Subject: [PATCH 22/26] chore(tinytools): update vendored revision Point the vendored tinytools submodule at the newer revision to incorporate its latest changes. Auto-committed-on: dragonfly Co-authored-by: Medulla --- vendor/tinytools | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/tinytools b/vendor/tinytools index f8992007..487a87f5 160000 --- a/vendor/tinytools +++ b/vendor/tinytools @@ -1 +1 @@ -Subproject commit f899200759f92febb34aa122bdb68ada372b0652 +Subproject commit 487a87f5ac7d281b468d2d72acddc29c2d8466bf From b34ae7523f274cfd9bd240d669eaaf02ca30f64b Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 29 Aug 2026 23:57:27 +0300 Subject: [PATCH 23/26] chore(vendor): update tinytools revision Update the vendored tinytools submodule to incorporate its latest changes. Auto-committed-on: dragonfly Co-authored-by: Medulla --- vendor/tinytools | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/tinytools b/vendor/tinytools index 487a87f5..6b922f7e 160000 --- a/vendor/tinytools +++ b/vendor/tinytools @@ -1 +1 @@ -Subproject commit 487a87f5ac7d281b468d2d72acddc29c2d8466bf +Subproject commit 6b922f7ea1e45b97f620b31b1c2785ef34d237e8 From 785c27ce9e05c697b9fc87232fe192c3277f4cef Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 30 Aug 2026 00:22:31 +0300 Subject: [PATCH 24/26] chore: drop the branch line from the tinytools submodule The sibling wiki entry declares no branch, so this repository's own convention is to omit it -- the line was copied from openhuman, which is a different repository with a different convention. It also has no effect here. A branch is read only by 'git submodule update --remote', and nothing in this repository runs that: CI uses actions/checkout with submodules: recursive, which checks out the gitlink commit. The pin is, and always was, the gitlink. Co-authored-by: Medulla --- .gitmodules | 1 - 1 file changed, 1 deletion(-) diff --git a/.gitmodules b/.gitmodules index 2ea42b7c..fc0c4cc4 100644 --- a/.gitmodules +++ b/.gitmodules @@ -4,4 +4,3 @@ [submodule "vendor/tinytools"] path = vendor/tinytools url = https://github.com/tinyhumansai/tinytools.git - branch = main From ad33569a7f8a45e56187b8b807b6f9f616c6126a Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 30 Aug 2026 00:41:55 +0300 Subject: [PATCH 25/26] chore(vendor): update tinytools submodule Bump the vendored tinytools revision to the newer upstream commit. Auto-committed-on: dragonfly Co-authored-by: Medulla --- vendor/tinytools | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vendor/tinytools b/vendor/tinytools index 6b922f7e..3fe45114 160000 --- a/vendor/tinytools +++ b/vendor/tinytools @@ -1 +1 @@ -Subproject commit 6b922f7ea1e45b97f620b31b1c2785ef34d237e8 +Subproject commit 3fe451147a5aaadd45dc15f6a771b83c1c4fccd7 From 1c7a62ec41f1a49c065c3659f79419db289c0de0 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sun, 30 Aug 2026 00:42:50 +0300 Subject: [PATCH 26/26] docs: import WorkspaceDescriptor in the workspace example The example constructs a WorkspaceDescriptor but imported only enforce_workspace_path, so it would not compile as written. Both come from harness::workspace -- the descriptor is re-exported there from tinytools -- so one import line covers it. Co-authored-by: Medulla --- docs/modules/harness/workspace.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/modules/harness/workspace.md b/docs/modules/harness/workspace.md index 716ead5f..d5b65e10 100644 --- a/docs/modules/harness/workspace.md +++ b/docs/modules/harness/workspace.md @@ -122,7 +122,7 @@ moved there with it — so the event-emitting half of the old `enforce()` method is a free function here instead of an inherent method on a foreign type. ```rust -use tinyagents::harness::workspace::enforce_workspace_path; +use tinyagents::harness::workspace::{WorkspaceDescriptor, enforce_workspace_path}; let ws = WorkspaceDescriptor::new("/work/agent-a"); enforce_workspace_path(&ws, std::path::Path::new("/work/agent-a/out.txt"), &events)?; // allowed, no event