diff --git a/AGENTS.md b/AGENTS.md index 36fe4da..7fbdd4a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,6 +31,7 @@ crates/ ├── context/ # `ToolRunContext` ├── progress/ # `ToolProgress`, `ProgressSink` ├── naming/ # rendering a call for a human + ├── rules/ # `ToolRules`: declarative allow/deny/hide/approval rules └── shared/ # `SharedTool`: an `Arc` as an owned belt entry # each: mod.rs / types.rs / mod_tests.rs docs/ diff --git a/README.md b/README.md index fa799b3..4bc3603 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,7 @@ compiles neither the harness nor the host. | `naming` | `humanize_tool_name`, `context_detail_from_args` — rendering a call for a human | | `shared` | `SharedTool`, `share_belt`, `owned_belt` — one built `Arc` handed out as many owned `Box` belts, forwarding every trait method | | `rank` | `ToolRanker`, `RankCandidate`, `RankHit`, `Bm25Ranker` — ranking a catalogue of tools against an intent, with the lexical ranker built in | +| `rules` | `ToolRules`, `ToolRuleSet`, `ToolRule`, `ToolSubject`, `RuleDecision` — declarative allow / deny / hide / approval rules over tools, evaluated by a harness on the catalogue, search and call surfaces | The workspace also contains `tinytools-agent`, a separate crate for model-facing tool-call parsing, dialects, catalogue/result rendering, and diff --git a/crates/tinytools/src/lib.rs b/crates/tinytools/src/lib.rs index 9e68611..d32225e 100644 --- a/crates/tinytools/src/lib.rs +++ b/crates/tinytools/src/lib.rs @@ -116,6 +116,7 @@ pub mod policy; pub mod progress; pub mod rank; pub mod result; +pub mod rules; pub mod shared; pub mod spec; pub mod tool; @@ -148,6 +149,11 @@ pub use rank::{ Bm25Index, Bm25Ranker, RankCandidate, RankContext, RankError, RankHit, ToolRanker, tokenize, }; pub use result::{FileData, ImageData, ToolContent, ToolControl, ToolErrorKind, ToolResult}; +pub use rules::{ + ApprovalDirective, ArgMatcher, DefaultEffect, IndirectCall, Patterns, RuleContext, + RuleDecision, RuleEffect, RuleRef, SideEffect, Surface, ToolMatcher, ToolRule, ToolRuleSet, + ToolRules, ToolSubject, glob_matches, +}; pub use shared::{SharedTool, owned_belt, share_belt}; pub use spec::ToolSpec; pub use tool::{Tool, ToolExposure}; diff --git a/crates/tinytools/src/rules/README.md b/crates/tinytools/src/rules/README.md new file mode 100644 index 0000000..4cca394 --- /dev/null +++ b/crates/tinytools/src/rules/README.md @@ -0,0 +1,50 @@ +# `rules`: declarative tool rules + +Allow, deny, hide and approval rules over tools, written by a host and +evaluated by a harness on every surface a tool reaches the model on. +Specification: [`docs/specs/tool-rules.md`](../../../../docs/specs/tool-rules.md). + +## Design + +A host restricts tools from many places, such as an agent's allowlist, a +channel's permission ceiling, an MCP server filter or a connector's curated +actions. This module gives all of them one vocabulary. It holds **no policy**: +the rules are data the host writes, and evaluation is mechanical. That is the +same line `deferral` draws. + +| File | Holds | +|---|---| +| `glob.rs` | `glob_matches`: `*` and `?`, ASCII case-insensitive, with no dependency | +| `types.rs` | `ToolRule`, `ToolMatcher`, `ArgMatcher`, `ToolRules`, `ToolRuleSet`, `RuleContext`, `RuleDecision`, `RuleRef`, `ApprovalDirective` | +| `subject.rs` | `ToolSubject`: what is evaluated, built from a live tool or by hand | +| `eval.rs` | Evaluation of a rule, a layer, a set, and a concrete call | + +## Public surface + +- **`ToolRule`** has an effect (`allow`, `deny`, `hide`, `require_approval`, + `auto_approve`), the surfaces it applies on (`catalog`, `search`, `call`), a + `match`, an optional `except` carve-out, `when` context conditions, and an + `id` and `reason` for refusals. +- **`ToolRules`** is one layer with a default. Inside a layer, effects combine + without regard to order: + - `deny` wins. + - With a `deny` default, a tool needs a matching `allow`. + - `hide` only clears visibility on the listing surfaces. + - `require_approval` beats `auto_approve`. +- **`ToolRuleSet`** stacks layers, and every layer must admit a tool. Adding a + layer can only narrow, so two allowlists intersect. +- **`ToolRuleSet::evaluate_call`** evaluates a call with the tool's + argument-aware permission. When the tool reports an `indirect_target`, an + `IndirectCall { target, arguments }`, it evaluates that target too. It uses + the target's own arguments when the dispatcher wraps them in an envelope, so + an argument-scoped rule cannot be sidestepped through the dispatcher. + +## Operational constraints + +- A matcher field naming an attribute the subject does not know does not + match. That fails open for `deny` and closed for `allow`, so evaluate + against the live tool wherever you have one. +- Off the call surface an `arg` condition cannot be decided. An `allow` reads + it optimistically and every other effect does not apply. +- A wrapper tool must forward `tags` and `indirect_target`, as `SharedTool` + does. Otherwise tag rules miss and a dispatcher's target escapes its rules. diff --git a/crates/tinytools/src/rules/eval.rs b/crates/tinytools/src/rules/eval.rs new file mode 100644 index 0000000..068cbee --- /dev/null +++ b/crates/tinytools/src/rules/eval.rs @@ -0,0 +1,384 @@ +//! Evaluating rules against a subject. + +use serde_json::Value; + +use crate::tool::Tool; + +use super::subject::ToolSubject; +use super::types::{ + ApprovalDirective, DefaultEffect, Patterns, RuleContext, RuleDecision, RuleEffect, RuleRef, + Surface, ToolMatcher, ToolRule, ToolRuleSet, ToolRules, +}; + +/// Whether `matcher` matches `subject`. `args` is the call's arguments on the +/// call surface and `None` elsewhere; `optimistic_args` makes an argument +/// match count as satisfied when there are no arguments to check. +/// +/// Every string comparison goes through [`Patterns::matches`], which is +/// [`glob_matches`](super::glob::glob_matches): ASCII case-insensitive. A +/// deny for `GMAIL_DELETE_*` therefore also refuses `gmail_delete_message`; +/// changing a name's case cannot sidestep a rule. +fn matcher_matches( + matcher: &ToolMatcher, + subject: &ToolSubject, + args: Option<&Value>, + optimistic_args: bool, +) -> bool { + let name_ok = matcher + .name + .as_ref() + .is_none_or(|patterns| patterns.matches(&subject.name)); + let family_ok = matcher.family.as_ref().is_none_or(|patterns| { + subject + .family + .as_deref() + .is_some_and(|family| patterns.matches(family)) + }); + let tags_ok = matcher + .tags + .as_ref() + .is_none_or(|patterns| patterns.matches_any(subject.tags.iter().map(String::as_str))); + let category_ok = matcher.category.as_ref().is_none_or(|categories| { + subject + .category + .is_some_and(|category| categories.contains(&category)) + }); + let exposure_ok = matcher.exposure.as_ref().is_none_or(|exposures| { + subject + .exposure + .is_some_and(|exposure| exposures.contains(&exposure)) + }); + let at_least_ok = matcher + .permission_at_least + .is_none_or(|floor| subject.permission.is_some_and(|level| level >= floor)); + let at_most_ok = matcher + .permission_at_most + .is_none_or(|ceiling| subject.permission.is_some_and(|level| level <= ceiling)); + let effects_ok = matcher.side_effects.as_ref().is_none_or(|wanted| { + subject + .side_effects + .is_some_and(|declared| wanted.iter().any(|effect| effect.declared_by(&declared))) + }); + let arg_ok = matcher.arg.as_ref().is_none_or(|arg| match args { + Some(args) => arg.matches(args), + None => optimistic_args, + }); + name_ok + && family_ok + && tags_ok + && category_ok + && exposure_ok + && at_least_ok + && at_most_ok + && effects_ok + && arg_ok +} + +fn context_matches( + when: &std::collections::BTreeMap, + context: &RuleContext, +) -> bool { + when.iter().all(|(key, patterns)| { + context + .get(key) + .is_some_and(|value| patterns.matches(value)) + }) +} + +impl ToolRule { + /// Whether this rule matches `subject` in `context` on `surface`. + /// + /// `args` is the call's arguments and is only consulted on + /// [`Surface::Call`]. Elsewhere an argument condition cannot be decided, + /// so it is read in the direction that never over-restricts a listing: + /// an `allow` with an argument condition still lists the tool (some call + /// of it may be allowed), while every other effect does not apply until + /// there is a call to check. The call itself is then decided exactly. + #[must_use] + pub fn matches( + &self, + subject: &ToolSubject, + context: &RuleContext, + surface: Surface, + args: Option<&Value>, + ) -> bool { + if !self.applies_on(surface) || !context_matches(&self.when, context) { + return false; + } + let args = if surface == Surface::Call { args } else { None }; + let optimistic = self.effect == RuleEffect::Allow && surface != Surface::Call; + if !matcher_matches(&self.matcher, subject, args, optimistic) { + return false; + } + // The carve-out is read pessimistically for the rule: an `except` + // with an argument condition carves nothing out until a call proves + // it applies. + !self + .except + .as_ref() + .is_some_and(|except| matcher_matches(except, subject, args, false)) + } +} + +impl ToolRules { + /// An empty layer that admits everything. + #[must_use] + pub fn allow_all() -> Self { + Self::default() + } + + /// An empty layer that refuses everything. + #[must_use] + pub fn deny_all() -> Self { + Self { + default: DefaultEffect::Deny, + ..Self::default() + } + } + + /// The common legacy shape: an optional allowlist and a denylist of name + /// globs. An empty `allow` admits everything not denied; a non-empty one + /// makes this layer an allowlist. + #[must_use] + pub fn from_allow_deny(allow: A, deny: D) -> Self + where + A: IntoIterator, + A::Item: Into, + D: IntoIterator, + D::Item: Into, + { + let allow: Patterns = allow.into_iter().collect(); + let deny: Patterns = deny.into_iter().collect(); + let mut rules = Self::allow_all(); + if !allow.0.is_empty() { + rules.default = DefaultEffect::Deny; + rules + .rules + .push(ToolRule::names(RuleEffect::Allow, allow.0)); + } + if !deny.0.is_empty() { + rules.rules.push(ToolRule::names(RuleEffect::Deny, deny.0)); + } + rules + } + + /// Sets [`Self::name`]. + #[must_use] + pub fn named(mut self, name: impl Into) -> Self { + self.name = Some(name.into()); + self + } + + /// Appends a rule. + #[must_use] + pub fn with_rule(mut self, rule: ToolRule) -> Self { + self.rules.push(rule); + self + } + + /// Whether this layer has no rules and admits everything, so a host can + /// skip it. + #[must_use] + pub fn is_permissive(&self) -> bool { + self.default == DefaultEffect::Allow && self.rules.is_empty() + } + + /// Decides `subject` on `surface`. See [`ToolRules`] for how effects + /// combine and [`ToolRule::matches`] for how argument conditions read + /// off the call surface. + #[must_use] + pub fn evaluate( + &self, + subject: &ToolSubject, + context: &RuleContext, + surface: Surface, + args: Option<&Value>, + ) -> RuleDecision { + self.evaluate_layer(0, subject, context, surface, args) + } + + fn evaluate_layer( + &self, + layer: usize, + subject: &ToolSubject, + context: &RuleContext, + surface: Surface, + args: Option<&Value>, + ) -> RuleDecision { + let reference = |index: Option| RuleRef { + layer, + layer_name: self.name.clone(), + rule: index, + id: index.and_then(|i| self.rules[i].id.clone()), + reason: index.and_then(|i| self.rules[i].reason.clone()), + }; + let (mut deny, mut hide, mut allowed) = (None, None, false); + let mut approval = ApprovalDirective::Default; + for (index, rule) in self.rules.iter().enumerate() { + if !rule.matches(subject, context, surface, args) { + continue; + } + match rule.effect { + RuleEffect::Allow => allowed = true, + RuleEffect::Deny => { + deny.get_or_insert(index); + } + RuleEffect::Hide => { + hide.get_or_insert(index); + } + RuleEffect::RequireApproval => approval = ApprovalDirective::Required, + RuleEffect::AutoApprove => { + approval = approval.strictest(ApprovalDirective::Waived); + } + } + } + + if let Some(index) = deny { + return RuleDecision { + visible: false, + callable: false, + approval, + blocked_by: Some(reference(Some(index))), + }; + } + if !allowed && self.default == DefaultEffect::Deny { + return RuleDecision { + visible: false, + callable: false, + approval, + blocked_by: Some(reference(None)), + }; + } + // `hide` only ever removes from the listing surfaces; a call it + // matches is still visible as far as that call is concerned. + let hide = hide.filter(|_| surface != Surface::Call); + RuleDecision { + visible: hide.is_none(), + callable: true, + approval, + blocked_by: hide.map(|index| reference(Some(index))), + } + } +} + +impl ToolRuleSet { + /// A set with no layers, which admits everything. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// A set holding one layer. + #[must_use] + pub fn single(layer: ToolRules) -> Self { + Self { + layers: vec![layer], + } + } + + /// Adds a layer. Adding can only narrow what the set admits. + #[must_use] + pub fn with_layer(mut self, layer: ToolRules) -> Self { + self.push(layer); + self + } + + /// Adds a layer in place, skipping one that admits everything. + pub fn push(&mut self, layer: ToolRules) { + if !layer.is_permissive() { + self.layers.push(layer); + } + } + + /// Adds every layer of `other`. + pub fn extend(&mut self, other: ToolRuleSet) { + for layer in other.layers { + self.push(layer); + } + } + + /// Whether the set admits everything, so a host can skip evaluation. + #[must_use] + pub fn is_permissive(&self) -> bool { + self.layers.iter().all(ToolRules::is_permissive) + } + + /// Decides `subject` on `surface` against every layer: each must admit + /// for the set to admit, the first refusal is reported, and approval + /// takes the strictest directive any layer gave. + #[must_use] + pub fn evaluate( + &self, + subject: &ToolSubject, + context: &RuleContext, + surface: Surface, + args: Option<&Value>, + ) -> RuleDecision { + let mut combined = RuleDecision::allow(); + for (index, layer) in self.layers.iter().enumerate() { + let decision = layer.evaluate_layer(index, subject, context, surface, args); + combined = combine(combined, decision); + } + combined + } + + /// Whether `subject` may be listed on `surface` (catalogue or search). + #[must_use] + pub fn visible(&self, subject: &ToolSubject, context: &RuleContext, surface: Surface) -> bool { + self.evaluate(subject, context, surface, None).visible + } + + /// Decides a concrete call of `tool` with `args`. + /// + /// The tool is evaluated with its argument-aware permission level, and + /// when it dispatches to another tool ([`Tool::indirect_target`]) the + /// target is evaluated too: a rule against `GMAIL_DELETE_*` refuses a + /// connector's generic execute tool aimed at it. Both must be callable, + /// and approval takes the stricter directive. + #[must_use] + pub fn evaluate_call( + &self, + tool: &dyn Tool, + context: &RuleContext, + args: &Value, + ) -> RuleDecision { + let subject = ToolSubject::of_call(tool, args); + let direct = self.evaluate(&subject, context, Surface::Call, Some(args)); + match tool.indirect_target(args) { + Some(call) => { + // The target's own arguments, so an argument-scoped rule reads + // the same values as on a direct call of the target. + let target_args = call.arguments.as_ref().unwrap_or(args); + let indirect = + self.evaluate(&call.target, context, Surface::Call, Some(target_args)); + combine(direct, indirect) + } + None => direct, + } + } +} + +/// Folds two decisions: both must admit; the first refusal is kept. +fn combine(first: RuleDecision, second: RuleDecision) -> RuleDecision { + let blocked_by = if !first.callable { + first.blocked_by + } else if !second.callable { + second.blocked_by + } else { + first.blocked_by.or(second.blocked_by) + }; + RuleDecision { + visible: first.visible && second.visible, + callable: first.callable && second.callable, + approval: first.approval.strictest(second.approval), + blocked_by, + } +} + +impl From for ToolRuleSet { + fn from(layer: ToolRules) -> Self { + let mut set = Self::new(); + set.push(layer); + set + } +} diff --git a/crates/tinytools/src/rules/glob.rs b/crates/tinytools/src/rules/glob.rs new file mode 100644 index 0000000..87a51a3 --- /dev/null +++ b/crates/tinytools/src/rules/glob.rs @@ -0,0 +1,44 @@ +//! The pattern language rules use: `*` and `?`, ASCII case-insensitive. +//! +//! Hand-rolled rather than pulled from a glob crate. Tool names are not paths, +//! so there is no separator for `*` to stop at and no character classes worth +//! their weight, and this crate's dependency list is reviewed in CI. + +/// Whether `text` matches `pattern`. +/// +/// `*` matches any run of characters (including none), `?` matches exactly +/// one, and everything else matches itself, ignoring ASCII case so +/// `gmail_*` matches the upper-case Composio slug `GMAIL_SEND_EMAIL`. An empty +/// pattern matches only an empty string. +#[must_use] +pub fn glob_matches(pattern: &str, text: &str) -> bool { + let pattern: Vec = pattern.chars().collect(); + let text: Vec = text.chars().collect(); + let (mut p, mut t) = (0usize, 0usize); + // Where the last `*` was, and the text position it is currently absorbing + // up to. Backtracking to it is the only backtracking needed: a later `*` + // subsumes every choice an earlier one could make. + let mut star: Option<(usize, usize)> = None; + + while t < text.len() { + match pattern.get(p) { + Some('*') => { + star = Some((p, t)); + p += 1; + } + Some(&c) if c == '?' || c.eq_ignore_ascii_case(&text[t]) => { + p += 1; + t += 1; + } + _ => match star { + Some((star_p, star_t)) => { + p = star_p + 1; + t = star_t + 1; + star = Some((star_p, star_t + 1)); + } + None => return false, + }, + } + } + pattern[p..].iter().all(|&c| c == '*') +} diff --git a/crates/tinytools/src/rules/mod.rs b/crates/tinytools/src/rules/mod.rs new file mode 100644 index 0000000..6eeac6a --- /dev/null +++ b/crates/tinytools/src/rules/mod.rs @@ -0,0 +1,80 @@ +//! Declarative allow / deny / hide / approval rules over tools. +//! +//! # Why this exists +//! +//! A host restricts tools from many places — an agent definition's +//! allowlist, a channel's permission ceiling, an MCP server's tool filter, a +//! connector's curated actions, a user's toggles — and each used to carry its +//! own exact-name matcher, applied on whichever surface its author thought +//! of. A tool denied by middleware still turned up in tool search; a +//! wildcard worked in one list and not the next. +//! +//! This module is the shared vocabulary for those decisions. The rules are +//! data: the host writes them from its own configuration and threat model, +//! and the harness evaluates them at every surface a tool can reach the +//! model on — the catalogue, search, and the call itself. Nothing here +//! chooses a policy; it only gives every policy the same words. +//! +//! # Shape +//! +//! - A [`ToolRule`] has an [`RuleEffect`], the [`Surface`]s it applies on, a +//! [`ToolMatcher`] (name, family and tag globs; category; exposure; +//! permission bounds; declared side effects; one argument), an optional +//! `except` carve-out and `when` context conditions. +//! - A [`ToolRules`] layer holds rules and a default. Inside a layer effects +//! combine without regard to order: `deny` beats everything, `hide` only +//! removes from listings, `require_approval` beats `auto_approve`. +//! - A [`ToolRuleSet`] stacks layers from independent sources; every layer +//! must admit, so adding one only ever narrows. +//! - A [`ToolSubject`] is what is evaluated: built from a live tool, or by +//! hand when a host only has a schema. +//! +//! # Example +//! +//! ``` +//! use tinytools::{RuleContext, Surface, ToolRules, ToolRuleSet, ToolSubject}; +//! +//! let rules: ToolRules = serde_json::from_value(serde_json::json!({ +//! "name": "config", +//! "rules": [ +//! { +//! "id": "no-mcp", +//! "effect": "deny", +//! "match": { "name": "mcp_*" }, +//! "except": { "family": "github" }, +//! "reason": "Only the GitHub server is approved.", +//! }, +//! { "effect": "hide", "match": { "name": "gmail_*" }, "on": ["catalog", "search"] }, +//! ], +//! }))?; +//! let set = ToolRuleSet::from(rules); +//! let context = RuleContext::new(); +//! +//! let slack = ToolSubject::named("mcp_slack_post_1a2b3c").with_family("slack"); +//! let github = ToolSubject::named("mcp_github_issue_4d5e6f").with_family("github"); +//! assert!(!set.visible(&slack, &context, Surface::Catalog)); +//! assert!(set.visible(&github, &context, Surface::Catalog)); +//! +//! // Hidden from listings, still callable. +//! let send = ToolSubject::named("GMAIL_SEND_EMAIL"); +//! let decision = set.evaluate(&send, &context, Surface::Call, None); +//! assert!(!set.visible(&send, &context, Surface::Search)); +//! assert!(decision.callable); +//! # Ok::<(), serde_json::Error>(()) +//! ``` + +mod eval; +mod glob; +mod subject; +mod types; + +pub use glob::glob_matches; +pub use subject::{IndirectCall, ToolSubject}; +pub use types::{ + ApprovalDirective, ArgMatcher, DefaultEffect, Patterns, RuleContext, RuleDecision, RuleEffect, + RuleRef, SideEffect, Surface, ToolMatcher, ToolRule, ToolRuleSet, ToolRules, +}; + +#[cfg(test)] +#[path = "mod_tests.rs"] +mod tests; diff --git a/crates/tinytools/src/rules/mod_tests.rs b/crates/tinytools/src/rules/mod_tests.rs new file mode 100644 index 0000000..7aef710 --- /dev/null +++ b/crates/tinytools/src/rules/mod_tests.rs @@ -0,0 +1,774 @@ +//! Behaviour of the rule engine: matching, precedence, surfaces, layering, +//! indirect targets and the serde representation. + +#![allow(clippy::expect_used, clippy::panic, clippy::unwrap_used)] + +use super::*; + +use async_trait::async_trait; +use serde_json::{Value, json}; + +use crate::{ + PermissionLevel, Tool, ToolCategory, ToolExposure, ToolPolicy, ToolResult, ToolSideEffects, +}; + +fn ctx() -> RuleContext { + RuleContext::new() +} + +fn layer(value: Value) -> ToolRules { + serde_json::from_value(value).expect("rules parse") +} + +fn decide(rules: &ToolRules, subject: &ToolSubject, surface: Surface) -> RuleDecision { + rules.evaluate(subject, &ctx(), surface, None) +} + +// ── glob ────────────────────────────────────────────────────────────────── + +#[test] +fn glob_star_and_question_mark() { + assert!(glob_matches("mcp_*", "mcp_github_issue_abc123")); + assert!(glob_matches("*", "")); + assert!(glob_matches("*", "anything")); + assert!(glob_matches("a?c", "abc")); + assert!(!glob_matches("a?c", "ac")); + assert!(glob_matches("*_issue_*", "mcp_github_issue_abc123")); + assert!(glob_matches("a*b*c", "aXXbYYbZZc")); + assert!(!glob_matches("a*b*c", "aXXbYY")); + assert!(glob_matches("**", "x")); +} + +#[test] +fn glob_is_ascii_case_insensitive() { + assert!(glob_matches("gmail_*", "GMAIL_SEND_EMAIL")); + assert!(glob_matches("GMAIL_SEND_EMAIL", "gmail_send_email")); +} + +#[test] +fn empty_glob_matches_only_empty() { + assert!(glob_matches("", "")); + assert!(!glob_matches("", "x")); + assert!(!glob_matches("x", "")); +} + +// ── matching ────────────────────────────────────────────────────────────── + +#[test] +fn empty_layer_admits_everything() { + let rules = ToolRules::allow_all(); + let decision = decide(&rules, &ToolSubject::named("shell"), Surface::Call); + assert_eq!(decision, RuleDecision::allow()); + assert!(rules.is_permissive()); +} + +#[test] +fn deny_beats_allow_inside_a_layer() { + let rules = layer(json!({ + "default": "deny", + "rules": [ + { "effect": "allow", "match": { "name": "*" } }, + { "id": "no-shell", "effect": "deny", "match": { "name": "shell" }, "reason": "no shells" }, + ], + })); + let decision = decide(&rules, &ToolSubject::named("shell"), Surface::Call); + assert!(!decision.callable && !decision.visible); + let by = decision.blocked_by.expect("blocked"); + assert_eq!(by.rule, Some(1)); + assert_eq!(by.id.as_deref(), Some("no-shell")); + assert!(decide(&rules, &ToolSubject::named("file_read"), Surface::Call).callable); +} + +#[test] +fn default_deny_needs_an_allow() { + let rules = ToolRules::from_allow_deny(["file_*"], Vec::::new()); + assert!(decide(&rules, &ToolSubject::named("file_read"), Surface::Catalog).visible); + let refused = decide(&rules, &ToolSubject::named("shell"), Surface::Catalog); + assert!(!refused.visible); + assert_eq!(refused.blocked_by.expect("blocked").rule, None); +} + +#[test] +fn from_allow_deny_with_empty_allow_is_a_denylist() { + let rules = ToolRules::from_allow_deny(Vec::::new(), ["spawn_*"]); + assert_eq!(rules.default, DefaultEffect::Allow); + assert!(!decide(&rules, &ToolSubject::named("spawn_subagent"), Surface::Call).callable); + assert!(decide(&rules, &ToolSubject::named("shell"), Surface::Call).callable); +} + +#[test] +fn hide_keeps_a_tool_callable() { + let rules = layer(json!({ "rules": [ { "effect": "hide", "match": { "tags": "pack:*" } } ] })); + let subject = ToolSubject::named("gmail_send").with_tag("pack:gmail"); + assert!(!decide(&rules, &subject, Surface::Catalog).visible); + assert!(!decide(&rules, &subject, Surface::Search).visible); + let call = decide(&rules, &subject, Surface::Call); + assert!(call.callable); + assert!(call.visible, "hide does not apply to the call surface"); + assert_eq!(call.blocked_by, None); + assert!(decide(&rules, &ToolSubject::named("shell"), Surface::Catalog).visible); +} + +#[test] +fn rules_apply_only_on_their_surfaces() { + let rules = layer( + json!({ "rules": [ { "effect": "deny", "on": ["search"], "match": { "name": "x" } } ] }), + ); + let subject = ToolSubject::named("x"); + assert!(decide(&rules, &subject, Surface::Catalog).visible); + assert!(!decide(&rules, &subject, Surface::Search).visible); + assert!(decide(&rules, &subject, Surface::Call).callable); +} + +#[test] +fn except_carves_out_of_a_rule() { + let rules = layer(json!({ "rules": [ + { "effect": "deny", "match": { "name": "mcp_*" }, "except": { "family": "github" } }, + ] })); + let github = ToolSubject::named("mcp_github_issue_1").with_family("github"); + let slack = ToolSubject::named("mcp_slack_post_1").with_family("slack"); + let unknown = ToolSubject::named("mcp_bare_1"); + assert!(decide(&rules, &github, Surface::Call).callable); + assert!(!decide(&rules, &slack, Surface::Call).callable); + assert!(!decide(&rules, &unknown, Surface::Call).callable); +} + +#[test] +fn unknown_attributes_do_not_match() { + let rules = layer(json!({ "rules": [ + { "effect": "deny", "match": { "permission_at_least": "Execute" } }, + ] })); + // A bare-name subject has no permission: the deny cannot apply. + assert!(decide(&rules, &ToolSubject::named("shell"), Surface::Call).callable); + let known = ToolSubject::named("shell").with_permission(PermissionLevel::Execute); + assert!(!decide(&rules, &known, Surface::Call).callable); +} + +#[test] +fn permission_bounds_category_exposure_and_effects() { + let subject = ToolSubject { + name: "pay".into(), + family: None, + tags: Vec::new(), + category: Some(ToolCategory::Workflow), + exposure: Some(ToolExposure::Deferred), + permission: Some(PermissionLevel::Write), + side_effects: Some(ToolSideEffects { + payment: true, + ..ToolSideEffects::default() + }), + }; + let denies = |matcher: Value| { + let rules = layer(json!({ "rules": [ { "effect": "deny", "match": matcher } ] })); + !decide(&rules, &subject, Surface::Call).callable + }; + assert!(denies(json!({ "permission_at_most": "Write" }))); + assert!(!denies(json!({ "permission_at_most": "ReadOnly" }))); + assert!(denies(json!({ "permission_at_least": "Write" }))); + // Named by value: `ToolCategory::Workflow` serializes as its pinned wire + // name `"skill"` (see `category_matches_its_pinned_wire_name`). + assert!(denies(json!({ "category": [ToolCategory::Workflow] }))); + assert!(!denies(json!({ "category": [ToolCategory::System] }))); + assert!(denies(json!({ "exposure": ["deferred"] }))); + assert!(!denies(json!({ "exposure": ["direct", "hidden"] }))); + assert!(denies( + json!({ "side_effects": ["destructive", "payment"] }) + )); + assert!(!denies(json!({ "side_effects": ["network"] }))); + assert!(!denies(json!({ "family": "*" }))); + assert!(denies( + json!({ "name": ["x", "p*"], "category": ["skill"] }) + )); +} + +#[test] +fn when_matches_the_context() { + let rules = layer(json!({ "rules": [ + { "effect": "deny", "match": { "name": "shell" }, "when": { "channel": "telegram*" } }, + ] })); + let shell = ToolSubject::named("shell"); + let telegram = RuleContext::new().with("channel", "telegram"); + let web = RuleContext::new().with("channel", "web"); + assert!( + !rules + .evaluate(&shell, &telegram, Surface::Call, None) + .callable + ); + assert!(rules.evaluate(&shell, &web, Surface::Call, None).callable); + assert!(rules.evaluate(&shell, &ctx(), Surface::Call, None).callable); +} + +// ── arguments ───────────────────────────────────────────────────────────── + +#[test] +fn arg_rules_decide_calls_and_read_listings_safely() { + let rules = layer(json!({ + "default": "deny", + "rules": [ + { "effect": "allow", "match": { "name": "composio_execute", "arg": { "pointer": "/action", "value": "GMAIL_*" } } }, + { "effect": "deny", "match": { "arg": { "pointer": "/action", "value": "*_DELETE_*" } } }, + ], + })); + let execute = ToolSubject::named("composio_execute"); + // Listings: the allow reads optimistically, the deny cannot apply yet. + assert!(decide(&rules, &execute, Surface::Catalog).visible); + assert!(decide(&rules, &execute, Surface::Search).visible); + let call = |action: &str| { + rules + .evaluate( + &execute, + &ctx(), + Surface::Call, + Some(&json!({ "action": action })), + ) + .callable + }; + assert!(call("GMAIL_SEND_EMAIL")); + assert!(!call("GMAIL_DELETE_EMAIL")); + assert!(!call("SLACK_POST")); + // A call with no arguments supplied cannot satisfy an argument allow. + assert!( + !rules + .evaluate(&execute, &ctx(), Surface::Call, None) + .callable + ); +} + +#[test] +fn category_matches_its_pinned_wire_name() { + // Agent definition files on disk spell `Workflow` as "skill". + let rules = + layer(json!({ "rules": [ { "effect": "deny", "match": { "category": ["skill"] } } ] })); + let subject = ToolSubject { + category: Some(ToolCategory::Workflow), + ..ToolSubject::named("x") + }; + assert!(!decide(&rules, &subject, Surface::Call).callable); +} + +#[test] +fn arg_matcher_supplies_a_missing_leading_slash() { + let bare = ArgMatcher { + pointer: "action".into(), + value: Patterns::one("GMAIL_*"), + }; + assert!(bare.matches(&json!({ "action": "GMAIL_SEND_EMAIL" }))); + let root = ArgMatcher { + pointer: String::new(), + value: Patterns::one("whole"), + }; + assert!(root.matches(&json!("whole"))); +} + +#[test] +fn arg_matcher_reads_scalars_only() { + let matcher = ArgMatcher { + pointer: "/n".into(), + value: Patterns::one("4*"), + }; + assert!(matcher.matches(&json!({ "n": 42 }))); + assert!(!matcher.matches(&json!({ "n": [42] }))); + assert!(!matcher.matches(&json!({}))); + let flag = ArgMatcher { + pointer: "/f".into(), + value: Patterns::one("true"), + }; + assert!(flag.matches(&json!({ "f": true }))); +} + +// ── approval ────────────────────────────────────────────────────────────── + +#[test] +fn require_approval_beats_auto_approve() { + let rules = layer(json!({ "rules": [ + { "effect": "auto_approve", "match": { "name": "*" } }, + { "effect": "require_approval", "match": { "name": "send_*" } }, + ] })); + let send = decide(&rules, &ToolSubject::named("send_email"), Surface::Call); + assert_eq!(send.approval, ApprovalDirective::Required); + assert!(send.callable); + let read = decide(&rules, &ToolSubject::named("read_file"), Surface::Call); + assert_eq!(read.approval, ApprovalDirective::Waived); + let none = decide( + &ToolRules::allow_all(), + &ToolSubject::named("x"), + Surface::Call, + ); + assert_eq!(none.approval, ApprovalDirective::Default); +} + +#[test] +fn approval_directive_strictest_order() { + use ApprovalDirective::{Default, Required, Waived}; + assert_eq!(Default.strictest(Waived), Waived); + assert_eq!(Waived.strictest(Required), Required); + assert_eq!(Default.strictest(Default), Default); +} + +// ── layering ────────────────────────────────────────────────────────────── + +#[test] +fn layers_intersect_allowlists() { + let set = ToolRuleSet::new() + .with_layer( + ToolRules::from_allow_deny(["file_*", "shell"], Vec::::new()).named("config"), + ) + .with_layer( + ToolRules::from_allow_deny(["file_*", "web_*"], Vec::::new()).named("agent"), + ); + let visible = |name: &str| set.visible(&ToolSubject::named(name), &ctx(), Surface::Catalog); + assert!(visible("file_read")); + assert!(!visible("shell")); + assert!(!visible("web_fetch")); + let refused = set.evaluate( + &ToolSubject::named("web_fetch"), + &ctx(), + Surface::Call, + None, + ); + let by = refused.blocked_by.expect("blocked"); + assert_eq!((by.layer, by.layer_name.as_deref()), (0, Some("config"))); +} + +#[test] +fn permissive_layers_are_skipped() { + let mut set = ToolRuleSet::new(); + set.push(ToolRules::allow_all()); + assert_eq!(set.layers, Vec::::new()); + assert!(set.is_permissive()); + set.extend(ToolRuleSet::single(ToolRules::deny_all())); + assert!(!set.is_permissive()); + assert!(!set.visible(&ToolSubject::named("x"), &ctx(), Surface::Catalog)); + assert_eq!( + ToolRuleSet::from(ToolRules::allow_all()).layers, + Vec::::new() + ); +} + +#[test] +fn layer_approval_takes_the_strictest() { + let set = ToolRuleSet::new() + .with_layer(layer( + json!({ "rules": [ { "effect": "auto_approve", "match": {} } ] }), + )) + .with_layer(layer( + json!({ "rules": [ { "effect": "require_approval", "match": { "name": "x" } } ] }), + )); + let decision = set.evaluate(&ToolSubject::named("x"), &ctx(), Surface::Call, None); + assert_eq!(decision.approval, ApprovalDirective::Required); +} + +#[test] +fn a_hidden_tool_reports_the_hiding_rule_but_stays_callable() { + let set = ToolRuleSet::new().with_layer(layer( + json!({ "rules": [ { "id": "quiet", "effect": "hide", "match": { "name": "x" } } ] }), + )); + let decision = set.evaluate(&ToolSubject::named("x"), &ctx(), Surface::Catalog, None); + assert!(!decision.admits(Surface::Catalog)); + assert!(decision.admits(Surface::Call)); + assert_eq!( + decision.blocked_by.expect("hidden").id.as_deref(), + Some("quiet") + ); +} + +// ── tools and indirect targets ──────────────────────────────────────────── + +struct Execute; + +#[async_trait] +impl Tool for Execute { + fn name(&self) -> &'static str { + "composio_execute" + } + fn description(&self) -> &'static str { + "Runs a connector action." + } + fn parameters_schema(&self) -> Value { + json!({ "type": "object" }) + } + async fn execute(&self, _args: Value) -> anyhow::Result { + Ok(ToolResult::success("ok")) + } + fn family(&self) -> Option<&str> { + Some("composio") + } + fn tags(&self) -> Vec { + vec!["connector".into()] + } + fn permission_level_with_args(&self, args: &Value) -> PermissionLevel { + if args["action"] + .as_str() + .is_some_and(|a| a.contains("DELETE")) + { + PermissionLevel::Dangerous + } else { + PermissionLevel::Write + } + } + fn policy(&self) -> ToolPolicy { + ToolPolicy::classified().with_side_effects(ToolSideEffects { + external_service: true, + ..ToolSideEffects::default() + }) + } + fn indirect_target(&self, args: &Value) -> Option { + let action = args.get("action")?.as_str()?; + let toolkit = action.split('_').next()?; + let call = + IndirectCall::new(ToolSubject::named(action).with_family(toolkit.to_ascii_lowercase())); + Some(match args.get("arguments") { + Some(inner) => call.with_arguments(inner.clone()), + None => call, + }) + } +} + +#[test] +fn subject_of_reads_the_tool_declarations() { + let subject = ToolSubject::of(&Execute); + assert_eq!(subject.name, "composio_execute"); + assert_eq!(subject.family.as_deref(), Some("composio")); + assert_eq!(subject.tags, vec!["connector".to_string()]); + assert_eq!(subject.category, Some(ToolCategory::System)); + assert_eq!(subject.exposure, Some(ToolExposure::Direct)); + assert_eq!(subject.permission, Some(PermissionLevel::ReadOnly)); + assert!(subject.side_effects.is_some_and(|e| e.external_service)); + let call = ToolSubject::of_call(&Execute, &json!({ "action": "GMAIL_DELETE_EMAIL" })); + assert_eq!(call.permission, Some(PermissionLevel::Dangerous)); +} + +struct LegacyEffect; + +#[async_trait] +impl Tool for LegacyEffect { + fn name(&self) -> &'static str { + "send_message" + } + fn description(&self) -> &'static str { + "Sends a message; declares its effect the pre-policy way." + } + fn parameters_schema(&self) -> Value { + json!({ "type": "object" }) + } + async fn execute(&self, _args: Value) -> anyhow::Result { + Ok(ToolResult::success("sent")) + } + fn external_effect_with_args(&self, args: &Value) -> bool { + args["dry_run"].as_bool() != Some(true) + } +} + +struct LegacyAlwaysExternal; + +#[async_trait] +impl Tool for LegacyAlwaysExternal { + fn name(&self) -> &'static str { + "post_webhook" + } + fn description(&self) -> &'static str { + "Posts a webhook." + } + fn parameters_schema(&self) -> Value { + json!({ "type": "object" }) + } + async fn execute(&self, _args: Value) -> anyhow::Result { + Ok(ToolResult::success("posted")) + } + fn external_effect(&self) -> bool { + true + } +} + +struct RefinedComposite; + +#[async_trait] +impl Tool for RefinedComposite { + fn name(&self) -> &'static str { + "crm" + } + fn description(&self) -> &'static str { + "Reads or writes a CRM record." + } + fn parameters_schema(&self) -> Value { + json!({ "type": "object" }) + } + async fn execute(&self, _args: Value) -> anyhow::Result { + Ok(ToolResult::success("ok")) + } + fn external_effect(&self) -> bool { + true + } + fn external_effect_with_args(&self, args: &Value) -> bool { + args["action"] != "read" + } +} + +#[test] +fn a_per_call_external_effect_supersedes_the_conservative_default() { + let set = ToolRuleSet::single(layer(json!({ "rules": [ + { "effect": "require_approval", "match": { "side_effects": ["external_service"] } }, + ] }))); + // Listed: conservatively external. + assert!( + ToolSubject::of(&RefinedComposite) + .side_effects + .is_some_and(|e| e.external_service) + ); + // Called: the read refines it away, the write keeps it. + let read = set.evaluate_call(&RefinedComposite, &ctx(), &json!({ "action": "read" })); + assert_eq!(read.approval, ApprovalDirective::Default); + let write = set.evaluate_call(&RefinedComposite, &ctx(), &json!({ "action": "write" })); + assert_eq!(write.approval, ApprovalDirective::Required); + // A policy that declares the effect explicitly keeps it for every call. + let declared = ToolSubject::of_call(&Execute, &json!({ "action": "X_READ" })); + assert!(declared.side_effects.is_some_and(|e| e.external_service)); +} + +#[test] +fn tool_subject_pins_its_wire_form() { + let subject = ToolSubject { + name: "mcp_github_issue_1".into(), + family: Some("github".into()), + tags: vec!["mcp.server:github".into()], + category: Some(ToolCategory::Workflow), + exposure: Some(ToolExposure::Deferred), + permission: Some(PermissionLevel::Execute), + side_effects: Some(ToolSideEffects { + external_service: true, + ..ToolSideEffects::default() + }), + }; + let value = serde_json::to_value(&subject).expect("serialize"); + assert_eq!(value["name"], "mcp_github_issue_1"); + assert_eq!(value["family"], "github"); + assert_eq!(value["tags"], json!(["mcp.server:github"])); + assert_eq!(value["category"], "skill"); + assert_eq!(value["exposure"], "deferred"); + assert_eq!(value["permission"], "Execute"); + assert_eq!(value["side_effects"]["external_service"], true); + assert_eq!( + serde_json::from_value::(value).expect("round trip"), + subject + ); + // Unknown attributes and empty tags are omitted. + assert_eq!( + serde_json::to_value(ToolSubject::named("x")).expect("bare"), + json!({ "name": "x" }) + ); +} + +#[test] +fn legacy_external_effect_declarations_reach_rules() { + let set = ToolRuleSet::single(layer(json!({ "rules": [ + { "effect": "require_approval", "match": { "side_effects": ["external_service"] } }, + ] }))); + // Argument-less declaration: listed and called subjects both see it. + let subject = ToolSubject::of(&LegacyAlwaysExternal); + assert!( + subject + .side_effects + .is_some_and(|effects| effects.external_service) + ); + assert_eq!( + set.evaluate_call(&LegacyAlwaysExternal, &ctx(), &json!({})) + .approval, + ApprovalDirective::Required + ); + // Argument-aware declaration: decided per call. + assert_eq!( + set.evaluate_call(&LegacyEffect, &ctx(), &json!({})) + .approval, + ApprovalDirective::Required + ); + assert_eq!( + set.evaluate_call(&LegacyEffect, &ctx(), &json!({ "dry_run": true })) + .approval, + ApprovalDirective::Default + ); +} + +#[test] +fn evaluate_call_checks_the_indirect_target() { + let set = ToolRuleSet::single(layer(json!({ "rules": [ + { "id": "no-gmail-delete", "effect": "deny", "match": { "name": "GMAIL_DELETE_*" } }, + { "effect": "require_approval", "match": { "family": "slack" } }, + ] }))); + let call = |action: &str| set.evaluate_call(&Execute, &ctx(), &json!({ "action": action })); + let refused = call("GMAIL_DELETE_EMAIL"); + assert!(!refused.callable); + assert_eq!( + refused.blocked_by.expect("blocked").id.as_deref(), + Some("no-gmail-delete") + ); + assert!(call("GMAIL_SEND_EMAIL").callable); + assert_eq!(call("SLACK_POST").approval, ApprovalDirective::Required); + // No target in the arguments: only the dispatcher is evaluated. + assert!(set.evaluate_call(&Execute, &ctx(), &json!({})).callable); +} + +#[test] +fn a_deny_cannot_be_sidestepped_by_changing_the_targets_case() { + let set = ToolRuleSet::single(layer(json!({ "rules": [ + { "id": "no-gmail-delete", "effect": "deny", "match": { "name": "GMAIL_DELETE_*" } }, + ] }))); + for action in [ + "GMAIL_DELETE_EMAIL", + "gmail_delete_email", + "Gmail_Delete_Email", + ] { + let decision = set.evaluate_call(&Execute, &ctx(), &json!({ "action": action })); + assert!(!decision.callable, "{action} must be refused"); + } + let direct = set.evaluate( + &ToolSubject::named("gmail_delete_message"), + &ctx(), + Surface::Call, + None, + ); + assert!(!direct.callable); +} + +#[test] +fn evaluate_call_reads_the_targets_own_arguments() { + let set = ToolRuleSet::single(layer(json!({ "rules": [ + { "id": "no-permanent-delete", "effect": "deny", + "match": { "name": "GMAIL_DELETE_*", "arg": { "pointer": "/permanent", "value": "true" } } }, + ] }))); + let wrapped = |permanent: bool| { + set.evaluate_call( + &Execute, + &ctx(), + &json!({ "action": "GMAIL_DELETE_EMAIL", "arguments": { "permanent": permanent } }), + ) + }; + let refused = wrapped(true); + assert!( + !refused.callable, + "the envelope does not hide the target's arguments" + ); + assert_eq!( + refused.blocked_by.expect("blocked").id.as_deref(), + Some("no-permanent-delete") + ); + assert!(wrapped(false).callable); + let call = IndirectCall::from(ToolSubject::named("x")); + assert_eq!(call.arguments, None); +} + +#[test] +fn evaluate_call_uses_the_argument_aware_permission() { + let set = ToolRuleSet::single(layer(json!({ "rules": [ + { "effect": "deny", "match": { "permission_at_least": "Dangerous" } }, + ] }))); + assert!( + !set.evaluate_call(&Execute, &ctx(), &json!({ "action": "X_DELETE" })) + .callable + ); + assert!( + set.evaluate_call(&Execute, &ctx(), &json!({ "action": "X_SEND" })) + .callable + ); +} + +// ── refusal text and serde ──────────────────────────────────────────────── + +#[test] +fn refusal_names_the_rule() { + let rules = layer(json!({ "name": "agent:researcher", "rules": [ + { "id": "no-shell", "effect": "deny", "match": { "name": "shell" }, "reason": "read-only agent" }, + { "effect": "deny", "match": { "name": "curl" } }, + ] })); + let message = decide(&rules, &ToolSubject::named("shell"), Surface::Call).refusal("shell"); + assert_eq!( + message, + "Tool 'shell' is not permitted by tool rules (rule 'no-shell') in 'agent:researcher': read-only agent." + ); + let anonymous = decide(&rules, &ToolSubject::named("curl"), Surface::Call).refusal("curl"); + assert!(anonymous.contains("(rule #1)"), "{anonymous}"); + let default = decide( + &ToolRules::deny_all(), + &ToolSubject::named("x"), + Surface::Call, + ) + .refusal("x"); + assert!(default.contains("(default deny)"), "{default}"); + assert_eq!( + RuleDecision::allow().refusal("x"), + "Tool 'x' is not permitted." + ); +} + +#[test] +fn serde_round_trips_and_pins_the_wire_form() { + let rules = ToolRules::from_allow_deny(["a"], ["b", "c"]) + .named("cfg") + .with_rule( + ToolRule::names(RuleEffect::Hide, ["x"]) + .with_id("h") + .with_reason("why") + .on([Surface::Catalog]) + .when("channel", Patterns::one("web")), + ) + .with_rule( + ToolRule::new(RuleEffect::Deny) + .matching(ToolMatcher { + family: Some(Patterns::one("slack")), + ..ToolMatcher::default() + }) + .except(ToolMatcher { + tags: Some(Patterns::one("safe")), + ..ToolMatcher::default() + }), + ); + let value = serde_json::to_value(&rules).expect("serialize"); + assert_eq!(value["default"], "deny"); + assert_eq!( + value["rules"][0], + json!({ "effect": "allow", "match": { "name": "a" } }) + ); + assert_eq!(value["rules"][1]["match"]["name"], json!(["b", "c"])); + assert_eq!( + value["rules"][2], + json!({ "id": "h", "effect": "hide", "on": ["catalog"], "match": { "name": "x" }, "when": { "channel": "web" }, "reason": "why" }) + ); + let back: ToolRules = serde_json::from_value(value).expect("deserialize"); + assert_eq!(back, rules); + + let set = ToolRuleSet::single(rules); + let wire = serde_json::to_value(&set).expect("serialize set"); + assert!(wire.is_array()); + assert_eq!( + serde_json::from_value::(wire).expect("set"), + set + ); +} + +#[test] +fn unknown_matcher_fields_are_rejected() { + let err = serde_json::from_value::(json!({ "rules": [ + { "effect": "deny", "match": { "nmae": "x" } }, + ] })) + .expect_err("typo rejected"); + assert!(err.to_string().contains("nmae"), "{err}"); +} + +#[test] +fn decision_serializes() { + let decision = decide( + &ToolRules::deny_all().named("n"), + &ToolSubject::named("x"), + Surface::Call, + ); + let value = serde_json::to_value(&decision).expect("serialize"); + assert_eq!(value["callable"], false); + assert_eq!(value["approval"], "default"); + assert_eq!( + value["blocked_by"], + json!({ "layer": 0, "layer_name": "n" }) + ); + let ctx_value = serde_json::to_value(RuleContext::new().with("channel", "web")).expect("ctx"); + assert_eq!(ctx_value, json!({ "channel": "web" })); + let subject = serde_json::to_value(ToolSubject::named("x").with_tag("t")).expect("subject"); + assert_eq!(subject, json!({ "name": "x", "tags": ["t"] })); +} diff --git a/crates/tinytools/src/rules/subject.rs b/crates/tinytools/src/rules/subject.rs new file mode 100644 index 0000000..5f723f5 --- /dev/null +++ b/crates/tinytools/src/rules/subject.rs @@ -0,0 +1,152 @@ +//! What rules are evaluated against: the attributes of one tool. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::classification::ToolCategory; +use crate::permission::PermissionLevel; +use crate::policy::ToolSideEffects; +use crate::tool::{Tool, ToolExposure}; + +/// The attributes of a tool a rule can match. +/// +/// Built from a live tool with [`Self::of`], which knows everything, or by +/// hand from whatever a host has at hand — a bare schema plus a family, a +/// target resolved from a dispatcher's arguments. Unknown attributes stay +/// `None`; see [`ToolMatcher`](super::ToolMatcher) for how that reads. +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +pub struct ToolSubject { + /// The tool name. + pub name: String, + /// The tool's family, when it has one. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub family: Option, + /// Host-assigned tags. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tags: Vec, + /// The tool's category, when known. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub category: Option, + /// The tool's exposure, when known. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub exposure: Option, + /// The privilege the tool (or this call of it) requires, when known. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub permission: Option, + /// The tool's declared side effects, when known. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub side_effects: Option, +} + +impl ToolSubject { + /// A subject that knows only its name. + #[must_use] + pub fn named(name: impl Into) -> Self { + Self { + name: name.into(), + ..Self::default() + } + } + + /// Sets [`Self::family`]. + #[must_use] + pub fn with_family(mut self, family: impl Into) -> Self { + self.family = Some(family.into()); + self + } + + /// Adds a tag. + #[must_use] + pub fn with_tag(mut self, tag: impl Into) -> Self { + self.tags.push(tag.into()); + self + } + + /// Sets [`Self::permission`]. + #[must_use] + pub fn with_permission(mut self, permission: PermissionLevel) -> Self { + self.permission = Some(permission); + self + } + + /// Everything `tool` declares about itself, with its argument-less + /// permission level. + /// + /// A tool that declares an outside effect only through the older + /// [`Tool::external_effect`] (its policy left unclassified) still reads as + /// `external_service`, so a rule on that side effect matches it. + #[must_use] + pub fn of(tool: &dyn Tool) -> Self { + let mut side_effects = tool.policy().side_effects; + side_effects.external_service |= tool.external_effect(); + Self { + name: tool.name().to_string(), + family: tool.family().map(str::to_string), + tags: tool.tags(), + category: Some(tool.category()), + exposure: Some(tool.exposure()), + permission: Some(tool.permission_level()), + side_effects: Some(side_effects), + } + } + + /// [`Self::of`], with the permission level and external effect `tool` + /// declares for `args`. + /// + /// The per-call answer supersedes the argument-free one, so a composite + /// that is conservatively external but refines a read-only call to + /// `false` reads as such; an effect the tool's policy declares explicitly + /// is kept either way. + #[must_use] + pub fn of_call(tool: &dyn Tool, args: &Value) -> Self { + let mut subject = Self::of(tool); + subject.permission = Some(tool.permission_level_with_args(args)); + let declared = tool.policy().side_effects.external_service; + if let Some(effects) = subject.side_effects.as_mut() { + effects.external_service = declared || tool.external_effect_with_args(args); + } + subject + } +} + +/// The tool a dispatcher's call actually reaches, and the arguments that +/// tool receives. +/// +/// Returned by [`Tool::indirect_target`]. A dispatcher that wraps its +/// target's arguments in an envelope — `{"action": "...", "arguments": {...}}` +/// — reports the inner arguments here, so an argument-scoped rule written for +/// the target reads the same values whether the target is called directly or +/// through the dispatcher. `None` means the target receives the dispatcher's +/// own arguments unchanged. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct IndirectCall { + /// The target tool. + pub target: ToolSubject, + /// The arguments the target receives, when they differ from the + /// dispatcher's. + pub arguments: Option, +} + +impl IndirectCall { + /// A call of `target` with the dispatcher's own arguments. + #[must_use] + pub fn new(target: ToolSubject) -> Self { + Self { + target, + arguments: None, + } + } + + /// Sets the arguments the target receives. + #[must_use] + pub fn with_arguments(mut self, arguments: Value) -> Self { + self.arguments = Some(arguments); + self + } +} + +impl From for IndirectCall { + fn from(target: ToolSubject) -> Self { + Self::new(target) + } +} diff --git a/crates/tinytools/src/rules/types.rs b/crates/tinytools/src/rules/types.rs new file mode 100644 index 0000000..c178ffb --- /dev/null +++ b/crates/tinytools/src/rules/types.rs @@ -0,0 +1,505 @@ +//! The declarative shapes of a rule set: what a rule matches, what it does, +//! and on which surfaces. + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::classification::ToolCategory; +use crate::permission::PermissionLevel; +use crate::policy::ToolSideEffects; +use crate::tool::ToolExposure; + +use super::glob::glob_matches; + +/// One or more glob patterns; a value matches when any pattern does. +/// +/// Serializes as a bare string when it holds exactly one pattern and as a list +/// otherwise, so `name = "mcp_*"` and `name = ["mcp_*", "composio_*"]` both +/// read naturally in TOML. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct Patterns(pub Vec); + +impl Patterns { + /// A single-pattern set. + #[must_use] + pub fn one(pattern: impl Into) -> Self { + Self(vec![pattern.into()]) + } + + /// Whether any pattern matches `text`. + #[must_use] + pub fn matches(&self, text: &str) -> bool { + self.0.iter().any(|pattern| glob_matches(pattern, text)) + } + + /// Whether any pattern matches any of `texts`. + #[must_use] + pub fn matches_any<'a>(&self, texts: impl IntoIterator) -> bool { + texts.into_iter().any(|text| self.matches(text)) + } +} + +impl> FromIterator for Patterns { + fn from_iter>(iter: I) -> Self { + Self(iter.into_iter().map(Into::into).collect()) + } +} + +impl Serialize for Patterns { + fn serialize(&self, serializer: S) -> Result { + match self.0.as_slice() { + [single] => serializer.serialize_str(single), + many => many.serialize(serializer), + } + } +} + +impl<'de> Deserialize<'de> for Patterns { + fn deserialize>(deserializer: D) -> Result { + #[derive(Deserialize)] + #[serde(untagged)] + enum OneOrMany { + One(String), + Many(Vec), + } + Ok(match OneOrMany::deserialize(deserializer)? { + OneOrMany::One(one) => Self(vec![one]), + OneOrMany::Many(many) => Self(many), + }) + } +} + +/// What a matching rule does. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RuleEffect { + /// Admit the tool. Only meaningful when something would otherwise refuse + /// it: a rule set whose [`ToolRules::default`] is [`DefaultEffect::Deny`]. + Allow, + /// Refuse the tool on the rule's surfaces: not listed, not searchable, + /// not callable. A deny beats every other effect. + Deny, + /// Keep the tool off the catalogue and out of search, but leave it + /// callable by name or through an indirect route a host provides. + Hide, + /// Admit the call only after explicit approval. + RequireApproval, + /// Waive approval for the call. Loses to [`Self::RequireApproval`]. + AutoApprove, +} + +/// What a rule set does with a tool no `allow` rule matched. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DefaultEffect { + /// Admit it. Rules only subtract. + #[default] + Allow, + /// Refuse it. The rule set is an allowlist. + Deny, +} + +/// Where a decision is being made. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Surface { + /// The tool schemas sent to the model up front. + Catalog, + /// On-demand discovery: a deferred catalogue, a tool-search index and the + /// results it returns. + Search, + /// Admission of a concrete call, with its arguments. + Call, +} + +impl Surface { + /// Every surface, in evaluation-independent order. + pub const ALL: [Self; 3] = [Self::Catalog, Self::Search, Self::Call]; +} + +/// One declared side effect, named as in [`ToolSideEffects`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SideEffect { + /// [`ToolSideEffects::read_only`]. + ReadOnly, + /// [`ToolSideEffects::writes_files`]. + WritesFiles, + /// [`ToolSideEffects::network`]. + Network, + /// [`ToolSideEffects::installs_dependencies`]. + InstallsDependencies, + /// [`ToolSideEffects::destructive`]. + Destructive, + /// [`ToolSideEffects::external_service`]. + ExternalService, + /// [`ToolSideEffects::payment`]. + Payment, +} + +impl SideEffect { + /// Whether `effects` declares this effect. + #[must_use] + pub fn declared_by(self, effects: &ToolSideEffects) -> bool { + match self { + Self::ReadOnly => effects.read_only, + Self::WritesFiles => effects.writes_files, + Self::Network => effects.network, + Self::InstallsDependencies => effects.installs_dependencies, + Self::Destructive => effects.destructive, + Self::ExternalService => effects.external_service, + Self::Payment => effects.payment, + } + } +} + +/// A match on one argument of a call, addressed by JSON pointer. +/// +/// Strings match as themselves; numbers and booleans match their JSON text; +/// anything else (absent, null, objects, arrays) never matches. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ArgMatcher { + /// RFC 6901 pointer into the call arguments, e.g. `/action`. A missing + /// leading `/` is supplied. + pub pointer: String, + /// Patterns the addressed value must match. + pub value: Patterns, +} + +impl ArgMatcher { + /// Whether `args` carries a matching value at [`Self::pointer`]. + #[must_use] + pub fn matches(&self, args: &Value) -> bool { + let found = if self.pointer.starts_with('/') || self.pointer.is_empty() { + args.pointer(&self.pointer) + } else { + args.pointer(&format!("/{}", self.pointer)) + }; + match found { + Some(Value::String(text)) => self.value.matches(text), + Some(value @ (Value::Number(_) | Value::Bool(_))) => { + self.value.matches(&value.to_string()) + } + _ => false, + } + } +} + +/// What a rule matches about a tool. Every field that is set must match; an +/// empty matcher matches every tool. +/// +/// A field that names an attribute the subject does not know (a permission +/// level on a subject built from a bare schema, say) does not match. That is +/// fail-open for a `deny` and fail-closed for an `allow`, which is why a host +/// should evaluate against the full tool wherever it has one. +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct ToolMatcher { + /// Tool name globs. + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + /// Globs over the tool's family: a toolpack, a connector toolkit, an MCP + /// server. + #[serde(skip_serializing_if = "Option::is_none")] + pub family: Option, + /// Globs over host-assigned tags; matches when any tag matches. + #[serde(skip_serializing_if = "Option::is_none")] + pub tags: Option, + /// Categories, any of which matches. + #[serde(skip_serializing_if = "Option::is_none")] + pub category: Option>, + /// Exposures, any of which matches. + #[serde(skip_serializing_if = "Option::is_none")] + pub exposure: Option>, + /// Matches tools requiring at least this permission. + #[serde(skip_serializing_if = "Option::is_none")] + pub permission_at_least: Option, + /// Matches tools requiring at most this permission. + #[serde(skip_serializing_if = "Option::is_none")] + pub permission_at_most: Option, + /// Declared side effects, any of which matches. + #[serde(skip_serializing_if = "Option::is_none")] + pub side_effects: Option>, + /// An argument match. Only a call has arguments; see + /// [`ToolRules::evaluate`](super::ToolRules::evaluate) for how such a rule + /// reads on the catalogue and search surfaces. + #[serde(skip_serializing_if = "Option::is_none")] + pub arg: Option, +} + +/// One rule: an effect, the surfaces it applies on, and what it matches. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ToolRule { + /// Stable identifier, reported in decisions and refusal messages. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub id: Option, + /// What the rule does. + pub effect: RuleEffect, + /// Surfaces the rule applies on. Empty means every surface. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub on: Vec, + /// What the rule matches. + #[serde(default, rename = "match")] + pub matcher: ToolMatcher, + /// Carve-out: a tool this matches is not matched by the rule, so + /// "deny `mcp_*` except the github server" is one rule. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub except: Option, + /// Context attributes that must all be present and match, e.g. + /// `channel = "telegram"`. Empty means any context. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub when: BTreeMap, + /// Why the rule exists, for audit logs and model-facing refusals. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reason: Option, +} + +impl ToolRule { + /// A rule with `effect` matching every tool on every surface. + #[must_use] + pub fn new(effect: RuleEffect) -> Self { + Self { + id: None, + effect, + on: Vec::new(), + matcher: ToolMatcher::default(), + except: None, + when: BTreeMap::new(), + reason: None, + } + } + + /// A rule with `effect` matching tool names against `patterns`. + #[must_use] + pub fn names>( + effect: RuleEffect, + patterns: impl IntoIterator, + ) -> Self { + let mut rule = Self::new(effect); + rule.matcher.name = Some(patterns.into_iter().collect()); + rule + } + + /// Sets [`Self::id`]. + #[must_use] + pub fn with_id(mut self, id: impl Into) -> Self { + self.id = Some(id.into()); + self + } + + /// Sets [`Self::reason`]. + #[must_use] + pub fn with_reason(mut self, reason: impl Into) -> Self { + self.reason = Some(reason.into()); + self + } + + /// Replaces [`Self::matcher`]. + #[must_use] + pub fn matching(mut self, matcher: ToolMatcher) -> Self { + self.matcher = matcher; + self + } + + /// Sets [`Self::except`]. + #[must_use] + pub fn except(mut self, matcher: ToolMatcher) -> Self { + self.except = Some(matcher); + self + } + + /// Restricts the rule to `surfaces`. + #[must_use] + pub fn on(mut self, surfaces: impl IntoIterator) -> Self { + self.on = surfaces.into_iter().collect(); + self + } + + /// Adds a context condition. + #[must_use] + pub fn when(mut self, key: impl Into, patterns: Patterns) -> Self { + self.when.insert(key.into(), patterns); + self + } + + /// Whether the rule applies on `surface`. + #[must_use] + pub fn applies_on(&self, surface: Surface) -> bool { + self.on.is_empty() || self.on.contains(&surface) + } +} + +/// One layer of rules with its own default. +/// +/// Inside a layer, effects combine without regard to order: a `deny` beats +/// everything, `hide` only removes from the listing surfaces, and +/// `require_approval` beats `auto_approve`. A tool is admitted when no `deny` +/// matches and either an `allow` matches or the default is `allow`. +/// +/// Layers from different sources stack in a [`ToolRuleSet`], where every +/// layer must admit — so adding a layer can only narrow. +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct ToolRules { + /// Where this layer came from (`config`, `agent:researcher`), for + /// decisions and refusal messages. + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + /// What happens to a tool no `allow` rule matched. + pub default: DefaultEffect, + /// The rules. + pub rules: Vec, +} + +/// Layers of [`ToolRules`] that must all admit a tool. +/// +/// This is how independent sources compose — global configuration, an agent +/// definition, a channel, a session overlay — without one widening another: +/// two allowlists intersect rather than union. +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(transparent)] +pub struct ToolRuleSet { + /// The layers, in the order they were added. + pub layers: Vec, +} + +/// Attributes of the situation a decision is made in: the channel, the +/// agent, the origin of the turn. Matched by [`ToolRule::when`]. +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(transparent)] +pub struct RuleContext { + /// Attribute values by key. + pub attributes: BTreeMap, +} + +impl RuleContext { + /// An empty context. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Adds an attribute. + #[must_use] + pub fn with(mut self, key: impl Into, value: impl Into) -> Self { + self.attributes.insert(key.into(), value.into()); + self + } + + /// The value of `key`, if set. + #[must_use] + pub fn get(&self, key: &str) -> Option<&str> { + self.attributes.get(key).map(String::as_str) + } +} + +/// Whether a call needs approval, as far as the rules say. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ApprovalDirective { + /// No rule spoke; the host's own approval policy applies. + #[default] + Default, + /// A rule requires approval. + Required, + /// A rule waives approval and none requires it. + Waived, +} + +impl ApprovalDirective { + /// The stricter of two directives: required, then waived, then default. + #[must_use] + pub fn strictest(self, other: Self) -> Self { + match (self, other) { + (Self::Required, _) | (_, Self::Required) => Self::Required, + (Self::Waived, _) | (_, Self::Waived) => Self::Waived, + _ => Self::Default, + } + } +} + +/// A pointer to the rule behind a decision. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RuleRef { + /// Index of the layer in its [`ToolRuleSet`] (0 for a lone [`ToolRules`]). + pub layer: usize, + /// The layer's [`ToolRules::name`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub layer_name: Option, + /// Index of the rule in its layer; `None` when the layer's default decided. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rule: Option, + /// The rule's [`ToolRule::id`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub id: Option, + /// The rule's [`ToolRule::reason`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reason: Option, +} + +/// The outcome of evaluating rules for one tool on one surface. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RuleDecision { + /// Whether the tool may be listed (catalogue) or found (search). + pub visible: bool, + /// Whether the tool may be called. + pub callable: bool, + /// Whether the call needs approval. + pub approval: ApprovalDirective, + /// What refused the tool, when it was refused or hidden. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub blocked_by: Option, +} + +impl RuleDecision { + /// A decision that admits everything and says nothing about approval. + #[must_use] + pub fn allow() -> Self { + Self { + visible: true, + callable: true, + approval: ApprovalDirective::Default, + blocked_by: None, + } + } + + /// Whether the tool is admitted on `surface`: visible for the listing + /// surfaces, callable for a call. + #[must_use] + pub fn admits(&self, surface: Surface) -> bool { + match surface { + Surface::Catalog | Surface::Search => self.visible, + Surface::Call => self.callable, + } + } + + /// A one-line refusal a model can read: names the rule and its reason. + #[must_use] + pub fn refusal(&self, tool: &str) -> String { + let Some(by) = &self.blocked_by else { + return format!("Tool '{tool}' is not permitted."); + }; + let rule = match (&by.id, by.rule) { + (Some(id), _) => format!("rule '{id}'"), + (None, Some(index)) => format!("rule #{index}"), + (None, None) => "default deny".to_string(), + }; + let layer = by + .layer_name + .as_ref() + .map(|layer| format!(" in '{layer}'")) + .unwrap_or_default(); + let reason = by + .reason + .as_ref() + .map(|reason| format!(": {reason}")) + .unwrap_or_default(); + let mut message = + format!("Tool '{tool}' is not permitted by tool rules ({rule}){layer}{reason}"); + message.push('.'); + message + } +} diff --git a/crates/tinytools/src/shared/mod_tests.rs b/crates/tinytools/src/shared/mod_tests.rs index 0aa7ce9..b116f18 100644 --- a/crates/tinytools/src/shared/mod_tests.rs +++ b/crates/tinytools/src/shared/mod_tests.rs @@ -113,6 +113,16 @@ impl Tool for Opinionated { Some("opinions") } + fn tags(&self) -> Vec { + vec!["pack:opinions".into()] + } + + fn indirect_target(&self, args: &Value) -> Option { + args.get("x")? + .as_str() + .map(|name| crate::ToolSubject::named(name).into()) + } + fn is_concurrency_safe(&self, _args: &Value) -> bool { true } @@ -212,6 +222,28 @@ fn the_wrapper_does_not_reveal_a_hidden_tool() { assert_eq!(tool.family(), Some("opinions")); } +/// Tool rules read these: a wrapper that dropped them would let a tag-based +/// deny miss, and a dispatcher's real target escape its rules. +#[test] +fn the_wrapper_keeps_what_tool_rules_read() { + let tool = wrapped(); + assert_eq!(tool.tags(), ["pack:opinions"]); + assert_eq!( + tool.indirect_target(&json!({ "x": "GMAIL_DELETE_EMAIL" })), + Some(crate::ToolSubject::named("GMAIL_DELETE_EMAIL").into()) + ); + let rules = crate::ToolRuleSet::single(crate::ToolRules::from_allow_deny( + Vec::::new(), + ["*_DELETE_*"], + )); + let decision = rules.evaluate_call( + &tool, + &crate::RuleContext::new(), + &json!({ "x": "GMAIL_DELETE_EMAIL" }), + ); + assert!(!decision.callable); +} + /// Dispatch and result handling read these. #[test] fn the_wrapper_keeps_runtime_and_result_declarations() { diff --git a/crates/tinytools/src/shared/types.rs b/crates/tinytools/src/shared/types.rs index d20e70a..f61f8b0 100644 --- a/crates/tinytools/src/shared/types.rs +++ b/crates/tinytools/src/shared/types.rs @@ -116,6 +116,14 @@ impl Tool for SharedTool { self.0.family() } + fn tags(&self) -> Vec { + self.0.tags() + } + + fn indirect_target(&self, args: &Value) -> Option { + self.0.indirect_target(args) + } + fn is_concurrency_safe(&self, args: &Value) -> bool { self.0.is_concurrency_safe(args) } diff --git a/crates/tinytools/src/tool/mod_tests.rs b/crates/tinytools/src/tool/mod_tests.rs index 392a377..f929751 100644 --- a/crates/tinytools/src/tool/mod_tests.rs +++ b/crates/tinytools/src/tool/mod_tests.rs @@ -103,6 +103,12 @@ fn the_declaration_defaults_are_the_conservative_answer() { assert!(tool.host_call_extension(&Value::Null).is_none()); assert_eq!(tool.policy(), ToolPolicy::default()); assert_eq!(tool.injected_arguments().len(), 0); + // Tool-rule metadata: no tags, and not a dispatcher to any other tool. + assert_eq!(tool.tags(), Vec::::new()); + assert_eq!( + tool.indirect_target(&serde_json::json!({ "action": "x" })), + None + ); } #[tokio::test] diff --git a/crates/tinytools/src/tool/types.rs b/crates/tinytools/src/tool/types.rs index cce2f4f..e6290ed 100644 --- a/crates/tinytools/src/tool/types.rs +++ b/crates/tinytools/src/tool/types.rs @@ -12,11 +12,15 @@ use crate::naming::{context_detail_from_args, humanize_tool_name}; use crate::permission::PermissionLevel; use crate::policy::ToolPolicy; use crate::result::ToolResult; +use crate::rules::IndirectCall; use crate::spec::ToolSpec; /// Whether a tool is advertised directly, discoverable on demand, or kept /// internal to a host-owned composite capability. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +#[derive( + Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize, +)] +#[serde(rename_all = "snake_case")] pub enum ToolExposure { /// Include the tool in the model's initial catalogue. #[default] @@ -200,6 +204,32 @@ pub trait Tool: Send + Sync { None } + /// Host-assigned labels a tool rule can match: a toolpack (`pack:gmail`), + /// a domain (`domain:web3`), a connector scope (`composio.scope:write`). + /// + /// Purely descriptive, like [`Self::family`]; a host's + /// [`ToolRules`](crate::ToolRules) decide what a tag means. Most tools + /// carry none. + fn tags(&self) -> Vec { + Vec::new() + } + + /// The tool this call actually reaches, for a dispatcher tool whose + /// arguments name another one: a connector's generic execute tool, a + /// skill runner, an MCP call bridge. + /// + /// A host evaluates its tool rules against the target as well as the + /// dispatcher, so a rule against the target cannot be sidestepped by + /// calling it indirectly (see + /// [`ToolRuleSet::evaluate_call`](crate::ToolRuleSet::evaluate_call)). + /// A dispatcher that wraps the target's arguments returns them on the + /// [`IndirectCall`] too, so the target's argument-scoped rules apply. + /// Return `None` when the arguments name no target or the tool is not a + /// dispatcher — the default. + fn indirect_target(&self, _args: &Value) -> Option { + None + } + /// Whether two concurrent invocations are safe to run in parallel within a /// single model turn. /// diff --git a/docs/plans/tool-rules.md b/docs/plans/tool-rules.md new file mode 100644 index 0000000..4967fff --- /dev/null +++ b/docs/plans/tool-rules.md @@ -0,0 +1,60 @@ +# Plan: Tool rules + +- **Status:** Implemented +- **Specification:** [`../specs/tool-rules.md`](../specs/tool-rules.md) + +## Goal + +Add a declarative, serializable rule language for tool visibility and call +admission, with no new dependency and no enforcement in this crate. + +## Task 1: Pattern matching + +**Files:** `crates/tinytools/src/rules/glob.rs` + +1. Write failing tests for `*`, `?`, ASCII case-insensitivity and the empty + pattern. +2. Implement `glob_matches` as a single-backtrack matcher. + +## Task 2: Vocabulary + +**Files:** `crates/tinytools/src/rules/{types,subject}.rs` + +1. Write failing tests pinning the wire form: `Patterns` as a string or a list, + snake_case effects and surfaces, `match` / `except` / `when` / `on`, and + unknown matcher fields rejected. +2. Implement `ToolRule`, `ToolMatcher`, `ArgMatcher`, `ToolRules`, `ToolRuleSet`, + `RuleContext`, `RuleDecision` and `ToolSubject`. +3. Derive serde on `ToolExposure` so a matcher can name it. + +## Task 3: Evaluation + +**Files:** `crates/tinytools/src/rules/eval.rs` + +1. Write failing tests for each precedence rule: + - deny beats allow + - a default deny needs an allow + - hide is listing-only + - `require_approval` beats `auto_approve` +2. Write failing tests for surface scoping and for argument conditions off the + call surface: optimistic for an allow, skipped for every other effect. +3. Write failing tests for layering (intersecting allowlists, the first + refusal reported, the strictest approval) and for refusal text. +4. Implement `ToolRules::evaluate` and `ToolRuleSet::evaluate`. + +## Task 4: Tool seams + +**Files:** `crates/tinytools/src/tool/types.rs`, `crates/tinytools/src/shared/types.rs` + +1. Write failing tests for `ToolSubject::of` / `of_call` and for + `ToolRuleSet::evaluate_call` refusing a dispatcher's denied target. +2. Add the defaulted `Tool::tags` and `Tool::indirect_target`. +3. Write a failing test that `SharedTool` forwards both; implement the + forwarding. + +## Task 5: Exports and docs + +1. Re-export from `lib.rs`. +2. Add the module README, the spec, the README module-table row and the + AGENTS.md layout entry. +3. Run the four contract commands. diff --git a/docs/specs/README.md b/docs/specs/README.md index 40ee9c4..e26eb13 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -22,4 +22,4 @@ the contract; production code still belongs under `src/`. See [`example-retry-policy.md`](example-retry-policy.md) for a complete sample. -Implemented host-facing helper contracts: [collapsed tools and standard helpers](collapsed-tools-and-standard-helpers.md). +Implemented host-facing helper contracts: [collapsed tools and standard helpers](collapsed-tools-and-standard-helpers.md), [tool rules](tool-rules.md). diff --git a/docs/specs/tool-rules.md b/docs/specs/tool-rules.md new file mode 100644 index 0000000..5eec0fc --- /dev/null +++ b/docs/specs/tool-rules.md @@ -0,0 +1,83 @@ +# Tool rules + +- **Status:** Implemented +- **Owner:** Maintainers +- **Plan:** [`../plans/tool-rules.md`](../plans/tool-rules.md) + +## Problem + +Hosts restrict tools in many places: an agent definition's allowlist, a +channel's permission ceiling, an MCP server's tool filter, a connector's +curated actions, a user's toggles, an approval allowlist. Each carried its own +exact-name matcher and was applied on whichever surface its author thought of, +so a tool denied by call-time middleware could still be found through tool +search, and a wildcard worked in one list but not the next. + +## Goals + +- One serializable vocabulary for allow / deny / hide / approval decisions + over tools, with glob patterns. +- The same rules decide every surface a tool reaches the model on: the + up-front catalogue, on-demand search, and the call. +- Independent sources compose without one widening another. +- A dispatcher tool (a connector's generic execute tool, a skill runner) + cannot be used to reach a target a rule forbids. + +## Non-goals + +- Choosing a policy. Rules are data the host writes from its own + configuration; this crate only evaluates them mechanically, the same way + `deferral` subtracts what a tool declares. +- Enforcement. The harness calls the evaluator at its listing and admission + points and reports a refusal. +- Stateful decisions (rate limits, budgets). Those stay host middleware. +- Argument, path and sandbox safety floors. + +## Proposed behavior + +- `ToolRule { id, effect, on, match, except, when, reason }`. + - `effect`: `allow`, `deny` (not listed, not callable), `hide` (not listed + or searchable, still callable), `require_approval`, `auto_approve`. + - `on`: `catalog`, `search`, `call`; empty means all three. + - `match` (`ToolMatcher`, every set field must match): `name`, `family`, + `tags` (globs, a string or a list); `category`; `exposure`; + `permission_at_least` / `permission_at_most`; `side_effects` (any of); + `arg { pointer, value }`. + - `except`: a carve-out matcher. + - `when`: context attributes (`channel`, `agent`, …) that must be present + and match. +- `ToolRules { name, default, rules }` is a layer. Effects combine without + regard to order: `deny` beats everything; with `default = "deny"` a tool + needs an `allow`; `hide` clears visibility only; `require_approval` beats + `auto_approve`. +- `ToolRuleSet` stacks layers; every layer must admit, and the strictest + approval directive wins. +- `ToolSubject` is what is evaluated: `ToolSubject::of(&dyn Tool)` or built by + hand. `RuleDecision { visible, callable, approval, blocked_by }` names the + layer and rule behind a refusal; `refusal(tool)` renders it for the model. +- `ToolRuleSet::evaluate_call(tool, ctx, args)` evaluates the tool with its + argument-aware permission and, when `Tool::indirect_target` returns an + `IndirectCall { target, arguments }`, evaluates the target against its own + arguments (falling back to the dispatcher's). +- `Tool` gains two defaulted, descriptive methods: `tags()` and + `indirect_target(args)`. + +Globs support `*` and `?` and ignore ASCII case, so `gmail_*` matches the +upper-case Composio slug `GMAIL_SEND_EMAIL`. + +## Invariants and constraints + +- Adding a rule layer can only narrow what is admitted. +- A matcher field naming an attribute the subject does not know does not + match: fail-open for `deny`, fail-closed for `allow`. Hosts evaluate against + the live tool wherever they have one. +- Off the call surface an `arg` condition cannot be decided: an `allow` reads + it optimistically (some call may be allowed, so the tool stays listed) and + every other effect does not apply until there is a call. +- No new dependency: the crate stays dependency-light. + +## Acceptance criteria + +The unit tests in `crates/tinytools/src/rules/mod_tests.rs` pin precedence, +surfaces, `except`, `when`, argument handling, layering, indirect targets, +refusal text and the serde wire form.