Skip to main content

polyc_capability/
lib.rs

1//! Capability taxonomy, derivation functions, and the pure gate decision
2//! engine (`#587`, `#591`).
3//!
4//! One tool call *requires* a set of [`Capability`]s (derived from its spec's
5//! MCP-style annotations plus its registry provenance) and is *granted* a set
6//! (derived from the agent's policy plus the conversation's provenance/taint
7//! state at that moment). [`decide`] compares them and returns the single
8//! [`GateOutcome`] for the call — the one decision path that replaces the
9//! previous OR of an argument-aware policy check, a sandbox-denial escalation,
10//! and a runtime "untrusted content + egress" override.
11//!
12//! The containment invariants live here as pure logic, testable exhaustively:
13//!
14//! - Untrusted content in context removes [`Capability::ArbitraryEgress`]
15//!   **and** [`Capability::MutateExternal`] from the granted set — a message
16//!   body or an issue title carries attacker-steered bytes out as surely as a
17//!   fetch does.
18//! - An operator-declared taint-resilient set survives that subtraction, and
19//!   only when the policy declares it ([`GrantPolicy::taint_resilient`]).
20//! - A tool whose spec cannot be classified requires the full privileged set
21//!   ([`CapabilitySet::all`]) — fail closed.
22//! - The model is monotonic: under a fixed policy, adding taint never adds a
23//!   capability.
24//!
25//! Everything here is a pure function over its inputs. No gate wiring, no IO,
26//! no clock: the executor surface (`#592`) derives the inputs and the agent's
27//! per-call gate (`#593`) is a thin adapter over [`decide`].
28
29use polyc_llm::ToolSpec;
30
31// ── Capability + set ─────────────────────────────────────────────────────────
32
33/// One thing a tool call can do — the unit of the containment model.
34///
35/// The taxonomy is deliberately small and rarely changes. Adding a member
36/// means extending this enum and the two derivation functions
37/// ([`required_capabilities`], [`granted_capabilities`]); the decision engine
38/// ([`decide`]) operates on sets generically and never needs to change (a
39/// pinned test demonstrates this).
40#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
41#[repr(u8)]
42pub enum Capability {
43    /// Read state confined to the conversation's sandbox (workspace files).
44    LocalRead = 1,
45    /// Mutate state confined to the conversation's sandbox (workspace writes,
46    /// sandboxed shell). Destructive *inside the box* is still local.
47    LocalWrite = 1 << 1,
48    /// Call an operator-registered connector endpoint (or a first-party
49    /// control-plane service) — a fixed destination the operator vouched for,
50    /// carrying only model-authored arguments. Taint never revokes this.
51    FixedConnectorRead = 1 << 2,
52    /// Send bytes to a model-controlled external destination — the built-in
53    /// web/paid fetchers. The classic exfiltration channel.
54    ArbitraryEgress = 1 << 3,
55    /// Perform a side effect outside the sandbox: mutate external state,
56    /// send a message, file an issue, spend money. An external mutation
57    /// carries model-authored bytes to destinations an attacker may read,
58    /// so it is an egress channel in effect even when the destination is
59    /// fixed.
60    MutateExternal = 1 << 4,
61}
62
63impl Capability {
64    /// Every member of the taxonomy, in declaration order.
65    pub const ALL: [Self; 5] = [
66        Self::LocalRead,
67        Self::LocalWrite,
68        Self::FixedConnectorRead,
69        Self::ArbitraryEgress,
70        Self::MutateExternal,
71    ];
72
73    /// Stable kebab-case name, used on the wire (the harness turn input), in
74    /// the Agent custom resource's taint-resilient list, and as a telemetry
75    /// label. Inverse of [`Self::from_name`].
76    #[must_use]
77    pub const fn as_str(self) -> &'static str {
78        match self {
79            Self::LocalRead => "local-read",
80            Self::LocalWrite => "local-write",
81            Self::FixedConnectorRead => "fixed-connector-read",
82            Self::ArbitraryEgress => "arbitrary-egress",
83            Self::MutateExternal => "mutate-external",
84        }
85    }
86
87    /// Parse a stable kebab-case name; `None` for anything unrecognized so a
88    /// caller reading operator config fails toward granting nothing.
89    #[must_use]
90    pub fn from_name(name: &str) -> Option<Self> {
91        Self::ALL.into_iter().find(|c| c.as_str() == name)
92    }
93}
94
95/// A set of [`Capability`]s. Small, `Copy`, and closed under the usual set
96/// algebra — the decision engine works only through these operations.
97#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Hash)]
98pub struct CapabilitySet(u8);
99
100impl CapabilitySet {
101    /// The empty set.
102    pub const EMPTY: Self = Self(0);
103
104    /// The full privileged set — every member of the taxonomy. This is the
105    /// fail-closed requirement for an unclassifiable tool.
106    #[must_use]
107    pub const fn all() -> Self {
108        let mut bits = 0u8;
109        let mut i = 0;
110        while i < Capability::ALL.len() {
111            bits |= Capability::ALL[i] as u8;
112            i += 1;
113        }
114        Self(bits)
115    }
116
117    /// The set containing exactly `capability`.
118    #[must_use]
119    pub const fn of(capability: Capability) -> Self {
120        Self(capability as u8)
121    }
122
123    /// This set plus `capability`.
124    #[must_use]
125    pub const fn with(self, capability: Capability) -> Self {
126        Self(self.0 | capability as u8)
127    }
128
129    /// Whether `capability` is in this set.
130    #[must_use]
131    pub const fn contains(self, capability: Capability) -> bool {
132        self.0 & capability as u8 != 0
133    }
134
135    /// Whether this set has no members.
136    #[must_use]
137    pub const fn is_empty(self) -> bool {
138        self.0 == 0
139    }
140
141    /// Whether every member of this set is also in `other`.
142    #[must_use]
143    pub const fn is_subset_of(self, other: Self) -> bool {
144        self.0 & !other.0 == 0
145    }
146
147    /// Set union.
148    #[must_use]
149    pub const fn union(self, other: Self) -> Self {
150        Self(self.0 | other.0)
151    }
152
153    /// Set intersection.
154    #[must_use]
155    pub const fn intersection(self, other: Self) -> Self {
156        Self(self.0 & other.0)
157    }
158
159    /// Set difference: the members of this set that are not in `other`.
160    #[must_use]
161    pub const fn difference(self, other: Self) -> Self {
162        Self(self.0 & !other.0)
163    }
164
165    /// The members of this set, in [`Capability::ALL`] order.
166    pub fn iter(self) -> impl Iterator<Item = Capability> {
167        Capability::ALL
168            .into_iter()
169            .filter(move |c| self.contains(*c))
170    }
171
172    /// Build a set from stable kebab-case names (e.g. an Agent custom
173    /// resource's taint-resilient list or the harness wire). Unrecognized
174    /// names are NOT granted — they are returned separately so the caller can
175    /// log them — which is the fail-closed direction: a typo in operator
176    /// config grants nothing rather than something unintended.
177    pub fn from_names<'a, I: IntoIterator<Item = &'a str>>(names: I) -> (Self, Vec<String>) {
178        let mut set = Self::EMPTY;
179        let mut unknown = Vec::new();
180        for name in names {
181            match Capability::from_name(name) {
182                Some(c) => set = set.with(c),
183                None => unknown.push(name.to_owned()),
184            }
185        }
186        (set, unknown)
187    }
188
189    /// The stable kebab-case names of this set's members, in
190    /// [`Capability::ALL`] order — the inverse of [`Self::from_names`].
191    #[must_use]
192    pub fn names(self) -> Vec<&'static str> {
193        self.iter().map(Capability::as_str).collect()
194    }
195}
196
197impl FromIterator<Capability> for CapabilitySet {
198    fn from_iter<I: IntoIterator<Item = Capability>>(iter: I) -> Self {
199        iter.into_iter().fold(Self::EMPTY, Self::with)
200    }
201}
202
203/// The capabilities that untrusted content in context revokes: both channels
204/// that carry model-authored bytes to destinations an attacker may read.
205///
206/// This is the single, tested home of the containment rule that used to be
207/// the "lethal trifecta override": [`Capability::ArbitraryEgress`] (a fetch to
208/// a model-chosen destination) **and** [`Capability::MutateExternal`] (a
209/// message body or issue field is an exfiltration channel the egress rule
210/// alone would miss).
211pub const TAINT_REVOKED: CapabilitySet =
212    CapabilitySet::of(Capability::ArbitraryEgress).with(Capability::MutateExternal);
213
214// ── Requirement derivation ────────────────────────────────────────────────────
215
216/// Where a tool comes from — the registry-provenance half of classification.
217///
218/// Trust scoping is the security-load-bearing part: taint-immune
219/// classification ([`Capability::FixedConnectorRead`]) is earned only by
220/// operator registration ([`ToolOrigin::RegisteredConnector`] /
221/// [`ToolOrigin::FirstParty`]) — never by a connector's self-declared
222/// annotation hints alone. This is what the MCP specification normatively
223/// requires: clients MUST treat tool annotations as untrusted unless the
224/// server is trusted.
225#[derive(Debug, Clone, Copy, PartialEq, Eq)]
226pub enum ToolOrigin {
227    /// A sandbox-confined built-in (the coding tools): runs inside the
228    /// conversation's execution unit, which has no outbound network.
229    LocalSandbox,
230    /// A built-in fetcher (the web/paid fetchers): brokered on the trusted
231    /// side to a model-controlled destination.
232    Fetcher,
233    /// A built-in that reads or acts on the caller's own first-party state via
234    /// the control plane (the history and wallet families): a fixed,
235    /// operator-owned destination.
236    FirstParty,
237    /// A connector tool whose server the operator registered (registry
238    /// provenance, e.g. the `ToolService` registry). Its annotations are
239    /// load-bearing inputs because the operator vouched for the server.
240    RegisteredConnector,
241    /// Anything else: an unregistered server's self-declared tool, an unknown
242    /// name, an unannotated spec. Fails closed to the privileged set.
243    Unknown,
244}
245
246/// The classification inputs for one tool: its spec's MCP-style annotations
247/// plus its registry provenance.
248///
249/// Built by the executor surface (`#592`) — [`ToolProfile::for_spec`] reads
250/// the annotations off the spec, and the executor supplies the origin from
251/// what it knows about the tool's source.
252#[derive(Debug, Clone, Copy, PartialEq, Eq)]
253pub struct ToolProfile {
254    /// Registry provenance — see [`ToolOrigin`].
255    pub origin: ToolOrigin,
256    /// MCP `readOnlyHint`: the tool does not modify its environment.
257    pub read_only: bool,
258    /// MCP `destructiveHint`: the tool may perform irreversible or
259    /// side-effecting changes.
260    pub destructive: bool,
261    /// MCP `openWorldHint`: the tool's RESULT may carry content of
262    /// uncontrolled provenance. An ingestion-source property: it drives
263    /// taint, not the required set (a registered connector's open-world read
264    /// still dials only its fixed, operator-vouched endpoint).
265    pub open_world: bool,
266}
267
268impl ToolProfile {
269    /// Read the classification annotations off `spec`, with the
270    /// executor-supplied registry provenance.
271    #[must_use]
272    pub const fn for_spec(spec: &ToolSpec, origin: ToolOrigin) -> Self {
273        Self {
274            origin,
275            read_only: spec.read_only,
276            destructive: spec.destructive,
277            open_world: spec.open_world,
278        }
279    }
280}
281
282/// Derive the capabilities a tool call requires from its profile — the
283/// spec's existing annotations plus registry provenance. No new hand-written
284/// per-tool metadata.
285///
286/// The mapping (see the `#587` design):
287///
288/// - sandbox-confined built-in ⇒ local read (+ local write unless read-only —
289///   destructive *inside the box* is still local);
290/// - built-in fetcher ⇒ arbitrary egress (+ external mutation when
291///   destructive, e.g. a paying fetch);
292/// - first-party / operator-registered connector ⇒ fixed-connector read
293///   (+ external mutation unless read-only and non-destructive);
294/// - unknown / unclassifiable ⇒ the full privileged set, fail closed: an
295///   unknown tool never slips through un-gated.
296///
297/// `open_world` is deliberately not consulted: it marks an ingestion source
298/// (drives taint when the result enters context), not an outbound
299/// capability.
300#[must_use]
301pub const fn required_capabilities(profile: ToolProfile) -> CapabilitySet {
302    match profile.origin {
303        ToolOrigin::LocalSandbox => {
304            if profile.read_only {
305                CapabilitySet::of(Capability::LocalRead)
306            } else {
307                CapabilitySet::of(Capability::LocalRead).with(Capability::LocalWrite)
308            }
309        }
310        ToolOrigin::Fetcher => {
311            if profile.destructive {
312                CapabilitySet::of(Capability::ArbitraryEgress).with(Capability::MutateExternal)
313            } else {
314                CapabilitySet::of(Capability::ArbitraryEgress)
315            }
316        }
317        ToolOrigin::FirstParty | ToolOrigin::RegisteredConnector => {
318            if profile.read_only && !profile.destructive {
319                CapabilitySet::of(Capability::FixedConnectorRead)
320            } else {
321                CapabilitySet::of(Capability::FixedConnectorRead).with(Capability::MutateExternal)
322            }
323        }
324        ToolOrigin::Unknown => CapabilitySet::all(),
325    }
326}
327
328/// Clamp a re-declared profile so a connector's runtime annotation change can
329/// only ever ADD required capabilities (`#598`).
330///
331/// A connector may re-declare its tools mid-conversation (list-changed). A
332/// re-declaration never removes a requirement and never earns taint-immunity
333/// at runtime: the merged profile keeps the *less* trusted value of each
334/// annotation (loses `read_only` if either side lost it, keeps `destructive`
335/// and `open_world` if either side had it). The origin is NOT an input from
336/// the re-declaration at all — registry provenance is an operator act the
337/// executor derives, never something a connector can assert about itself —
338/// so the merged profile keeps the origin the conversation started with.
339/// The result is pinned monotonic by test:
340/// `required_capabilities(monotonic_redeclaration(old, new))` is always a
341/// superset of `required_capabilities(old)`.
342#[must_use]
343pub const fn monotonic_redeclaration(old: ToolProfile, new: ToolProfile) -> ToolProfile {
344    ToolProfile {
345        origin: old.origin,
346        read_only: old.read_only && new.read_only,
347        destructive: old.destructive || new.destructive,
348        open_world: old.open_world || new.open_world,
349    }
350}
351
352// ── Grant derivation ──────────────────────────────────────────────────────────
353
354/// Whether untrusted content is in the conversation's context at this gate
355/// decision.
356///
357/// The provenance (taint) input to grant derivation, computed from the
358/// durable seed OR the live transcript scan, never from the turn's own
359/// output.
360#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
361pub enum TaintState {
362    /// No untrusted content in context.
363    #[default]
364    Clean,
365    /// Untrusted content is in context: the granted set loses
366    /// [`TAINT_REVOKED`] except what the policy declares taint-resilient.
367    Tainted,
368}
369
370/// The agent's configured capability policy — the operator-controlled half of
371/// grant derivation.
372///
373/// Both fields come from cluster config (the Agent custom resource),
374/// unreachable from within a conversation or the request path.
375#[derive(Debug, Clone, Copy, PartialEq, Eq)]
376pub struct GrantPolicy {
377    /// The capabilities the agent's policy grants on a clean context.
378    pub base: CapabilitySet,
379    /// Operator-declared capabilities that survive the taint subtraction for
380    /// this agent's conversations (the generalization of the trusted-egress
381    /// flag: how an unattended routine keeps posting after it reads).
382    /// Default empty. Never grants beyond [`Self::base`].
383    pub taint_resilient: CapabilitySet,
384}
385
386impl Default for GrantPolicy {
387    /// Grant everything on a clean context, nothing taint-resilient — the
388    /// posture of every conversation without an operator declaration.
389    fn default() -> Self {
390        Self {
391            base: CapabilitySet::all(),
392            taint_resilient: CapabilitySet::EMPTY,
393        }
394    }
395}
396
397/// Derive the capabilities granted to one gate decision from the agent's
398/// policy and the provenance state **at that moment**.
399///
400/// Recomputed per call, because taint can enter mid-turn and must revoke for
401/// the very next call.
402///
403/// The containment rule: taint present ⇒ [`TAINT_REVOKED`] (arbitrary egress
404/// AND external mutation) removed from the granted set; the operator-declared
405/// taint-resilient set survives the subtraction, but never grants beyond the
406/// base. Monotonic under a fixed policy: adding taint never adds a
407/// capability (pinned by test).
408#[must_use]
409pub const fn granted_capabilities(policy: GrantPolicy, taint: TaintState) -> CapabilitySet {
410    match taint {
411        TaintState::Clean => policy.base,
412        TaintState::Tainted => {
413            let survivors = policy.taint_resilient.intersection(TAINT_REVOKED);
414            policy
415                .base
416                .difference(TAINT_REVOKED)
417                .union(policy.base.intersection(survivors))
418        }
419    }
420}
421
422// ── Decision engine ───────────────────────────────────────────────────────────
423
424/// An argument transform the argument-aware dispatch policy asked for, honored
425/// only when the call is otherwise allowed.
426#[derive(Debug, Clone, PartialEq, Eq, Default)]
427pub enum ArgTransform {
428    /// Run the call as proposed.
429    #[default]
430    None,
431    /// Run the call with these replacement arguments.
432    Rewrite(String),
433    /// Prepend this context as an internal-only note before the call runs.
434    InjectContext(String),
435}
436
437/// The per-call policy verdicts [`decide`] folds into the one outcome.
438///
439/// Carries the argument-aware dispatch decision plus the sandbox-escalation
440/// check, already evaluated against the call's arguments by the caller.
441#[derive(Debug, Clone, PartialEq, Eq, Default)]
442pub struct CallPolicy {
443    /// A hard policy veto with its reason: the call is blocked without a
444    /// human prompt and a human approval cannot satisfy it. Takes precedence
445    /// over everything else.
446    pub veto: Option<String>,
447    /// The argument-aware policy (or the tool's intrinsic gate) demands a
448    /// human decision for this call regardless of capabilities.
449    pub requires_human: bool,
450    /// The sandbox would deny this call before any side effect and the
451    /// deployment escalates such denials to a human instead of running into
452    /// the flat denial.
453    pub sandbox_escalation: bool,
454    /// The argument transform to honor when the call is allowed.
455    pub transform: ArgTransform,
456}
457
458/// The single unified gate result for one tool call — replaces both the
459/// argument-aware `ToolDecision` and the `needs_approval`/override booleans.
460#[derive(Debug, Clone, PartialEq, Eq)]
461pub enum GateOutcome {
462    /// Execute the call as proposed.
463    Allow,
464    /// Execute the call with rewritten arguments.
465    Modify(
466        /// The replacement arguments (JSON).
467        String,
468    ),
469    /// Prepend this context as an internal-only note, then execute.
470    InjectContext(
471        /// The note text.
472        String,
473    ),
474    /// Pause for a human decision.
475    Escalate {
476        /// Why, in plain language for the approval card. Empty for an
477        /// ordinary policy/sandbox gate (the edge renders its default
478        /// prompt); non-empty when capabilities are missing.
479        reason: String,
480        /// The required capabilities the call's granted set does not cover.
481        /// Empty when the escalation is a policy/sandbox gate rather than a
482        /// capability shortfall.
483        missing: CapabilitySet,
484    },
485    /// Block the call without a human prompt; the reason is surfaced to the
486    /// model as the tool result so it can adapt.
487    Deny(
488        /// The policy's reason.
489        String,
490    ),
491}
492
493impl GateOutcome {
494    /// Stable lowercase label for telemetry — one counter per gate outcome.
495    #[must_use]
496    pub const fn label(&self) -> &'static str {
497        match self {
498            Self::Allow => "allow",
499            Self::Modify(_) => "modify",
500            Self::InjectContext(_) => "inject_context",
501            Self::Escalate { .. } => "escalate",
502            Self::Deny(_) => "deny",
503        }
504    }
505}
506
507/// The one pure decision: compare what the call requires with what it is
508/// granted, under the argument-aware policy verdicts, and return the single
509/// [`GateOutcome`].
510///
511/// Precedence, pinned by test:
512///
513/// 1. hard policy veto ⇒ [`GateOutcome::Deny`];
514/// 2. required ⊄ granted ⇒ [`GateOutcome::Escalate`] carrying the missing
515///    set and a plain-language reason;
516/// 3. the policy demands a human (argument-aware gate or sandbox-denial
517///    escalation) ⇒ [`GateOutcome::Escalate`] with an empty missing set;
518/// 4. otherwise honor the argument transform or allow.
519///
520/// The engine is pure set algebra over the capability sets — it never
521/// matches on a specific [`Capability`], so extending the taxonomy requires
522/// no change here (pinned by test).
523#[must_use]
524pub fn decide(
525    required: CapabilitySet,
526    granted: CapabilitySet,
527    policy: &CallPolicy,
528    tool_name: &str,
529) -> GateOutcome {
530    if let Some(reason) = &policy.veto {
531        return GateOutcome::Deny(reason.clone());
532    }
533    let missing = required.difference(granted);
534    if !missing.is_empty() {
535        return GateOutcome::Escalate {
536            reason: escalation_reason(tool_name, missing),
537            missing,
538        };
539    }
540    if policy.requires_human || policy.sandbox_escalation {
541        return GateOutcome::Escalate {
542            reason: String::new(),
543            missing: CapabilitySet::EMPTY,
544        };
545    }
546    match &policy.transform {
547        ArgTransform::None => GateOutcome::Allow,
548        ArgTransform::Rewrite(args) => GateOutcome::Modify(args.clone()),
549        ArgTransform::InjectContext(note) => GateOutcome::InjectContext(note.clone()),
550    }
551}
552
553/// The plain-language reason for a missing-capability escalation, rendered
554/// verbatim on the approval card on every edge (one shared helper so the
555/// wording never differs by surface).
556///
557/// User-facing copy: no internal terms, active sentences, honest about risk
558/// without overclaiming. In the current model a capability is only ever
559/// missing because untrusted content entered the conversation (the base
560/// policy grants everything), so the copy names that cause; a future
561/// narrowed base policy reuses the same wording — the access is missing
562/// either way, and the approver's decision is the same.
563#[must_use]
564pub fn escalation_reason(tool_name: &str, missing: CapabilitySet) -> String {
565    let reaches_out = missing.contains(Capability::ArbitraryEgress);
566    let mutates = missing.contains(Capability::MutateExternal);
567    match (reaches_out, mutates) {
568        (true, true) => format!(
569            "this conversation has taken in content from outside sources, so `{tool_name}` \
570             needs a quick human check before it sends anything out or changes anything \
571             beyond this conversation"
572        ),
573        (true, false) => format!(
574            "this conversation has taken in content from outside sources, so `{tool_name}` \
575             needs a quick human check before it reaches an outside address"
576        ),
577        (false, true) => format!(
578            "this conversation has taken in content from outside sources, so `{tool_name}` \
579             needs a quick human check before it changes anything beyond this conversation"
580        ),
581        (false, false) => format!(
582            "`{tool_name}` needs more access than this conversation currently has, so a \
583             human check is needed first"
584        ),
585    }
586}
587
588#[cfg(test)]
589mod tests;