Skip to main content

agentd/sec/
scope.rs

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