Skip to main content

adk_guardrail/
tool.rs

1//! Guardrails for tool calls.
2//!
3//! [`Guardrail`](crate::Guardrail) validates [`Content`](adk_core::Content) — a user message or a
4//! model response. It never sees a tool call, so it cannot express "this tool may run, but not
5//! with these arguments." That left argument-level policy with nowhere to live:
6//! [`ToolConfirmationPolicy`](adk_core::ToolConfirmationPolicy) decides per *tool name*, and a
7//! plugin short-circuit is a general-purpose hook rather than a stated policy.
8//!
9//! [`ToolGuardrail`] closes that gap. It runs before a tool executes, sees the tool name and the
10//! arguments, and can allow, deny, or revise.
11
12use 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/// Outcome of validating a tool call.
22#[derive(Debug, Clone)]
23pub enum ToolGuardrailResult {
24    /// The call may proceed unchanged.
25    Allow,
26    /// The call is refused. The tool does not run.
27    Deny {
28        /// Why the call was refused. Surfaced to the model so it can adjust.
29        reason: String,
30        /// How serious the violation is.
31        severity: Severity,
32    },
33    /// The call may proceed, with these arguments instead.
34    ///
35    /// Use for narrowing rather than broadening — clamping a limit, forcing a dry-run flag,
36    /// dropping a field the caller should not set.
37    ReviseArgs {
38        /// Arguments the tool is invoked with.
39        args: Value,
40        /// Why the arguments were changed.
41        reason: String,
42    },
43}
44
45impl ToolGuardrailResult {
46    /// Refuses the call.
47    pub fn deny(reason: impl Into<String>, severity: Severity) -> Self {
48        Self::Deny { reason: reason.into(), severity }
49    }
50
51    /// Allows the call with replaced arguments.
52    pub fn revise(args: Value, reason: impl Into<String>) -> Self {
53        Self::ReviseArgs { args, reason: reason.into() }
54    }
55
56    /// Whether the call may proceed.
57    pub fn is_allowed(&self) -> bool {
58        !matches!(self, Self::Deny { .. })
59    }
60}
61
62/// Validates a tool call before it executes.
63///
64/// # Example
65///
66/// ```rust
67/// use adk_guardrail::{Severity, ToolGuardrail, ToolGuardrailResult};
68/// use async_trait::async_trait;
69/// use serde_json::Value;
70///
71/// /// Refuses a recursive delete regardless of which tool is asked to perform it.
72/// struct NoRecursiveDelete;
73///
74/// #[async_trait]
75/// impl ToolGuardrail for NoRecursiveDelete {
76///     fn name(&self) -> &str {
77///         "no-recursive-delete"
78///     }
79///
80///     async fn validate_call(&self, _tool: &str, args: &Value) -> ToolGuardrailResult {
81///         if args.to_string().contains("-rf") {
82///             return ToolGuardrailResult::deny("recursive delete is not permitted", Severity::Critical);
83///         }
84///         ToolGuardrailResult::Allow
85///     }
86/// }
87/// ```
88#[async_trait]
89pub trait ToolGuardrail: Send + Sync {
90    /// Unique name, used in denial messages and logs.
91    fn name(&self) -> &str;
92
93    /// Validates a call to `tool_name` with `args`.
94    async fn validate_call(&self, tool_name: &str, args: &Value) -> ToolGuardrailResult;
95
96    /// Whether this guardrail applies to `tool_name`. Defaults to every tool.
97    ///
98    /// Prefer this over an early `Allow` inside
99    /// [`validate_call`](Self::validate_call) — a guardrail that declares its scope can be skipped
100    /// without being run.
101    fn applies_to(&self, _tool_name: &str) -> bool {
102        true
103    }
104}
105
106/// What a [`ToolGuardrailSet`] decided about a call.
107#[derive(Debug, Clone)]
108pub enum ToolCallDecision {
109    /// The call may proceed with these arguments, which may have been revised.
110    Allow {
111        /// Arguments to invoke the tool with.
112        args: Value,
113    },
114    /// The call is refused.
115    Deny {
116        /// Guardrail that refused it.
117        guardrail: String,
118        /// Why.
119        reason: String,
120        /// How serious.
121        severity: Severity,
122    },
123}
124
125impl ToolCallDecision {
126    /// Whether the call may proceed.
127    pub fn is_allowed(&self) -> bool {
128        matches!(self, Self::Allow { .. })
129    }
130}
131
132/// A collection of [`ToolGuardrail`]s evaluated together.
133///
134/// # Example
135///
136/// ```rust
137/// use adk_guardrail::{PathAllowList, Severity, ToolGuardrailSet};
138///
139/// let guardrails = ToolGuardrailSet::new().with(
140///     PathAllowList::new("plist-paths", ["path"], ["/Users/me/Library/LaunchAgents"])
141///         .on_tools(["plist_write"]),
142/// );
143///
144/// assert_eq!(guardrails.guardrails().len(), 1);
145/// ```
146#[derive(Default)]
147pub struct ToolGuardrailSet {
148    guardrails: Vec<Arc<dyn ToolGuardrail>>,
149}
150
151impl ToolGuardrailSet {
152    /// Creates an empty set.
153    pub fn new() -> Self {
154        Self { guardrails: Vec::new() }
155    }
156
157    /// Adds a guardrail.
158    pub fn with(mut self, guardrail: impl ToolGuardrail + 'static) -> Self {
159        self.guardrails.push(Arc::new(guardrail));
160        self
161    }
162
163    /// Adds a pre-wrapped guardrail.
164    pub fn with_arc(mut self, guardrail: Arc<dyn ToolGuardrail>) -> Self {
165        self.guardrails.push(guardrail);
166        self
167    }
168
169    /// The registered guardrails.
170    pub fn guardrails(&self) -> &[Arc<dyn ToolGuardrail>] {
171        &self.guardrails
172    }
173
174    /// Whether no guardrails have been added.
175    pub fn is_empty(&self) -> bool {
176        self.guardrails.is_empty()
177    }
178
179    /// Evaluates a call against every applicable guardrail.
180    ///
181    /// Guardrails run in order and revisions compose: a later guardrail sees the arguments an
182    /// earlier one produced. Evaluation is sequential rather than parallel because a revision has
183    /// to be visible to whatever runs next — parallel evaluation would make the outcome depend on
184    /// completion order. The first denial stops evaluation, so a denied call is never revised and
185    /// never reaches a later guardrail.
186    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, &current).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
226/// Denies a call whose serialized arguments match a pattern.
227///
228/// The pattern is matched against the JSON encoding of the whole argument object, so it catches a
229/// value wherever it appears rather than requiring the field to be named up front.
230///
231/// # Example
232///
233/// ```rust
234/// use adk_guardrail::{DeniedArgumentPattern, Severity};
235///
236/// # fn main() -> Result<(), regex::Error> {
237/// let guardrail = DeniedArgumentPattern::new("no-force-push", r"--force\b", Severity::High)?
238///     .on_tools(["run_command"]);
239/// # Ok(())
240/// # }
241/// ```
242pub struct DeniedArgumentPattern {
243    name: String,
244    pattern: Regex,
245    severity: Severity,
246    tools: Option<Vec<String>>,
247}
248
249impl DeniedArgumentPattern {
250    /// Creates a guardrail denying calls whose arguments match `pattern`.
251    ///
252    /// # Errors
253    ///
254    /// Returns an error if `pattern` is not a valid regular expression.
255    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    /// Restricts this guardrail to the named tools. Without this it applies to every tool.
264    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
301/// Denies a call whose path-valued arguments fall outside a set of allowed roots.
302///
303/// Checks the named arguments when present, and requires each to be an absolute path contained by
304/// one of the allowed roots. Containment is compared by path component and after resolving the
305/// allowed root and every existing candidate component, so string-prefix, dangling-symlink, and
306/// resolved-symlink escapes are refused. Any path containing a `..` component is denied outright.
307///
308/// This is a preflight policy check, not a replacement for opening filesystem paths relative to a
309/// trusted directory handle. A hostile process able to replace path components between validation
310/// and tool execution can create a time-of-check/time-of-use race; filesystem tools operating
311/// across such a trust boundary must still use platform secure-open primitives.
312///
313/// # Example
314///
315/// ```rust
316/// use adk_guardrail::PathAllowList;
317///
318/// let guardrail = PathAllowList::new(
319///     "launch-agents-only",
320///     ["path"],
321///     ["/Users/me/Library/LaunchAgents"],
322/// );
323/// ```
324pub 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    /// Creates a guardrail confining `arg_names` to `allowed_roots`.
334    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    /// Sets the severity reported on denial. Defaults to [`Severity::Critical`].
351    pub fn with_severity(mut self, severity: Severity) -> Self {
352        self.severity = severity;
353        self
354    }
355
356    /// Restricts this guardrail to the named tools. Without this it applies to every tool.
357    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    /// Whether `candidate` is an absolute, traversal-free path inside an allowed root.
367    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        // A `..` cannot be resolved for a path that may not exist, so it is refused rather than
375        // normalized.
376        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                // An unresolved policy root cannot establish a trustworthy boundary.
390                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(&current) {
400                    Ok(_) => {
401                        let Ok(canonical) = std::fs::canonicalize(&current) else {
402                            // Includes dangling symlinks, whose eventual target cannot be trusted.
403                            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                        // Once a component is absent, every remaining component is new and cannot
411                        // currently hide a symlink. The tool may create this suffix.
412                        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    /// Revises rather than denies, so composition can be observed.
476    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        /// Denies unless a previous guardrail already set `dry_run`.
549        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        // `/etc/passwd-backup` shares a string prefix with `/etc/passwd` but is a different file.
653        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}