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}