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}