1use 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#[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#[derive(Debug, Clone, Deserialize, Serialize)]
46pub struct ToolPolicyConfig {
47 #[serde(default)]
48 pub mode: ToolPolicyMode,
49 #[serde(default)]
51 pub deny_tools: Vec<String>,
52 #[serde(default)]
56 pub max_risk: Option<ToolRisk>,
57 #[serde(default)]
59 pub allow_tools: Vec<String>,
60 #[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 #[serde(default)]
92 pub chat_id: Option<i64>,
93 #[serde(default)]
95 pub channel: Option<String>,
96 #[serde(default)]
98 pub principal: Option<String>,
99 #[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#[derive(Debug, Clone, PartialEq, Eq)]
110pub enum PolicyDecision {
111 Allow,
112 Warn(String),
114 Block(String),
116}
117
118pub 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
124pub 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
162pub 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
172pub 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
261const NO_PROGRESS_WARN_AFTER: usize = 2;
263const SAME_TOOL_FAILURE_WARN_AFTER: usize = 3;
264
265#[derive(Default)]
266pub struct GuardrailController {
267 no_progress: HashMap<(String, String), (String, usize)>,
269 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 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 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 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 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 assert!(matches!(
400 evaluate_tool_policy(&c, "bash"),
401 PolicyDecision::Block(_)
402 ));
403 assert_eq!(
405 evaluate_tool_policy(&c, "write_file"),
406 PolicyDecision::Allow
407 );
408 assert_eq!(evaluate_tool_policy(&c, "read_file"), PolicyDecision::Allow);
410 }
411
412 #[test]
413 fn policy_max_risk_covers_mcp_tools() {
414 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 let mut c = cfg(ToolPolicyMode::Block);
428 c.max_risk = Some(ToolRisk::Medium);
429 assert!(matches!(
431 evaluate_tool_policy(&c, "mcp_fs_delete_file"),
432 PolicyDecision::Allow
433 ));
434 assert!(matches!(
436 evaluate_tool_policy_with_risk(&c, "mcp_fs_delete_file", ToolRisk::High),
437 PolicyDecision::Block(_)
438 ));
439 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 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 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 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 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 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}