1use std::path::{Component, Path, PathBuf};
13use std::sync::Arc;
14
15use async_trait::async_trait;
16use regex::Regex;
17use serde_json::Value;
18
19use crate::Severity;
20
21#[derive(Debug, Clone)]
23pub enum ToolGuardrailResult {
24 Allow,
26 Deny {
28 reason: String,
30 severity: Severity,
32 },
33 ReviseArgs {
38 args: Value,
40 reason: String,
42 },
43}
44
45impl ToolGuardrailResult {
46 pub fn deny(reason: impl Into<String>, severity: Severity) -> Self {
48 Self::Deny { reason: reason.into(), severity }
49 }
50
51 pub fn revise(args: Value, reason: impl Into<String>) -> Self {
53 Self::ReviseArgs { args, reason: reason.into() }
54 }
55
56 pub fn is_allowed(&self) -> bool {
58 !matches!(self, Self::Deny { .. })
59 }
60}
61
62#[async_trait]
89pub trait ToolGuardrail: Send + Sync {
90 fn name(&self) -> &str;
92
93 async fn validate_call(&self, tool_name: &str, args: &Value) -> ToolGuardrailResult;
95
96 fn applies_to(&self, _tool_name: &str) -> bool {
102 true
103 }
104}
105
106#[derive(Debug, Clone)]
108pub enum ToolCallDecision {
109 Allow {
111 args: Value,
113 },
114 Deny {
116 guardrail: String,
118 reason: String,
120 severity: Severity,
122 },
123}
124
125impl ToolCallDecision {
126 pub fn is_allowed(&self) -> bool {
128 matches!(self, Self::Allow { .. })
129 }
130}
131
132#[derive(Default)]
147pub struct ToolGuardrailSet {
148 guardrails: Vec<Arc<dyn ToolGuardrail>>,
149}
150
151impl ToolGuardrailSet {
152 pub fn new() -> Self {
154 Self { guardrails: Vec::new() }
155 }
156
157 pub fn with(mut self, guardrail: impl ToolGuardrail + 'static) -> Self {
159 self.guardrails.push(Arc::new(guardrail));
160 self
161 }
162
163 pub fn with_arc(mut self, guardrail: Arc<dyn ToolGuardrail>) -> Self {
165 self.guardrails.push(guardrail);
166 self
167 }
168
169 pub fn guardrails(&self) -> &[Arc<dyn ToolGuardrail>] {
171 &self.guardrails
172 }
173
174 pub fn is_empty(&self) -> bool {
176 self.guardrails.is_empty()
177 }
178
179 pub async fn evaluate(&self, tool_name: &str, args: &Value) -> ToolCallDecision {
187 let mut current = args.clone();
188
189 for guardrail in &self.guardrails {
190 if !guardrail.applies_to(tool_name) {
191 continue;
192 }
193
194 match guardrail.validate_call(tool_name, ¤t).await {
195 ToolGuardrailResult::Allow => {}
196 ToolGuardrailResult::Deny { reason, severity } => {
197 tracing::warn!(
198 guardrail = guardrail.name(),
199 tool = tool_name,
200 reason = %reason,
201 ?severity,
202 "tool call denied by guardrail"
203 );
204 return ToolCallDecision::Deny {
205 guardrail: guardrail.name().to_string(),
206 reason,
207 severity,
208 };
209 }
210 ToolGuardrailResult::ReviseArgs { args: revised, reason } => {
211 tracing::debug!(
212 guardrail = guardrail.name(),
213 tool = tool_name,
214 reason = %reason,
215 "tool call arguments revised by guardrail"
216 );
217 current = revised;
218 }
219 }
220 }
221
222 ToolCallDecision::Allow { args: current }
223 }
224}
225
226pub struct DeniedArgumentPattern {
243 name: String,
244 pattern: Regex,
245 severity: Severity,
246 tools: Option<Vec<String>>,
247}
248
249impl DeniedArgumentPattern {
250 pub fn new(
256 name: impl Into<String>,
257 pattern: &str,
258 severity: Severity,
259 ) -> std::result::Result<Self, regex::Error> {
260 Ok(Self { name: name.into(), pattern: Regex::new(pattern)?, severity, tools: None })
261 }
262
263 pub fn on_tools<I, S>(mut self, tools: I) -> Self
265 where
266 I: IntoIterator<Item = S>,
267 S: Into<String>,
268 {
269 self.tools = Some(tools.into_iter().map(Into::into).collect());
270 self
271 }
272}
273
274#[async_trait]
275impl ToolGuardrail for DeniedArgumentPattern {
276 fn name(&self) -> &str {
277 &self.name
278 }
279
280 fn applies_to(&self, tool_name: &str) -> bool {
281 match &self.tools {
282 Some(tools) => tools.iter().any(|t| t == tool_name),
283 None => true,
284 }
285 }
286
287 async fn validate_call(&self, tool_name: &str, args: &Value) -> ToolGuardrailResult {
288 if self.pattern.is_match(&args.to_string()) {
289 return ToolGuardrailResult::deny(
290 format!(
291 "arguments to `{tool_name}` match the denied pattern `{}`",
292 self.pattern.as_str()
293 ),
294 self.severity,
295 );
296 }
297 ToolGuardrailResult::Allow
298 }
299}
300
301pub struct PathAllowList {
325 name: String,
326 arg_names: Vec<String>,
327 allowed_roots: Vec<PathBuf>,
328 severity: Severity,
329 tools: Option<Vec<String>>,
330}
331
332impl PathAllowList {
333 pub fn new<A, S, R, P>(name: impl Into<String>, arg_names: A, allowed_roots: R) -> Self
335 where
336 A: IntoIterator<Item = S>,
337 S: Into<String>,
338 R: IntoIterator<Item = P>,
339 P: Into<PathBuf>,
340 {
341 Self {
342 name: name.into(),
343 arg_names: arg_names.into_iter().map(Into::into).collect(),
344 allowed_roots: allowed_roots.into_iter().map(Into::into).collect(),
345 severity: Severity::Critical,
346 tools: None,
347 }
348 }
349
350 pub fn with_severity(mut self, severity: Severity) -> Self {
352 self.severity = severity;
353 self
354 }
355
356 pub fn on_tools<I, S>(mut self, tools: I) -> Self
358 where
359 I: IntoIterator<Item = S>,
360 S: Into<String>,
361 {
362 self.tools = Some(tools.into_iter().map(Into::into).collect());
363 self
364 }
365
366 fn is_permitted(&self, candidate: &str) -> bool {
368 let path = Path::new(candidate);
369
370 if !path.is_absolute() {
371 return false;
372 }
373
374 if path.components().any(|c| matches!(c, Component::ParentDir)) {
377 return false;
378 }
379
380 self.allowed_roots.iter().any(|root| {
381 if !root.is_absolute()
382 || root.components().any(|component| matches!(component, Component::ParentDir))
383 || !path.starts_with(root)
384 {
385 return false;
386 }
387
388 let Ok(canonical_root) = std::fs::canonicalize(root) else {
389 return false;
391 };
392
393 let Ok(relative) = path.strip_prefix(root) else {
394 return false;
395 };
396 let mut current = root.clone();
397 for component in relative.components() {
398 current.push(component);
399 match std::fs::symlink_metadata(¤t) {
400 Ok(_) => {
401 let Ok(canonical) = std::fs::canonicalize(¤t) else {
402 return false;
404 };
405 if !canonical.starts_with(&canonical_root) {
406 return false;
407 }
408 }
409 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
410 break;
413 }
414 Err(_) => return false,
415 }
416 }
417
418 true
419 })
420 }
421}
422
423#[async_trait]
424impl ToolGuardrail for PathAllowList {
425 fn name(&self) -> &str {
426 &self.name
427 }
428
429 fn applies_to(&self, tool_name: &str) -> bool {
430 match &self.tools {
431 Some(tools) => tools.iter().any(|t| t == tool_name),
432 None => true,
433 }
434 }
435
436 async fn validate_call(&self, tool_name: &str, args: &Value) -> ToolGuardrailResult {
437 for arg_name in &self.arg_names {
438 let Some(value) = args.get(arg_name) else {
439 continue;
440 };
441
442 let Some(candidate) = value.as_str() else {
443 return ToolGuardrailResult::deny(
444 format!(
445 "argument `{arg_name}` of `{tool_name}` must be a path string, got \
446 {value}"
447 ),
448 self.severity,
449 );
450 };
451
452 if !self.is_permitted(candidate) {
453 let roots: Vec<_> =
454 self.allowed_roots.iter().map(|r| r.display().to_string()).collect();
455 return ToolGuardrailResult::deny(
456 format!(
457 "argument `{arg_name}` of `{tool_name}` is {candidate:?}, which is not an \
458 absolute path inside an allowed root ({})",
459 roots.join(", ")
460 ),
461 self.severity,
462 );
463 }
464 }
465
466 ToolGuardrailResult::Allow
467 }
468}
469
470#[cfg(test)]
471mod tests {
472 use super::*;
473 use serde_json::json;
474
475 struct ForceDryRun;
477
478 #[async_trait]
479 impl ToolGuardrail for ForceDryRun {
480 fn name(&self) -> &str {
481 "force-dry-run"
482 }
483 async fn validate_call(&self, _tool: &str, args: &Value) -> ToolGuardrailResult {
484 let mut revised = args.clone();
485 if let Some(object) = revised.as_object_mut() {
486 object.insert("dry_run".to_string(), json!(true));
487 }
488 ToolGuardrailResult::revise(revised, "dry-run is mandatory here")
489 }
490 }
491
492 struct DenyAll;
493
494 #[async_trait]
495 impl ToolGuardrail for DenyAll {
496 fn name(&self) -> &str {
497 "deny-all"
498 }
499 async fn validate_call(&self, _tool: &str, _args: &Value) -> ToolGuardrailResult {
500 ToolGuardrailResult::deny("nothing is permitted", Severity::Critical)
501 }
502 }
503
504 #[tokio::test]
505 async fn an_empty_set_allows_the_call_unchanged() {
506 let decision = ToolGuardrailSet::new().evaluate("any", &json!({ "a": 1 })).await;
507
508 match decision {
509 ToolCallDecision::Allow { args } => assert_eq!(args, json!({ "a": 1 })),
510 other => panic!("expected Allow, got {other:?}"),
511 }
512 }
513
514 #[tokio::test]
515 async fn a_revision_is_returned_to_the_caller() {
516 let set = ToolGuardrailSet::new().with(ForceDryRun);
517
518 match set.evaluate("delete", &json!({ "path": "/tmp/x" })).await {
519 ToolCallDecision::Allow { args } => {
520 assert_eq!(args, json!({ "path": "/tmp/x", "dry_run": true }));
521 }
522 other => panic!("expected Allow, got {other:?}"),
523 }
524 }
525
526 #[tokio::test]
527 async fn a_denial_names_the_guardrail_that_refused() {
528 let set = ToolGuardrailSet::new().with(DenyAll);
529
530 match set.evaluate("delete", &json!({})).await {
531 ToolCallDecision::Deny { guardrail, severity, .. } => {
532 assert_eq!(guardrail, "deny-all");
533 assert_eq!(severity, Severity::Critical);
534 }
535 other => panic!("expected Deny, got {other:?}"),
536 }
537 }
538
539 #[tokio::test]
540 async fn a_denial_stops_evaluation_so_a_denied_call_is_never_revised() {
541 let set = ToolGuardrailSet::new().with(DenyAll).with(ForceDryRun);
542
543 assert!(!set.evaluate("delete", &json!({})).await.is_allowed());
544 }
545
546 #[tokio::test]
547 async fn a_later_guardrail_sees_an_earlier_revision() {
548 struct RequireDryRun;
550
551 #[async_trait]
552 impl ToolGuardrail for RequireDryRun {
553 fn name(&self) -> &str {
554 "require-dry-run"
555 }
556 async fn validate_call(&self, _tool: &str, args: &Value) -> ToolGuardrailResult {
557 if args.get("dry_run") == Some(&json!(true)) {
558 ToolGuardrailResult::Allow
559 } else {
560 ToolGuardrailResult::deny("dry_run was not set", Severity::High)
561 }
562 }
563 }
564
565 let set = ToolGuardrailSet::new().with(ForceDryRun).with(RequireDryRun);
566 assert!(
567 set.evaluate("delete", &json!({})).await.is_allowed(),
568 "revisions must compose in order"
569 );
570
571 let reversed = ToolGuardrailSet::new().with(RequireDryRun).with(ForceDryRun);
572 assert!(
573 !reversed.evaluate("delete", &json!({})).await.is_allowed(),
574 "order is meaningful and must not be silently reordered"
575 );
576 }
577
578 #[tokio::test]
579 async fn applies_to_skips_an_unrelated_tool() {
580 let set = ToolGuardrailSet::new().with(
581 DeniedArgumentPattern::new("no-rf", r"-rf", Severity::Critical)
582 .expect("valid pattern")
583 .on_tools(["run_command"]),
584 );
585
586 assert!(set.evaluate("read_file", &json!({ "flags": "-rf" })).await.is_allowed());
587 assert!(!set.evaluate("run_command", &json!({ "flags": "-rf" })).await.is_allowed());
588 }
589
590 #[tokio::test]
591 async fn a_denied_pattern_matches_anywhere_in_the_arguments() {
592 let guardrail = DeniedArgumentPattern::new("no-rf", r"-rf\b", Severity::Critical)
593 .expect("valid pattern");
594
595 for args in [
596 json!({ "cmd": "rm -rf /" }),
597 json!({ "nested": { "cmd": "rm -rf ." } }),
598 json!({ "argv": ["rm", "-rf", "/tmp"] }),
599 ] {
600 assert!(
601 !guardrail.validate_call("run_command", &args).await.is_allowed(),
602 "should deny {args}"
603 );
604 }
605
606 assert!(
607 guardrail.validate_call("run_command", &json!({ "cmd": "ls -l" })).await.is_allowed()
608 );
609 }
610
611 #[test]
612 fn an_invalid_pattern_is_reported() {
613 assert!(DeniedArgumentPattern::new("bad", "([unclosed", Severity::Low).is_err());
614 }
615
616 #[tokio::test]
617 async fn a_path_inside_an_allowed_root_is_permitted() {
618 let root = tempfile::tempdir().expect("allowed root");
619 let guardrail = PathAllowList::new("agents", ["path"], [root.path()]);
620 let candidate = root.path().join("x.plist");
621
622 assert!(
623 guardrail
624 .validate_call("plist_write", &json!({ "path": candidate }))
625 .await
626 .is_allowed()
627 );
628 }
629
630 #[tokio::test]
631 async fn traversal_and_escape_attempts_are_denied() {
632 let guardrail = PathAllowList::new("agents", ["path"], ["/Users/me/Library/LaunchAgents"]);
633
634 for candidate in [
635 "/Users/me/Library/LaunchAgents/../../../etc/passwd",
636 "/etc/passwd",
637 "relative/path.plist",
638 "/Users/me/Library/LaunchAgentsEvil/x.plist",
639 ] {
640 assert!(
641 !guardrail
642 .validate_call("plist_write", &json!({ "path": candidate }))
643 .await
644 .is_allowed(),
645 "should deny {candidate:?}"
646 );
647 }
648 }
649
650 #[tokio::test]
651 async fn a_sibling_root_is_not_admitted_by_string_prefix() {
652 let guardrail = PathAllowList::new("etc", ["path"], ["/etc/passwd"]);
654
655 assert!(
656 !guardrail
657 .validate_call("read", &json!({ "path": "/etc/passwd-backup" }))
658 .await
659 .is_allowed()
660 );
661 }
662
663 #[tokio::test]
664 async fn a_non_string_path_argument_is_denied() {
665 let guardrail = PathAllowList::new("agents", ["path"], ["/tmp"]);
666
667 assert!(
668 !guardrail.validate_call("write", &json!({ "path": 42 })).await.is_allowed(),
669 "a non-string path cannot be checked and must not be waved through"
670 );
671 }
672
673 #[cfg(unix)]
674 #[tokio::test]
675 async fn a_symlink_inside_the_root_cannot_escape_it() {
676 let root = tempfile::tempdir().expect("allowed root");
677 let outside = tempfile::tempdir().expect("outside root");
678 std::os::unix::fs::symlink(outside.path(), root.path().join("escape"))
679 .expect("create symlink");
680 let guardrail = PathAllowList::new("root", ["path"], [root.path()]);
681 let candidate = root.path().join("escape/secret.txt");
682
683 assert!(
684 !guardrail.validate_call("write", &json!({ "path": candidate })).await.is_allowed(),
685 "a lexical child resolving outside the allowed root must be denied"
686 );
687 }
688
689 #[cfg(unix)]
690 #[tokio::test]
691 async fn a_dangling_symlink_inside_the_root_is_denied() {
692 let root = tempfile::tempdir().expect("allowed root");
693 let outside = tempfile::tempdir().expect("outside root");
694 let missing_target = outside.path().join("not-created");
695 std::os::unix::fs::symlink(&missing_target, root.path().join("escape"))
696 .expect("create dangling symlink");
697 let guardrail = PathAllowList::new("root", ["path"], [root.path()]);
698
699 assert!(
700 !guardrail
701 .validate_call("write", &json!({ "path": root.path().join("escape/secret.txt") }))
702 .await
703 .is_allowed()
704 );
705 }
706
707 #[tokio::test]
708 async fn an_unresolvable_allowed_root_is_fail_closed() {
709 let root = tempfile::tempdir().expect("root");
710 let missing = root.path().join("not-created");
711 let guardrail = PathAllowList::new("missing", ["path"], [&missing]);
712
713 assert!(
714 !guardrail
715 .validate_call("write", &json!({ "path": missing.join("file.txt") }))
716 .await
717 .is_allowed()
718 );
719 }
720
721 #[tokio::test]
722 async fn an_absent_path_argument_is_not_checked() {
723 let guardrail = PathAllowList::new("agents", ["path"], ["/tmp"]);
724
725 assert!(
726 guardrail.validate_call("write", &json!({ "other": 1 })).await.is_allowed(),
727 "a guardrail on `path` says nothing about a call that has no `path`"
728 );
729 }
730}