Skip to main content

agentd/sec/
policy.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2//! **Tool-call policy**: an ordered list of operator verdicts on the call
3//! itself — allow, deny, ask a person, or hold it and say so.
4//!
5//! Grants answer *may this caller reach this tool at all*, and they are name
6//! patterns, so they cannot express "delete anything outside `/tmp`" or "a
7//! person signs off before any egress-tagged call". `agent.approval` only
8//! decides whether to honour a gate the MODEL asked for. This is the layer in
9//! between: the arguments are already schema-validated one step earlier, so
10//! judging them here costs nothing extra and is the only place it can happen.
11//!
12//! It composes with the machinery either side rather than duplicating it:
13//! `tools.overrides` says WHERE a call goes, this says WHETHER, and an `ask`
14//! verdict suspends on the same deferred-human path `ask_human` and the
15//! `human` node already use.
16//!
17//! First match wins and no match is allow, so an empty list is exactly today's
18//! behaviour and the common path pays one `is_empty` check.
19
20use crate::config::v2::{Policy, PolicyAction, PolicyCaller};
21use crate::sec::scope::TrifectaTag;
22use serde_json::Value;
23
24/// What a call looks like to the policy list.
25pub struct Call<'a> {
26    pub tool: &'a str,
27    /// The trifecta tags the registry computed for this tool. Until now these
28    /// were folded once at startup and then never consulted again.
29    pub tags: &'a [TrifectaTag],
30    pub caller: PolicyCaller,
31    pub principal: Option<&'a str>,
32    pub args: &'a Value,
33}
34
35/// The verdict, plus which rule produced it (for the log and the audit line —
36/// "denied" without "by which rule" is not an answer an operator can act on).
37pub struct Verdict {
38    pub action: PolicyAction,
39    pub rule: usize,
40    pub question: Option<String>,
41    pub on_timeout: PolicyAction,
42    pub timeout_ms: Option<u64>,
43}
44
45/// The tag name an operator writes in `match: {tags: [...]}`.
46fn tag_name(t: TrifectaTag) -> &'static str {
47    match t {
48        TrifectaTag::UntrustedInput => "untrusted_input",
49        TrifectaTag::Sensitive => "sensitive",
50        TrifectaTag::Egress => "egress",
51    }
52}
53
54/// Whether a rule matches. Every present condition must hold — conditions are
55/// ANDed, so adding one always narrows and never widens. That direction
56/// matters: an operator adding `caller: [subagent]` to a `deny` rule expects
57/// to be tightening their configuration, not loosening it.
58fn matches(p: &Policy, call: &Call<'_>, cel_ok: &mut bool) -> bool {
59    if let Some(pat) = &p.matcher.tool
60        && !crate::registry::pattern_matches(pat, call.tool)
61    {
62        return false;
63    }
64    if !p.matcher.tags.is_empty() {
65        let have: Vec<&str> = call.tags.iter().map(|t| tag_name(*t)).collect();
66        if !p.matcher.tags.iter().all(|w| have.contains(&w.as_str())) {
67            return false;
68        }
69    }
70    if !p.matcher.caller.is_empty() && !p.matcher.caller.contains(&call.caller) {
71        return false;
72    }
73    if let Some(pat) = &p.matcher.principal {
74        match call.principal {
75            None => return false,
76            Some(id) if !crate::registry::pattern_matches(pat, id) => return false,
77            Some(_) => {}
78        }
79    }
80    if let Some(expr) = &p.matcher.args {
81        let tool = Value::String(call.tool.to_string());
82        let caller = Value::String(caller_name(call.caller).to_string());
83        let vars: Vec<(&str, &Value)> =
84            vec![("args", call.args), ("tool", &tool), ("caller", &caller)];
85        match crate::cel::eval_bool(expr.trim().trim_start_matches("CEL:").trim(), &vars) {
86            Ok(true) => {}
87            Ok(false) => return false,
88            Err(_) => {
89                // An argument guard that cannot be evaluated must not silently
90                // become "no match" — that turns a `deny` into an allow at
91                // exactly the moment it was supposed to bite. The caller
92                // refuses the call outright.
93                *cel_ok = false;
94                return false;
95            }
96        }
97    }
98    true
99}
100
101pub fn caller_name(c: PolicyCaller) -> &'static str {
102    match c {
103        PolicyCaller::Root => "root",
104        PolicyCaller::Workflow => "workflow",
105        PolicyCaller::Subagent => "subagent",
106    }
107}
108
109/// Evaluate the list. `Ok(None)` means no rule matched (allow);
110/// `Err(rule)` means a rule's argument guard failed to evaluate, which is
111/// fail-closed rather than a pass.
112pub fn evaluate(policies: &[Policy], call: &Call<'_>) -> Result<Option<Verdict>, usize> {
113    for (i, p) in policies.iter().enumerate() {
114        let mut cel_ok = true;
115        let hit = matches(p, call, &mut cel_ok);
116        if !cel_ok {
117            return Err(i);
118        }
119        if !hit {
120            continue;
121        }
122        if p.action == PolicyAction::Allow {
123            // An explicit allow stops the scan — that is what makes an
124            // exception before a broad deny expressible at all.
125            return Ok(Some(Verdict {
126                action: PolicyAction::Allow,
127                rule: i,
128                question: None,
129                on_timeout: PolicyAction::Deny,
130                timeout_ms: None,
131            }));
132        }
133        return Ok(Some(Verdict {
134            action: p.action,
135            rule: i,
136            question: p.question.clone(),
137            // A gate nobody answered has not been approved.
138            on_timeout: p.on_timeout.unwrap_or(PolicyAction::Deny),
139            timeout_ms: p.timeout.as_ref().map(|d| d.0.as_millis() as u64),
140        }));
141    }
142    Ok(None)
143}
144
145/// Whether any rule could apply to this tool for this caller, ignoring the
146/// argument guard (which needs the actual call).
147///
148/// Used to decide which tools a turn worker must round-trip for. A child
149/// dials its MCP tools DIRECTLY from its route map and never reaches
150/// `execute_tool`, so a policy that covered root turns but not subagent turns
151/// would be worse than none: the operator would believe they were covered.
152/// Anything a rule might touch is moved out of the child's route map and
153/// served by the runtime instead, so gated tools pay one round-trip and
154/// everything else keeps the fast path.
155pub fn could_apply(
156    policies: &[Policy],
157    tool: &str,
158    tags: &[TrifectaTag],
159    caller: PolicyCaller,
160) -> bool {
161    policies.iter().any(|p| {
162        if let Some(pat) = &p.matcher.tool
163            && !crate::registry::pattern_matches(pat, tool)
164        {
165            return false;
166        }
167        if !p.matcher.tags.is_empty() {
168            let have: Vec<&str> = tags.iter().map(|t| tag_name(*t)).collect();
169            if !p.matcher.tags.iter().all(|w| have.contains(&w.as_str())) {
170                return false;
171            }
172        }
173        if !p.matcher.caller.is_empty() && !p.matcher.caller.contains(&caller) {
174            return false;
175        }
176        // `principal` and `args` are call-time facts, so a rule carrying them
177        // is treated as "might apply" — the conservative direction.
178        true
179    })
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185    use crate::config::v2::PolicyMatch;
186
187    fn pol(m: PolicyMatch, a: PolicyAction) -> Policy {
188        Policy {
189            matcher: m,
190            action: a,
191            ..Default::default()
192        }
193    }
194
195    fn call<'a>(
196        tool: &'a str,
197        tags: &'a [TrifectaTag],
198        caller: PolicyCaller,
199        args: &'a Value,
200    ) -> Call<'a> {
201        Call {
202            tool,
203            tags,
204            caller,
205            principal: None,
206            args,
207        }
208    }
209
210    #[test]
211    fn no_rules_is_allow_and_costs_nothing() {
212        let args = Value::Null;
213        let c = call("anything", &[], PolicyCaller::Root, &args);
214        assert!(evaluate(&[], &c).unwrap().is_none());
215    }
216
217    #[test]
218    fn first_match_wins_so_an_exception_can_precede_a_broad_deny() {
219        let args = Value::Null;
220        let rules = vec![
221            pol(
222                PolicyMatch {
223                    tool: Some("fs.read".into()),
224                    ..Default::default()
225                },
226                PolicyAction::Allow,
227            ),
228            pol(
229                PolicyMatch {
230                    tool: Some("fs.*".into()),
231                    ..Default::default()
232                },
233                PolicyAction::Deny,
234            ),
235        ];
236        let v = evaluate(&rules, &call("fs.read", &[], PolicyCaller::Root, &args))
237            .unwrap()
238            .expect("matched");
239        assert_eq!(v.action, PolicyAction::Allow);
240        let v = evaluate(&rules, &call("fs.delete", &[], PolicyCaller::Root, &args))
241            .unwrap()
242            .expect("matched");
243        assert_eq!(v.action, PolicyAction::Deny);
244    }
245
246    /// Tags finally do something at runtime. Every listed tag must be present,
247    /// so `tags: [sensitive, egress]` is the pair, not either one.
248    #[test]
249    fn tag_conditions_require_all_of_them() {
250        let args = Value::Null;
251        let rules = vec![pol(
252            PolicyMatch {
253                tags: vec!["sensitive".into(), "egress".into()],
254                ..Default::default()
255            },
256            PolicyAction::Deny,
257        )];
258        let both = [TrifectaTag::Sensitive, TrifectaTag::Egress];
259        let one = [TrifectaTag::Egress];
260        assert!(
261            evaluate(&rules, &call("t", &both, PolicyCaller::Root, &args))
262                .unwrap()
263                .is_some()
264        );
265        assert!(
266            evaluate(&rules, &call("t", &one, PolicyCaller::Root, &args))
267                .unwrap()
268                .is_none()
269        );
270    }
271
272    #[test]
273    fn caller_narrows_rather_than_widens() {
274        let args = Value::Null;
275        let rules = vec![pol(
276            PolicyMatch {
277                tool: Some("*".into()),
278                caller: vec![PolicyCaller::Subagent],
279                ..Default::default()
280            },
281            PolicyAction::Deny,
282        )];
283        assert!(
284            evaluate(&rules, &call("t", &[], PolicyCaller::Subagent, &args))
285                .unwrap()
286                .is_some()
287        );
288        assert!(
289            evaluate(&rules, &call("t", &[], PolicyCaller::Root, &args))
290                .unwrap()
291                .is_none()
292        );
293    }
294
295    /// The conservative direction: a rule that might apply once the arguments
296    /// are known must pull the tool out of the child's direct route map, or
297    /// the gate silently misses every call the child makes.
298    #[test]
299    fn could_apply_is_conservative_about_call_time_facts() {
300        let rules = vec![pol(
301            PolicyMatch {
302                tool: Some("fs.*".into()),
303                args: Some("CEL: args.path != '/tmp'".into()),
304                ..Default::default()
305            },
306            PolicyAction::Deny,
307        )];
308        assert!(could_apply(
309            &rules,
310            "fs.delete",
311            &[],
312            PolicyCaller::Subagent
313        ));
314        assert!(!could_apply(
315            &rules,
316            "http.get",
317            &[],
318            PolicyCaller::Subagent
319        ));
320    }
321
322    /// An argument guard that will not evaluate is fail-closed. Returning "no
323    /// match" would turn a deny into an allow at exactly the moment it was
324    /// meant to bite.
325    #[test]
326    #[cfg(feature = "cel")]
327    fn an_unevaluatable_argument_guard_fails_closed() {
328        let args = serde_json::json!({"path": "/etc"});
329        let rules = vec![pol(
330            PolicyMatch {
331                tool: Some("*".into()),
332                args: Some("CEL: this is not an expression((".into()),
333                ..Default::default()
334            },
335            PolicyAction::Deny,
336        )];
337        assert!(evaluate(&rules, &call("t", &[], PolicyCaller::Root, &args)).is_err());
338    }
339}