Skip to main content

agentd/sec/
scope.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2//! Capability scoping — the granted MCP subset, interpreted as a Rule-of-Two
3//! trust budget.
4//!
5//! agentd has no policy engine; a subagent's authority *is* the subset of MCP
6//! servers/tools its parent grants. Two invariants this module encodes:
7//!
8//! 1. **Monotonic narrowing.** A child's scope is the *intersection* with its
9//!    parent's — a child can never widen beyond what its parent holds. Because
10//!    narrowing only ever shrinks, a check made over the root grant bounds
11//!    every descendant, which is what lets the trifecta gate run once at
12//!    startup rather than at every spawn.
13//! 2. **Rule of Two.** Servers and tools are tagged `untrusted_input` /
14//!    `sensitive` / `egress`; granting one agent all three legs of the lethal
15//!    trifecta is refused unless the operator overrides it
16//!    (`security.allow_trifecta`).
17//!
18//! Everything here is pure logic with no I/O; callers apply it. Config
19//! validation folds the whole root grant, so startup and `--validate-config`
20//! can never reach different verdicts, and the `subagent.run` tool folds the
21//! child's requested server subset before minting the child.
22
23use serde::{Deserialize, Serialize};
24use std::collections::BTreeSet;
25
26/// A whitelist over names: everything, or an explicit set. `BTreeSet` for
27/// deterministic ordering (stable logs/serialization).
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29#[serde(rename_all = "snake_case")]
30pub enum Scope {
31    All,
32    Only(BTreeSet<String>),
33}
34
35impl Scope {
36    pub fn only<I, S>(names: I) -> Scope
37    where
38        I: IntoIterator<Item = S>,
39        S: Into<String>,
40    {
41        Scope::Only(names.into_iter().map(Into::into).collect())
42    }
43
44    pub fn allows(&self, name: &str) -> bool {
45        match self {
46            Scope::All => true,
47            Scope::Only(set) => set.contains(name),
48        }
49    }
50
51    /// Intersect a child's requested scope with this (the parent's). The
52    /// result never exceeds the parent — `All ∩ x = x`, `Only(p) ∩ All =
53    /// Only(p)`, `Only(p) ∩ Only(r) = Only(p ∩ r)` (names the parent lacks are
54    /// silently dropped — a clamp, not an error).
55    pub fn narrow(&self, requested: &Scope) -> Scope {
56        match (self, requested) {
57            (Scope::All, r) => r.clone(),
58            (p @ Scope::Only(_), Scope::All) => p.clone(),
59            (Scope::Only(p), Scope::Only(r)) => Scope::Only(p.intersection(r).cloned().collect()),
60        }
61    }
62}
63
64/// A subagent's tool scope: which MCP servers it may reach, and (optionally)
65/// which tools within them. Both must pass for a call to be allowed.
66#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
67pub struct ToolScope {
68    pub servers: Scope,
69    pub tools: Scope,
70}
71
72impl ToolScope {
73    /// The root agent's scope — everything the operator configured.
74    pub fn all() -> ToolScope {
75        ToolScope {
76            servers: Scope::All,
77            tools: Scope::All,
78        }
79    }
80
81    pub fn allows_server(&self, server: &str) -> bool {
82        self.servers.allows(server)
83    }
84
85    /// A tool call is allowed only if both its server and its tool name are in
86    /// scope.
87    pub fn allows(&self, server: &str, tool: &str) -> bool {
88        self.servers.allows(server) && self.tools.allows(tool)
89    }
90
91    /// Narrow a child's request against this parent scope (both dimensions).
92    pub fn narrow(&self, requested: &ToolScope) -> ToolScope {
93        ToolScope {
94            servers: self.servers.narrow(&requested.servers),
95            tools: self.tools.narrow(&requested.tools),
96        }
97    }
98}
99
100/// The three legs of the "lethal trifecta". An agent holding all three — it
101/// reads untrusted content, can touch sensitive data, and can send data out —
102/// is the dangerous combination: one injected instruction in the content it
103/// reads is enough to make it fetch a secret and forward it.
104#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
105pub struct Trifecta {
106    pub untrusted_input: bool,
107    pub sensitive: bool,
108    pub egress: bool,
109}
110
111impl Trifecta {
112    pub fn legs(self) -> u8 {
113        self.untrusted_input as u8 + self.sensitive as u8 + self.egress as u8
114    }
115
116    /// Fold a tool's tags into the running total for a grant.
117    pub fn merge(self, other: Trifecta) -> Trifecta {
118        Trifecta {
119            untrusted_input: self.untrusted_input || other.untrusted_input,
120            sensitive: self.sensitive || other.sensitive,
121            egress: self.egress || other.egress,
122        }
123    }
124}
125
126/// The verdict on a grant. `Ok` ≤ 2 legs; all 3 legs → `Refuse` (or `Warn`
127/// with `--allow-trifecta`).
128#[derive(Debug, Clone, Copy, PartialEq, Eq)]
129pub enum RuleOfTwo {
130    Ok,
131    Warn,
132    Refuse,
133}
134
135/// Evaluate a grant's trifecta exposure. The Rule of Two is satisfied at ≤2
136/// legs; 3 legs violates it — refused unless `allow_trifecta` downgrades the
137/// refusal to a loud warning.
138pub fn evaluate(tags: Trifecta, allow_trifecta: bool) -> RuleOfTwo {
139    if tags.legs() < 3 {
140        RuleOfTwo::Ok
141    } else if allow_trifecta {
142        RuleOfTwo::Warn
143    } else {
144        RuleOfTwo::Refuse
145    }
146}
147
148// ---------------------------------------------------------------------------
149// Rule-of-Two tag check
150// ---------------------------------------------------------------------------
151//
152// [`Trifecta`] above is the *accumulated* budget (the OR across a granted
153// set). [`TrifectaTag`] below is the per-leg label an operator attaches to a
154// server or tool; [`check_trifecta`] folds a tag stream into the budget and
155// returns the verdict the caller acts on. The two layers share one source of
156// truth — a tag is just a single-leg [`Trifecta`] — so "which combination is
157// lethal" is defined in exactly one place.
158
159/// One leg of the lethal trifecta — an operator-declared risk capability a
160/// tool carries. Tags come from the operator's own config and nowhere else:
161/// never from a tool description, a model's claim about itself, or anything
162/// an MCP server advertises, because a server able to tag itself could simply
163/// declare itself harmless and buy back the leg it was meant to be charged.
164///
165/// The three legs are access to private data, exposure to untrusted content,
166/// and the ability to act or send data outward. Holding any two is fine;
167/// holding all three is the shape where one injected instruction suffices to
168/// read a secret and forward it, which is what the Rule of Two refuses to
169/// co-locate in a single agent.
170#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
171#[serde(rename_all = "snake_case")]
172pub enum TrifectaTag {
173    /// Tool returns content from an uncontrolled source (web pages, inbound
174    /// email, issue text, arbitrary files) — a possible injection carrier.
175    UntrustedInput,
176    /// Tool exposes private data or privileged systems (secrets store,
177    /// internal DB, prod control plane).
178    Sensitive,
179    /// Tool can move data out of the trust boundary or change external state
180    /// (HTTP POST, send mail, open PR, `exec`).
181    Egress,
182}
183
184impl TrifectaTag {
185    /// Parse an operator-declared tag string (`--mcp-tags name=…`). Snake-case,
186    /// matching the serde wire form; unknown tags return `None`.
187    pub fn parse(s: &str) -> Option<TrifectaTag> {
188        match s {
189            "untrusted_input" => Some(TrifectaTag::UntrustedInput),
190            "sensitive" => Some(TrifectaTag::Sensitive),
191            "egress" => Some(TrifectaTag::Egress),
192            _ => None,
193        }
194    }
195
196    /// This tag as a single-leg [`Trifecta`], so the accumulation logic lives
197    /// in exactly one place (`Trifecta::merge`).
198    pub fn as_trifecta(self) -> Trifecta {
199        match self {
200            TrifectaTag::UntrustedInput => Trifecta {
201                untrusted_input: true,
202                ..Default::default()
203            },
204            TrifectaTag::Sensitive => Trifecta {
205                sensitive: true,
206                ..Default::default()
207            },
208            TrifectaTag::Egress => Trifecta {
209                egress: true,
210                ..Default::default()
211            },
212        }
213    }
214}
215
216/// The verdict on a grant's trifecta exposure. A refusal is never a crash —
217/// each caller turns it into an outcome its layer can act on:
218///
219/// - [`TrifectaVerdict::Ok`] — two legs or fewer; the grant proceeds silently.
220/// - [`TrifectaVerdict::RefusedTrifecta`] — all three legs and no override.
221///   Config validation reports it as a startup error; the `subagent.run` tool
222///   returns an error result the parent's model can act on by splitting the
223///   work across two narrower children.
224/// - [`TrifectaVerdict::AllowedWithWarning`] — all three legs, but the
225///   operator set `security.allow_trifecta`, so the grant proceeds.
226///
227/// This mirrors [`RuleOfTwo`] (`Ok`/`Warn`/`Refuse`) with self-describing
228/// names for the grant path; both sit on the same [`Trifecta`] budget.
229#[derive(Debug, Clone, Copy, PartialEq, Eq)]
230pub enum TrifectaVerdict {
231    /// ≤2 legs — the Rule of Two holds; grant silently.
232    Ok,
233    /// All three legs and no `--allow-trifecta` — the grant is refused.
234    RefusedTrifecta,
235    /// All three legs but `--allow-trifecta` downgrades the refusal to a loud,
236    /// auditable warning.
237    AllowedWithWarning,
238}
239
240impl TrifectaVerdict {
241    /// Whether the grant must be blocked at the chokepoint. Only
242    /// [`TrifectaVerdict::RefusedTrifecta`] blocks; a warning still proceeds.
243    pub fn is_refused(self) -> bool {
244        matches!(self, TrifectaVerdict::RefusedTrifecta)
245    }
246}
247
248/// Pure Rule-of-Two check. Folds the tags of a granted tool set (`OR` across
249/// legs) and judges the accumulated budget:
250///
251/// - fewer than three legs → [`TrifectaVerdict::Ok`] (any *two* is fine);
252/// - all three legs → [`TrifectaVerdict::RefusedTrifecta`], unless
253///   `allow_trifecta` downgrades it to [`TrifectaVerdict::AllowedWithWarning`].
254///
255/// Structural only — it never inspects tool *content* and never asks the
256/// model to judge; it is a budget on co-located capability. Call it over the
257/// tags of the already-narrowed grant and *before* the grant is handed out:
258/// refusing once a child already holds the capability is too late.
259pub fn check_trifecta<I>(tags: I, allow_trifecta: bool) -> TrifectaVerdict
260where
261    I: IntoIterator<Item = TrifectaTag>,
262{
263    let budget = tags
264        .into_iter()
265        .fold(Trifecta::default(), |acc, t| acc.merge(t.as_trifecta()));
266    match evaluate(budget, allow_trifecta) {
267        RuleOfTwo::Ok => TrifectaVerdict::Ok,
268        RuleOfTwo::Warn => TrifectaVerdict::AllowedWithWarning,
269        RuleOfTwo::Refuse => TrifectaVerdict::RefusedTrifecta,
270    }
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276
277    #[test]
278    fn scope_all_allows_everything() {
279        assert!(Scope::All.allows("anything"));
280    }
281
282    #[test]
283    fn scope_only_is_a_whitelist() {
284        let s = Scope::only(["read_file", "list_dir"]);
285        assert!(s.allows("read_file"));
286        assert!(!s.allows("write_file"));
287    }
288
289    #[test]
290    fn narrow_never_widens() {
291        let parent = Scope::only(["a", "b"]);
292        // child asks for everything -> clamped to parent
293        assert_eq!(parent.narrow(&Scope::All), parent);
294        // child asks for a superset -> clamped to the intersection
295        let child = Scope::only(["a", "c"]);
296        assert_eq!(parent.narrow(&child), Scope::only(["a"]));
297        // parent All -> child gets exactly what it asked
298        assert_eq!(Scope::All.narrow(&child), child);
299    }
300
301    #[test]
302    fn tool_scope_requires_both_dimensions() {
303        let scope = ToolScope {
304            servers: Scope::only(["fs"]),
305            tools: Scope::only(["read_file"]),
306        };
307        assert!(scope.allows("fs", "read_file"));
308        assert!(!scope.allows("github", "read_file")); // wrong server
309        assert!(!scope.allows("fs", "write_file")); // wrong tool
310    }
311
312    #[test]
313    fn tool_scope_narrow_intersects_both() {
314        let parent = ToolScope {
315            servers: Scope::only(["fs", "db"]),
316            tools: Scope::All,
317        };
318        let child = ToolScope {
319            servers: Scope::only(["fs", "net"]),
320            tools: Scope::only(["read"]),
321        };
322        let n = parent.narrow(&child);
323        assert_eq!(n.servers, Scope::only(["fs"]));
324        assert_eq!(n.tools, Scope::only(["read"]));
325    }
326
327    #[test]
328    fn rule_of_two() {
329        let two = Trifecta {
330            untrusted_input: true,
331            sensitive: true,
332            egress: false,
333        };
334        assert_eq!(evaluate(two, false), RuleOfTwo::Ok);
335        let three = Trifecta {
336            untrusted_input: true,
337            sensitive: true,
338            egress: true,
339        };
340        assert_eq!(evaluate(three, false), RuleOfTwo::Refuse);
341        assert_eq!(evaluate(three, true), RuleOfTwo::Warn);
342        assert_eq!(three.legs(), 3);
343    }
344
345    #[test]
346    fn trifecta_merge_accumulates() {
347        let a = Trifecta {
348            untrusted_input: true,
349            ..Default::default()
350        };
351        let b = Trifecta {
352            egress: true,
353            ..Default::default()
354        };
355        assert_eq!(a.merge(b).legs(), 2);
356    }
357
358    // -----------------------------------------------------------------
359    // Rule-of-Two tag check
360    // -----------------------------------------------------------------
361
362    use TrifectaTag::{Egress, Sensitive, UntrustedInput};
363
364    #[test]
365    fn tag_maps_to_single_leg() {
366        assert_eq!(UntrustedInput.as_trifecta().legs(), 1);
367        assert_eq!(Sensitive.as_trifecta().legs(), 1);
368        assert_eq!(Egress.as_trifecta().legs(), 1);
369        assert!(UntrustedInput.as_trifecta().untrusted_input);
370        assert!(Sensitive.as_trifecta().sensitive);
371        assert!(Egress.as_trifecta().egress);
372    }
373
374    #[test]
375    fn empty_grant_is_ok() {
376        assert_eq!(check_trifecta([], false), TrifectaVerdict::Ok);
377    }
378
379    #[test]
380    fn each_single_leg_is_ok() {
381        for tag in [UntrustedInput, Sensitive, Egress] {
382            assert_eq!(check_trifecta([tag], false), TrifectaVerdict::Ok);
383        }
384    }
385
386    #[test]
387    fn every_pair_is_allowed() {
388        // The three two-leg combinations — each is fine under the Rule of Two.
389        let pairs = [
390            [UntrustedInput, Sensitive],
391            [UntrustedInput, Egress],
392            [Sensitive, Egress],
393        ];
394        for pair in pairs {
395            assert_eq!(
396                check_trifecta(pair, false),
397                TrifectaVerdict::Ok,
398                "pair {pair:?} should be allowed"
399            );
400            // The override never *tightens* a verdict — a pair stays Ok.
401            assert_eq!(check_trifecta(pair, true), TrifectaVerdict::Ok);
402        }
403    }
404
405    #[test]
406    fn all_three_refused_without_override() {
407        assert_eq!(
408            check_trifecta([UntrustedInput, Sensitive, Egress], false),
409            TrifectaVerdict::RefusedTrifecta
410        );
411    }
412
413    #[test]
414    fn all_three_warns_with_override() {
415        assert_eq!(
416            check_trifecta([UntrustedInput, Sensitive, Egress], true),
417            TrifectaVerdict::AllowedWithWarning
418        );
419    }
420
421    #[test]
422    fn duplicate_tags_do_not_inflate_legs() {
423        // OR-fold, not a count: repeating a leg never crosses into trifecta.
424        assert_eq!(
425            check_trifecta([Egress, Egress, Egress], false),
426            TrifectaVerdict::Ok
427        );
428        // Two distinct legs, each repeated, is still a pair.
429        assert_eq!(
430            check_trifecta([Sensitive, Sensitive, Egress, Egress], false),
431            TrifectaVerdict::Ok
432        );
433    }
434
435    #[test]
436    fn only_refused_blocks_the_chokepoint() {
437        assert!(TrifectaVerdict::RefusedTrifecta.is_refused());
438        assert!(!TrifectaVerdict::Ok.is_refused());
439        assert!(!TrifectaVerdict::AllowedWithWarning.is_refused());
440    }
441
442    #[test]
443    fn tag_serde_roundtrips_snake_case() {
444        // Tags arrive from MCP server config as snake_case.
445        let json = serde_json::to_string(&UntrustedInput).unwrap();
446        assert_eq!(json, "\"untrusted_input\"");
447        let back: TrifectaTag = serde_json::from_str("\"egress\"").unwrap();
448        assert_eq!(back, Egress);
449    }
450}