Skip to main content

microclaw_engine/
tool_guardrails.rs

1//! Per-turn tool-call guardrails.
2//!
3//! Sits alongside the existing `recent_tool_call_keys` circuit breaker (which
4//! short-circuits identical-(tool,args) calls after N repeats). This module
5//! adds two finer-grained signals from hermes-agent's `tool_guardrails.py`:
6//!
7//! * **Idempotent no-progress**: if a read-only tool returns the same result
8//!   hash N times for the same args, append a guidance suffix to the tool
9//!   result asking the model to use what it already has instead of repeating.
10//! * **Same-tool failure streak**: if any tool fails N times this turn — with
11//!   ANY args — append guidance suggesting a different approach.
12//!
13//! Both signals only emit warnings (the result still goes back to the model);
14//! they never block execution. The existing duplicate-call circuit breaker
15//! handles the blocking case for identical (tool, args) repeats.
16//!
17//! The Controller is reset per turn by the agent loop.
18
19use std::collections::HashMap;
20
21use serde::{Deserialize, Serialize};
22use sha2::{Digest, Sha256};
23
24use crate::internal::tool_runtime::runtime::{tool_risk, ToolRisk};
25use crate::tools::IDEMPOTENT_TOOLS;
26
27/// Pre-tool-call policy mode. `Off` preserves historical behavior (no policy
28/// evaluation at all); `Warn` logs violations to the audit trail but lets the
29/// call run; `Block` short-circuits the call with an error tool_result.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
31#[serde(rename_all = "lowercase")]
32pub enum ToolPolicyMode {
33    #[default]
34    Off,
35    Warn,
36    Block,
37}
38
39/// Config-driven pre-tool-call policy (`tool_policy` in config.yaml).
40///
41/// Complements the warn-only loop guardrails below: where `GuardrailController`
42/// nudges the model out of unproductive loops, this policy is a hard gate the
43/// operator sets ahead of time. Default mode is `off` — existing deployments
44/// see no behavior change.
45#[derive(Debug, Clone, Deserialize, Serialize)]
46pub struct ToolPolicyConfig {
47    #[serde(default)]
48    pub mode: ToolPolicyMode,
49    /// Tool names that are always denied (subject to `mode`).
50    #[serde(default)]
51    pub deny_tools: Vec<String>,
52    /// Highest permitted risk tier: low | medium | high. Tools whose built-in
53    /// risk exceeds this are denied. Unset = all tiers permitted. Typos are
54    /// rejected at config load (typed enum).
55    #[serde(default)]
56    pub max_risk: Option<ToolRisk>,
57    /// Tool names exempt from `deny_tools` / `max_risk` (allow wins).
58    #[serde(default)]
59    pub allow_tools: Vec<String>,
60    /// Per-chat/per-principal capability enforcement. Separate from the
61    /// global mode so deployments can introduce grants in warn mode first.
62    #[serde(default)]
63    pub grants_mode: ToolPolicyMode,
64    #[serde(default = "default_control_chat_grant_bypass")]
65    pub control_chat_bypass: bool,
66    #[serde(default)]
67    pub grants: Vec<ToolGrantRule>,
68}
69
70impl Default for ToolPolicyConfig {
71    fn default() -> Self {
72        Self {
73            mode: ToolPolicyMode::Off,
74            deny_tools: Vec::new(),
75            max_risk: None,
76            allow_tools: Vec::new(),
77            grants_mode: ToolPolicyMode::Off,
78            control_chat_bypass: true,
79            grants: Vec::new(),
80        }
81    }
82}
83
84fn default_control_chat_grant_bypass() -> bool {
85    true
86}
87
88#[derive(Debug, Clone, Default, Deserialize, Serialize)]
89pub struct ToolGrantRule {
90    /// Omit to match every chat.
91    #[serde(default)]
92    pub chat_id: Option<i64>,
93    /// Omit to match every channel.
94    #[serde(default)]
95    pub channel: Option<String>,
96    /// Exact principal or trailing wildcard (`subagent:*`). Omit for all.
97    #[serde(default)]
98    pub principal: Option<String>,
99    /// Capability allowlist. `*` permits all tools subject to deny/max_risk.
100    #[serde(default)]
101    pub allow_tools: Vec<String>,
102    #[serde(default)]
103    pub deny_tools: Vec<String>,
104    #[serde(default)]
105    pub max_risk: Option<ToolRisk>,
106}
107
108/// Outcome of evaluating one pending tool call against the policy.
109#[derive(Debug, Clone, PartialEq, Eq)]
110pub enum PolicyDecision {
111    Allow,
112    /// Violation under `warn` mode: execute, but audit-log the reason.
113    Warn(String),
114    /// Violation under `block` mode: short-circuit with this reason.
115    Block(String),
116}
117
118/// Evaluate `tool_name` against the configured policy. Pure — callers handle
119/// audit logging and short-circuiting.
120pub fn evaluate_tool_policy(cfg: &ToolPolicyConfig, tool_name: &str) -> PolicyDecision {
121    evaluate_tool_policy_with_risk(cfg, tool_name, tool_risk(tool_name))
122}
123
124/// Like [`evaluate_tool_policy`] but with the tool's effective risk supplied
125/// by the caller — used when a registration-time override (e.g. an MCP
126/// server trust tier) replaces the name-derived risk.
127pub fn evaluate_tool_policy_with_risk(
128    cfg: &ToolPolicyConfig,
129    tool_name: &str,
130    risk: crate::internal::tool_runtime::runtime::ToolRisk,
131) -> PolicyDecision {
132    if cfg.mode == ToolPolicyMode::Off {
133        return PolicyDecision::Allow;
134    }
135    if cfg.allow_tools.iter().any(|t| t == tool_name) {
136        return PolicyDecision::Allow;
137    }
138    let violation = if cfg.deny_tools.iter().any(|t| t == tool_name) {
139        Some(format!("tool `{tool_name}` is in tool_policy.deny_tools"))
140    } else {
141        match cfg.max_risk {
142            Some(max_risk) if risk > max_risk => Some(format!(
143                "tool `{tool_name}` risk `{}` exceeds tool_policy.max_risk `{}`",
144                risk.as_str(),
145                max_risk.as_str()
146            )),
147            _ => None,
148        }
149    };
150    match violation {
151        None => PolicyDecision::Allow,
152        Some(reason) => {
153            if cfg.mode == ToolPolicyMode::Block {
154                PolicyDecision::Block(reason)
155            } else {
156                PolicyDecision::Warn(reason)
157            }
158        }
159    }
160}
161
162/// Evaluate the global policy and the caller's least-privilege capability
163/// grants. A grant never weakens a global block.
164pub fn evaluate_tool_policy_for_auth(
165    cfg: &ToolPolicyConfig,
166    tool_name: &str,
167    auth: &crate::internal::tool_runtime::runtime::ToolAuthContext,
168) -> PolicyDecision {
169    evaluate_tool_policy_for_auth_with_risk(cfg, tool_name, auth, tool_risk(tool_name))
170}
171
172/// Like [`evaluate_tool_policy_for_auth`] but with the tool's effective risk
173/// supplied by the caller (registration-time overrides such as MCP server
174/// trust tiers).
175pub fn evaluate_tool_policy_for_auth_with_risk(
176    cfg: &ToolPolicyConfig,
177    tool_name: &str,
178    auth: &crate::internal::tool_runtime::runtime::ToolAuthContext,
179    risk: crate::internal::tool_runtime::runtime::ToolRisk,
180) -> PolicyDecision {
181    let global = evaluate_tool_policy_with_risk(cfg, tool_name, risk);
182    if matches!(global, PolicyDecision::Block(_))
183        || cfg.grants_mode == ToolPolicyMode::Off
184        || (cfg.control_chat_bypass && auth.is_control_chat())
185    {
186        return global;
187    }
188
189    let matching: Vec<&ToolGrantRule> = cfg
190        .grants
191        .iter()
192        .filter(|rule| grant_matches(rule, auth))
193        .collect();
194    let violation = if matching.is_empty() {
195        Some(format!(
196            "no tool grant matches chat {} principal `{}`",
197            auth.caller_chat_id, auth.principal
198        ))
199    } else if matching
200        .iter()
201        .any(|rule| tool_list_contains(&rule.deny_tools, tool_name))
202    {
203        Some(format!(
204            "tool `{tool_name}` is denied for principal `{}`",
205            auth.principal
206        ))
207    } else if matching
208        .iter()
209        .any(|rule| rule.max_risk.is_some_and(|max_risk| risk > max_risk))
210    {
211        Some(format!(
212            "tool `{tool_name}` risk `{}` exceeds a matching capability grant",
213            risk.as_str()
214        ))
215    } else if !matching
216        .iter()
217        .any(|rule| tool_list_contains(&rule.allow_tools, tool_name))
218    {
219        Some(format!(
220            "tool `{tool_name}` is not granted to chat {} principal `{}`",
221            auth.caller_chat_id, auth.principal
222        ))
223    } else {
224        None
225    };
226
227    match violation {
228        Some(reason) if cfg.grants_mode == ToolPolicyMode::Block => PolicyDecision::Block(reason),
229        Some(reason) => PolicyDecision::Warn(reason),
230        None => global,
231    }
232}
233
234fn grant_matches(
235    rule: &ToolGrantRule,
236    auth: &crate::internal::tool_runtime::runtime::ToolAuthContext,
237) -> bool {
238    rule.chat_id
239        .is_none_or(|chat_id| chat_id == auth.caller_chat_id)
240        && rule
241            .channel
242            .as_deref()
243            .is_none_or(|channel| channel.eq_ignore_ascii_case(&auth.caller_channel))
244        && rule
245            .principal
246            .as_deref()
247            .is_none_or(|principal| wildcard_match(principal, &auth.principal))
248}
249
250fn wildcard_match(pattern: &str, value: &str) -> bool {
251    pattern
252        .strip_suffix('*')
253        .map_or(pattern == value, |prefix| value.starts_with(prefix))
254}
255
256fn tool_list_contains(list: &[String], tool_name: &str) -> bool {
257    list.iter()
258        .any(|pattern| pattern == "*" || wildcard_match(pattern, tool_name))
259}
260
261/// Default thresholds (keeping warnings light — these are nudges, not gates).
262const NO_PROGRESS_WARN_AFTER: usize = 2;
263const SAME_TOOL_FAILURE_WARN_AFTER: usize = 3;
264
265#[derive(Default)]
266pub struct GuardrailController {
267    /// (tool_name, args_hash) -> (last_result_hash, repeat_count)
268    no_progress: HashMap<(String, String), (String, usize)>,
269    /// tool_name -> failure_count
270    same_tool_failures: HashMap<String, usize>,
271}
272
273impl GuardrailController {
274    pub fn new() -> Self {
275        Self::default()
276    }
277
278    pub fn reset_for_turn(&mut self) {
279        self.no_progress.clear();
280        self.same_tool_failures.clear();
281    }
282
283    /// Record a tool result and return an optional guidance suffix to append
284    /// to the tool's result content.
285    pub fn after_call(
286        &mut self,
287        tool_name: &str,
288        args_hash: &str,
289        result: &str,
290        failed: bool,
291    ) -> Option<String> {
292        if failed {
293            // Reset no-progress tracking for this signature; failure isn't
294            // "no progress", it's "regression".
295            self.no_progress
296                .remove(&(tool_name.to_string(), args_hash.to_string()));
297            let count = self
298                .same_tool_failures
299                .entry(tool_name.to_string())
300                .or_insert(0);
301            *count += 1;
302            if *count >= SAME_TOOL_FAILURE_WARN_AFTER {
303                return Some(format!(
304                    "\n\n[Tool loop warning: `{tool_name}` has failed {count} time(s) this turn. \
305                     This looks like a loop — change approach (different tool, different inputs, \
306                     or summarize what you know and proceed) before retrying.]"
307                ));
308            }
309            return None;
310        }
311
312        // Successful call — reset failure streak for this tool.
313        self.same_tool_failures.remove(tool_name);
314
315        if !is_idempotent(tool_name) {
316            return None;
317        }
318
319        let result_hash = hash_str(result);
320        let key = (tool_name.to_string(), args_hash.to_string());
321        let entry = self
322            .no_progress
323            .entry(key)
324            .or_insert((result_hash.clone(), 0));
325        if entry.0 == result_hash {
326            entry.1 += 1;
327        } else {
328            *entry = (result_hash, 1);
329        }
330        let count = entry.1;
331        if count >= NO_PROGRESS_WARN_AFTER {
332            return Some(format!(
333                "\n\n[Tool loop warning: `{tool_name}` has returned the same result \
334                 {count} time(s) for the same arguments. Use the result you already have \
335                 — re-running it won't change the answer.]"
336            ));
337        }
338        None
339    }
340}
341
342fn is_idempotent(tool_name: &str) -> bool {
343    IDEMPOTENT_TOOLS.contains(&tool_name)
344}
345
346fn hash_str(s: &str) -> String {
347    let mut hasher = Sha256::new();
348    hasher.update(s.as_bytes());
349    let digest = hasher.finalize();
350    // First 16 hex chars are plenty for collision resistance within a turn.
351    format!("{:x}", digest)[..16].to_string()
352}
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357
358    fn cfg(mode: ToolPolicyMode) -> ToolPolicyConfig {
359        ToolPolicyConfig {
360            mode,
361            ..Default::default()
362        }
363    }
364
365    #[test]
366    fn policy_off_allows_everything() {
367        let mut c = cfg(ToolPolicyMode::Off);
368        c.deny_tools = vec!["bash".into()];
369        c.max_risk = Some(ToolRisk::Low);
370        assert_eq!(evaluate_tool_policy(&c, "bash"), PolicyDecision::Allow);
371    }
372
373    #[test]
374    fn policy_blocks_denied_tool() {
375        let mut c = cfg(ToolPolicyMode::Block);
376        c.deny_tools = vec!["bash".into()];
377        assert!(matches!(
378            evaluate_tool_policy(&c, "bash"),
379            PolicyDecision::Block(_)
380        ));
381        assert_eq!(evaluate_tool_policy(&c, "read_file"), PolicyDecision::Allow);
382    }
383
384    #[test]
385    fn policy_warn_mode_warns_not_blocks() {
386        let mut c = cfg(ToolPolicyMode::Warn);
387        c.deny_tools = vec!["bash".into()];
388        assert!(matches!(
389            evaluate_tool_policy(&c, "bash"),
390            PolicyDecision::Warn(_)
391        ));
392    }
393
394    #[test]
395    fn policy_max_risk_blocks_higher_tiers() {
396        let mut c = cfg(ToolPolicyMode::Block);
397        c.max_risk = Some(ToolRisk::Medium);
398        // bash is High risk -> blocked
399        assert!(matches!(
400            evaluate_tool_policy(&c, "bash"),
401            PolicyDecision::Block(_)
402        ));
403        // write_file is Medium -> allowed at max_risk=medium
404        assert_eq!(
405            evaluate_tool_policy(&c, "write_file"),
406            PolicyDecision::Allow
407        );
408        // read_file is Low -> allowed
409        assert_eq!(evaluate_tool_policy(&c, "read_file"), PolicyDecision::Allow);
410    }
411
412    #[test]
413    fn policy_max_risk_covers_mcp_tools() {
414        // MCP tools rate Medium by default, so max_risk=low restricts them.
415        let mut c = cfg(ToolPolicyMode::Block);
416        c.max_risk = Some(ToolRisk::Low);
417        assert!(matches!(
418            evaluate_tool_policy(&c, "mcp_fs_delete_file"),
419            PolicyDecision::Block(_)
420        ));
421    }
422
423    #[test]
424    fn policy_with_risk_override_replaces_name_derived_risk() {
425        // Server trust tiers pass an explicit risk that replaces the
426        // name-derived Medium for MCP tools in both directions.
427        let mut c = cfg(ToolPolicyMode::Block);
428        c.max_risk = Some(ToolRisk::Medium);
429        // Uniform default: allowed at medium.
430        assert!(matches!(
431            evaluate_tool_policy(&c, "mcp_fs_delete_file"),
432            PolicyDecision::Allow
433        ));
434        // Sandboxed server → High: blocked at medium.
435        assert!(matches!(
436            evaluate_tool_policy_with_risk(&c, "mcp_fs_delete_file", ToolRisk::High),
437            PolicyDecision::Block(_)
438        ));
439        // Trusted server → Low: allowed even at max_risk=low.
440        c.max_risk = Some(ToolRisk::Low);
441        assert!(matches!(
442            evaluate_tool_policy_with_risk(&c, "mcp_fs_delete_file", ToolRisk::Low),
443            PolicyDecision::Allow
444        ));
445    }
446
447    #[test]
448    fn policy_allow_list_overrides_deny() {
449        let mut c = cfg(ToolPolicyMode::Block);
450        c.deny_tools = vec!["bash".into()];
451        c.max_risk = Some(ToolRisk::Low);
452        c.allow_tools = vec!["bash".into()];
453        assert_eq!(evaluate_tool_policy(&c, "bash"), PolicyDecision::Allow);
454    }
455
456    #[test]
457    fn policy_invalid_max_risk_rejected_at_parse() {
458        // Typos now fail at config load instead of runtime fail-closed.
459        assert!(
460            serde_yaml::from_str::<ToolPolicyConfig>("mode: block\nmax_risk: extreme").is_err()
461        );
462        let ok: ToolPolicyConfig = serde_yaml::from_str("mode: block\nmax_risk: medium").unwrap();
463        assert_eq!(ok.max_risk, Some(ToolRisk::Medium));
464    }
465
466    fn auth(
467        chat_id: i64,
468        principal: &str,
469    ) -> crate::internal::tool_runtime::runtime::ToolAuthContext {
470        crate::internal::tool_runtime::runtime::ToolAuthContext {
471            caller_channel: "telegram".into(),
472            caller_chat_id: chat_id,
473            principal: principal.into(),
474            control_chat_ids: vec![],
475            env_files: vec![],
476        }
477    }
478
479    #[test]
480    fn capability_grants_are_chat_and_principal_scoped() {
481        let mut config = cfg(ToolPolicyMode::Off);
482        config.grants_mode = ToolPolicyMode::Block;
483        config.grants = vec![ToolGrantRule {
484            chat_id: Some(7),
485            principal: Some("subagent:*".into()),
486            allow_tools: vec!["read_*".into(), "web_search".into()],
487            max_risk: Some(ToolRisk::Low),
488            ..Default::default()
489        }];
490
491        assert_eq!(
492            evaluate_tool_policy_for_auth(&config, "read_file", &auth(7, "subagent:abc")),
493            PolicyDecision::Allow
494        );
495        assert!(matches!(
496            evaluate_tool_policy_for_auth(&config, "write_file", &auth(7, "subagent:abc")),
497            PolicyDecision::Block(_)
498        ));
499        assert!(matches!(
500            evaluate_tool_policy_for_auth(&config, "read_file", &auth(8, "subagent:abc")),
501            PolicyDecision::Block(_)
502        ));
503        assert!(matches!(
504            evaluate_tool_policy_for_auth(&config, "read_file", &auth(7, "main")),
505            PolicyDecision::Block(_)
506        ));
507    }
508
509    #[test]
510    fn global_block_cannot_be_weakened_by_grant() {
511        let mut config = cfg(ToolPolicyMode::Block);
512        config.deny_tools = vec!["bash".into()];
513        config.grants_mode = ToolPolicyMode::Block;
514        config.grants = vec![ToolGrantRule {
515            allow_tools: vec!["*".into()],
516            ..Default::default()
517        }];
518        assert!(matches!(
519            evaluate_tool_policy_for_auth(&config, "bash", &auth(7, "main")),
520            PolicyDecision::Block(_)
521        ));
522    }
523
524    #[test]
525    fn idempotent_no_progress_warns_on_third_identical() {
526        let mut c = GuardrailController::new();
527        assert!(c
528            .after_call("read_file", "h1", "file contents", false)
529            .is_none());
530        // 2nd call same args same result => count=2 => warn at threshold (2)
531        let warn = c.after_call("read_file", "h1", "file contents", false);
532        assert!(warn.is_some());
533        assert!(warn.unwrap().contains("same result"));
534    }
535
536    #[test]
537    fn idempotent_changing_result_does_not_warn() {
538        let mut c = GuardrailController::new();
539        c.after_call("read_file", "h1", "v1", false);
540        c.after_call("read_file", "h1", "v2", false);
541        let warn = c.after_call("read_file", "h1", "v3", false);
542        assert!(warn.is_none());
543    }
544
545    #[test]
546    fn mutating_tool_no_warning_on_repeat() {
547        let mut c = GuardrailController::new();
548        c.after_call("write_file", "h1", "ok", false);
549        let warn = c.after_call("write_file", "h1", "ok", false);
550        assert!(warn.is_none());
551    }
552
553    #[test]
554    fn same_tool_failure_streak_warns() {
555        let mut c = GuardrailController::new();
556        c.after_call("bash", "h1", "err1", true);
557        c.after_call("bash", "h2", "err2", true);
558        // Third failure (any args) => warn
559        let warn = c.after_call("bash", "h3", "err3", true);
560        assert!(warn.is_some());
561        assert!(warn.unwrap().contains("failed 3"));
562    }
563
564    #[test]
565    fn success_resets_failure_streak() {
566        let mut c = GuardrailController::new();
567        c.after_call("bash", "h1", "err1", true);
568        c.after_call("bash", "h2", "err2", true);
569        c.after_call("bash", "h3", "ok", false);
570        // After success, streak resets; next two failures should NOT warn.
571        let warn = c.after_call("bash", "h4", "err4", true);
572        assert!(warn.is_none());
573        let warn = c.after_call("bash", "h5", "err5", true);
574        assert!(warn.is_none());
575    }
576
577    #[test]
578    fn failure_does_not_increment_no_progress() {
579        let mut c = GuardrailController::new();
580        c.after_call("read_file", "h1", "ok", false);
581        c.after_call("read_file", "h1", "err", true);
582        let warn = c.after_call("read_file", "h1", "ok", false);
583        // Failure cleared the no-progress counter, so we're at count=1 again.
584        assert!(warn.is_none());
585    }
586
587    #[test]
588    fn reset_for_turn_clears_state() {
589        let mut c = GuardrailController::new();
590        c.after_call("read_file", "h1", "v1", false);
591        c.after_call("read_file", "h1", "v1", false);
592        c.reset_for_turn();
593        let warn = c.after_call("read_file", "h1", "v1", false);
594        assert!(warn.is_none());
595    }
596}