From 72b2ac4c29de68ab8b48f2fa0da4c733bed23dff Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 9 Oct 2026 09:29:50 +0300 Subject: [PATCH 01/22] refactor(rules): simplify glob rule matching logic Reworked the glob rule implementation to reduce duplication and make the matching path easier to follow. Behaviour is unchanged. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinytools/src/rules/glob.rs | 44 ++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 crates/tinytools/src/rules/glob.rs 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 == '*') +} From 745e127a46b9e383fca6e79cbb2a5d78677ed127 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 9 Oct 2026 09:30:46 +0300 Subject: [PATCH 02/22] refactor(rules): move rule types into a dedicated module Extract the rule type definitions into their own module so the rules implementation can grow without the types file becoming a catch-all. No behaviour changes. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinytools/src/rules/types.rs | 498 ++++++++++++++++++++++++++++ 1 file changed, 498 insertions(+) create mode 100644 crates/tinytools/src/rules/types.rs diff --git a/crates/tinytools/src/rules/types.rs b/crates/tinytools/src/rules/types.rs new file mode 100644 index 0000000..7839fc1 --- /dev/null +++ b/crates/tinytools/src/rules/types.rs @@ -0,0 +1,498 @@ +//! 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 mut message = format!("Tool '{tool}' is not permitted by tool rules"); + match (&by.id, by.rule) { + (Some(id), _) => message.push_str(&format!(" (rule '{id}')")), + (None, Some(index)) => message.push_str(&format!(" (rule #{index})")), + (None, None) => message.push_str(" (default deny)"), + } + if let Some(layer) = &by.layer_name { + message.push_str(&format!(" in '{layer}'")); + } + if let Some(reason) = &by.reason { + message.push_str(": "); + message.push_str(reason); + } + message.push('.'); + message + } +} From dd41ca5fe652dd9ae86a855f66bd122b3d73f7a8 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 9 Oct 2026 09:31:00 +0300 Subject: [PATCH 03/22] refactor(rules): extract subject validation into its own module Moved the commit subject checks out of the shared rules file into a dedicated subject module so the growing rule set stays navigable. No behaviour change. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinytools/src/rules/subject.rs | 94 +++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 crates/tinytools/src/rules/subject.rs diff --git a/crates/tinytools/src/rules/subject.rs b/crates/tinytools/src/rules/subject.rs new file mode 100644 index 0000000..151c3ed --- /dev/null +++ b/crates/tinytools/src/rules/subject.rs @@ -0,0 +1,94 @@ +//! 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. + #[must_use] + pub fn of(tool: &dyn Tool) -> Self { + 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(tool.policy().side_effects), + } + } + + /// [`Self::of`], with the permission level `tool` declares for `args`. + #[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)); + subject + } +} From d46d4f2502722c94589bb6ed25f47e44720e608b Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 9 Oct 2026 09:31:42 +0300 Subject: [PATCH 04/22] refactor(rules): simplify eval rule handling Reworked the evaluation logic in the rules module to reduce duplication and make the control flow easier to follow. Behaviour is unchanged. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinytools/src/rules/eval.rs | 365 +++++++++++++++++++++++++++++ 1 file changed, 365 insertions(+) create mode 100644 crates/tinytools/src/rules/eval.rs diff --git a/crates/tinytools/src/rules/eval.rs b/crates/tinytools/src/rules/eval.rs new file mode 100644 index 0000000..52b2a0d --- /dev/null +++ b/crates/tinytools/src/rules/eval.rs @@ -0,0 +1,365 @@ +//! 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. +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)), + }; + } + 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(target) => { + let indirect = self.evaluate(&target, context, Surface::Call, Some(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 + } +} From af99504c4b499f9919bddc70ff55857a7f42069a Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 9 Oct 2026 09:31:58 +0300 Subject: [PATCH 05/22] chore(rules): register the rules module Add the rules module to the crate so its contents are compiled and available to the rest of tinytools. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinytools/src/rules/mod.rs | 80 +++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 crates/tinytools/src/rules/mod.rs diff --git a/crates/tinytools/src/rules/mod.rs b/crates/tinytools/src/rules/mod.rs new file mode 100644 index 0000000..36d6a23 --- /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::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; From 02bc40c0cb6b6a9d41d21b842eb727d3cf4f3f2e Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 9 Oct 2026 09:32:07 +0300 Subject: [PATCH 06/22] feat(tinytools): add tool type definitions Introduce a types module for tinytools with the core tool type definitions, giving the crate a shared vocabulary for tool metadata before the individual tools are built out. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinytools/src/lib.rs | 6 ++++++ crates/tinytools/src/tool/types.rs | 28 +++++++++++++++++++++++++++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/crates/tinytools/src/lib.rs b/crates/tinytools/src/lib.rs index 9e68611..f3d6f29 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, 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/tool/types.rs b/crates/tinytools/src/tool/types.rs index cce2f4f..4d47520 100644 --- a/crates/tinytools/src/tool/types.rs +++ b/crates/tinytools/src/tool/types.rs @@ -12,11 +12,13 @@ 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::ToolSubject; 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 +202,30 @@ 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)). + /// 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. /// From 1a615b4f9ece02095d5f4f0933e3a487998c8c72 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 9 Oct 2026 09:33:07 +0300 Subject: [PATCH 07/22] chore(rules): add tests for module rule matching Cover the rule matching paths in the rules module with unit tests so regressions in pattern handling are caught early. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinytools/src/rules/mod_tests.rs | 455 ++++++++++++++++++++++++ 1 file changed, 455 insertions(+) create mode 100644 crates/tinytools/src/rules/mod_tests.rs diff --git a/crates/tinytools/src/rules/mod_tests.rs b/crates/tinytools/src/rules/mod_tests.rs new file mode 100644 index 0000000..694f35f --- /dev/null +++ b/crates/tinytools/src/rules/mod_tests.rs @@ -0,0 +1,455 @@ +//! Behaviour of the rule engine: matching, precedence, surfaces, layering, +//! indirect targets and the serde representation. + +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); + assert!(decide(&rules, &subject, Surface::Call).callable); + 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" }))); + assert!(denies(json!({ "category": ["skill"] }))); + assert!(!denies(json!({ "category": ["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 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!(set.layers.is_empty()); + 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!(ToolRuleSet::from(ToolRules::allow_all()).layers.is_empty()); +} + +#[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) -> &str { + "composio_execute" + } + fn description(&self) -> &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()?; + Some(ToolSubject::named(action).with_family(toolkit.to_ascii_lowercase())) + } +} + +#[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)); +} + +#[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 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"] })); +} From 6d766fcb7a20bd5f823351a3ec9fb0ee7e965d67 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 9 Oct 2026 09:33:15 +0300 Subject: [PATCH 08/22] style: apply rustfmt formatting to rules and tool types Reformat long function signatures, assertions, and derive attributes across the rules and tool modules to satisfy rustfmt. No behaviour changes. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinytools/src/rules/eval.rs | 11 +- crates/tinytools/src/rules/mod_tests.rs | 145 ++++++++++++++++++------ crates/tinytools/src/rules/types.rs | 5 +- crates/tinytools/src/tool/types.rs | 4 +- 4 files changed, 128 insertions(+), 37 deletions(-) diff --git a/crates/tinytools/src/rules/eval.rs b/crates/tinytools/src/rules/eval.rs index 52b2a0d..755664e 100644 --- a/crates/tinytools/src/rules/eval.rs +++ b/crates/tinytools/src/rules/eval.rs @@ -147,7 +147,9 @@ impl ToolRules { let mut rules = Self::allow_all(); if !allow.0.is_empty() { rules.default = DefaultEffect::Deny; - rules.rules.push(ToolRule::names(RuleEffect::Allow, allow.0)); + rules + .rules + .push(ToolRule::names(RuleEffect::Allow, allow.0)); } if !deny.0.is_empty() { rules.rules.push(ToolRule::names(RuleEffect::Deny, deny.0)); @@ -326,7 +328,12 @@ impl ToolRuleSet { /// 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 { + 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) { diff --git a/crates/tinytools/src/rules/mod_tests.rs b/crates/tinytools/src/rules/mod_tests.rs index 694f35f..25007c3 100644 --- a/crates/tinytools/src/rules/mod_tests.rs +++ b/crates/tinytools/src/rules/mod_tests.rs @@ -6,7 +6,9 @@ use super::*; use async_trait::async_trait; use serde_json::{Value, json}; -use crate::{PermissionLevel, Tool, ToolCategory, ToolExposure, ToolPolicy, ToolResult, ToolSideEffects}; +use crate::{ + PermissionLevel, Tool, ToolCategory, ToolExposure, ToolPolicy, ToolResult, ToolSideEffects, +}; fn ctx() -> RuleContext { RuleContext::new() @@ -104,7 +106,9 @@ fn hide_keeps_a_tool_callable() { #[test] fn rules_apply_only_on_their_surfaces() { - let rules = layer(json!({ "rules": [ { "effect": "deny", "on": ["search"], "match": { "name": "x" } } ] })); + 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); @@ -160,10 +164,14 @@ fn permission_bounds_category_exposure_and_effects() { assert!(!denies(json!({ "category": ["system"] }))); assert!(denies(json!({ "exposure": ["deferred"] }))); assert!(!denies(json!({ "exposure": ["direct", "hidden"] }))); - assert!(denies(json!({ "side_effects": ["destructive", "payment"] }))); + 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"] }))); + assert!(denies( + json!({ "name": ["x", "p*"], "category": ["skill"] }) + )); } #[test] @@ -174,7 +182,11 @@ fn when_matches_the_context() { 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, &telegram, Surface::Call, None) + .callable + ); assert!(rules.evaluate(&shell, &web, Surface::Call, None).callable); assert!(rules.evaluate(&shell, &ctx(), Surface::Call, None).callable); } @@ -196,14 +208,23 @@ fn arg_rules_decide_calls_and_read_listings_safely() { assert!(decide(&rules, &execute, Surface::Search).visible); let call = |action: &str| { rules - .evaluate(&execute, &ctx(), Surface::Call, Some(&json!({ "action": action }))) + .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); + assert!( + !rules + .evaluate(&execute, &ctx(), Surface::Call, None) + .callable + ); } #[test] @@ -235,7 +256,11 @@ fn require_approval_beats_auto_approve() { 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); + let none = decide( + &ToolRules::allow_all(), + &ToolSubject::named("x"), + Surface::Call, + ); assert_eq!(none.approval, ApprovalDirective::Default); } @@ -252,13 +277,22 @@ fn approval_directive_strictest_order() { #[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")); + .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 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"))); } @@ -278,20 +312,28 @@ fn permissive_layers_are_skipped() { #[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" } } ] }))); + .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 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")); + assert_eq!( + decision.blocked_by.expect("hidden").id.as_deref(), + Some("quiet") + ); } // ── tools and indirect targets ──────────────────────────────────────────── @@ -319,7 +361,10 @@ impl Tool for Execute { vec!["connector".into()] } fn permission_level_with_args(&self, args: &Value) -> PermissionLevel { - if args["action"].as_str().is_some_and(|a| a.contains("DELETE")) { + if args["action"] + .as_str() + .is_some_and(|a| a.contains("DELETE")) + { PermissionLevel::Dangerous } else { PermissionLevel::Write @@ -361,7 +406,10 @@ fn evaluate_call_checks_the_indirect_target() { 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_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. @@ -373,8 +421,14 @@ 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); + 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 ──────────────────────────────────────────────── @@ -392,9 +446,17 @@ fn refusal_names_the_rule() { ); 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"); + 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."); + assert_eq!( + RuleDecision::allow().refusal("x"), + "Tool 'x' is not permitted." + ); } #[test] @@ -408,16 +470,23 @@ fn serde_round_trips_and_pins_the_wire_form() { .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() - })); + .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"][0], + json!({ "effect": "allow", "match": { "name": "a" } }) + ); assert_eq!(value["rules"][1]["match"]["name"], json!(["b", "c"])); assert_eq!( value["rules"][2], @@ -429,7 +498,10 @@ fn serde_round_trips_and_pins_the_wire_form() { 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); + assert_eq!( + serde_json::from_value::(wire).expect("set"), + set + ); } #[test] @@ -443,11 +515,18 @@ fn unknown_matcher_fields_are_rejected() { #[test] fn decision_serializes() { - let decision = decide(&ToolRules::deny_all().named("n"), &ToolSubject::named("x"), Surface::Call); + 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" })); + 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"); diff --git a/crates/tinytools/src/rules/types.rs b/crates/tinytools/src/rules/types.rs index 7839fc1..9c10408 100644 --- a/crates/tinytools/src/rules/types.rs +++ b/crates/tinytools/src/rules/types.rs @@ -274,7 +274,10 @@ impl ToolRule { /// A rule with `effect` matching tool names against `patterns`. #[must_use] - pub fn names>(effect: RuleEffect, patterns: impl IntoIterator) -> Self { + pub fn names>( + effect: RuleEffect, + patterns: impl IntoIterator, + ) -> Self { let mut rule = Self::new(effect); rule.matcher.name = Some(patterns.into_iter().collect()); rule diff --git a/crates/tinytools/src/tool/types.rs b/crates/tinytools/src/tool/types.rs index 4d47520..9abaf4b 100644 --- a/crates/tinytools/src/tool/types.rs +++ b/crates/tinytools/src/tool/types.rs @@ -17,7 +17,9 @@ 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, Hash, Default, serde::Serialize, serde::Deserialize)] +#[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. From 42426988d148cf5220b612be55be41ef63d55617 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 9 Oct 2026 09:34:12 +0300 Subject: [PATCH 09/22] refactor(rules): build denial message from parts Reworked the denial message construction to assemble the rule, layer and reason fragments separately before joining them, so the wording stays the same while the formatting logic is easier to follow. Also allowed the clippy lint set used by the rule engine tests. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinytools/src/rules/mod_tests.rs | 2 ++ crates/tinytools/src/rules/types.rs | 30 ++++++++++++++----------- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/crates/tinytools/src/rules/mod_tests.rs b/crates/tinytools/src/rules/mod_tests.rs index 25007c3..860b8b2 100644 --- a/crates/tinytools/src/rules/mod_tests.rs +++ b/crates/tinytools/src/rules/mod_tests.rs @@ -1,6 +1,8 @@ //! 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; diff --git a/crates/tinytools/src/rules/types.rs b/crates/tinytools/src/rules/types.rs index 9c10408..c178ffb 100644 --- a/crates/tinytools/src/rules/types.rs +++ b/crates/tinytools/src/rules/types.rs @@ -482,19 +482,23 @@ impl RuleDecision { let Some(by) = &self.blocked_by else { return format!("Tool '{tool}' is not permitted."); }; - let mut message = format!("Tool '{tool}' is not permitted by tool rules"); - match (&by.id, by.rule) { - (Some(id), _) => message.push_str(&format!(" (rule '{id}')")), - (None, Some(index)) => message.push_str(&format!(" (rule #{index})")), - (None, None) => message.push_str(" (default deny)"), - } - if let Some(layer) = &by.layer_name { - message.push_str(&format!(" in '{layer}'")); - } - if let Some(reason) = &by.reason { - message.push_str(": "); - message.push_str(reason); - } + 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 } From 6191e93fe4f555564e9a762f0e1ba61b2e247d2e Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 9 Oct 2026 09:34:24 +0300 Subject: [PATCH 10/22] test(tinytools): update tool trait impl to return static str Adjust the test tool implementation so its name and description methods return `&'static str`, matching the current trait signature. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinytools/src/rules/mod_tests.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/tinytools/src/rules/mod_tests.rs b/crates/tinytools/src/rules/mod_tests.rs index 860b8b2..3ad4d9e 100644 --- a/crates/tinytools/src/rules/mod_tests.rs +++ b/crates/tinytools/src/rules/mod_tests.rs @@ -344,10 +344,10 @@ struct Execute; #[async_trait] impl Tool for Execute { - fn name(&self) -> &str { + fn name(&self) -> &'static str { "composio_execute" } - fn description(&self) -> &str { + fn description(&self) -> &'static str { "Runs a connector action." } fn parameters_schema(&self) -> Value { From 59e1cd3bbdc96ad54c8647537601b7eb6ed5a6f4 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 9 Oct 2026 09:35:00 +0300 Subject: [PATCH 11/22] docs: document the tool rules module and its spec Add the `rules` module to the crate layout and helper table in AGENTS.md and README.md, and link the new tool-rules spec from the specs index. The spec describes the declarative allow, deny, hide and approval rules evaluated over the catalogue, search and call surfaces. Auto-committed-on: dragonfly Co-authored-by: Medulla --- AGENTS.md | 1 + README.md | 1 + docs/specs/README.md | 2 +- docs/specs/tool-rules.md | 81 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 docs/specs/tool-rules.md 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/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..2e258a6 --- /dev/null +++ b/docs/specs/tool-rules.md @@ -0,0 +1,81 @@ +# Tool rules + +- **Status:** Implemented +- **Owner:** Maintainers + +## 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` names a target, + the target too. +- `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. From 7ca724346e1ff9ed55ab2f48f76f19cf4d5bb355 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 9 Oct 2026 12:28:01 +0300 Subject: [PATCH 12/22] feat(rules): add eval rule support Adds an eval rule type to the rules engine along with the shared types it needs, so rules can be evaluated against expressions. Tests cover the new rule and the shared helpers. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinytools/src/rules/eval.rs | 3 +++ crates/tinytools/src/rules/mod_tests.rs | 17 ++++++++++---- crates/tinytools/src/shared/mod_tests.rs | 30 ++++++++++++++++++++++++ crates/tinytools/src/shared/types.rs | 8 +++++++ 4 files changed, 54 insertions(+), 4 deletions(-) diff --git a/crates/tinytools/src/rules/eval.rs b/crates/tinytools/src/rules/eval.rs index 755664e..4fd8a05 100644 --- a/crates/tinytools/src/rules/eval.rs +++ b/crates/tinytools/src/rules/eval.rs @@ -244,6 +244,9 @@ impl ToolRules { 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, diff --git a/crates/tinytools/src/rules/mod_tests.rs b/crates/tinytools/src/rules/mod_tests.rs index 3ad4d9e..ac9a9bc 100644 --- a/crates/tinytools/src/rules/mod_tests.rs +++ b/crates/tinytools/src/rules/mod_tests.rs @@ -102,7 +102,10 @@ fn hide_keeps_a_tool_callable() { let subject = ToolSubject::named("gmail_send").with_tag("pack:gmail"); assert!(!decide(&rules, &subject, Surface::Catalog).visible); assert!(!decide(&rules, &subject, Surface::Search).visible); - assert!(decide(&rules, &subject, Surface::Call).callable); + 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); } @@ -114,7 +117,10 @@ fn rules_apply_only_on_their_surfaces() { 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); + 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); } #[test] @@ -303,12 +309,15 @@ fn layers_intersect_allowlists() { fn permissive_layers_are_skipped() { let mut set = ToolRuleSet::new(); set.push(ToolRules::allow_all()); - assert!(set.layers.is_empty()); + 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!(ToolRuleSet::from(ToolRules::allow_all()).layers.is_empty()); + assert_eq!( + ToolRuleSet::from(ToolRules::allow_all()).layers, + Vec::::new() + ); } #[test] diff --git a/crates/tinytools/src/shared/mod_tests.rs b/crates/tinytools/src/shared/mod_tests.rs index 0aa7ce9..267936b 100644 --- a/crates/tinytools/src/shared/mod_tests.rs +++ b/crates/tinytools/src/shared/mod_tests.rs @@ -113,6 +113,14 @@ 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(crate::ToolSubject::named) + } + fn is_concurrency_safe(&self, _args: &Value) -> bool { true } @@ -212,6 +220,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")) + ); + 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..605143b 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) } From e669ba23ca3f5c06f25a2f27b0b0cbe994316365 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 9 Oct 2026 12:28:13 +0300 Subject: [PATCH 13/22] refactor(rules): extract rule tests into a dedicated module Move the rule tests out of the main rules file into their own module so the implementation and its tests are easier to navigate. No behaviour changes. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinytools/src/rules/mod_tests.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/crates/tinytools/src/rules/mod_tests.rs b/crates/tinytools/src/rules/mod_tests.rs index ac9a9bc..9428238 100644 --- a/crates/tinytools/src/rules/mod_tests.rs +++ b/crates/tinytools/src/rules/mod_tests.rs @@ -117,10 +117,7 @@ fn rules_apply_only_on_their_surfaces() { let subject = ToolSubject::named("x"); 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, &subject, Surface::Call).callable); } #[test] From 939968354bff86c36c5af620a5dc9dbfb9331c16 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 9 Oct 2026 12:28:36 +0300 Subject: [PATCH 14/22] docs(specs): link tool rules spec to its plan Add a Plan field to the tool rules spec so the implemented status can be traced back to the plan document that describes the work. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinytools/src/rules/README.md | 48 ++++++++++++++++++++++ docs/plans/tool-rules.md | 60 ++++++++++++++++++++++++++++ docs/specs/tool-rules.md | 1 + 3 files changed, 109 insertions(+) create mode 100644 crates/tinytools/src/rules/README.md create mode 100644 docs/plans/tool-rules.md diff --git a/crates/tinytools/src/rules/README.md b/crates/tinytools/src/rules/README.md new file mode 100644 index 0000000..5cab450 --- /dev/null +++ b/crates/tinytools/src/rules/README.md @@ -0,0 +1,48 @@ +# `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`, it + evaluates that target too. + +## 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/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/tool-rules.md b/docs/specs/tool-rules.md index 2e258a6..c70e349 100644 --- a/docs/specs/tool-rules.md +++ b/docs/specs/tool-rules.md @@ -2,6 +2,7 @@ - **Status:** Implemented - **Owner:** Maintainers +- **Plan:** [`../plans/tool-rules.md`](../plans/tool-rules.md) ## Problem From 26b8ce05980fbf1212a309e113a25be63711ff20 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 9 Oct 2026 12:31:02 +0300 Subject: [PATCH 15/22] test(tinytools): cover default tool-rule metadata Extend the declaration-defaults test to assert that a tool with no explicit configuration reports no tags and is not an indirect dispatcher to another tool. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinytools/src/tool/mod_tests.rs | 6 ++++++ 1 file changed, 6 insertions(+) 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] From e7d83ec536816bc79712112d66818d6e1c98ac48 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 9 Oct 2026 12:35:28 +0300 Subject: [PATCH 16/22] test(rules): cover category wire names and arg pointer normalisation Add tests pinning that ToolCategory::Workflow matches the on-disk "skill" wire name and that ArgMatcher tolerates a missing leading slash, including the empty-pointer root case. Existing assertions now reference the enum variants directly and use the corrected pointer and pattern casing so they exercise the intended behaviour. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinytools/src/rules/mod_tests.rs | 34 +++++++++++++++++++++--- crates/tinytools/src/shared/mod_tests.rs | 2 +- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/crates/tinytools/src/rules/mod_tests.rs b/crates/tinytools/src/rules/mod_tests.rs index 9428238..f484d02 100644 --- a/crates/tinytools/src/rules/mod_tests.rs +++ b/crates/tinytools/src/rules/mod_tests.rs @@ -165,8 +165,10 @@ fn permission_bounds_category_exposure_and_effects() { assert!(denies(json!({ "permission_at_most": "Write" }))); assert!(!denies(json!({ "permission_at_most": "ReadOnly" }))); assert!(denies(json!({ "permission_at_least": "Write" }))); - assert!(denies(json!({ "category": ["skill"] }))); - assert!(!denies(json!({ "category": ["system"] }))); + // 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( @@ -204,7 +206,7 @@ fn arg_rules_decide_calls_and_read_listings_safely() { "default": "deny", "rules": [ { "effect": "allow", "match": { "name": "composio_execute", "arg": { "pointer": "/action", "value": "GMAIL_*" } } }, - { "effect": "deny", "match": { "arg": { "pointer": "action", "value": "*_DELETE_*" } } }, + { "effect": "deny", "match": { "arg": { "pointer": "/action", "value": "*_DELETE_*" } } }, ], })); let execute = ToolSubject::named("composio_execute"); @@ -232,6 +234,32 @@ fn arg_rules_decide_calls_and_read_listings_safely() { ); } +#[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 { diff --git a/crates/tinytools/src/shared/mod_tests.rs b/crates/tinytools/src/shared/mod_tests.rs index 267936b..70eb47f 100644 --- a/crates/tinytools/src/shared/mod_tests.rs +++ b/crates/tinytools/src/shared/mod_tests.rs @@ -232,7 +232,7 @@ fn the_wrapper_keeps_what_tool_rules_read() { ); let rules = crate::ToolRuleSet::single(crate::ToolRules::from_allow_deny( Vec::::new(), - ["*_delete_*"], + ["*_DELETE_*"], )); let decision = rules.evaluate_call( &tool, From 42b892adceec5c18eedec6e1581ed684c31ec2aa Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 9 Oct 2026 12:51:01 +0300 Subject: [PATCH 17/22] feat(rules): add subject rule for commit message validation Add a new subject rule that validates commit message subject lines, checking length and formatting constraints. This extends the rules module so commit messages can be linted against conventional commit subject requirements. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinytools/src/lib.rs | 3 +- crates/tinytools/src/rules/eval.rs | 8 +++-- crates/tinytools/src/rules/mod.rs | 2 +- crates/tinytools/src/rules/mod_tests.rs | 31 +++++++++++++++-- crates/tinytools/src/rules/subject.rs | 42 ++++++++++++++++++++++++ crates/tinytools/src/shared/mod_tests.rs | 8 +++-- crates/tinytools/src/shared/types.rs | 2 +- crates/tinytools/src/tool/types.rs | 6 ++-- 8 files changed, 90 insertions(+), 12 deletions(-) diff --git a/crates/tinytools/src/lib.rs b/crates/tinytools/src/lib.rs index f3d6f29..9ec7d2e 100644 --- a/crates/tinytools/src/lib.rs +++ b/crates/tinytools/src/lib.rs @@ -151,7 +151,8 @@ pub use rank::{ pub use result::{FileData, ImageData, ToolContent, ToolControl, ToolErrorKind, ToolResult}; pub use rules::{ ApprovalDirective, ArgMatcher, DefaultEffect, Patterns, RuleContext, RuleDecision, RuleEffect, - RuleRef, SideEffect, Surface, ToolMatcher, ToolRule, ToolRuleSet, ToolRules, ToolSubject, + IndirectCall, RuleRef, SideEffect, Surface, ToolMatcher, ToolRule, ToolRuleSet, ToolRules, + ToolSubject, glob_matches, }; pub use shared::{SharedTool, owned_belt, share_belt}; diff --git a/crates/tinytools/src/rules/eval.rs b/crates/tinytools/src/rules/eval.rs index 4fd8a05..b0077cd 100644 --- a/crates/tinytools/src/rules/eval.rs +++ b/crates/tinytools/src/rules/eval.rs @@ -340,8 +340,12 @@ impl ToolRuleSet { let subject = ToolSubject::of_call(tool, args); let direct = self.evaluate(&subject, context, Surface::Call, Some(args)); match tool.indirect_target(args) { - Some(target) => { - let indirect = self.evaluate(&target, context, Surface::Call, Some(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, diff --git a/crates/tinytools/src/rules/mod.rs b/crates/tinytools/src/rules/mod.rs index 36d6a23..6eeac6a 100644 --- a/crates/tinytools/src/rules/mod.rs +++ b/crates/tinytools/src/rules/mod.rs @@ -69,7 +69,7 @@ mod subject; mod types; pub use glob::glob_matches; -pub use subject::ToolSubject; +pub use subject::{IndirectCall, ToolSubject}; pub use types::{ ApprovalDirective, ArgMatcher, DefaultEffect, Patterns, RuleContext, RuleDecision, RuleEffect, RuleRef, SideEffect, Surface, ToolMatcher, ToolRule, ToolRuleSet, ToolRules, diff --git a/crates/tinytools/src/rules/mod_tests.rs b/crates/tinytools/src/rules/mod_tests.rs index f484d02..a92ca5e 100644 --- a/crates/tinytools/src/rules/mod_tests.rs +++ b/crates/tinytools/src/rules/mod_tests.rs @@ -412,10 +412,16 @@ impl Tool for Execute { ..ToolSideEffects::default() }) } - fn indirect_target(&self, args: &Value) -> Option { + fn indirect_target(&self, args: &Value) -> Option { let action = args.get("action")?.as_str()?; let toolkit = action.split('_').next()?; - Some(ToolSubject::named(action).with_family(toolkit.to_ascii_lowercase())) + 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, + }) } } @@ -452,6 +458,27 @@ fn evaluate_call_checks_the_indirect_target() { assert!(set.evaluate_call(&Execute, &ctx(), &json!({})).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": [ diff --git a/crates/tinytools/src/rules/subject.rs b/crates/tinytools/src/rules/subject.rs index 151c3ed..d4921b7 100644 --- a/crates/tinytools/src/rules/subject.rs +++ b/crates/tinytools/src/rules/subject.rs @@ -92,3 +92,45 @@ impl ToolSubject { 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/shared/mod_tests.rs b/crates/tinytools/src/shared/mod_tests.rs index 70eb47f..b116f18 100644 --- a/crates/tinytools/src/shared/mod_tests.rs +++ b/crates/tinytools/src/shared/mod_tests.rs @@ -117,8 +117,10 @@ impl Tool for Opinionated { vec!["pack:opinions".into()] } - fn indirect_target(&self, args: &Value) -> Option { - args.get("x")?.as_str().map(crate::ToolSubject::named) + 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 { @@ -228,7 +230,7 @@ fn the_wrapper_keeps_what_tool_rules_read() { assert_eq!(tool.tags(), ["pack:opinions"]); assert_eq!( tool.indirect_target(&json!({ "x": "GMAIL_DELETE_EMAIL" })), - Some(crate::ToolSubject::named("GMAIL_DELETE_EMAIL")) + Some(crate::ToolSubject::named("GMAIL_DELETE_EMAIL").into()) ); let rules = crate::ToolRuleSet::single(crate::ToolRules::from_allow_deny( Vec::::new(), diff --git a/crates/tinytools/src/shared/types.rs b/crates/tinytools/src/shared/types.rs index 605143b..f61f8b0 100644 --- a/crates/tinytools/src/shared/types.rs +++ b/crates/tinytools/src/shared/types.rs @@ -120,7 +120,7 @@ impl Tool for SharedTool { self.0.tags() } - fn indirect_target(&self, args: &Value) -> Option { + fn indirect_target(&self, args: &Value) -> Option { self.0.indirect_target(args) } diff --git a/crates/tinytools/src/tool/types.rs b/crates/tinytools/src/tool/types.rs index 9abaf4b..e6290ed 100644 --- a/crates/tinytools/src/tool/types.rs +++ b/crates/tinytools/src/tool/types.rs @@ -12,7 +12,7 @@ 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::ToolSubject; +use crate::rules::IndirectCall; use crate::spec::ToolSpec; /// Whether a tool is advertised directly, discoverable on demand, or kept @@ -222,9 +222,11 @@ pub trait Tool: Send + Sync { /// 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 { + fn indirect_target(&self, _args: &Value) -> Option { None } From 5abb92f86e88d6d67db3b09249e2f497f2eb74b1 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 9 Oct 2026 12:51:24 +0300 Subject: [PATCH 18/22] docs(rules): document indirect call arguments The rules README and tool-rules spec now spell out that an indirect target is carried as an IndirectCall with its own arguments, and that evaluation uses those arguments so an argument-scoped rule cannot be sidestepped through a dispatcher envelope. The re-exports and test formatting were tidied to match. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinytools/src/lib.rs | 7 +++---- crates/tinytools/src/rules/README.md | 6 ++++-- crates/tinytools/src/rules/mod_tests.rs | 15 ++++++++++----- docs/specs/tool-rules.md | 4 +++- 4 files changed, 20 insertions(+), 12 deletions(-) diff --git a/crates/tinytools/src/lib.rs b/crates/tinytools/src/lib.rs index 9ec7d2e..d32225e 100644 --- a/crates/tinytools/src/lib.rs +++ b/crates/tinytools/src/lib.rs @@ -150,10 +150,9 @@ pub use rank::{ }; pub use result::{FileData, ImageData, ToolContent, ToolControl, ToolErrorKind, ToolResult}; pub use rules::{ - ApprovalDirective, ArgMatcher, DefaultEffect, Patterns, RuleContext, RuleDecision, RuleEffect, - IndirectCall, RuleRef, SideEffect, Surface, ToolMatcher, ToolRule, ToolRuleSet, ToolRules, - ToolSubject, - glob_matches, + 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; diff --git a/crates/tinytools/src/rules/README.md b/crates/tinytools/src/rules/README.md index 5cab450..4cca394 100644 --- a/crates/tinytools/src/rules/README.md +++ b/crates/tinytools/src/rules/README.md @@ -34,8 +34,10 @@ same line `deferral` draws. - **`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`, it - evaluates that target too. + 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 diff --git a/crates/tinytools/src/rules/mod_tests.rs b/crates/tinytools/src/rules/mod_tests.rs index a92ca5e..2ec549a 100644 --- a/crates/tinytools/src/rules/mod_tests.rs +++ b/crates/tinytools/src/rules/mod_tests.rs @@ -415,9 +415,8 @@ impl Tool for Execute { 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()), - ); + 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, @@ -472,8 +471,14 @@ fn evaluate_call_reads_the_targets_own_arguments() { ) }; 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!( + !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); diff --git a/docs/specs/tool-rules.md b/docs/specs/tool-rules.md index c70e349..b7b2b82 100644 --- a/docs/specs/tool-rules.md +++ b/docs/specs/tool-rules.md @@ -56,7 +56,9 @@ search, and a wildcard worked in one list but not the next. 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` names a target, + 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) — the target too. - `Tool` gains two defaulted, descriptive methods: `tags()` and `indirect_target(args)`. From 138920eeea0f79442609ea57ea6a98911713fa08 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 9 Oct 2026 12:51:28 +0300 Subject: [PATCH 19/22] fix(rules): evaluate an indirect target against its own arguments indirect_target now returns an IndirectCall carrying the target's own arguments, so a dispatcher envelope cannot hide them from argument-scoped rules. Co-authored-by: Medulla --- docs/specs/tool-rules.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/specs/tool-rules.md b/docs/specs/tool-rules.md index b7b2b82..5eec0fc 100644 --- a/docs/specs/tool-rules.md +++ b/docs/specs/tool-rules.md @@ -58,8 +58,7 @@ search, and a wildcard worked in one list but not the next. - `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) — - the target too. + arguments (falling back to the dispatcher's). - `Tool` gains two defaulted, descriptive methods: `tags()` and `indirect_target(args)`. From 6f69b334768da5d25885dda24290c0e011dbd775 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 9 Oct 2026 12:56:14 +0300 Subject: [PATCH 20/22] test(rules): pin that a deny cannot be sidestepped by name case Co-authored-by: Medulla --- crates/tinytools/src/rules/eval.rs | 5 +++++ crates/tinytools/src/rules/mod_tests.rs | 22 ++++++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/crates/tinytools/src/rules/eval.rs b/crates/tinytools/src/rules/eval.rs index b0077cd..068cbee 100644 --- a/crates/tinytools/src/rules/eval.rs +++ b/crates/tinytools/src/rules/eval.rs @@ -13,6 +13,11 @@ use super::types::{ /// 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, diff --git a/crates/tinytools/src/rules/mod_tests.rs b/crates/tinytools/src/rules/mod_tests.rs index 2ec549a..13e5272 100644 --- a/crates/tinytools/src/rules/mod_tests.rs +++ b/crates/tinytools/src/rules/mod_tests.rs @@ -457,6 +457,28 @@ fn evaluate_call_checks_the_indirect_target() { 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": [ From fabda18037f732f0a5d3427e000a189d6345b939 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 9 Oct 2026 13:01:37 +0300 Subject: [PATCH 21/22] fix(rules): honour legacy external effect declarations Tools that declare an outside effect only through the older `external_effect`/`external_effect_with_args` hooks now surface as `external_service` in their subject, so rules matching on that side effect apply to them. Previously such tools were treated as having no external effect, letting approval rules silently skip them. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinytools/src/rules/mod_tests.rs | 72 +++++++++++++++++++++++++ crates/tinytools/src/rules/subject.rs | 14 ++++- 2 files changed, 84 insertions(+), 2 deletions(-) diff --git a/crates/tinytools/src/rules/mod_tests.rs b/crates/tinytools/src/rules/mod_tests.rs index 13e5272..d8f0838 100644 --- a/crates/tinytools/src/rules/mod_tests.rs +++ b/crates/tinytools/src/rules/mod_tests.rs @@ -438,6 +438,78 @@ fn subject_of_reads_the_tool_declarations() { 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 + } +} + +#[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": [ diff --git a/crates/tinytools/src/rules/subject.rs b/crates/tinytools/src/rules/subject.rs index d4921b7..ee5cf90 100644 --- a/crates/tinytools/src/rules/subject.rs +++ b/crates/tinytools/src/rules/subject.rs @@ -71,8 +71,14 @@ impl ToolSubject { /// 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), @@ -80,15 +86,19 @@ impl ToolSubject { category: Some(tool.category()), exposure: Some(tool.exposure()), permission: Some(tool.permission_level()), - side_effects: Some(tool.policy().side_effects), + side_effects: Some(side_effects), } } - /// [`Self::of`], with the permission level `tool` declares for `args`. + /// [`Self::of`], with the permission level and external effect `tool` + /// declares for `args`. #[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)); + if let Some(effects) = subject.side_effects.as_mut() { + effects.external_service |= tool.external_effect_with_args(args); + } subject } } From 738ce29dc4ca5fb664baf34dfc465679a2781f19 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 9 Oct 2026 13:08:12 +0300 Subject: [PATCH 22/22] fix(rules): let per-call external effect supersede the conservative default A tool that is conservatively marked external when listed can now refine a read-only call to non-external, while an effect declared explicitly by the tool's policy is kept for every call. Tests cover the refinement and pin the wire form of ToolSubject. Auto-committed-on: dragonfly Co-authored-by: Medulla --- crates/tinytools/src/rules/mod_tests.rs | 78 +++++++++++++++++++++++++ crates/tinytools/src/rules/subject.rs | 8 ++- 2 files changed, 85 insertions(+), 1 deletion(-) diff --git a/crates/tinytools/src/rules/mod_tests.rs b/crates/tinytools/src/rules/mod_tests.rs index d8f0838..7aef710 100644 --- a/crates/tinytools/src/rules/mod_tests.rs +++ b/crates/tinytools/src/rules/mod_tests.rs @@ -480,6 +480,84 @@ impl Tool for LegacyAlwaysExternal { } } +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": [ diff --git a/crates/tinytools/src/rules/subject.rs b/crates/tinytools/src/rules/subject.rs index ee5cf90..5f723f5 100644 --- a/crates/tinytools/src/rules/subject.rs +++ b/crates/tinytools/src/rules/subject.rs @@ -92,12 +92,18 @@ impl ToolSubject { /// [`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 |= tool.external_effect_with_args(args); + effects.external_service = declared || tool.external_effect_with_args(args); } subject }