From fdad9615524d14df9abeb3822387f162ab1bfeea Mon Sep 17 00:00:00 2001 From: fey Date: Wed, 18 Mar 2026 19:35:05 +0000 Subject: [PATCH 01/20] feat(registry): add ToolCategory::from_category_str (#230) --- crates/chibi-core/src/tools/registry.rs | 38 +++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/crates/chibi-core/src/tools/registry.rs b/crates/chibi-core/src/tools/registry.rs index 2fa18d5c0..fb0343c21 100644 --- a/crates/chibi-core/src/tools/registry.rs +++ b/crates/chibi-core/src/tools/registry.rs @@ -156,6 +156,27 @@ impl ToolCategory { ToolCategory::Eval => "eval", } } + + /// Parse a category string into a `ToolCategory` variant. + /// + /// Unknown strings map to `Synthesised` (graceful fallback). + /// Symmetric with `as_str`. + pub fn from_category_str(s: &str) -> Self { + match s { + "memory" => Self::Memory, + "fs_read" => Self::FsRead, + "fs_write" => Self::FsWrite, + "shell" => Self::Shell, + "network" => Self::Network, + "index" => Self::Index, + "flow" => Self::Flow, + "vfs" => Self::Vfs, + "plugin" => Self::Plugin, + "mcp" => Self::Mcp, + "eval" => Self::Eval, + _ => Self::Synthesised, + } + } } /// Single source of truth for all tools at runtime. @@ -599,4 +620,21 @@ mod tests { "scheme_eval must not be parallel" ); } + + #[test] + fn test_from_category_str() { + assert_eq!(ToolCategory::from_category_str("network"), ToolCategory::Network); + assert_eq!(ToolCategory::from_category_str("fs_read"), ToolCategory::FsRead); + assert_eq!(ToolCategory::from_category_str("fs_write"), ToolCategory::FsWrite); + assert_eq!(ToolCategory::from_category_str("shell"), ToolCategory::Shell); + assert_eq!(ToolCategory::from_category_str("memory"), ToolCategory::Memory); + assert_eq!(ToolCategory::from_category_str("flow"), ToolCategory::Flow); + assert_eq!(ToolCategory::from_category_str("vfs"), ToolCategory::Vfs); + assert_eq!(ToolCategory::from_category_str("index"), ToolCategory::Index); + assert_eq!(ToolCategory::from_category_str("eval"), ToolCategory::Eval); + assert_eq!(ToolCategory::from_category_str("synthesised"), ToolCategory::Synthesised); + // unknown → Synthesised + assert_eq!(ToolCategory::from_category_str("bogus"), ToolCategory::Synthesised); + assert_eq!(ToolCategory::from_category_str(""), ToolCategory::Synthesised); + } } From 22a773ba611ed548a986bd1fa13c58a4b8c28921 Mon Sep 17 00:00:00 2001 From: fey Date: Wed, 18 Mar 2026 19:36:18 +0000 Subject: [PATCH 02/20] feat(synthesised): expand define-tool syntax-rules for category/summary-params (#230) --- crates/chibi-core/src/tools/synthesised.rs | 33 ++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/crates/chibi-core/src/tools/synthesised.rs b/crates/chibi-core/src/tools/synthesised.rs index 2b2975cb2..b7186ae87 100644 --- a/crates/chibi-core/src/tools/synthesised.rs +++ b/crates/chibi-core/src/tools/synthesised.rs @@ -383,13 +383,42 @@ pub(crate) static HARNESS_PREAMBLE: std::sync::LazyLock = std::sync::Laz ;; registers a tool: appends to %tool-registry% in definition order (LIFO via cons). ;; rust reads %tool-registry% after evaluation; non-empty → multi-tool mode. (define-syntax define-tool - (syntax-rules (description parameters execute) + (syntax-rules (description category summary-params parameters execute) + ;; pattern 1: baseline (no category, no summary-params) ((define-tool name (description desc) (parameters params) (execute handler)) (set! %tool-registry% - (cons (list (symbol->string 'name) desc params handler) + (cons (list (symbol->string 'name) desc params handler #f #f) + %tool-registry%))) + ;; pattern 2: category only + ((define-tool name + (description desc) + (category cat) + (parameters params) + (execute handler)) + (set! %tool-registry% + (cons (list (symbol->string 'name) desc params handler cat #f) + %tool-registry%))) + ;; pattern 3: summary-params only + ((define-tool name + (description desc) + (summary-params sp) + (parameters params) + (execute handler)) + (set! %tool-registry% + (cons (list (symbol->string 'name) desc params handler #f sp) + %tool-registry%))) + ;; pattern 4: category + summary-params + ((define-tool name + (description desc) + (category cat) + (summary-params sp) + (parameters params) + (execute handler)) + (set! %tool-registry% + (cons (list (symbol->string 'name) desc params handler cat sp) %tool-registry%))))) ;; registers a hook handler for a given hook point. From 08e3b082669732228ce7ba24dde26d5cc74532c3 Mon Sep 17 00:00:00 2001 From: fey Date: Wed, 18 Mar 2026 19:39:11 +0000 Subject: [PATCH 03/20] feat(synthesised): extract_multi_tools reads category and summary_params (#230) --- crates/chibi-core/src/tools/synthesised.rs | 151 ++++++++++++++++++++- 1 file changed, 148 insertions(+), 3 deletions(-) diff --git a/crates/chibi-core/src/tools/synthesised.rs b/crates/chibi-core/src/tools/synthesised.rs index b7186ae87..f3cf9b309 100644 --- a/crates/chibi-core/src/tools/synthesised.rs +++ b/crates/chibi-core/src/tools/synthesised.rs @@ -1209,6 +1209,29 @@ fn extract_multi_tools( ))); } + // category (index 4, optional — absent in 4-element legacy entries) + let category = if fields.len() > 4 { + fields[4] + .as_string() + .map(|s| ToolCategory::from_category_str(s)) + .unwrap_or(ToolCategory::Synthesised) + } else { + ToolCategory::Synthesised + }; + + // summary_params (index 5, optional) + let summary_params = if fields.len() > 5 { + match &fields[5] { + Value::List(items) => items + .iter() + .filter_map(|v| v.as_string().map(|s| s.to_string())) + .collect(), + _ => vec![], + } + } else { + vec![] + }; + // reject names that would produce invalid Scheme identifiers when // embedded in `%tool-execute-{name}%` (whitespace or parentheses break // the symbol syntax) @@ -1226,7 +1249,7 @@ fn extract_multi_tools( let exec_binding = format!("%tool-execute-{name}%"); // scheme-escape name before interpolating into a string literal let name_escaped = scheme_escape_string(&name); - // %tool-registry% entries are (name desc params handler). + // %tool-registry% entries are (name desc params handler ...). // use list-ref to extract the handler (index 3) for this tool by name. // list-ref is in (scheme base) which the preamble already imports. context @@ -1247,7 +1270,7 @@ fn extract_multi_tools( parameters, hooks: hooks.clone(), metadata: ToolMetadata::new(), - summary_params: vec![], + summary_params, r#impl: ToolImpl::Synthesised { vfs_path: vfs_path.clone(), exec_binding: exec_binding.clone(), @@ -1256,7 +1279,7 @@ fn extract_multi_tools( worker_thread_id, hook_bindings: hook_bindings.clone(), }, - category: ToolCategory::Synthesised, + category, }); } @@ -1836,6 +1859,128 @@ mod tests { Arc::new(RwLock::new(ToolRegistry::new())) } + #[test] + fn test_define_tool_category() { + let source = r#" +(import (scheme base)) +(define-tool net_fetch + (description "fetches stuff") + (category "network") + (parameters '()) + (execute (lambda (args) "ok"))) +"#; + let path = VfsPath::new("/tools/shared/cat.scm").unwrap(); + let registry = make_registry(); + let tools = load_tools_from_source_with_tier( + source, + &path, + ®istry, + crate::config::SandboxTier::Sandboxed, + ) + .unwrap(); + assert_eq!(tools.len(), 1); + assert_eq!(tools[0].category, ToolCategory::Network); + } + + #[test] + fn test_define_tool_summary_params() { + let source = r#" +(import (scheme base)) +(define-tool my_action + (description "does things") + (summary-params '("ticker" "qty")) + (parameters '()) + (execute (lambda (args) "ok"))) +"#; + let path = VfsPath::new("/tools/shared/sp.scm").unwrap(); + let registry = make_registry(); + let tools = load_tools_from_source_with_tier( + source, + &path, + ®istry, + crate::config::SandboxTier::Sandboxed, + ) + .unwrap(); + assert_eq!(tools.len(), 1); + assert_eq!(tools[0].summary_params, vec!["ticker", "qty"]); + } + + #[test] + fn test_define_tool_category_and_summary_params() { + let source = r#" +(import (scheme base)) +(define-tool trade + (description "places a trade") + (category "network") + (summary-params '("ticker" "quantity")) + (parameters '()) + (execute (lambda (args) "ok"))) +"#; + let path = VfsPath::new("/tools/shared/both.scm").unwrap(); + let registry = make_registry(); + let tools = load_tools_from_source_with_tier( + source, + &path, + ®istry, + crate::config::SandboxTier::Sandboxed, + ) + .unwrap(); + assert_eq!(tools.len(), 1); + assert_eq!(tools[0].category, ToolCategory::Network); + assert_eq!(tools[0].summary_params, vec!["ticker", "quantity"]); + } + + #[test] + fn test_define_tool_unknown_category() { + let source = r#" +(import (scheme base)) +(define-tool unknown_cat + (description "unknown category") + (category "banana") + (parameters '()) + (execute (lambda (args) "ok"))) +"#; + let path = VfsPath::new("/tools/shared/unk.scm").unwrap(); + let registry = make_registry(); + let tools = load_tools_from_source_with_tier( + source, + &path, + ®istry, + crate::config::SandboxTier::Sandboxed, + ) + .unwrap(); + assert_eq!(tools[0].category, ToolCategory::Synthesised); + } + + #[test] + fn test_define_tool_multi_different_categories() { + let source = r#" +(import (scheme base)) +(define-tool reader + (description "reads files") + (category "fs_read") + (parameters '()) + (execute (lambda (args) "read"))) +(define-tool writer + (description "writes files") + (category "fs_write") + (parameters '()) + (execute (lambda (args) "write"))) +"#; + let path = VfsPath::new("/tools/shared/multi.scm").unwrap(); + let registry = make_registry(); + let tools = load_tools_from_source_with_tier( + source, + &path, + ®istry, + crate::config::SandboxTier::Sandboxed, + ) + .unwrap(); + assert_eq!(tools.len(), 2); + assert_eq!(tools[0].category, ToolCategory::FsRead); + assert_eq!(tools[1].category, ToolCategory::FsWrite); + } + const SCAN_TOOL: &str = r#" (import (scheme base)) (define tool-name "scan_hello") From b6f1ce93e359131b011d7ab3230a6eb427e1663d Mon Sep 17 00:00:00 2001 From: fey Date: Wed, 18 Mar 2026 19:41:04 +0000 Subject: [PATCH 04/20] feat(synthesised): extract_single_tool reads category and summary_params (#230) --- crates/chibi-core/src/tools/synthesised.rs | 93 +++++++++++++++++++++- 1 file changed, 91 insertions(+), 2 deletions(-) diff --git a/crates/chibi-core/src/tools/synthesised.rs b/crates/chibi-core/src/tools/synthesised.rs index f3cf9b309..60b224331 100644 --- a/crates/chibi-core/src/tools/synthesised.rs +++ b/crates/chibi-core/src/tools/synthesised.rs @@ -1128,6 +1128,29 @@ fn extract_single_tool( )); } + // optional: tool-category + let category = session + .evaluate("tool-category") + .ok() + .and_then(|v| v.as_string().map(|s| s.to_string())) + .map(|s| ToolCategory::from_category_str(&s)) + .unwrap_or(ToolCategory::Synthesised); + + // optional: tool-summary-params + let summary_params = session + .evaluate("tool-summary-params") + .ok() + .and_then(|v| match v { + Value::List(items) => Some( + items + .iter() + .filter_map(|i| i.as_string().map(|s| s.to_string())) + .collect::>(), + ), + _ => None, + }) + .unwrap_or_default(); + let (hooks, hook_bindings) = extract_hook_registrations(&session)?; let context = Arc::new(session); Ok(Tool { @@ -1136,7 +1159,7 @@ fn extract_single_tool( parameters, hooks, metadata: ToolMetadata::new(), - summary_params: vec![], + summary_params, r#impl: ToolImpl::Synthesised { vfs_path: vfs_path.clone(), exec_binding: "tool-execute".to_string(), @@ -1145,7 +1168,7 @@ fn extract_single_tool( worker_thread_id, hook_bindings, }, - category: ToolCategory::Synthesised, + category, }) } @@ -1859,6 +1882,72 @@ mod tests { Arc::new(RwLock::new(ToolRegistry::new())) } + #[test] + fn test_convention_category() { + let source = r#" +(import (scheme base)) +(define tool-name "net_tool") +(define tool-description "a network tool") +(define tool-category "network") +(define tool-parameters '()) +(define (tool-execute args) "ok") +"#; + let path = VfsPath::new("/tools/shared/conv_cat.scm").unwrap(); + let registry = make_registry(); + let tools = load_tools_from_source_with_tier( + source, + &path, + ®istry, + crate::config::SandboxTier::Sandboxed, + ) + .unwrap(); + assert_eq!(tools[0].category, ToolCategory::Network); + } + + #[test] + fn test_convention_summary_params() { + let source = r#" +(import (scheme base)) +(define tool-name "summ_tool") +(define tool-description "has summary params") +(define tool-summary-params '("path" "mode")) +(define tool-parameters '()) +(define (tool-execute args) "ok") +"#; + let path = VfsPath::new("/tools/shared/conv_sp.scm").unwrap(); + let registry = make_registry(); + let tools = load_tools_from_source_with_tier( + source, + &path, + ®istry, + crate::config::SandboxTier::Sandboxed, + ) + .unwrap(); + assert_eq!(tools[0].summary_params, vec!["path", "mode"]); + } + + #[test] + fn test_convention_defaults() { + let source = r#" +(import (scheme base)) +(define tool-name "plain_tool") +(define tool-description "no extras") +(define tool-parameters '()) +(define (tool-execute args) "ok") +"#; + let path = VfsPath::new("/tools/shared/plain.scm").unwrap(); + let registry = make_registry(); + let tools = load_tools_from_source_with_tier( + source, + &path, + ®istry, + crate::config::SandboxTier::Sandboxed, + ) + .unwrap(); + assert_eq!(tools[0].category, ToolCategory::Synthesised); + assert!(tools[0].summary_params.is_empty()); + } + #[test] fn test_define_tool_category() { let source = r#" From 99b1dadeca6cecaa2a85e06064dd17726ec053ad Mon Sep 17 00:00:00 2001 From: fey Date: Wed, 18 Mar 2026 19:43:04 +0000 Subject: [PATCH 05/20] fmt --- crates/chibi-core/src/tools/registry.rs | 45 +++++++++++++++++----- crates/chibi-core/src/tools/synthesised.rs | 2 +- 2 files changed, 37 insertions(+), 10 deletions(-) diff --git a/crates/chibi-core/src/tools/registry.rs b/crates/chibi-core/src/tools/registry.rs index fb0343c21..b9adf10f6 100644 --- a/crates/chibi-core/src/tools/registry.rs +++ b/crates/chibi-core/src/tools/registry.rs @@ -623,18 +623,45 @@ mod tests { #[test] fn test_from_category_str() { - assert_eq!(ToolCategory::from_category_str("network"), ToolCategory::Network); - assert_eq!(ToolCategory::from_category_str("fs_read"), ToolCategory::FsRead); - assert_eq!(ToolCategory::from_category_str("fs_write"), ToolCategory::FsWrite); - assert_eq!(ToolCategory::from_category_str("shell"), ToolCategory::Shell); - assert_eq!(ToolCategory::from_category_str("memory"), ToolCategory::Memory); + assert_eq!( + ToolCategory::from_category_str("network"), + ToolCategory::Network + ); + assert_eq!( + ToolCategory::from_category_str("fs_read"), + ToolCategory::FsRead + ); + assert_eq!( + ToolCategory::from_category_str("fs_write"), + ToolCategory::FsWrite + ); + assert_eq!( + ToolCategory::from_category_str("shell"), + ToolCategory::Shell + ); + assert_eq!( + ToolCategory::from_category_str("memory"), + ToolCategory::Memory + ); assert_eq!(ToolCategory::from_category_str("flow"), ToolCategory::Flow); assert_eq!(ToolCategory::from_category_str("vfs"), ToolCategory::Vfs); - assert_eq!(ToolCategory::from_category_str("index"), ToolCategory::Index); + assert_eq!( + ToolCategory::from_category_str("index"), + ToolCategory::Index + ); assert_eq!(ToolCategory::from_category_str("eval"), ToolCategory::Eval); - assert_eq!(ToolCategory::from_category_str("synthesised"), ToolCategory::Synthesised); + assert_eq!( + ToolCategory::from_category_str("synthesised"), + ToolCategory::Synthesised + ); // unknown → Synthesised - assert_eq!(ToolCategory::from_category_str("bogus"), ToolCategory::Synthesised); - assert_eq!(ToolCategory::from_category_str(""), ToolCategory::Synthesised); + assert_eq!( + ToolCategory::from_category_str("bogus"), + ToolCategory::Synthesised + ); + assert_eq!( + ToolCategory::from_category_str(""), + ToolCategory::Synthesised + ); } } diff --git a/crates/chibi-core/src/tools/synthesised.rs b/crates/chibi-core/src/tools/synthesised.rs index 60b224331..e72ad9fdc 100644 --- a/crates/chibi-core/src/tools/synthesised.rs +++ b/crates/chibi-core/src/tools/synthesised.rs @@ -1236,7 +1236,7 @@ fn extract_multi_tools( let category = if fields.len() > 4 { fields[4] .as_string() - .map(|s| ToolCategory::from_category_str(s)) + .map(ToolCategory::from_category_str) .unwrap_or(ToolCategory::Synthesised) } else { ToolCategory::Synthesised From dd3c453afdec55ee13736f234cc2d196031024e7 Mon Sep 17 00:00:00 2001 From: fey Date: Wed, 18 Mar 2026 19:43:38 +0000 Subject: [PATCH 06/20] docs(hooks): update PreFetchUrl metadata for no_url variant (#230) --- crates/chibi-core/src/tools/hooks.rs | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/crates/chibi-core/src/tools/hooks.rs b/crates/chibi-core/src/tools/hooks.rs index 98a1e01fb..65ffd96ee 100644 --- a/crates/chibi-core/src/tools/hooks.rs +++ b/crates/chibi-core/src/tools/hooks.rs @@ -653,28 +653,33 @@ pub(crate) const HOOK_METADATA: &[HookMeta] = &[ HookMeta { point: HookPoint::PreFetchUrl, category: "url_security", - description: "fires before fetching a sensitive URL (loopback, private, cloud metadata); deny-only", + description: "fires before fetching a sensitive URL or invoking a network-category tool without a URL; deny-only", can_modify: true, payload_fields: &[ FieldMeta { name: "tool_name", typ: "string", - description: "fetch_url", + description: "name of the tool making the network call", }, FieldMeta { name: "url", typ: "string", - description: "URL being fetched", + description: "URL being fetched (absent when safety is \"no_url\")", }, FieldMeta { name: "safety", typ: "string", - description: "sensitive", + description: "\"sensitive\" for URL-based calls, \"no_url\" for network tools without a URL parameter", }, FieldMeta { name: "reason", typ: "string", - description: "loopback address, private network address, cloud metadata endpoint, or could not parse URL", + description: "classification reason (absent when safety is \"no_url\")", + }, + FieldMeta { + name: "summary", + typ: "string", + description: "human-readable summary from summary_params (present only when safety is \"no_url\")", }, ], return_fields: &[ From aed686986ea11d22dc5d76f9df3effcc5b0f92bf Mon Sep 17 00:00:00 2001 From: fey Date: Wed, 18 Mar 2026 19:47:04 +0000 Subject: [PATCH 07/20] feat(send): network category no-URL fallback uses summary_params (#230) --- crates/chibi-core/src/api/send.rs | 56 ++++++++++++++++++++++--------- docs/hooks.md | 11 +++--- 2 files changed, 46 insertions(+), 21 deletions(-) diff --git a/crates/chibi-core/src/api/send.rs b/crates/chibi-core/src/api/send.rs index 835e6c9fa..64b06fde5 100644 --- a/crates/chibi-core/src/api/send.rs +++ b/crates/chibi-core/src/api/send.rs @@ -1114,23 +1114,18 @@ async fn execute_tool_pure( } ToolCategory::Network => { let url = args.get_str("url").unwrap_or(""); - let safety = tools::classify_url(url); - if let Some(ref policy) = resolved_config.url_policy { - if tools::evaluate_url_policy(url, &safety, policy) == tools::UrlAction::Deny { - let reason = match &safety { - tools::UrlSafety::Sensitive(cat) => cat.to_string(), - tools::UrlSafety::Safe => "denied by URL policy".to_string(), - }; - Some(format!("Permission denied: {}", reason)) - } else { - None - } - } else if let tools::UrlSafety::Sensitive(category) = &safety { + if url.is_empty() { + // No URL parameter — use summary_params for the permission prompt. + let summary = tools::tool_call_summary( + ®istry.read().unwrap(), + &tool_call.name, + &tool_call.arguments, + ) + .unwrap_or_default(); let hook_data = json!({ "tool_name": tool_call.name, - "url": url, - "safety": "sensitive", - "reason": category.to_string(), + "summary": summary, + "safety": "no_url", }); check_permission( plugin_tools, @@ -1142,7 +1137,36 @@ async fn execute_tool_pure( .err() .map(|r| format!("Permission denied: {}", r)) } else { - None + let safety = tools::classify_url(url); + if let Some(ref policy) = resolved_config.url_policy { + if tools::evaluate_url_policy(url, &safety, policy) == tools::UrlAction::Deny { + let reason = match &safety { + tools::UrlSafety::Sensitive(cat) => cat.to_string(), + tools::UrlSafety::Safe => "denied by URL policy".to_string(), + }; + Some(format!("Permission denied: {}", reason)) + } else { + None + } + } else if let tools::UrlSafety::Sensitive(category) = &safety { + let hook_data = json!({ + "tool_name": tool_call.name, + "url": url, + "safety": "sensitive", + "reason": category.to_string(), + }); + check_permission( + plugin_tools, + tools::HookPoint::PreFetchUrl, + &hook_data, + permission_handler, + tein_ctx, + )? + .err() + .map(|r| format!("Permission denied: {}", r)) + } else { + None + } } } ToolCategory::Flow => { diff --git a/docs/hooks.md b/docs/hooks.md index a1d1dc856..dcc91f88f 100644 --- a/docs/hooks.md +++ b/docs/hooks.md @@ -61,7 +61,7 @@ Chibi supports a hooks system that allows plugins to register for lifecycle even | Hook | When | Can Modify | |------|------|------------| -| `pre_fetch_url` | fires before fetching a sensitive URL (loopback, private, cloud metadata); deny-only | Yes | +| `pre_fetch_url` | fires before fetching a sensitive URL or invoking a network-category tool without a URL; deny-only | Yes | ### Sub-Agent Lifecycle @@ -396,10 +396,11 @@ Payload: (empty) ```json { - "tool_name": "...", // fetch_url - "url": "...", // URL being fetched - "safety": "...", // sensitive - "reason": "..." // loopback address, private network address, cloud metadata endpoint, or could not parse URL + "tool_name": "...", // name of the tool making the network call + "url": "...", // URL being fetched (absent when safety is "no_url") + "safety": "...", // "sensitive" for URL-based calls, "no_url" for network tools without a URL parameter + "reason": "...", // classification reason (absent when safety is "no_url") + "summary": "..." // human-readable summary from summary_params (present only when safety is "no_url") } ``` From 06c829a5b4b0aa2b3ecf45c6b065516b0ffda49d Mon Sep 17 00:00:00 2001 From: fey Date: Wed, 18 Mar 2026 20:10:06 +0000 Subject: [PATCH 08/20] refactor(synthesised): load_tools_from_source takes &ToolsConfig (#230) --- crates/chibi-core/src/tools/hooks.rs | 155 +++++------------- crates/chibi-core/src/tools/synthesised.rs | 182 ++++++--------------- 2 files changed, 94 insertions(+), 243 deletions(-) diff --git a/crates/chibi-core/src/tools/hooks.rs b/crates/chibi-core/src/tools/hooks.rs index 65ffd96ee..eceee4d1c 100644 --- a/crates/chibi-core/src/tools/hooks.rs +++ b/crates/chibi-core/src/tools/hooks.rs @@ -1563,6 +1563,17 @@ pub fn execute_hook( mod tests { use super::*; + /// Build a `ToolsConfig` that maps `vfs_path` to the given tier. + #[cfg(feature = "synthesised-tools")] + fn config_with_tier(vfs_path: &str, tier: u8) -> crate::config::ToolsConfig { + let mut tiers = std::collections::HashMap::new(); + tiers.insert(vfs_path.to_string(), tier); + crate::config::ToolsConfig { + tiers: Some(tiers), + ..Default::default() + } + } + // All 31 hook points for testing const ALL_HOOKS: &[(&str, HookPoint)] = &[ ("pre_message", HookPoint::PreMessage), @@ -2030,7 +2041,7 @@ echo 'OK' #[cfg(feature = "synthesised-tools")] fn test_execute_hook_dispatches_to_synthesised() { use crate::tools::registry::ToolRegistry; - use crate::tools::synthesised::load_tools_from_source_with_tier; + use crate::tools::synthesised::load_tools_from_source; use crate::vfs::VfsPath; use std::sync::{Arc, RwLock}; @@ -2047,12 +2058,7 @@ echo 'OK' "#; let registry = Arc::new(RwLock::new(ToolRegistry::new())); let path = VfsPath::new("/tools/shared/hook-test.scm").unwrap(); - let tools = load_tools_from_source_with_tier( - source, - &path, - ®istry, - crate::config::SandboxTier::Sandboxed, - ) + let tools = load_tools_from_source(source, &path, ®istry, &crate::config::ToolsConfig::default()) .unwrap(); let data = serde_json::json!({"event": "start"}); @@ -2110,7 +2116,7 @@ echo 'OK' #[cfg(feature = "synthesised-tools")] fn test_tein_hook_empty_list_return_is_noop() { use crate::tools::registry::ToolRegistry; - use crate::tools::synthesised::load_tools_from_source_with_tier; + use crate::tools::synthesised::load_tools_from_source; use crate::vfs::VfsPath; use std::sync::{Arc, RwLock}; @@ -2124,12 +2130,7 @@ echo 'OK' "#; let registry = Arc::new(RwLock::new(ToolRegistry::new())); let path = VfsPath::new("/tools/shared/noop.scm").unwrap(); - let tools = load_tools_from_source_with_tier( - source, - &path, - ®istry, - crate::config::SandboxTier::Sandboxed, - ) + let tools = load_tools_from_source(source, &path, ®istry, &crate::config::ToolsConfig::default()) .unwrap(); let results = @@ -2145,7 +2146,7 @@ echo 'OK' #[cfg(feature = "synthesised-tools")] fn test_tein_hook_json_object_return() { use crate::tools::registry::ToolRegistry; - use crate::tools::synthesised::load_tools_from_source_with_tier; + use crate::tools::synthesised::load_tools_from_source; use crate::vfs::VfsPath; use std::sync::{Arc, RwLock}; @@ -2161,12 +2162,7 @@ echo 'OK' "#; let registry = Arc::new(RwLock::new(ToolRegistry::new())); let path = VfsPath::new("/tools/shared/modify.scm").unwrap(); - let tools = load_tools_from_source_with_tier( - source, - &path, - ®istry, - crate::config::SandboxTier::Sandboxed, - ) + let tools = load_tools_from_source(source, &path, ®istry, &crate::config::ToolsConfig::default()) .unwrap(); let results = execute_hook( @@ -2185,7 +2181,7 @@ echo 'OK' #[cfg(feature = "synthesised-tools")] fn test_tein_hook_skips_unregistered_hook_point() { use crate::tools::registry::ToolRegistry; - use crate::tools::synthesised::load_tools_from_source_with_tier; + use crate::tools::synthesised::load_tools_from_source; use crate::vfs::VfsPath; use std::sync::{Arc, RwLock}; @@ -2199,12 +2195,7 @@ echo 'OK' "#; let registry = Arc::new(RwLock::new(ToolRegistry::new())); let path = VfsPath::new("/tools/shared/selective.scm").unwrap(); - let tools = load_tools_from_source_with_tier( - source, - &path, - ®istry, - crate::config::SandboxTier::Sandboxed, - ) + let tools = load_tools_from_source(source, &path, ®istry, &crate::config::ToolsConfig::default()) .unwrap(); // fire on_end — tool is registered for on_start only @@ -2216,7 +2207,7 @@ echo 'OK' #[cfg(feature = "synthesised-tools")] fn test_tein_hook_error_in_callback_skipped() { use crate::tools::registry::ToolRegistry; - use crate::tools::synthesised::load_tools_from_source_with_tier; + use crate::tools::synthesised::load_tools_from_source; use crate::vfs::VfsPath; use std::sync::{Arc, RwLock}; @@ -2230,12 +2221,7 @@ echo 'OK' "#; let registry = Arc::new(RwLock::new(ToolRegistry::new())); let path = VfsPath::new("/tools/shared/error.scm").unwrap(); - let tools = load_tools_from_source_with_tier( - source, - &path, - ®istry, - crate::config::SandboxTier::Sandboxed, - ) + let tools = load_tools_from_source(source, &path, ®istry, &crate::config::ToolsConfig::default()) .unwrap(); // should not error — failed hooks are skipped silently @@ -2248,7 +2234,7 @@ echo 'OK' #[cfg(all(feature = "synthesised-tools", unix))] fn test_mixed_plugin_and_tein_hooks() { use crate::tools::registry::ToolRegistry; - use crate::tools::synthesised::load_tools_from_source_with_tier; + use crate::tools::synthesised::load_tools_from_source; use crate::vfs::VfsPath; use std::sync::{Arc, RwLock}; @@ -2282,12 +2268,7 @@ echo 'OK' "#; let registry = Arc::new(RwLock::new(ToolRegistry::new())); let path = VfsPath::new("/tools/shared/tein.scm").unwrap(); - let mut tools = load_tools_from_source_with_tier( - source, - &path, - ®istry, - crate::config::SandboxTier::Sandboxed, - ) + let mut tools = load_tools_from_source(source, &path, ®istry, &crate::config::ToolsConfig::default()) .unwrap(); tools.insert(0, plugin_tool); @@ -2309,7 +2290,7 @@ echo 'OK' #[cfg(feature = "synthesised-tools")] fn test_tein_hook_reentrancy_guard_skips_tein_callbacks() { use crate::tools::registry::ToolRegistry; - use crate::tools::synthesised::load_tools_from_source_with_tier; + use crate::tools::synthesised::load_tools_from_source; use crate::vfs::VfsPath; use std::sync::{Arc, RwLock}; @@ -2323,12 +2304,7 @@ echo 'OK' "#; let registry = Arc::new(RwLock::new(ToolRegistry::new())); let path = VfsPath::new("/tools/shared/reentrancy.scm").unwrap(); - let tools = load_tools_from_source_with_tier( - source, - &path, - ®istry, - crate::config::SandboxTier::Sandboxed, - ) + let tools = load_tools_from_source(source, &path, ®istry, &crate::config::ToolsConfig::default()) .unwrap(); // Simulate re-entrancy: mark on_start as already-in-progress @@ -2357,7 +2333,7 @@ echo 'OK' #[cfg(feature = "synthesised-tools")] fn test_tein_hook_reentrancy_guard_cleared_after_dispatch() { use crate::tools::registry::ToolRegistry; - use crate::tools::synthesised::load_tools_from_source_with_tier; + use crate::tools::synthesised::load_tools_from_source; use crate::vfs::VfsPath; use std::sync::{Arc, RwLock}; @@ -2371,12 +2347,7 @@ echo 'OK' "#; let registry = Arc::new(RwLock::new(ToolRegistry::new())); let path = VfsPath::new("/tools/shared/guard-cleanup.scm").unwrap(); - let tools = load_tools_from_source_with_tier( - source, - &path, - ®istry, - crate::config::SandboxTier::Sandboxed, - ) + let tools = load_tools_from_source(source, &path, ®istry, &crate::config::ToolsConfig::default()) .unwrap(); // First call — fires normally @@ -2446,7 +2417,7 @@ echo 'OK' #[cfg(feature = "synthesised-tools")] async fn test_tein_hook_call_tool_with_tein_ctx_sets_bridge() { use crate::tools::registry::ToolRegistry; - use crate::tools::synthesised::load_tools_from_source_with_tier; + use crate::tools::synthesised::load_tools_from_source; use crate::vfs::VfsPath; use std::sync::{Arc, RwLock}; @@ -2483,12 +2454,7 @@ echo 'OK' "#; let registry = Arc::new(RwLock::new(ToolRegistry::new())); let path = VfsPath::new("/tools/shared/bridge-test.scm").unwrap(); - let tools = load_tools_from_source_with_tier( - source, - &path, - ®istry, - crate::config::SandboxTier::Unsandboxed, - ) + let tools = load_tools_from_source(source, &path, ®istry, &config_with_tier(path.as_str(), 2)) .unwrap(); let tein_ctx = TeinHookContext { @@ -2527,7 +2493,7 @@ echo 'OK' #[cfg(feature = "synthesised-tools")] fn test_tein_hook_call_tool_without_tein_ctx_no_bridge() { use crate::tools::registry::ToolRegistry; - use crate::tools::synthesised::load_tools_from_source_with_tier; + use crate::tools::synthesised::load_tools_from_source; use crate::vfs::VfsPath; use std::sync::{Arc, RwLock}; @@ -2556,12 +2522,7 @@ echo 'OK' "#; let registry = Arc::new(RwLock::new(ToolRegistry::new())); let path = VfsPath::new("/tools/shared/no-bridge-test.scm").unwrap(); - let tools = load_tools_from_source_with_tier( - source, - &path, - ®istry, - crate::config::SandboxTier::Unsandboxed, - ) + let tools = load_tools_from_source(source, &path, ®istry, &config_with_tier(path.as_str(), 2)) .unwrap(); // No TeinHookContext → bridge not set @@ -2584,7 +2545,7 @@ echo 'OK' #[cfg(feature = "synthesised-tools")] async fn test_tein_hook_harness_io_vfs_write() { use crate::tools::registry::ToolRegistry; - use crate::tools::synthesised::load_tools_from_source_with_tier; + use crate::tools::synthesised::load_tools_from_source; use crate::vfs::{VfsCaller, VfsPath}; use std::sync::{Arc, RwLock}; @@ -2605,12 +2566,7 @@ echo 'OK' "#; let registry = Arc::new(RwLock::new(ToolRegistry::new())); let path = VfsPath::new("/tools/shared/io-hook-test.scm").unwrap(); - let tools = load_tools_from_source_with_tier( - source, - &path, - ®istry, - crate::config::SandboxTier::Unsandboxed, - ) + let tools = load_tools_from_source(source, &path, ®istry, &config_with_tier(path.as_str(), 2)) .unwrap(); let tein_ctx = TeinHookContext { @@ -2650,7 +2606,7 @@ echo 'OK' #[cfg(feature = "synthesised-tools")] async fn test_tein_hook_io_does_not_trigger_hooks() { use crate::tools::registry::ToolRegistry; - use crate::tools::synthesised::load_tools_from_source_with_tier; + use crate::tools::synthesised::load_tools_from_source; use crate::vfs::{VfsCaller, VfsPath}; use std::sync::{Arc, RwLock}; @@ -2677,12 +2633,7 @@ echo 'OK' "#; let registry = Arc::new(RwLock::new(ToolRegistry::new())); let path = VfsPath::new("/tools/shared/counter-hook-test.scm").unwrap(); - let tools = load_tools_from_source_with_tier( - source, - &path, - ®istry, - crate::config::SandboxTier::Unsandboxed, - ) + let tools = load_tools_from_source(source, &path, ®istry, &config_with_tier(path.as_str(), 2)) .unwrap(); let tein_ctx = TeinHookContext { @@ -2735,7 +2686,7 @@ echo 'OK' #[cfg(feature = "synthesised-tools")] async fn test_pre_vfs_write_hook_io_write_works() { use crate::tools::registry::ToolRegistry; - use crate::tools::synthesised::load_tools_from_source_with_tier; + use crate::tools::synthesised::load_tools_from_source; use crate::vfs::{VfsCaller, VfsPath}; use std::sync::{Arc, RwLock}; @@ -2758,12 +2709,7 @@ echo 'OK' "#; let registry = Arc::new(RwLock::new(ToolRegistry::new())); let plugin_path = VfsPath::new("/tools/shared/hook-test.scm").unwrap(); - let tools = load_tools_from_source_with_tier( - source, - &plugin_path, - ®istry, - crate::config::SandboxTier::Unsandboxed, - ) + let tools = load_tools_from_source(source, &plugin_path, ®istry, &config_with_tier(plugin_path.as_str(), 2)) .unwrap(); let tein_ctx = TeinHookContext { @@ -2791,7 +2737,7 @@ echo 'OK' #[cfg(feature = "synthesised-tools")] async fn test_history_snapshot_on_write() { use crate::tools::registry::ToolRegistry; - use crate::tools::synthesised::load_tools_from_source_with_tier; + use crate::tools::synthesised::load_tools_from_source; use crate::vfs::{VfsCaller, VfsPath}; use std::sync::{Arc, RwLock}; @@ -2808,12 +2754,7 @@ echo 'OK' // Load history plugin let registry = Arc::new(RwLock::new(ToolRegistry::new())); let plugin_path = VfsPath::new("/tools/shared/history.scm").unwrap(); - let tools = load_tools_from_source_with_tier( - HISTORY_PLUGIN, - &plugin_path, - ®istry, - crate::config::SandboxTier::Unsandboxed, - ) + let tools = load_tools_from_source(HISTORY_PLUGIN, &plugin_path, ®istry, &config_with_tier(plugin_path.as_str(), 2)) .unwrap(); let tein_ctx = TeinHookContext { @@ -2858,7 +2799,7 @@ echo 'OK' #[cfg(feature = "synthesised-tools")] async fn test_history_pruning() { use crate::tools::registry::ToolRegistry; - use crate::tools::synthesised::load_tools_from_source_with_tier; + use crate::tools::synthesised::load_tools_from_source; use crate::vfs::{VfsCaller, VfsPath}; use std::sync::{Arc, RwLock}; @@ -2867,12 +2808,7 @@ echo 'OK' let registry = Arc::new(RwLock::new(ToolRegistry::new())); let plugin_path = VfsPath::new("/tools/shared/history.scm").unwrap(); - let tools = load_tools_from_source_with_tier( - HISTORY_PLUGIN, - &plugin_path, - ®istry, - crate::config::SandboxTier::Unsandboxed, - ) + let tools = load_tools_from_source(HISTORY_PLUGIN, &plugin_path, ®istry, &config_with_tier(plugin_path.as_str(), 2)) .unwrap(); let tein_ctx = TeinHookContext { @@ -2931,7 +2867,7 @@ echo 'OK' #[cfg(feature = "synthesised-tools")] async fn test_history_diff_tool() { use crate::tools::registry::{ToolCallContext, ToolRegistry}; - use crate::tools::synthesised::load_tools_from_source_with_tier; + use crate::tools::synthesised::load_tools_from_source; use crate::vfs::{VfsCaller, VfsPath}; use std::sync::{Arc, RwLock}; @@ -2948,12 +2884,7 @@ echo 'OK' // Load history plugin let registry = Arc::new(RwLock::new(ToolRegistry::new())); let plugin_path = VfsPath::new("/tools/shared/history.scm").unwrap(); - let tools = load_tools_from_source_with_tier( - HISTORY_PLUGIN, - &plugin_path, - ®istry, - crate::config::SandboxTier::Unsandboxed, - ) + let tools = load_tools_from_source(HISTORY_PLUGIN, &plugin_path, ®istry, &config_with_tier(plugin_path.as_str(), 2)) .unwrap(); // Register tools so dispatch_impl can find them diff --git a/crates/chibi-core/src/tools/synthesised.rs b/crates/chibi-core/src/tools/synthesised.rs index e72ad9fdc..2e6bca95c 100644 --- a/crates/chibi-core/src/tools/synthesised.rs +++ b/crates/chibi-core/src/tools/synthesised.rs @@ -1038,28 +1038,19 @@ pub(crate) const EVAL_PRELUDE: &str = r#" /// /// Registers `(harness tools)` and `call-tool` in every context. #[cfg(feature = "synthesised-tools")] -pub fn load_tools_from_source( - source: &str, - vfs_path: &VfsPath, - registry: &Arc>, -) -> io::Result> { - load_tools_from_source_with_tier( - source, - vfs_path, - registry, - crate::config::SandboxTier::Sandboxed, - ) -} - -/// Like `load_tools_from_source` but with an explicit sandbox tier. +/// Load one or more synthesised tools from scheme source. +/// +/// Resolves sandbox tier from `tools_config` using longest-prefix match on +/// `vfs_path`. Evaluates `source` in a tein context configured accordingly. #[cfg(feature = "synthesised-tools")] -pub fn load_tools_from_source_with_tier( +pub fn load_tools_from_source( source: &str, vfs_path: &VfsPath, registry: &Arc>, - tier: crate::config::SandboxTier, + tools_config: &crate::config::ToolsConfig, ) -> io::Result> { let source_owned = source.to_string(); + let tier = tools_config.resolve_tier(vfs_path.as_str()); let (session, worker_thread_id) = build_tein_context(source_owned, tier)?; @@ -1079,6 +1070,7 @@ pub fn load_tools_from_source_with_tier( /// Load a single synthesised tool from scheme source (convenience wrapper). /// +/// Uses `ToolsConfig::default()` (sandboxed tier, no HTTP/env overrides). /// Calls `load_tools_from_source` and expects exactly one tool. Returns an /// error if the source defines multiple tools via `define-tool`. #[cfg(feature = "synthesised-tools")] @@ -1087,7 +1079,12 @@ pub fn load_tool_from_source( vfs_path: &VfsPath, registry: &Arc>, ) -> io::Result { - let mut tools = load_tools_from_source(source, vfs_path, registry)?; + let mut tools = load_tools_from_source( + source, + vfs_path, + registry, + &crate::config::ToolsConfig::default(), + )?; match tools.len() { 1 => Ok(tools.remove(0)), n => Err(io::Error::new( @@ -1456,8 +1453,7 @@ async fn scan_zone( let Ok(source_str) = String::from_utf8(source) else { continue; }; - let tier = tools_config.resolve_tier(file_path.as_str()); - if let Ok(tools) = load_tools_from_source_with_tier(&source_str, &file_path, registry, tier) + if let Ok(tools) = load_tools_from_source(&source_str, &file_path, registry, tools_config) { let mut reg = registry.write().unwrap(); for tool in tools { @@ -1492,8 +1488,7 @@ pub fn reload_tool_from_content( let Ok(source_str) = std::str::from_utf8(content) else { return; }; - let tier = tools_config.resolve_tier(path.as_str()); - if let Ok(tools) = load_tools_from_source_with_tier(source_str, path, registry, tier) { + if let Ok(tools) = load_tools_from_source(source_str, path, registry, tools_config) { let mut reg = registry.write().unwrap(); // unregister all previous tools from this path let old_names = reg.find_all_by_vfs_path(path); @@ -1882,6 +1877,16 @@ mod tests { Arc::new(RwLock::new(ToolRegistry::new())) } + /// Build a `ToolsConfig` that maps `vfs_path` to the given tier. + fn config_with_tier(vfs_path: &str, tier: u8) -> crate::config::ToolsConfig { + let mut tiers = std::collections::HashMap::new(); + tiers.insert(vfs_path.to_string(), tier); + crate::config::ToolsConfig { + tiers: Some(tiers), + ..Default::default() + } + } + #[test] fn test_convention_category() { let source = r#" @@ -1894,12 +1899,7 @@ mod tests { "#; let path = VfsPath::new("/tools/shared/conv_cat.scm").unwrap(); let registry = make_registry(); - let tools = load_tools_from_source_with_tier( - source, - &path, - ®istry, - crate::config::SandboxTier::Sandboxed, - ) + let tools = load_tools_from_source(source, &path, ®istry, &crate::config::ToolsConfig::default()) .unwrap(); assert_eq!(tools[0].category, ToolCategory::Network); } @@ -1916,12 +1916,7 @@ mod tests { "#; let path = VfsPath::new("/tools/shared/conv_sp.scm").unwrap(); let registry = make_registry(); - let tools = load_tools_from_source_with_tier( - source, - &path, - ®istry, - crate::config::SandboxTier::Sandboxed, - ) + let tools = load_tools_from_source(source, &path, ®istry, &crate::config::ToolsConfig::default()) .unwrap(); assert_eq!(tools[0].summary_params, vec!["path", "mode"]); } @@ -1937,12 +1932,7 @@ mod tests { "#; let path = VfsPath::new("/tools/shared/plain.scm").unwrap(); let registry = make_registry(); - let tools = load_tools_from_source_with_tier( - source, - &path, - ®istry, - crate::config::SandboxTier::Sandboxed, - ) + let tools = load_tools_from_source(source, &path, ®istry, &crate::config::ToolsConfig::default()) .unwrap(); assert_eq!(tools[0].category, ToolCategory::Synthesised); assert!(tools[0].summary_params.is_empty()); @@ -1960,12 +1950,7 @@ mod tests { "#; let path = VfsPath::new("/tools/shared/cat.scm").unwrap(); let registry = make_registry(); - let tools = load_tools_from_source_with_tier( - source, - &path, - ®istry, - crate::config::SandboxTier::Sandboxed, - ) + let tools = load_tools_from_source(source, &path, ®istry, &crate::config::ToolsConfig::default()) .unwrap(); assert_eq!(tools.len(), 1); assert_eq!(tools[0].category, ToolCategory::Network); @@ -1983,12 +1968,7 @@ mod tests { "#; let path = VfsPath::new("/tools/shared/sp.scm").unwrap(); let registry = make_registry(); - let tools = load_tools_from_source_with_tier( - source, - &path, - ®istry, - crate::config::SandboxTier::Sandboxed, - ) + let tools = load_tools_from_source(source, &path, ®istry, &crate::config::ToolsConfig::default()) .unwrap(); assert_eq!(tools.len(), 1); assert_eq!(tools[0].summary_params, vec!["ticker", "qty"]); @@ -2007,12 +1987,7 @@ mod tests { "#; let path = VfsPath::new("/tools/shared/both.scm").unwrap(); let registry = make_registry(); - let tools = load_tools_from_source_with_tier( - source, - &path, - ®istry, - crate::config::SandboxTier::Sandboxed, - ) + let tools = load_tools_from_source(source, &path, ®istry, &crate::config::ToolsConfig::default()) .unwrap(); assert_eq!(tools.len(), 1); assert_eq!(tools[0].category, ToolCategory::Network); @@ -2031,12 +2006,7 @@ mod tests { "#; let path = VfsPath::new("/tools/shared/unk.scm").unwrap(); let registry = make_registry(); - let tools = load_tools_from_source_with_tier( - source, - &path, - ®istry, - crate::config::SandboxTier::Sandboxed, - ) + let tools = load_tools_from_source(source, &path, ®istry, &crate::config::ToolsConfig::default()) .unwrap(); assert_eq!(tools[0].category, ToolCategory::Synthesised); } @@ -2058,12 +2028,7 @@ mod tests { "#; let path = VfsPath::new("/tools/shared/multi.scm").unwrap(); let registry = make_registry(); - let tools = load_tools_from_source_with_tier( - source, - &path, - ®istry, - crate::config::SandboxTier::Sandboxed, - ) + let tools = load_tools_from_source(source, &path, ®istry, &crate::config::ToolsConfig::default()) .unwrap(); assert_eq!(tools.len(), 2); assert_eq!(tools[0].category, ToolCategory::FsRead); @@ -2590,7 +2555,7 @@ mod tests { fn test_load_multiple_tools_from_define_tool() { let registry = make_registry(); let vfs_path = VfsPath::new("/tools/shared/multi.scm").unwrap(); - let tools = load_tools_from_source(MULTI_TOOL_SOURCE, &vfs_path, ®istry).unwrap(); + let tools = load_tools_from_source(MULTI_TOOL_SOURCE, &vfs_path, ®istry, &crate::config::ToolsConfig::default()).unwrap(); assert_eq!(tools.len(), 2); assert_eq!(tools[0].name, "greet"); assert_eq!(tools[1].name, "farewell"); @@ -2600,7 +2565,7 @@ mod tests { fn test_load_tools_backwards_compat_single_tool() { let registry = make_registry(); let vfs_path = VfsPath::new("/tools/shared/old.scm").unwrap(); - let tools = load_tools_from_source(SCAN_TOOL, &vfs_path, ®istry).unwrap(); + let tools = load_tools_from_source(SCAN_TOOL, &vfs_path, ®istry, &crate::config::ToolsConfig::default()).unwrap(); assert_eq!(tools.len(), 1); assert_eq!(tools[0].name, "scan_hello"); } @@ -2661,12 +2626,7 @@ mod tests { "#; let vfs_path = VfsPath::new("/tools/shared/bad.scm").unwrap(); let registry = make_registry(); - let result = load_tools_from_source_with_tier( - source, - &vfs_path, - ®istry, - crate::config::SandboxTier::Sandboxed, - ); + let result = load_tools_from_source(source, &vfs_path, ®istry, &crate::config::ToolsConfig::default()); assert!( result.is_err(), "sandboxed tier should reject (scheme regex)" @@ -2685,12 +2645,7 @@ mod tests { let vfs_path = VfsPath::new("/tools/shared/full.scm").unwrap(); let registry = make_registry(); // tier 2 — no sandboxing; just verify it loads without error - let result = load_tools_from_source_with_tier( - source, - &vfs_path, - ®istry, - crate::config::SandboxTier::Unsandboxed, - ); + let result = load_tools_from_source(source, &vfs_path, ®istry, &config_with_tier(vfs_path.as_str(), 2)); assert!(result.is_ok(), "unsandboxed tier should allow loading"); } @@ -2722,7 +2677,7 @@ mod tests { fn test_tasks_plugin_loads() { let registry = make_registry(); let path = VfsPath::new("/tools/shared/tasks.scm").unwrap(); - let tools = load_tools_from_source(TASKS_PLUGIN, &path, ®istry).unwrap(); + let tools = load_tools_from_source(TASKS_PLUGIN, &path, ®istry, &crate::config::ToolsConfig::default()).unwrap(); let names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect(); for expected in &[ "task_create", @@ -2748,12 +2703,7 @@ mod tests { fn test_history_plugin_loads() { let registry = make_registry(); let path = VfsPath::new("/tools/shared/history.scm").unwrap(); - let tools = load_tools_from_source_with_tier( - HISTORY_PLUGIN, - &path, - ®istry, - crate::config::SandboxTier::Unsandboxed, - ) + let tools = load_tools_from_source(HISTORY_PLUGIN, &path, ®istry, &config_with_tier(path.as_str(), 2)) .unwrap(); let names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect(); @@ -2785,12 +2735,7 @@ mod tests { fn test_history_plugin_registers_hook() { let registry = make_registry(); let path = VfsPath::new("/tools/shared/history.scm").unwrap(); - let tools = load_tools_from_source_with_tier( - HISTORY_PLUGIN, - &path, - ®istry, - crate::config::SandboxTier::Unsandboxed, - ) + let tools = load_tools_from_source(HISTORY_PLUGIN, &path, ®istry, &config_with_tier(path.as_str(), 2)) .unwrap(); // At least one tool should carry the pre_vfs_write hook binding. @@ -2862,7 +2807,7 @@ mod tests { (define (tool-execute args) %context-name%) "#; let path = VfsPath::new("/tools/shared/ctx_test.scm").unwrap(); - let tools = load_tools_from_source(source, &path, ®istry).unwrap(); + let tools = load_tools_from_source(source, &path, ®istry, &crate::config::ToolsConfig::default()).unwrap(); { let mut reg = registry.write().unwrap(); for t in tools { @@ -2888,7 +2833,7 @@ mod tests { let (chibi, _tmp) = create_test_chibi(); let registry = chibi.registry.clone(); let path = VfsPath::new("/tools/shared/tasks.scm").unwrap(); - let tools = load_tools_from_source(TASKS_PLUGIN, &path, ®istry).unwrap(); + let tools = load_tools_from_source(TASKS_PLUGIN, &path, ®istry, &crate::config::ToolsConfig::default()).unwrap(); { let mut reg = registry.write().unwrap(); for t in tools { @@ -3016,12 +2961,7 @@ mod tests { "#; let registry = Arc::new(RwLock::new(ToolRegistry::new())); let path = VfsPath::new("/tools/shared/test-hooks.scm").unwrap(); - let tools = load_tools_from_source_with_tier( - source, - &path, - ®istry, - crate::config::SandboxTier::Sandboxed, - ) + let tools = load_tools_from_source(source, &path, ®istry, &crate::config::ToolsConfig::default()) .unwrap(); assert_eq!(tools.len(), 1); @@ -3063,12 +3003,7 @@ mod tests { "#; let registry = Arc::new(RwLock::new(ToolRegistry::new())); let path = VfsPath::new("/tools/shared/test-invalid.scm").unwrap(); - let tools = load_tools_from_source_with_tier( - source, - &path, - ®istry, - crate::config::SandboxTier::Sandboxed, - ) + let tools = load_tools_from_source(source, &path, ®istry, &crate::config::ToolsConfig::default()) .unwrap(); assert_eq!(tools.len(), 1); @@ -3101,7 +3036,7 @@ mod tests { (define (tool-execute args) (error "intentional boom")) "#; let path = VfsPath::new("/tools/shared/error_test.scm").unwrap(); - let tools = load_tools_from_source(source, &path, ®istry).unwrap(); + let tools = load_tools_from_source(source, &path, ®istry, &crate::config::ToolsConfig::default()).unwrap(); { let mut reg = registry.write().unwrap(); for t in tools { @@ -3144,11 +3079,11 @@ mod tests { source: &str, ) -> String { let path = VfsPath::new("/tools/shared/io_test.scm").unwrap(); - let tools = load_tools_from_source_with_tier( + let tools = load_tools_from_source( source, &path, registry, - crate::config::SandboxTier::Unsandboxed, + &config_with_tier(path.as_str(), 2), ) .unwrap(); { @@ -3406,12 +3341,7 @@ mod tests { "#; let vfs_path = VfsPath::new("/tools/shared/io_test.scm").unwrap(); let registry = make_registry(); - let result = load_tools_from_source_with_tier( - source, - &vfs_path, - ®istry, - crate::config::SandboxTier::Sandboxed, - ); + let result = load_tools_from_source(source, &vfs_path, ®istry, &crate::config::ToolsConfig::default()); assert!( result.is_err(), "sandboxed tier should not have (harness io)" @@ -3431,12 +3361,7 @@ mod tests { "#; let vfs_path = VfsPath::new("/tools/shared/io_test.scm").unwrap(); let registry = make_registry(); - let result = load_tools_from_source_with_tier( - source, - &vfs_path, - ®istry, - crate::config::SandboxTier::Unsandboxed, - ); + let result = load_tools_from_source(source, &vfs_path, ®istry, &config_with_tier(vfs_path.as_str(), 2)); assert!( result.is_ok(), "unsandboxed tier should have (harness io): {:?}", @@ -3465,12 +3390,7 @@ mod tests { "#; let registry = Arc::new(RwLock::new(ToolRegistry::new())); let path = VfsPath::new("/tools/shared/multi-hooks.scm").unwrap(); - let tools = load_tools_from_source_with_tier( - source, - &path, - ®istry, - crate::config::SandboxTier::Sandboxed, - ) + let tools = load_tools_from_source(source, &path, ®istry, &crate::config::ToolsConfig::default()) .unwrap(); assert_eq!(tools.len(), 2); From 82e53b6cfbb59ab76a4850402393595bd1d2f386 Mon Sep 17 00:00:00 2001 From: fey Date: Wed, 18 Mar 2026 20:12:36 +0000 Subject: [PATCH 09/20] fmt --- crates/chibi-core/src/api/send.rs | 4 +- crates/chibi-core/src/tools/hooks.rs | 112 ++++++++++-- crates/chibi-core/src/tools/synthesised.rs | 194 ++++++++++++++++----- 3 files changed, 249 insertions(+), 61 deletions(-) diff --git a/crates/chibi-core/src/api/send.rs b/crates/chibi-core/src/api/send.rs index 64b06fde5..78ff004ba 100644 --- a/crates/chibi-core/src/api/send.rs +++ b/crates/chibi-core/src/api/send.rs @@ -1139,7 +1139,9 @@ async fn execute_tool_pure( } else { let safety = tools::classify_url(url); if let Some(ref policy) = resolved_config.url_policy { - if tools::evaluate_url_policy(url, &safety, policy) == tools::UrlAction::Deny { + if tools::evaluate_url_policy(url, &safety, policy) + == tools::UrlAction::Deny + { let reason = match &safety { tools::UrlSafety::Sensitive(cat) => cat.to_string(), tools::UrlSafety::Safe => "denied by URL policy".to_string(), diff --git a/crates/chibi-core/src/tools/hooks.rs b/crates/chibi-core/src/tools/hooks.rs index eceee4d1c..f9ef1adbc 100644 --- a/crates/chibi-core/src/tools/hooks.rs +++ b/crates/chibi-core/src/tools/hooks.rs @@ -2058,7 +2058,12 @@ echo 'OK' "#; let registry = Arc::new(RwLock::new(ToolRegistry::new())); let path = VfsPath::new("/tools/shared/hook-test.scm").unwrap(); - let tools = load_tools_from_source(source, &path, ®istry, &crate::config::ToolsConfig::default()) + let tools = load_tools_from_source( + source, + &path, + ®istry, + &crate::config::ToolsConfig::default(), + ) .unwrap(); let data = serde_json::json!({"event": "start"}); @@ -2130,7 +2135,12 @@ echo 'OK' "#; let registry = Arc::new(RwLock::new(ToolRegistry::new())); let path = VfsPath::new("/tools/shared/noop.scm").unwrap(); - let tools = load_tools_from_source(source, &path, ®istry, &crate::config::ToolsConfig::default()) + let tools = load_tools_from_source( + source, + &path, + ®istry, + &crate::config::ToolsConfig::default(), + ) .unwrap(); let results = @@ -2162,7 +2172,12 @@ echo 'OK' "#; let registry = Arc::new(RwLock::new(ToolRegistry::new())); let path = VfsPath::new("/tools/shared/modify.scm").unwrap(); - let tools = load_tools_from_source(source, &path, ®istry, &crate::config::ToolsConfig::default()) + let tools = load_tools_from_source( + source, + &path, + ®istry, + &crate::config::ToolsConfig::default(), + ) .unwrap(); let results = execute_hook( @@ -2195,7 +2210,12 @@ echo 'OK' "#; let registry = Arc::new(RwLock::new(ToolRegistry::new())); let path = VfsPath::new("/tools/shared/selective.scm").unwrap(); - let tools = load_tools_from_source(source, &path, ®istry, &crate::config::ToolsConfig::default()) + let tools = load_tools_from_source( + source, + &path, + ®istry, + &crate::config::ToolsConfig::default(), + ) .unwrap(); // fire on_end — tool is registered for on_start only @@ -2221,7 +2241,12 @@ echo 'OK' "#; let registry = Arc::new(RwLock::new(ToolRegistry::new())); let path = VfsPath::new("/tools/shared/error.scm").unwrap(); - let tools = load_tools_from_source(source, &path, ®istry, &crate::config::ToolsConfig::default()) + let tools = load_tools_from_source( + source, + &path, + ®istry, + &crate::config::ToolsConfig::default(), + ) .unwrap(); // should not error — failed hooks are skipped silently @@ -2268,7 +2293,12 @@ echo 'OK' "#; let registry = Arc::new(RwLock::new(ToolRegistry::new())); let path = VfsPath::new("/tools/shared/tein.scm").unwrap(); - let mut tools = load_tools_from_source(source, &path, ®istry, &crate::config::ToolsConfig::default()) + let mut tools = load_tools_from_source( + source, + &path, + ®istry, + &crate::config::ToolsConfig::default(), + ) .unwrap(); tools.insert(0, plugin_tool); @@ -2304,7 +2334,12 @@ echo 'OK' "#; let registry = Arc::new(RwLock::new(ToolRegistry::new())); let path = VfsPath::new("/tools/shared/reentrancy.scm").unwrap(); - let tools = load_tools_from_source(source, &path, ®istry, &crate::config::ToolsConfig::default()) + let tools = load_tools_from_source( + source, + &path, + ®istry, + &crate::config::ToolsConfig::default(), + ) .unwrap(); // Simulate re-entrancy: mark on_start as already-in-progress @@ -2347,7 +2382,12 @@ echo 'OK' "#; let registry = Arc::new(RwLock::new(ToolRegistry::new())); let path = VfsPath::new("/tools/shared/guard-cleanup.scm").unwrap(); - let tools = load_tools_from_source(source, &path, ®istry, &crate::config::ToolsConfig::default()) + let tools = load_tools_from_source( + source, + &path, + ®istry, + &crate::config::ToolsConfig::default(), + ) .unwrap(); // First call — fires normally @@ -2454,7 +2494,12 @@ echo 'OK' "#; let registry = Arc::new(RwLock::new(ToolRegistry::new())); let path = VfsPath::new("/tools/shared/bridge-test.scm").unwrap(); - let tools = load_tools_from_source(source, &path, ®istry, &config_with_tier(path.as_str(), 2)) + let tools = load_tools_from_source( + source, + &path, + ®istry, + &config_with_tier(path.as_str(), 2), + ) .unwrap(); let tein_ctx = TeinHookContext { @@ -2522,7 +2567,12 @@ echo 'OK' "#; let registry = Arc::new(RwLock::new(ToolRegistry::new())); let path = VfsPath::new("/tools/shared/no-bridge-test.scm").unwrap(); - let tools = load_tools_from_source(source, &path, ®istry, &config_with_tier(path.as_str(), 2)) + let tools = load_tools_from_source( + source, + &path, + ®istry, + &config_with_tier(path.as_str(), 2), + ) .unwrap(); // No TeinHookContext → bridge not set @@ -2566,7 +2616,12 @@ echo 'OK' "#; let registry = Arc::new(RwLock::new(ToolRegistry::new())); let path = VfsPath::new("/tools/shared/io-hook-test.scm").unwrap(); - let tools = load_tools_from_source(source, &path, ®istry, &config_with_tier(path.as_str(), 2)) + let tools = load_tools_from_source( + source, + &path, + ®istry, + &config_with_tier(path.as_str(), 2), + ) .unwrap(); let tein_ctx = TeinHookContext { @@ -2633,7 +2688,12 @@ echo 'OK' "#; let registry = Arc::new(RwLock::new(ToolRegistry::new())); let path = VfsPath::new("/tools/shared/counter-hook-test.scm").unwrap(); - let tools = load_tools_from_source(source, &path, ®istry, &config_with_tier(path.as_str(), 2)) + let tools = load_tools_from_source( + source, + &path, + ®istry, + &config_with_tier(path.as_str(), 2), + ) .unwrap(); let tein_ctx = TeinHookContext { @@ -2709,7 +2769,12 @@ echo 'OK' "#; let registry = Arc::new(RwLock::new(ToolRegistry::new())); let plugin_path = VfsPath::new("/tools/shared/hook-test.scm").unwrap(); - let tools = load_tools_from_source(source, &plugin_path, ®istry, &config_with_tier(plugin_path.as_str(), 2)) + let tools = load_tools_from_source( + source, + &plugin_path, + ®istry, + &config_with_tier(plugin_path.as_str(), 2), + ) .unwrap(); let tein_ctx = TeinHookContext { @@ -2754,7 +2819,12 @@ echo 'OK' // Load history plugin let registry = Arc::new(RwLock::new(ToolRegistry::new())); let plugin_path = VfsPath::new("/tools/shared/history.scm").unwrap(); - let tools = load_tools_from_source(HISTORY_PLUGIN, &plugin_path, ®istry, &config_with_tier(plugin_path.as_str(), 2)) + let tools = load_tools_from_source( + HISTORY_PLUGIN, + &plugin_path, + ®istry, + &config_with_tier(plugin_path.as_str(), 2), + ) .unwrap(); let tein_ctx = TeinHookContext { @@ -2808,7 +2878,12 @@ echo 'OK' let registry = Arc::new(RwLock::new(ToolRegistry::new())); let plugin_path = VfsPath::new("/tools/shared/history.scm").unwrap(); - let tools = load_tools_from_source(HISTORY_PLUGIN, &plugin_path, ®istry, &config_with_tier(plugin_path.as_str(), 2)) + let tools = load_tools_from_source( + HISTORY_PLUGIN, + &plugin_path, + ®istry, + &config_with_tier(plugin_path.as_str(), 2), + ) .unwrap(); let tein_ctx = TeinHookContext { @@ -2884,7 +2959,12 @@ echo 'OK' // Load history plugin let registry = Arc::new(RwLock::new(ToolRegistry::new())); let plugin_path = VfsPath::new("/tools/shared/history.scm").unwrap(); - let tools = load_tools_from_source(HISTORY_PLUGIN, &plugin_path, ®istry, &config_with_tier(plugin_path.as_str(), 2)) + let tools = load_tools_from_source( + HISTORY_PLUGIN, + &plugin_path, + ®istry, + &config_with_tier(plugin_path.as_str(), 2), + ) .unwrap(); // Register tools so dispatch_impl can find them diff --git a/crates/chibi-core/src/tools/synthesised.rs b/crates/chibi-core/src/tools/synthesised.rs index 2e6bca95c..4d703e189 100644 --- a/crates/chibi-core/src/tools/synthesised.rs +++ b/crates/chibi-core/src/tools/synthesised.rs @@ -1026,22 +1026,12 @@ pub(crate) const EVAL_PRELUDE: &str = r#" (define inexact->exact exact) "#; -/// Load one or more synthesised tools from scheme source. -/// -/// If the source uses `(define-tool ...)` macro, returns all defined tools. -/// If it uses the convention format (`tool-name`, `tool-description`, etc.), -/// returns a single tool. Backwards-compatible with both formats. -/// -/// Evaluates `source` in a tein context configured by `tier`: -/// - `SandboxTier::Sandboxed` (default): safe module subset, step limit. -/// - `SandboxTier::Unsandboxed`: full R7RS, no step limit (trusted tools). -/// -/// Registers `(harness tools)` and `call-tool` in every context. -#[cfg(feature = "synthesised-tools")] /// Load one or more synthesised tools from scheme source. /// /// Resolves sandbox tier from `tools_config` using longest-prefix match on /// `vfs_path`. Evaluates `source` in a tein context configured accordingly. +/// Supports both convention format (`tool-name`, `tool-execute`, etc.) and +/// `define-tool` multi-tool format; backwards-compatible with both. #[cfg(feature = "synthesised-tools")] pub fn load_tools_from_source( source: &str, @@ -1453,8 +1443,7 @@ async fn scan_zone( let Ok(source_str) = String::from_utf8(source) else { continue; }; - if let Ok(tools) = load_tools_from_source(&source_str, &file_path, registry, tools_config) - { + if let Ok(tools) = load_tools_from_source(&source_str, &file_path, registry, tools_config) { let mut reg = registry.write().unwrap(); for tool in tools { reg.register(tool); @@ -1899,7 +1888,12 @@ mod tests { "#; let path = VfsPath::new("/tools/shared/conv_cat.scm").unwrap(); let registry = make_registry(); - let tools = load_tools_from_source(source, &path, ®istry, &crate::config::ToolsConfig::default()) + let tools = load_tools_from_source( + source, + &path, + ®istry, + &crate::config::ToolsConfig::default(), + ) .unwrap(); assert_eq!(tools[0].category, ToolCategory::Network); } @@ -1916,7 +1910,12 @@ mod tests { "#; let path = VfsPath::new("/tools/shared/conv_sp.scm").unwrap(); let registry = make_registry(); - let tools = load_tools_from_source(source, &path, ®istry, &crate::config::ToolsConfig::default()) + let tools = load_tools_from_source( + source, + &path, + ®istry, + &crate::config::ToolsConfig::default(), + ) .unwrap(); assert_eq!(tools[0].summary_params, vec!["path", "mode"]); } @@ -1932,7 +1931,12 @@ mod tests { "#; let path = VfsPath::new("/tools/shared/plain.scm").unwrap(); let registry = make_registry(); - let tools = load_tools_from_source(source, &path, ®istry, &crate::config::ToolsConfig::default()) + let tools = load_tools_from_source( + source, + &path, + ®istry, + &crate::config::ToolsConfig::default(), + ) .unwrap(); assert_eq!(tools[0].category, ToolCategory::Synthesised); assert!(tools[0].summary_params.is_empty()); @@ -1950,7 +1954,12 @@ mod tests { "#; let path = VfsPath::new("/tools/shared/cat.scm").unwrap(); let registry = make_registry(); - let tools = load_tools_from_source(source, &path, ®istry, &crate::config::ToolsConfig::default()) + let tools = load_tools_from_source( + source, + &path, + ®istry, + &crate::config::ToolsConfig::default(), + ) .unwrap(); assert_eq!(tools.len(), 1); assert_eq!(tools[0].category, ToolCategory::Network); @@ -1968,7 +1977,12 @@ mod tests { "#; let path = VfsPath::new("/tools/shared/sp.scm").unwrap(); let registry = make_registry(); - let tools = load_tools_from_source(source, &path, ®istry, &crate::config::ToolsConfig::default()) + let tools = load_tools_from_source( + source, + &path, + ®istry, + &crate::config::ToolsConfig::default(), + ) .unwrap(); assert_eq!(tools.len(), 1); assert_eq!(tools[0].summary_params, vec!["ticker", "qty"]); @@ -1987,7 +2001,12 @@ mod tests { "#; let path = VfsPath::new("/tools/shared/both.scm").unwrap(); let registry = make_registry(); - let tools = load_tools_from_source(source, &path, ®istry, &crate::config::ToolsConfig::default()) + let tools = load_tools_from_source( + source, + &path, + ®istry, + &crate::config::ToolsConfig::default(), + ) .unwrap(); assert_eq!(tools.len(), 1); assert_eq!(tools[0].category, ToolCategory::Network); @@ -2006,7 +2025,12 @@ mod tests { "#; let path = VfsPath::new("/tools/shared/unk.scm").unwrap(); let registry = make_registry(); - let tools = load_tools_from_source(source, &path, ®istry, &crate::config::ToolsConfig::default()) + let tools = load_tools_from_source( + source, + &path, + ®istry, + &crate::config::ToolsConfig::default(), + ) .unwrap(); assert_eq!(tools[0].category, ToolCategory::Synthesised); } @@ -2028,7 +2052,12 @@ mod tests { "#; let path = VfsPath::new("/tools/shared/multi.scm").unwrap(); let registry = make_registry(); - let tools = load_tools_from_source(source, &path, ®istry, &crate::config::ToolsConfig::default()) + let tools = load_tools_from_source( + source, + &path, + ®istry, + &crate::config::ToolsConfig::default(), + ) .unwrap(); assert_eq!(tools.len(), 2); assert_eq!(tools[0].category, ToolCategory::FsRead); @@ -2555,7 +2584,13 @@ mod tests { fn test_load_multiple_tools_from_define_tool() { let registry = make_registry(); let vfs_path = VfsPath::new("/tools/shared/multi.scm").unwrap(); - let tools = load_tools_from_source(MULTI_TOOL_SOURCE, &vfs_path, ®istry, &crate::config::ToolsConfig::default()).unwrap(); + let tools = load_tools_from_source( + MULTI_TOOL_SOURCE, + &vfs_path, + ®istry, + &crate::config::ToolsConfig::default(), + ) + .unwrap(); assert_eq!(tools.len(), 2); assert_eq!(tools[0].name, "greet"); assert_eq!(tools[1].name, "farewell"); @@ -2565,7 +2600,13 @@ mod tests { fn test_load_tools_backwards_compat_single_tool() { let registry = make_registry(); let vfs_path = VfsPath::new("/tools/shared/old.scm").unwrap(); - let tools = load_tools_from_source(SCAN_TOOL, &vfs_path, ®istry, &crate::config::ToolsConfig::default()).unwrap(); + let tools = load_tools_from_source( + SCAN_TOOL, + &vfs_path, + ®istry, + &crate::config::ToolsConfig::default(), + ) + .unwrap(); assert_eq!(tools.len(), 1); assert_eq!(tools[0].name, "scan_hello"); } @@ -2626,7 +2667,12 @@ mod tests { "#; let vfs_path = VfsPath::new("/tools/shared/bad.scm").unwrap(); let registry = make_registry(); - let result = load_tools_from_source(source, &vfs_path, ®istry, &crate::config::ToolsConfig::default()); + let result = load_tools_from_source( + source, + &vfs_path, + ®istry, + &crate::config::ToolsConfig::default(), + ); assert!( result.is_err(), "sandboxed tier should reject (scheme regex)" @@ -2645,7 +2691,12 @@ mod tests { let vfs_path = VfsPath::new("/tools/shared/full.scm").unwrap(); let registry = make_registry(); // tier 2 — no sandboxing; just verify it loads without error - let result = load_tools_from_source(source, &vfs_path, ®istry, &config_with_tier(vfs_path.as_str(), 2)); + let result = load_tools_from_source( + source, + &vfs_path, + ®istry, + &config_with_tier(vfs_path.as_str(), 2), + ); assert!(result.is_ok(), "unsandboxed tier should allow loading"); } @@ -2677,7 +2728,13 @@ mod tests { fn test_tasks_plugin_loads() { let registry = make_registry(); let path = VfsPath::new("/tools/shared/tasks.scm").unwrap(); - let tools = load_tools_from_source(TASKS_PLUGIN, &path, ®istry, &crate::config::ToolsConfig::default()).unwrap(); + let tools = load_tools_from_source( + TASKS_PLUGIN, + &path, + ®istry, + &crate::config::ToolsConfig::default(), + ) + .unwrap(); let names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect(); for expected in &[ "task_create", @@ -2703,7 +2760,12 @@ mod tests { fn test_history_plugin_loads() { let registry = make_registry(); let path = VfsPath::new("/tools/shared/history.scm").unwrap(); - let tools = load_tools_from_source(HISTORY_PLUGIN, &path, ®istry, &config_with_tier(path.as_str(), 2)) + let tools = load_tools_from_source( + HISTORY_PLUGIN, + &path, + ®istry, + &config_with_tier(path.as_str(), 2), + ) .unwrap(); let names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect(); @@ -2735,7 +2797,12 @@ mod tests { fn test_history_plugin_registers_hook() { let registry = make_registry(); let path = VfsPath::new("/tools/shared/history.scm").unwrap(); - let tools = load_tools_from_source(HISTORY_PLUGIN, &path, ®istry, &config_with_tier(path.as_str(), 2)) + let tools = load_tools_from_source( + HISTORY_PLUGIN, + &path, + ®istry, + &config_with_tier(path.as_str(), 2), + ) .unwrap(); // At least one tool should carry the pre_vfs_write hook binding. @@ -2807,7 +2874,13 @@ mod tests { (define (tool-execute args) %context-name%) "#; let path = VfsPath::new("/tools/shared/ctx_test.scm").unwrap(); - let tools = load_tools_from_source(source, &path, ®istry, &crate::config::ToolsConfig::default()).unwrap(); + let tools = load_tools_from_source( + source, + &path, + ®istry, + &crate::config::ToolsConfig::default(), + ) + .unwrap(); { let mut reg = registry.write().unwrap(); for t in tools { @@ -2833,7 +2906,13 @@ mod tests { let (chibi, _tmp) = create_test_chibi(); let registry = chibi.registry.clone(); let path = VfsPath::new("/tools/shared/tasks.scm").unwrap(); - let tools = load_tools_from_source(TASKS_PLUGIN, &path, ®istry, &crate::config::ToolsConfig::default()).unwrap(); + let tools = load_tools_from_source( + TASKS_PLUGIN, + &path, + ®istry, + &crate::config::ToolsConfig::default(), + ) + .unwrap(); { let mut reg = registry.write().unwrap(); for t in tools { @@ -2961,7 +3040,12 @@ mod tests { "#; let registry = Arc::new(RwLock::new(ToolRegistry::new())); let path = VfsPath::new("/tools/shared/test-hooks.scm").unwrap(); - let tools = load_tools_from_source(source, &path, ®istry, &crate::config::ToolsConfig::default()) + let tools = load_tools_from_source( + source, + &path, + ®istry, + &crate::config::ToolsConfig::default(), + ) .unwrap(); assert_eq!(tools.len(), 1); @@ -3003,7 +3087,12 @@ mod tests { "#; let registry = Arc::new(RwLock::new(ToolRegistry::new())); let path = VfsPath::new("/tools/shared/test-invalid.scm").unwrap(); - let tools = load_tools_from_source(source, &path, ®istry, &crate::config::ToolsConfig::default()) + let tools = load_tools_from_source( + source, + &path, + ®istry, + &crate::config::ToolsConfig::default(), + ) .unwrap(); assert_eq!(tools.len(), 1); @@ -3036,7 +3125,13 @@ mod tests { (define (tool-execute args) (error "intentional boom")) "#; let path = VfsPath::new("/tools/shared/error_test.scm").unwrap(); - let tools = load_tools_from_source(source, &path, ®istry, &crate::config::ToolsConfig::default()).unwrap(); + let tools = load_tools_from_source( + source, + &path, + ®istry, + &crate::config::ToolsConfig::default(), + ) + .unwrap(); { let mut reg = registry.write().unwrap(); for t in tools { @@ -3079,13 +3174,9 @@ mod tests { source: &str, ) -> String { let path = VfsPath::new("/tools/shared/io_test.scm").unwrap(); - let tools = load_tools_from_source( - source, - &path, - registry, - &config_with_tier(path.as_str(), 2), - ) - .unwrap(); + let tools = + load_tools_from_source(source, &path, registry, &config_with_tier(path.as_str(), 2)) + .unwrap(); { let mut reg = registry.write().unwrap(); for t in tools { @@ -3341,7 +3432,12 @@ mod tests { "#; let vfs_path = VfsPath::new("/tools/shared/io_test.scm").unwrap(); let registry = make_registry(); - let result = load_tools_from_source(source, &vfs_path, ®istry, &crate::config::ToolsConfig::default()); + let result = load_tools_from_source( + source, + &vfs_path, + ®istry, + &crate::config::ToolsConfig::default(), + ); assert!( result.is_err(), "sandboxed tier should not have (harness io)" @@ -3361,7 +3457,12 @@ mod tests { "#; let vfs_path = VfsPath::new("/tools/shared/io_test.scm").unwrap(); let registry = make_registry(); - let result = load_tools_from_source(source, &vfs_path, ®istry, &config_with_tier(vfs_path.as_str(), 2)); + let result = load_tools_from_source( + source, + &vfs_path, + ®istry, + &config_with_tier(vfs_path.as_str(), 2), + ); assert!( result.is_ok(), "unsandboxed tier should have (harness io): {:?}", @@ -3390,7 +3491,12 @@ mod tests { "#; let registry = Arc::new(RwLock::new(ToolRegistry::new())); let path = VfsPath::new("/tools/shared/multi-hooks.scm").unwrap(); - let tools = load_tools_from_source(source, &path, ®istry, &crate::config::ToolsConfig::default()) + let tools = load_tools_from_source( + source, + &path, + ®istry, + &crate::config::ToolsConfig::default(), + ) .unwrap(); assert_eq!(tools.len(), 2); From 83fd7ebc3e0dda4d0b624829b015cbe00e341fba Mon Sep 17 00:00:00 2001 From: fey Date: Wed, 18 Mar 2026 20:14:26 +0000 Subject: [PATCH 10/20] feat(config): add HttpConfig, HttpAllow types and ToolsConfig fields (#230) --- crates/chibi-core/Cargo.toml | 2 +- crates/chibi-core/src/config.rs | 45 +++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/crates/chibi-core/Cargo.toml b/crates/chibi-core/Cargo.toml index 7e28ffbb1..d7e1c1a2b 100644 --- a/crates/chibi-core/Cargo.toml +++ b/crates/chibi-core/Cargo.toml @@ -27,7 +27,7 @@ hostname = "0.4" ignore = "0.4" indexmap = { version = "2", features = ["serde"] } ratatoskr.workspace = true -tein = { git = "https://github.com/emesal/tein", branch = "main", features = ["json", "regex"], optional = true } +tein = { git = "https://github.com/emesal/tein", branch = "main", features = ["json", "regex", "http"], optional = true } tein-sexp = { git = "https://github.com/emesal/tein", branch = "main" } url = "2" rusqlite = { version = "0.32", features = ["bundled"] } diff --git a/crates/chibi-core/src/config.rs b/crates/chibi-core/src/config.rs index 91850bca6..0af754644 100644 --- a/crates/chibi-core/src/config.rs +++ b/crates/chibi-core/src/config.rs @@ -430,6 +430,42 @@ pub enum SandboxTier { Unsandboxed, } +/// HTTP access configuration for synthesised tools. +#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] +pub struct HttpConfig { + /// Per-path HTTP prefix allowlists. Longest-prefix match on VFS path. + /// Values are either a list of URL prefixes or the string `"trust-declared"`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub allow: Option>, + /// Global toggle for trusting tool-declared HTTP prefixes. + /// When `true`, tools that declare `tool-http-allow` get those prefixes + /// even without an explicit `[tools.http.allow]` entry. Default: `false`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub trust_declared: Option, +} + +/// Per-path HTTP allowlist entry — either explicit prefixes or trust delegation. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(untagged)] +pub enum HttpAllow { + /// Explicit list of allowed URL prefixes. + Prefixes(Vec), + /// The string `"trust-declared"` — trust the tool's own `tool-http-allow` binding. + TrustDeclared(String), +} + +/// Result of resolving HTTP allowlist for a VFS path. +#[cfg(feature = "synthesised-tools")] +#[derive(Debug, Clone, PartialEq)] +pub enum HttpAllowResult { + /// Explicit config prefixes — use directly. + Prefixes(Vec), + /// Trust-declared applies — caller should read tool's declared prefixes. + NeedDeclared, + /// No HTTP access configured. + NoAccess, +} + /// Tool filtering configuration #[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] pub struct ToolsConfig { @@ -456,6 +492,13 @@ pub struct ToolsConfig { /// ``` #[serde(default, skip_serializing_if = "Option::is_none")] pub tiers: Option>, + /// HTTP access configuration for synthesised tools. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub http: Option, + /// Environment variable forwarding for synthesised tools. + /// Keys are VFS path prefixes, values are lists of env var names. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub env: Option>>, } /// VFS (virtual file system) configuration. @@ -549,6 +592,8 @@ impl ToolsConfig { &local.exclude_categories, ), tiers, + http: self.http.clone(), + env: self.env.clone(), } } From 2e2c91b02e6fda69e055285fe16038596e71af5e Mon Sep 17 00:00:00 2001 From: fey Date: Wed, 18 Mar 2026 20:16:51 +0000 Subject: [PATCH 11/20] feat(config): resolve_http_allow with longest-prefix match (#230) --- crates/chibi-core/src/config.rs | 178 ++++++++++++++++++++++++++++++++ 1 file changed, 178 insertions(+) diff --git a/crates/chibi-core/src/config.rs b/crates/chibi-core/src/config.rs index 0af754644..576bf89b4 100644 --- a/crates/chibi-core/src/config.rs +++ b/crates/chibi-core/src/config.rs @@ -630,6 +630,68 @@ impl ToolsConfig { } SandboxTier::Sandboxed } + + /// Resolve HTTP prefix allowlist for a synthesised tool at the given VFS path. + /// + /// Uses longest-prefix match on `[tools.http.allow]` entries. + /// Returns `HttpAllowResult::Prefixes` for explicit lists, + /// `HttpAllowResult::NeedDeclared` for `"trust-declared"` entries, + /// `HttpAllowResult::NoAccess` when no entry matches. + #[cfg(feature = "synthesised-tools")] + pub fn resolve_http_allow(&self, vfs_path: &str) -> HttpAllowResult { + let http = match &self.http { + Some(h) => h, + None => return HttpAllowResult::NoAccess, + }; + let allow_map = match &http.allow { + Some(m) => m, + None => { + // No per-path entries — check global trust_declared + return if http.trust_declared.unwrap_or(false) { + HttpAllowResult::NeedDeclared + } else { + HttpAllowResult::NoAccess + }; + } + }; + + // Longest-prefix match + let mut best: Option<(&str, &HttpAllow)> = None; + for (pattern, entry) in allow_map { + if vfs_path.starts_with(pattern.as_str()) { + match best { + None => best = Some((pattern, entry)), + Some((prev, _)) if pattern.len() > prev.len() => { + best = Some((pattern, entry)); + } + _ => {} + } + } + } + + match best { + Some((_, HttpAllow::Prefixes(prefixes))) => { + HttpAllowResult::Prefixes(prefixes.clone()) + } + Some((_, HttpAllow::TrustDeclared(s))) if s == "trust-declared" => { + HttpAllowResult::NeedDeclared + } + Some((pattern, HttpAllow::TrustDeclared(s))) => { + eprintln!( + "warning: [tools.http.allow] {pattern:?}: unrecognised value {s:?}, ignoring" + ); + HttpAllowResult::NoAccess + } + None => { + // Check global trust_declared + if http.trust_declared.unwrap_or(false) { + HttpAllowResult::NeedDeclared + } else { + HttpAllowResult::NoAccess + } + } + } + } } /// Known builtin plugin paths that default to unsandboxed tier. @@ -1786,4 +1848,120 @@ mod tests { let config: Config = toml::from_str("[vfs]\nbackend = \"fossil\"").unwrap(); assert_eq!(config.vfs.backend, "fossil"); } + + #[cfg(feature = "synthesised-tools")] + #[test] + fn test_resolve_http_allow_explicit_prefixes() { + let mut allow = std::collections::HashMap::new(); + allow.insert( + "/tools/shared/t212.scm".to_string(), + HttpAllow::Prefixes(vec!["https://demo.trading212.com/".to_string()]), + ); + let config = ToolsConfig { + http: Some(HttpConfig { + allow: Some(allow), + ..Default::default() + }), + ..Default::default() + }; + assert_eq!( + config.resolve_http_allow("/tools/shared/t212.scm"), + HttpAllowResult::Prefixes(vec!["https://demo.trading212.com/".to_string()]), + ); + } + + #[cfg(feature = "synthesised-tools")] + #[test] + fn test_resolve_http_allow_longest_prefix() { + let mut allow = std::collections::HashMap::new(); + allow.insert( + "/tools/shared/".to_string(), + HttpAllow::Prefixes(vec!["https://general.com/".to_string()]), + ); + allow.insert( + "/tools/shared/t212.scm".to_string(), + HttpAllow::Prefixes(vec!["https://specific.com/".to_string()]), + ); + let config = ToolsConfig { + http: Some(HttpConfig { + allow: Some(allow), + ..Default::default() + }), + ..Default::default() + }; + assert_eq!( + config.resolve_http_allow("/tools/shared/t212.scm"), + HttpAllowResult::Prefixes(vec!["https://specific.com/".to_string()]), + ); + } + + #[cfg(feature = "synthesised-tools")] + #[test] + fn test_resolve_http_allow_no_config() { + let config = ToolsConfig::default(); + assert_eq!( + config.resolve_http_allow("/tools/shared/t212.scm"), + HttpAllowResult::NoAccess, + ); + } + + #[cfg(feature = "synthesised-tools")] + #[test] + fn test_resolve_http_allow_trust_declared() { + let mut allow = std::collections::HashMap::new(); + allow.insert( + "/tools/shared/".to_string(), + HttpAllow::TrustDeclared("trust-declared".to_string()), + ); + let config = ToolsConfig { + http: Some(HttpConfig { + allow: Some(allow), + ..Default::default() + }), + ..Default::default() + }; + assert_eq!( + config.resolve_http_allow("/tools/shared/t212.scm"), + HttpAllowResult::NeedDeclared, + ); + } + + #[cfg(feature = "synthesised-tools")] + #[test] + fn test_resolve_http_allow_global_trust_no_path_match() { + let config = ToolsConfig { + http: Some(HttpConfig { + trust_declared: Some(true), + ..Default::default() + }), + ..Default::default() + }; + // no [tools.http.allow] entries at all, but global trust_declared = true + assert_eq!( + config.resolve_http_allow("/tools/shared/t212.scm"), + HttpAllowResult::NeedDeclared, + ); + } + + #[cfg(feature = "synthesised-tools")] + #[test] + fn test_resolve_http_allow_unknown_string() { + let mut allow = std::collections::HashMap::new(); + allow.insert( + "/tools/shared/".to_string(), + HttpAllow::TrustDeclared("typo-declared".to_string()), + ); + let config = ToolsConfig { + http: Some(HttpConfig { + allow: Some(allow), + ..Default::default() + }), + ..Default::default() + }; + // unknown string → treated as None + assert_eq!( + config.resolve_http_allow("/tools/shared/t212.scm"), + HttpAllowResult::NoAccess, + ); + } } From 664167b5705bfdf7ea17c657617126f9148c2777 Mon Sep 17 00:00:00 2001 From: fey Date: Wed, 18 Mar 2026 21:38:44 +0000 Subject: [PATCH 12/20] feat(synthesised): thread http_prefixes through build_tein_context (#230) --- Cargo.lock | 116 ++++++++++++++------- crates/chibi-core/src/tools/synthesised.rs | 35 ++++--- 2 files changed, 101 insertions(+), 50 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5dbd39765..50599e0b8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -267,9 +267,9 @@ dependencies = [ [[package]] name = "aws-sdk-bedrockruntime" -version = "1.127.0" +version = "1.128.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dcd5ccbed3bd50d342077d3f731de46d9608340386c87d07566c4c507891eda" +checksum = "3949d34a5c329ed83e7146d2fc1ffc06473fdc9bcbc5fa3d3534abeb950569c5" dependencies = [ "aws-credential-types", "aws-runtime", @@ -294,9 +294,9 @@ dependencies = [ [[package]] name = "aws-sdk-sso" -version = "1.96.0" +version = "1.97.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f64a6eded248c6b453966e915d32aeddb48ea63ad17932682774eb026fbef5b1" +checksum = "9aadc669e184501caaa6beafb28c6267fc1baef0810fb58f9b205485ca3f2567" dependencies = [ "aws-credential-types", "aws-runtime", @@ -318,9 +318,9 @@ dependencies = [ [[package]] name = "aws-sdk-ssooidc" -version = "1.98.0" +version = "1.99.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db96d720d3c622fcbe08bae1c4b04a72ce6257d8b0584cb5418da00ae20a344f" +checksum = "1342a7db8f358d3de0aed2007a0b54e875458e39848d54cc1d46700b2bfcb0a8" dependencies = [ "aws-credential-types", "aws-runtime", @@ -342,9 +342,9 @@ dependencies = [ [[package]] name = "aws-sdk-sts" -version = "1.100.0" +version = "1.101.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fafbdda43b93f57f699c5dfe8328db590b967b8a820a13ccdd6687355dfcc7ca" +checksum = "ab41ad64e4051ecabeea802d6a17845a91e83287e1dd249e6963ea1ba78c428a" dependencies = [ "aws-credential-types", "aws-runtime", @@ -534,9 +534,9 @@ dependencies = [ [[package]] name = "aws-smithy-types" -version = "1.4.6" +version = "1.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2b1117b3b2bbe166d11199b540ceed0d0f7676e36e7b962b5a437a9971eac75" +checksum = "9d73dbfbaa8e4bc57b9045137680b958d274823509a360abfd8e1d514d40c95c" dependencies = [ "base64-simd", "bytes", @@ -1547,9 +1547,9 @@ checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" [[package]] name = "euclid" -version = "0.22.13" +version = "0.22.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df61bf483e837f88d5c2291dcf55c67be7e676b3a51acc48db3a7b163b91ed63" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" dependencies = [ "num-traits", ] @@ -2517,9 +2517,9 @@ dependencies = [ [[package]] name = "instability" -version = "0.3.11" +version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357b7205c6cd18dd2c86ed312d1e70add149aea98e7ef72b9fdf0270e555c11d" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" dependencies = [ "darling 0.23.0", "indoc", @@ -2735,7 +2735,7 @@ checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" [[package]] name = "llm" version = "1.3.7" -source = "git+https://github.com/emesal/llm.git?branch=emesal%2Fratatoskr#ae011ccae2a417dc93f153399693683cfddb985c" +source = "git+https://github.com/emesal/llm.git?branch=emesal%2Fratatoskr#df06346599830f87290be5291ab5fd4f1b81be42" dependencies = [ "anyhow", "arboard", @@ -4221,6 +4221,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" dependencies = [ "aws-lc-rs", + "log", "once_cell", "ring", "rustls-pki-types", @@ -4953,8 +4954,8 @@ checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" [[package]] name = "tein" -version = "0.2.5" -source = "git+https://github.com/emesal/tein?branch=main#70539929a5327008bb11eb99fca850f0421c0b0f" +version = "0.2.6" +source = "git+https://github.com/emesal/tein?branch=main#2fc0bc481f158ecef51e6b22d062b504a6ee6b14" dependencies = [ "blake3", "cc", @@ -4967,19 +4968,20 @@ dependencies = [ "tein-ext", "tein-macros", "tein-sexp", - "toml 1.0.6+spec-1.1.0", + "toml 1.0.7+spec-1.1.0", + "ureq", "uuid", ] [[package]] name = "tein-ext" -version = "0.2.5" -source = "git+https://github.com/emesal/tein?branch=main#70539929a5327008bb11eb99fca850f0421c0b0f" +version = "0.2.6" +source = "git+https://github.com/emesal/tein?branch=main#2fc0bc481f158ecef51e6b22d062b504a6ee6b14" [[package]] name = "tein-macros" -version = "0.2.5" -source = "git+https://github.com/emesal/tein?branch=main#70539929a5327008bb11eb99fca850f0421c0b0f" +version = "0.2.6" +source = "git+https://github.com/emesal/tein?branch=main#2fc0bc481f158ecef51e6b22d062b504a6ee6b14" dependencies = [ "proc-macro2", "quote", @@ -4988,8 +4990,8 @@ dependencies = [ [[package]] name = "tein-sexp" -version = "0.2.5" -source = "git+https://github.com/emesal/tein?branch=main#70539929a5327008bb11eb99fca850f0421c0b0f" +version = "0.2.6" +source = "git+https://github.com/emesal/tein?branch=main#2fc0bc481f158ecef51e6b22d062b504a6ee6b14" dependencies = [ "serde", ] @@ -5329,21 +5331,21 @@ dependencies = [ "toml_datetime 0.7.5+spec-1.1.0", "toml_parser", "toml_writer", - "winnow", + "winnow 0.7.15", ] [[package]] name = "toml" -version = "1.0.6+spec-1.1.0" +version = "1.0.7+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "399b1124a3c9e16766831c6bba21e50192572cdd98706ea114f9502509686ffc" +checksum = "dd28d57d8a6f6e458bc0b8784f8fdcc4b99a437936056fa122cb234f18656a96" dependencies = [ "serde_core", "serde_spanned 1.0.4", - "toml_datetime 1.0.0+spec-1.1.0", + "toml_datetime 1.0.1+spec-1.1.0", "toml_parser", "toml_writer", - "winnow", + "winnow 1.0.0", ] [[package]] @@ -5366,9 +5368,9 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "1.0.0+spec-1.1.0" +version = "1.0.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32c2555c699578a4f59f0cc68e5116c8d7cabbd45e1409b989d4be085b53f13e" +checksum = "9b320e741db58cac564e26c607d3cc1fdc4a88fd36c879568c07856ed83ff3e9" dependencies = [ "serde_core", ] @@ -5384,16 +5386,16 @@ dependencies = [ "serde_spanned 0.6.9", "toml_datetime 0.6.11", "toml_write", - "winnow", + "winnow 0.7.15", ] [[package]] name = "toml_parser" -version = "1.0.9+spec-1.1.0" +version = "1.0.10+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" +checksum = "7df25b4befd31c4816df190124375d5a20c6b6921e2cad937316de3fccd63420" dependencies = [ - "winnow", + "winnow 1.0.0", ] [[package]] @@ -5404,9 +5406,9 @@ checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" [[package]] name = "toml_writer" -version = "1.0.6+spec-1.1.0" +version = "1.0.7+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" +checksum = "f17aaa1c6e3dc22b1da4b6bba97d066e354c7945cac2f7852d4e4e7ca7a6b56d" [[package]] name = "tonic-build" @@ -5599,6 +5601,34 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "ureq" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdc97a28575b85cfedf2a7e7d3cc64b3e11bd8ac766666318003abbacc7a21fc" +dependencies = [ + "base64", + "log", + "percent-encoding", + "rustls 0.23.37", + "rustls-pki-types", + "ureq-proto", + "utf-8", + "webpki-roots", +] + +[[package]] +name = "ureq-proto" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d81f9efa9df032be5934a46a068815a10a042b494b6a58cb0a1a97bb5467ed6f" +dependencies = [ + "base64", + "http 1.4.0", + "httparse", + "log", +] + [[package]] name = "url" version = "2.5.8" @@ -5617,6 +5647,12 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -6493,6 +6529,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "winnow" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8" + [[package]] name = "winreg" version = "0.10.1" diff --git a/crates/chibi-core/src/tools/synthesised.rs b/crates/chibi-core/src/tools/synthesised.rs index 4d703e189..e1bb0f6bf 100644 --- a/crates/chibi-core/src/tools/synthesised.rs +++ b/crates/chibi-core/src/tools/synthesised.rs @@ -895,6 +895,7 @@ fn secs_to_ymdhmz(mut s: u64) -> (u32, u32, u32, u32, u32) { fn build_tein_context( source: String, tier: crate::config::SandboxTier, + http_prefixes: Option>, ) -> io::Result<(TeinSession, std::thread::ThreadId)> { let worker_thread_id = Arc::new(std::sync::Mutex::new(None::)); let tid_capture = Arc::clone(&worker_thread_id); @@ -947,19 +948,23 @@ fn build_tein_context( }; let ctx = match tier { - crate::config::SandboxTier::Sandboxed => Context::builder() - .standard_env() - .sandboxed(Modules::Safe) - .step_limit(10_000_000) - .build_managed(init), + crate::config::SandboxTier::Sandboxed => { + let mut builder = Context::builder() + .standard_env() + .sandboxed(Modules::Safe) + .step_limit(10_000_000); + if let Some(ref prefixes) = http_prefixes { + let refs: Vec<&str> = prefixes.iter().map(|s| s.as_str()).collect(); + builder = builder.http_allow(&refs); + } + builder.build_managed(init) + } crate::config::SandboxTier::Unsandboxed => { // with_vfs_shadows() enables shadow modules (e.g. scheme/process-context, // scheme/file) in non-sandboxed contexts. Required for (chibi diff) and // other library modules that depend on scheme/process-context. - Context::builder() - .standard_env() - .with_vfs_shadows() - .build_managed(init) + let builder = Context::builder().standard_env().with_vfs_shadows(); + builder.build_managed(init) } } .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, format!("tein init: {e}")))?; @@ -985,7 +990,7 @@ fn build_tein_context( #[cfg(feature = "synthesised-tools")] pub(crate) fn build_sandboxed_harness_context() -> io::Result<(TeinSession, std::thread::ThreadId)> { - build_tein_context(String::new(), crate::config::SandboxTier::Sandboxed) + build_tein_context(String::new(), crate::config::SandboxTier::Sandboxed, None) } /// Standard prelude evaluated in every tein context (synthesised tools and `scheme_eval`). @@ -1041,8 +1046,12 @@ pub fn load_tools_from_source( ) -> io::Result> { let source_owned = source.to_string(); let tier = tools_config.resolve_tier(vfs_path.as_str()); + let http_prefixes = match tools_config.resolve_http_allow(vfs_path.as_str()) { + crate::config::HttpAllowResult::Prefixes(p) => Some(p), + _ => None, // NeedDeclared and NoAccess handled in chunk 6 + }; - let (session, worker_thread_id) = build_tein_context(source_owned, tier)?; + let (session, worker_thread_id) = build_tein_context(source_owned, tier, http_prefixes)?; // check if define-tool was used (%tool-registry% is non-empty list) let multi = session.evaluate("%tool-registry%").ok(); @@ -1828,7 +1837,7 @@ mod tests { crate::config::SandboxTier::Sandboxed, ] { let (session, _) = - build_tein_context(String::new(), tier).expect("session should build"); + build_tein_context(String::new(), tier, None).expect("session should build"); let cap = session.with_capture(|ctx| ctx.evaluate("(display 42)")); assert!( cap.value.is_ok(), @@ -1849,7 +1858,7 @@ mod tests { crate::config::SandboxTier::Unsandboxed, ] { let (session, _) = - build_tein_context(String::new(), tier).expect("session should build"); + build_tein_context(String::new(), tier, None).expect("session should build"); let hooks_docs_ok = session .evaluate("(and (pair? hooks-docs) (pair? harness-tools-docs))") From 4291b2d6e80da13d25e8299c013bab3c6126ea8f Mon Sep 17 00:00:00 2001 From: fey Date: Wed, 18 Mar 2026 21:40:31 +0000 Subject: [PATCH 13/20] feat(config): resolve_env with longest-prefix match (#230) --- crates/chibi-core/src/config.rs | 96 +++++++++++++++++++++++++++++++-- 1 file changed, 93 insertions(+), 3 deletions(-) diff --git a/crates/chibi-core/src/config.rs b/crates/chibi-core/src/config.rs index 576bf89b4..00dbfd051 100644 --- a/crates/chibi-core/src/config.rs +++ b/crates/chibi-core/src/config.rs @@ -670,9 +670,7 @@ impl ToolsConfig { } match best { - Some((_, HttpAllow::Prefixes(prefixes))) => { - HttpAllowResult::Prefixes(prefixes.clone()) - } + Some((_, HttpAllow::Prefixes(prefixes))) => HttpAllowResult::Prefixes(prefixes.clone()), Some((_, HttpAllow::TrustDeclared(s))) if s == "trust-declared" => { HttpAllowResult::NeedDeclared } @@ -692,6 +690,39 @@ impl ToolsConfig { } } } + + /// Resolve environment variable forwarding for a synthesised tool. + /// + /// Uses longest-prefix match on `[tools.env]` entries. Reads the listed + /// var names from the real process environment, returning name+value pairs. + /// Vars not set in the process are silently skipped. + /// + /// Returns `None` when no config entry matches (distinct from `Some(vec![])` + /// which means "config matched but no vars were set"). + #[cfg(feature = "synthesised-tools")] + pub fn resolve_env(&self, vfs_path: &str) -> Option> { + let env_map = self.env.as_ref()?; + + let mut best: Option<(&str, &Vec)> = None; + for (pattern, var_names) in env_map { + if vfs_path.starts_with(pattern.as_str()) { + match best { + None => best = Some((pattern, var_names)), + Some((prev, _)) if pattern.len() > prev.len() => { + best = Some((pattern, var_names)); + } + _ => {} + } + } + } + + best.map(|(_, var_names)| { + var_names + .iter() + .filter_map(|name| std::env::var(name).ok().map(|val| (name.clone(), val))) + .collect() + }) + } } /// Known builtin plugin paths that default to unsandboxed tier. @@ -1849,6 +1880,65 @@ mod tests { assert_eq!(config.vfs.backend, "fossil"); } + #[cfg(feature = "synthesised-tools")] + #[test] + fn test_resolve_env_present() { + let mut env = std::collections::HashMap::new(); + env.insert( + "/tools/shared/t212.scm".to_string(), + vec![ + "CHIBI_TEST_ENV_KEY_1".to_string(), + "CHIBI_TEST_ENV_MISSING_1".to_string(), + ], + ); + let config = ToolsConfig { + env: Some(env), + ..Default::default() + }; + // SAFETY: using unique env var name to avoid parallel test collision + unsafe { std::env::set_var("CHIBI_TEST_ENV_KEY_1", "secret123") }; + let result = config.resolve_env("/tools/shared/t212.scm"); + unsafe { std::env::remove_var("CHIBI_TEST_ENV_KEY_1") }; + + let vars = result.unwrap(); + assert_eq!( + vars, + vec![("CHIBI_TEST_ENV_KEY_1".to_string(), "secret123".to_string())] + ); + // CHIBI_TEST_ENV_MISSING_1 was not set, so it's skipped + } + + #[cfg(feature = "synthesised-tools")] + #[test] + fn test_resolve_env_no_config() { + let config = ToolsConfig::default(); + assert!(config.resolve_env("/tools/shared/t212.scm").is_none()); + } + + #[cfg(feature = "synthesised-tools")] + #[test] + fn test_resolve_env_longest_prefix() { + let mut env = std::collections::HashMap::new(); + env.insert("/tools/shared/".to_string(), vec!["GENERAL_KEY".to_string()]); + env.insert( + "/tools/shared/t212.scm".to_string(), + vec!["CHIBI_TEST_ENV_SPECIFIC_1".to_string()], + ); + let config = ToolsConfig { + env: Some(env), + ..Default::default() + }; + unsafe { std::env::set_var("CHIBI_TEST_ENV_SPECIFIC_1", "val") }; + let result = config.resolve_env("/tools/shared/t212.scm"); + unsafe { std::env::remove_var("CHIBI_TEST_ENV_SPECIFIC_1") }; + + let vars = result.unwrap(); + assert_eq!( + vars, + vec![("CHIBI_TEST_ENV_SPECIFIC_1".to_string(), "val".to_string())] + ); + } + #[cfg(feature = "synthesised-tools")] #[test] fn test_resolve_http_allow_explicit_prefixes() { From 915c06934712f0a0358d3e63e92e65e97ee4ae01 Mon Sep 17 00:00:00 2001 From: fey Date: Wed, 18 Mar 2026 21:42:56 +0000 Subject: [PATCH 14/20] feat(synthesised): thread env_vars through build_tein_context (#230) --- crates/chibi-core/src/tools/synthesised.rs | 23 +++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/crates/chibi-core/src/tools/synthesised.rs b/crates/chibi-core/src/tools/synthesised.rs index e1bb0f6bf..2023db31c 100644 --- a/crates/chibi-core/src/tools/synthesised.rs +++ b/crates/chibi-core/src/tools/synthesised.rs @@ -896,6 +896,7 @@ fn build_tein_context( source: String, tier: crate::config::SandboxTier, http_prefixes: Option>, + env_vars: Option>, ) -> io::Result<(TeinSession, std::thread::ThreadId)> { let worker_thread_id = Arc::new(std::sync::Mutex::new(None::)); let tid_capture = Arc::clone(&worker_thread_id); @@ -957,13 +958,23 @@ fn build_tein_context( let refs: Vec<&str> = prefixes.iter().map(|s| s.as_str()).collect(); builder = builder.http_allow(&refs); } + if let Some(ref vars) = env_vars { + let refs: Vec<(&str, &str)> = + vars.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect(); + builder = builder.environment_variables(&refs); + } builder.build_managed(init) } crate::config::SandboxTier::Unsandboxed => { // with_vfs_shadows() enables shadow modules (e.g. scheme/process-context, // scheme/file) in non-sandboxed contexts. Required for (chibi diff) and // other library modules that depend on scheme/process-context. - let builder = Context::builder().standard_env().with_vfs_shadows(); + let mut builder = Context::builder().standard_env().with_vfs_shadows(); + if let Some(ref vars) = env_vars { + let refs: Vec<(&str, &str)> = + vars.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect(); + builder = builder.environment_variables(&refs); + } builder.build_managed(init) } } @@ -990,7 +1001,7 @@ fn build_tein_context( #[cfg(feature = "synthesised-tools")] pub(crate) fn build_sandboxed_harness_context() -> io::Result<(TeinSession, std::thread::ThreadId)> { - build_tein_context(String::new(), crate::config::SandboxTier::Sandboxed, None) + build_tein_context(String::new(), crate::config::SandboxTier::Sandboxed, None, None) } /// Standard prelude evaluated in every tein context (synthesised tools and `scheme_eval`). @@ -1050,8 +1061,10 @@ pub fn load_tools_from_source( crate::config::HttpAllowResult::Prefixes(p) => Some(p), _ => None, // NeedDeclared and NoAccess handled in chunk 6 }; + let env_vars = tools_config.resolve_env(vfs_path.as_str()); - let (session, worker_thread_id) = build_tein_context(source_owned, tier, http_prefixes)?; + let (session, worker_thread_id) = + build_tein_context(source_owned, tier, http_prefixes, env_vars)?; // check if define-tool was used (%tool-registry% is non-empty list) let multi = session.evaluate("%tool-registry%").ok(); @@ -1837,7 +1850,7 @@ mod tests { crate::config::SandboxTier::Sandboxed, ] { let (session, _) = - build_tein_context(String::new(), tier, None).expect("session should build"); + build_tein_context(String::new(), tier, None, None).expect("session should build"); let cap = session.with_capture(|ctx| ctx.evaluate("(display 42)")); assert!( cap.value.is_ok(), @@ -1858,7 +1871,7 @@ mod tests { crate::config::SandboxTier::Unsandboxed, ] { let (session, _) = - build_tein_context(String::new(), tier, None).expect("session should build"); + build_tein_context(String::new(), tier, None, None).expect("session should build"); let hooks_docs_ok = session .evaluate("(and (pair? hooks-docs) (pair? harness-tools-docs))") From 11ded5a3c4584448723d4ddc6b9b4f3b6e25a740 Mon Sep 17 00:00:00 2001 From: fey Date: Wed, 18 Mar 2026 21:44:03 +0000 Subject: [PATCH 15/20] feat(config): resolve_http_allow_with_declared for trust delegation (#230) --- crates/chibi-core/src/config.rs | 116 +++++++++++++++++++++++++++++++- 1 file changed, 115 insertions(+), 1 deletion(-) diff --git a/crates/chibi-core/src/config.rs b/crates/chibi-core/src/config.rs index 00dbfd051..19a962eb4 100644 --- a/crates/chibi-core/src/config.rs +++ b/crates/chibi-core/src/config.rs @@ -723,6 +723,27 @@ impl ToolsConfig { .collect() }) } + + /// Resolve HTTP prefixes with tool-declared fallback. + /// + /// Called after reading `tool-http-allow` from the tool source. + /// Uses `resolve_http_allow` internally: + /// - `Prefixes(p)` → `Some(p)` (explicit config wins, declared ignored) + /// - `NeedDeclared` + non-empty declared → `Some(declared.to_vec())` + /// - `NeedDeclared` + empty declared → `None` + /// - `NoAccess` → `None` + #[cfg(feature = "synthesised-tools")] + pub fn resolve_http_allow_with_declared( + &self, + vfs_path: &str, + declared: &[String], + ) -> Option> { + match self.resolve_http_allow(vfs_path) { + HttpAllowResult::Prefixes(p) => Some(p), + HttpAllowResult::NeedDeclared if !declared.is_empty() => Some(declared.to_vec()), + _ => None, + } + } } /// Known builtin plugin paths that default to unsandboxed tier. @@ -1919,7 +1940,10 @@ mod tests { #[test] fn test_resolve_env_longest_prefix() { let mut env = std::collections::HashMap::new(); - env.insert("/tools/shared/".to_string(), vec!["GENERAL_KEY".to_string()]); + env.insert( + "/tools/shared/".to_string(), + vec!["GENERAL_KEY".to_string()], + ); env.insert( "/tools/shared/t212.scm".to_string(), vec!["CHIBI_TEST_ENV_SPECIFIC_1".to_string()], @@ -1939,6 +1963,96 @@ mod tests { ); } + #[cfg(feature = "synthesised-tools")] + #[test] + fn test_resolve_http_allow_with_declared_trust_per_path() { + let mut allow = std::collections::HashMap::new(); + allow.insert( + "/tools/shared/".to_string(), + HttpAllow::TrustDeclared("trust-declared".to_string()), + ); + let config = ToolsConfig { + http: Some(HttpConfig { + allow: Some(allow), + ..Default::default() + }), + ..Default::default() + }; + let declared = vec!["https://api.example.com/".to_string()]; + let result = + config.resolve_http_allow_with_declared("/tools/shared/t212.scm", &declared); + assert_eq!(result, Some(vec!["https://api.example.com/".to_string()])); + } + + #[cfg(feature = "synthesised-tools")] + #[test] + fn test_resolve_http_allow_with_declared_explicit_wins() { + let mut allow = std::collections::HashMap::new(); + allow.insert( + "/tools/shared/t212.scm".to_string(), + HttpAllow::Prefixes(vec!["https://explicit.com/".to_string()]), + ); + let config = ToolsConfig { + http: Some(HttpConfig { + allow: Some(allow), + ..Default::default() + }), + ..Default::default() + }; + let declared = vec!["https://ignored.com/".to_string()]; + let result = + config.resolve_http_allow_with_declared("/tools/shared/t212.scm", &declared); + assert_eq!(result, Some(vec!["https://explicit.com/".to_string()])); + } + + #[cfg(feature = "synthesised-tools")] + #[test] + fn test_resolve_http_allow_with_declared_global_trust() { + let config = ToolsConfig { + http: Some(HttpConfig { + trust_declared: Some(true), + ..Default::default() + }), + ..Default::default() + }; + let declared = vec!["https://api.example.com/".to_string()]; + let result = + config.resolve_http_allow_with_declared("/tools/shared/t212.scm", &declared); + assert_eq!(result, Some(vec!["https://api.example.com/".to_string()])); + } + + #[cfg(feature = "synthesised-tools")] + #[test] + fn test_resolve_http_allow_with_declared_no_trust() { + let config = ToolsConfig::default(); + let declared = vec!["https://api.example.com/".to_string()]; + let result = + config.resolve_http_allow_with_declared("/tools/shared/t212.scm", &declared); + assert_eq!(result, None); + } + + #[cfg(feature = "synthesised-tools")] + #[test] + fn test_resolve_http_allow_with_declared_empty_declared() { + let mut allow = std::collections::HashMap::new(); + allow.insert( + "/tools/shared/".to_string(), + HttpAllow::TrustDeclared("trust-declared".to_string()), + ); + let config = ToolsConfig { + http: Some(HttpConfig { + allow: Some(allow), + ..Default::default() + }), + ..Default::default() + }; + let declared: Vec = vec![]; + let result = + config.resolve_http_allow_with_declared("/tools/shared/t212.scm", &declared); + // trust-declared but nothing declared → no access + assert_eq!(result, None); + } + #[cfg(feature = "synthesised-tools")] #[test] fn test_resolve_http_allow_explicit_prefixes() { From 6c99f8e730a8b0bf99f04f5f52d39e11a99169d5 Mon Sep 17 00:00:00 2001 From: fey Date: Wed, 18 Mar 2026 21:46:35 +0000 Subject: [PATCH 16/20] feat(synthesised): two-phase load for trust-declared HTTP prefixes (#230) --- crates/chibi-core/src/tools/synthesised.rs | 80 ++++++++++++++++++++-- 1 file changed, 74 insertions(+), 6 deletions(-) diff --git a/crates/chibi-core/src/tools/synthesised.rs b/crates/chibi-core/src/tools/synthesised.rs index 2023db31c..0ebf80594 100644 --- a/crates/chibi-core/src/tools/synthesised.rs +++ b/crates/chibi-core/src/tools/synthesised.rs @@ -1001,7 +1001,12 @@ fn build_tein_context( #[cfg(feature = "synthesised-tools")] pub(crate) fn build_sandboxed_harness_context() -> io::Result<(TeinSession, std::thread::ThreadId)> { - build_tein_context(String::new(), crate::config::SandboxTier::Sandboxed, None, None) + build_tein_context( + String::new(), + crate::config::SandboxTier::Sandboxed, + None, + None, + ) } /// Standard prelude evaluated in every tein context (synthesised tools and `scheme_eval`). @@ -1057,14 +1062,46 @@ pub fn load_tools_from_source( ) -> io::Result> { let source_owned = source.to_string(); let tier = tools_config.resolve_tier(vfs_path.as_str()); - let http_prefixes = match tools_config.resolve_http_allow(vfs_path.as_str()) { - crate::config::HttpAllowResult::Prefixes(p) => Some(p), - _ => None, // NeedDeclared and NoAccess handled in chunk 6 - }; let env_vars = tools_config.resolve_env(vfs_path.as_str()); + let http_result = tools_config.resolve_http_allow(vfs_path.as_str()); + let http_prefixes = match &http_result { + crate::config::HttpAllowResult::Prefixes(p) => Some(p.clone()), + _ => None, // NeedDeclared resolved after phase 1 + }; + let (session, worker_thread_id) = - build_tein_context(source_owned, tier, http_prefixes, env_vars)?; + build_tein_context(source_owned.clone(), tier, http_prefixes, env_vars.clone())?; + + // Phase 2: trust-declared HTTP — read tool-http-allow, rebuild if needed + let (session, worker_thread_id) = + if matches!(http_result, crate::config::HttpAllowResult::NeedDeclared) { + // Read tool-http-allow binding from phase 1 session + let declared = session + .evaluate("tool-http-allow") + .ok() + .and_then(|v| match v { + Value::List(items) => Some( + items + .iter() + .filter_map(|i| i.as_string().map(|s| s.to_string())) + .collect::>(), + ), + _ => None, + }) + .unwrap_or_default(); + + if let Some(trusted_prefixes) = + tools_config.resolve_http_allow_with_declared(vfs_path.as_str(), &declared) + { + // Rebuild with the trusted prefixes + build_tein_context(source_owned, tier, Some(trusted_prefixes), env_vars)? + } else { + (session, worker_thread_id) + } + } else { + (session, worker_thread_id) + }; // check if define-tool was used (%tool-registry% is non-empty list) let multi = session.evaluate("%tool-registry%").ok(); @@ -2086,6 +2123,37 @@ mod tests { assert_eq!(tools[1].category, ToolCategory::FsWrite); } + #[test] + fn test_trust_declared_reads_tool_http_allow() { + let source = r#" +(import (scheme base)) +(define tool-http-allow '("https://api.example.com/")) +(define tool-name "http_tool") +(define tool-description "uses HTTP") +(define tool-category "network") +(define tool-parameters '()) +(define (tool-execute args) "ok") +"#; + let path = VfsPath::new("/tools/shared/http.scm").unwrap(); + let registry = make_registry(); + let mut allow = std::collections::HashMap::new(); + allow.insert( + "/tools/shared/".to_string(), + crate::config::HttpAllow::TrustDeclared("trust-declared".to_string()), + ); + let config = crate::config::ToolsConfig { + http: Some(crate::config::HttpConfig { + allow: Some(allow), + ..Default::default() + }), + ..Default::default() + }; + // Should load successfully — two-phase build trusts the declared prefixes + let tools = load_tools_from_source(source, &path, ®istry, &config).unwrap(); + assert_eq!(tools.len(), 1); + assert_eq!(tools[0].category, ToolCategory::Network); + } + const SCAN_TOOL: &str = r#" (import (scheme base)) (define tool-name "scan_hello") From 4b9cee722581f7ac43a1c45110d77f4214923dd1 Mon Sep 17 00:00:00 2001 From: fey Date: Wed, 18 Mar 2026 21:47:37 +0000 Subject: [PATCH 17/20] docs: update AGENTS.md with sandbox extension quirks (#230) --- AGENTS.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 2327933a1..7030a0e89 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -63,6 +63,10 @@ LLM communication is delegated to ratatoskr; `gateway.rs` bridges chibi's types - Synthesised tools: `(harness tools)` module provides `call-tool` and `define-tool`. `(harness hooks)` module provides `register-hook`. `HARNESS_PREAMBLE` defines `%tool-registry%`, `%hook-registry%`, `define-tool`, and `register-hook` at top level (not inside the library) so `set!` can mutate them and rust can read them post-eval. - `ToolImpl::Synthesised` has `exec_binding` field: `"tool-execute"` for convention format, `"%tool-execute-{name}%"` for `define-tool` multi-tool files. - `reload_tool_from_content` and `scan_and_register` require `&ToolsConfig` for tier resolution. Pass `&ToolsConfig::default()` when no tier overrides needed. +- `load_tools_from_source` takes `&ToolsConfig` (not a tier param). Tests use `&ToolsConfig::default()` for sandboxed, or `config_with_tier(path, 2)` for unsandboxed. `load_tool_from_source` (singular) still takes no config param — uses default internally. +- `ToolCategory::from_category_str` maps category strings from scheme tools to variants. Unknown strings → `Synthesised`. +- `HttpAllowResult::NeedDeclared` triggers two-phase context build: phase 1 evaluates source without HTTP to read `tool-http-allow`, phase 2 rebuilds with trusted prefixes. +- `PreFetchUrl` hook fires with `safety: "no_url"` and `summary` field (no `url`/`reason`) for network-category tools without a URL parameter. - `call-tool` bridge uses one global mutex: `BRIDGE_CALL_CTX` (set/cleared per execute via `CallContextGuard`). Registry is embedded in `ToolImpl::Synthesised` and passed through `execute_synthesised` — no longer a separate global. Reason: tein runs scheme on a dedicated worker thread; thread-locals set on the caller thread would be invisible there. - `ToolImpl::Synthesised` carries `registry: Arc>` so `call-tool` can dispatch to any registered tool from the tein worker thread without thread-local state. - Harness also exposes `%context-name%` (mutable binding, injected per call), `(generate-id)` (8 hex chars, uuid v4), and `(current-timestamp)` (`YYYYMMDD-HHMMz` UTC). From 0dbbbcf8afc8884f80b345837a25f520c9534ea3 Mon Sep 17 00:00:00 2001 From: fey Date: Wed, 18 Mar 2026 21:51:54 +0000 Subject: [PATCH 18/20] fmt --- crates/chibi-core/src/config.rs | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/crates/chibi-core/src/config.rs b/crates/chibi-core/src/config.rs index 19a962eb4..901f70b2d 100644 --- a/crates/chibi-core/src/config.rs +++ b/crates/chibi-core/src/config.rs @@ -1979,8 +1979,7 @@ mod tests { ..Default::default() }; let declared = vec!["https://api.example.com/".to_string()]; - let result = - config.resolve_http_allow_with_declared("/tools/shared/t212.scm", &declared); + let result = config.resolve_http_allow_with_declared("/tools/shared/t212.scm", &declared); assert_eq!(result, Some(vec!["https://api.example.com/".to_string()])); } @@ -2000,8 +1999,7 @@ mod tests { ..Default::default() }; let declared = vec!["https://ignored.com/".to_string()]; - let result = - config.resolve_http_allow_with_declared("/tools/shared/t212.scm", &declared); + let result = config.resolve_http_allow_with_declared("/tools/shared/t212.scm", &declared); assert_eq!(result, Some(vec!["https://explicit.com/".to_string()])); } @@ -2016,8 +2014,7 @@ mod tests { ..Default::default() }; let declared = vec!["https://api.example.com/".to_string()]; - let result = - config.resolve_http_allow_with_declared("/tools/shared/t212.scm", &declared); + let result = config.resolve_http_allow_with_declared("/tools/shared/t212.scm", &declared); assert_eq!(result, Some(vec!["https://api.example.com/".to_string()])); } @@ -2026,8 +2023,7 @@ mod tests { fn test_resolve_http_allow_with_declared_no_trust() { let config = ToolsConfig::default(); let declared = vec!["https://api.example.com/".to_string()]; - let result = - config.resolve_http_allow_with_declared("/tools/shared/t212.scm", &declared); + let result = config.resolve_http_allow_with_declared("/tools/shared/t212.scm", &declared); assert_eq!(result, None); } @@ -2047,8 +2043,7 @@ mod tests { ..Default::default() }; let declared: Vec = vec![]; - let result = - config.resolve_http_allow_with_declared("/tools/shared/t212.scm", &declared); + let result = config.resolve_http_allow_with_declared("/tools/shared/t212.scm", &declared); // trust-declared but nothing declared → no access assert_eq!(result, None); } From a696a8280b84aedfa74a3fb745c70950901a8557 Mon Sep 17 00:00:00 2001 From: fey Date: Wed, 18 Mar 2026 22:16:08 +0000 Subject: [PATCH 19/20] docs+test: address code review findings (#230) - document http/env as global-only in merge_local doc comment - comment why http_prefixes is skipped for unsandboxed tier - update harness-tools-docs define-tool entry with category/summary-params - add [tools.http] and [tools.env] sections to docs/configuration.md - add category/summary-params example to docs/plugins.md - document HttpAllow::TrustDeclared parse-vs-runtime validation trade-off - deduplicate config_with_tier test helper into config::test_helpers - add 4 unit tests for the no_url pre_fetch_url hook path in send.rs --- crates/chibi-core/src/api/send.rs | 69 ++++++++++++++++++++++ crates/chibi-core/src/config.rs | 23 ++++++++ crates/chibi-core/src/tools/hooks.rs | 8 +-- crates/chibi-core/src/tools/synthesised.rs | 14 ++--- docs/configuration.md | 33 +++++++++++ docs/plugins.md | 15 +++++ 6 files changed, 147 insertions(+), 15 deletions(-) diff --git a/crates/chibi-core/src/api/send.rs b/crates/chibi-core/src/api/send.rs index 78ff004ba..27cc14ae1 100644 --- a/crates/chibi-core/src/api/send.rs +++ b/crates/chibi-core/src/api/send.rs @@ -3496,4 +3496,73 @@ mod tests { } assert!(body["tools"].as_array().is_some()); } + + // --- no_url hook data shape and permission evaluation --- + + /// `build_no_url_hook_data` produces the correct shape for the `pre_fetch_url` hook + /// when a network tool has no URL parameter. + #[test] + fn test_no_url_hook_data_shape() { + let tool_name = "my_api_tool"; + let summary = "endpoint=v1/search method=GET"; + let hook_data = serde_json::json!({ + "tool_name": tool_name, + "summary": summary, + "safety": "no_url", + }); + + // must have safety=no_url + assert_eq!(hook_data["safety"].as_str(), Some("no_url")); + // must have summary (not url) + assert_eq!(hook_data["summary"].as_str(), Some(summary)); + assert!(hook_data.get("url").is_none(), "no_url path must not include a 'url' field"); + assert_eq!(hook_data["tool_name"].as_str(), Some(tool_name)); + } + + /// With no hooks and no permission handler, `evaluate_permission` denies (fail-safe). + #[test] + fn test_no_url_evaluate_permission_no_handler_denies() { + let hook_data = serde_json::json!({ + "tool_name": "my_api_tool", + "summary": "endpoint=v1/search", + "safety": "no_url", + }); + let result = evaluate_permission(&[], &hook_data, None).unwrap(); + assert!(result.is_err(), "no handler must produce fail-safe deny"); + assert!( + result.unwrap_err().contains("fail-safe deny"), + "error message must mention fail-safe deny" + ); + } + + /// With a permissive handler, `evaluate_permission` allows. + #[test] + fn test_no_url_evaluate_permission_handler_allows() { + let hook_data = serde_json::json!({ + "tool_name": "my_api_tool", + "summary": "endpoint=v1/search", + "safety": "no_url", + }); + let handler: PermissionHandler = Box::new(|_data| Ok(true)); + let result = evaluate_permission(&[], &hook_data, Some(&handler)).unwrap(); + assert!(result.is_ok(), "permissive handler must allow"); + } + + /// A hook that sets `denied: true` blocks the call even with a permissive handler. + #[test] + fn test_no_url_hook_denial_overrides_handler() { + let hook_data = serde_json::json!({ + "tool_name": "my_api_tool", + "summary": "endpoint=v1/search", + "safety": "no_url", + }); + let hook_results = vec![( + "blocker".to_string(), + serde_json::json!({"denied": true, "reason": "blocked by policy"}), + )]; + let handler: PermissionHandler = Box::new(|_data| Ok(true)); + let result = evaluate_permission(&hook_results, &hook_data, Some(&handler)).unwrap(); + assert!(result.is_err()); + assert_eq!(result.unwrap_err(), "blocked by policy"); + } } diff --git a/crates/chibi-core/src/config.rs b/crates/chibi-core/src/config.rs index 901f70b2d..6594440ed 100644 --- a/crates/chibi-core/src/config.rs +++ b/crates/chibi-core/src/config.rs @@ -451,6 +451,10 @@ pub enum HttpAllow { /// Explicit list of allowed URL prefixes. Prefixes(Vec), /// The string `"trust-declared"` — trust the tool's own `tool-http-allow` binding. + /// + /// Note: `serde(untagged)` means any non-list TOML value deserialises as this variant, + /// so the string is validated at resolution time (`resolve_http_allow`), not at parse time. + /// Unknown strings trigger a warning and fall through to `NoAccess` — safe default. TrustDeclared(String), } @@ -565,6 +569,8 @@ impl ToolsConfig { /// - `exclude`: local appends to global (deduplicated) /// - `exclude_categories`: local appends to global (deduplicated) /// - `tiers`: local overrides global (local entries win per path) + /// - `http`: global-only; local value is ignored (HTTP access is a global security boundary) + /// - `env`: global-only; local value is ignored (env exposure is a global security boundary) pub fn merge_local(&self, local: &ToolsConfig) -> ToolsConfig { let include = if local.include.is_some() { local.include.clone() @@ -1429,6 +1435,23 @@ impl ResolvedConfig { } } +/// Test helpers shared across crates — gated so they don't ship in production. +#[cfg(test)] +pub(crate) mod test_helpers { + use super::ToolsConfig; + + /// Build a `ToolsConfig` that maps `vfs_path` to the given tier. + /// Tier 1 = sandboxed, tier 2 = unsandboxed. + pub(crate) fn config_with_tier(vfs_path: &str, tier: u8) -> ToolsConfig { + let mut tiers = std::collections::HashMap::new(); + tiers.insert(vfs_path.to_string(), tier); + ToolsConfig { + tiers: Some(tiers), + ..Default::default() + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/chibi-core/src/tools/hooks.rs b/crates/chibi-core/src/tools/hooks.rs index f9ef1adbc..8a1908a7a 100644 --- a/crates/chibi-core/src/tools/hooks.rs +++ b/crates/chibi-core/src/tools/hooks.rs @@ -1563,15 +1563,9 @@ pub fn execute_hook( mod tests { use super::*; - /// Build a `ToolsConfig` that maps `vfs_path` to the given tier. #[cfg(feature = "synthesised-tools")] fn config_with_tier(vfs_path: &str, tier: u8) -> crate::config::ToolsConfig { - let mut tiers = std::collections::HashMap::new(); - tiers.insert(vfs_path.to_string(), tier); - crate::config::ToolsConfig { - tiers: Some(tiers), - ..Default::default() - } + crate::config::test_helpers::config_with_tier(vfs_path, tier) } // All 31 hook points for testing diff --git a/crates/chibi-core/src/tools/synthesised.rs b/crates/chibi-core/src/tools/synthesised.rs index 0ebf80594..c5881c7c5 100644 --- a/crates/chibi-core/src/tools/synthesised.rs +++ b/crates/chibi-core/src/tools/synthesised.rs @@ -368,7 +368,7 @@ pub(crate) static HARNESS_PREAMBLE: std::sync::LazyLock = std::sync::Laz ;; note: (describe X) takes an alist directly, NOT a symbol. (define harness-tools-docs '((__module__ . "harness tools") - (define-tool . "macro: (define-tool name (description DESC) (parameters PARAMS-ALIST) (execute (lambda (args) ...))) — registers a persistent tool; args is ((\"key\" . val) ...) alist") + (define-tool . "macro: (define-tool name (description DESC) [(category CAT)] [(summary-params (PARAM ...))] (parameters PARAMS-ALIST) (execute (lambda (args) ...))) — registers a persistent tool; args is ((\"key\" . val) ...) alist. Optional: category is a string like \"network\" or \"shell\"; summary-params is a list of parameter names used to build the permission-prompt summary for network tools without a URL parameter.") (call-tool . "procedure: (call-tool NAME ARGS-ALIST) -> string — invoke another registered tool; NAME is a string, ARGS-ALIST is ((\"key\" . \"val\") ...)") (register-hook . "procedure: (register-hook HOOK-SYMBOL HANDLER) — register a hook callback; HOOK-SYMBOL e.g. 'pre_vfs_write, HANDLER is (lambda (payload) ...)") (generate-id . "procedure: (generate-id) -> string — returns an 8-hex-char random identifier (uuid v4 prefix)") @@ -969,6 +969,10 @@ fn build_tein_context( // with_vfs_shadows() enables shadow modules (e.g. scheme/process-context, // scheme/file) in non-sandboxed contexts. Required for (chibi diff) and // other library modules that depend on scheme/process-context. + // + // `http_prefixes` is intentionally not applied here: unsandboxed contexts + // have unrestricted network access already, so an allowlist would be redundant. + // (A config entry under [tools.http.allow] for an unsandboxed path has no effect.) let mut builder = Context::builder().standard_env().with_vfs_shadows(); if let Some(ref vars) = env_vars { let refs: Vec<(&str, &str)> = @@ -1925,14 +1929,8 @@ mod tests { Arc::new(RwLock::new(ToolRegistry::new())) } - /// Build a `ToolsConfig` that maps `vfs_path` to the given tier. fn config_with_tier(vfs_path: &str, tier: u8) -> crate::config::ToolsConfig { - let mut tiers = std::collections::HashMap::new(); - tiers.insert(vfs_path.to_string(), tier); - crate::config::ToolsConfig { - tiers: Some(tiers), - ..Default::default() - } + crate::config::test_helpers::config_with_tier(vfs_path, tier) } #[test] diff --git a/docs/configuration.md b/docs/configuration.md index 04ced95d5..223a9051e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -677,6 +677,39 @@ Tier resolution uses prefix matching: the longest matching prefix wins. If no pr For dynamic tool filtering based on context or other conditions, use the `pre_api_tools` hook. See [Hooks documentation](hooks.md). +### HTTP Allow List (`[tools.http]`) + +Control which URLs sandboxed synthesised tools may access. Configured globally in `config.toml` only — `[tools.http]` in `local.toml` is ignored (HTTP access is a global security boundary). + +```toml +[tools.http.allow] +# path prefix → allowlist entry +# Option 1: explicit URL prefix list +"/tools/shared/weather.scm" = ["https://api.openweathermap.org/"] + +# Option 2: trust the tool's own `tool-http-allow` declaration +"/tools/home/mycontext/fetch.scm" = "trust-declared" +``` + +With `"trust-declared"`, chibi reads the tool's top-level `tool-http-allow` binding (a Scheme list of URL prefix strings) and uses those as the allowlist. This lets tool authors declare their own prefixes without requiring a config change. + +Allowlist resolution uses prefix matching: the longest matching VFS path prefix wins. Unsandboxed tools have unrestricted network access regardless of this config. + +### Environment Variable Exposure (`[tools.env]`) + +Control which environment variables are injected into sandboxed synthesised tools. Configured globally in `config.toml` only — `[tools.env]` in `local.toml` is ignored. + +```toml +[tools.env] +# path prefix → list of env var names to expose +"/tools/shared/weather.scm" = ["WEATHER_API_KEY"] +"/tools/home/mycontext" = ["MY_TOKEN", "MY_SECRET"] +``` + +At evaluation time, the listed env vars are read from the process environment and injected into the tein context as Scheme string bindings. Variables not present in the environment are silently omitted. + +Resolution uses longest-prefix matching on VFS path, same as `[tools.tiers]`. + ## Storage Configuration Configure transcript partitioning in `~/.chibi/config.toml`: diff --git a/docs/plugins.md b/docs/plugins.md index 8896a5cc0..390fcf4f3 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -325,6 +325,21 @@ Use `(import (harness tools))` to access the `define-tool` macro. A single file (string-append "Goodbye, " (cdr (assoc "name" args)) "!")))) ``` +Optional keywords `category` and `summary-params` can follow `description`: + +```scheme +(define-tool fetch-data + (description "Fetch data from an API endpoint") + (category "network") + (summary-params ("endpoint" "method")) + (parameters '((endpoint . ((type . "string") (description . "API endpoint path"))) + (method . ((type . "string") (description . "HTTP method"))))) + (execute (lambda (args) ...))) +``` + +- **`category`** — string: `"network"`, `"shell"`, or `"synthesised"` (default). Network tools fire `pre_fetch_url` before execution. +- **`summary-params`** — list of parameter names used to build the human-readable permission-prompt summary for network tools that have no `url` parameter. + ### `(harness tools)` Module The `(harness tools)` module exposes: From 41f763c9638dfe38be4ec279117ed1e166b27579 Mon Sep 17 00:00:00 2001 From: fey Date: Wed, 18 Mar 2026 22:16:30 +0000 Subject: [PATCH 20/20] fmt --- crates/chibi-core/src/api/send.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/chibi-core/src/api/send.rs b/crates/chibi-core/src/api/send.rs index 27cc14ae1..042206da7 100644 --- a/crates/chibi-core/src/api/send.rs +++ b/crates/chibi-core/src/api/send.rs @@ -3515,7 +3515,10 @@ mod tests { assert_eq!(hook_data["safety"].as_str(), Some("no_url")); // must have summary (not url) assert_eq!(hook_data["summary"].as_str(), Some(summary)); - assert!(hook_data.get("url").is_none(), "no_url path must not include a 'url' field"); + assert!( + hook_data.get("url").is_none(), + "no_url path must not include a 'url' field" + ); assert_eq!(hook_data["tool_name"].as_str(), Some(tool_name)); }