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    /// Grant a third party access to the system itself — the admin invite
62    /// (`#700`). Deliberately held OUT of [`Self::ALL`], so
63    /// it is never in [`CapabilitySet::all`], never in the default grant, and —
64    /// because [`Self::from_name`] only recognizes members of [`Self::ALL`] —
65    /// unnameable in operator config: no policy or wire input can ever seed it
66    /// into a granted set. A tool that requires it therefore always exceeds its
67    /// granted set and always escalates to a human, in every taint state and
68    /// policy mode. This is the structural mechanism behind "an access-grant is
69    /// never autonomous — a person always confirms the exact invitee".
70    GrantAccess = 1 << 5,
71    /// Remove a third party's access to the system itself — the admin
72    /// de-admission (`#713`), the offboarding sibling of [`Self::GrantAccess`].
73    /// Held OUT of [`Self::ALL`] for the identical reason: never in
74    /// [`CapabilitySet::all`], never in the default grant, and unnameable in
75    /// operator config ([`Self::from_name`] only recognizes [`Self::ALL`]
76    /// members), so a tool requiring it always exceeds its granted set and
77    /// always escalates to a human, in every taint state and policy mode. This
78    /// is the structural mechanism behind "a removal is never autonomous — a
79    /// person always confirms the exact person being removed".
80    RevokeAccess = 1 << 6,
81    /// Take away a persona's ADMIN ROLE — the `demote` tool (`#715`), and the
82    /// sibling that completes the admin-management set alongside
83    /// [`Self::GrantAccess`]/[`Self::RevokeAccess`]. Held OUT of [`Self::ALL`]
84    /// for the identical reason: never in [`CapabilitySet::all`], never in the
85    /// default grant, and unnameable in operator config ([`Self::from_name`]
86    /// only recognizes [`Self::ALL`] members), so a tool requiring it always
87    /// exceeds its granted set and always escalates to a human, in every
88    /// taint state and policy mode. This is the structural mechanism behind
89    /// "an admin's role is never removed autonomously — a person always
90    /// confirms exactly whose role is being taken away".
91    ///
92    /// This occupies the last bit `u8` can hold (`1 << 7`); the NEXT
93    /// never-granted marker added to this taxonomy needs `CapabilitySet` (and
94    /// this enum's `#[repr]`) widened from `u8` to `u16` first.
95    ManageAdmin = 1 << 7,
96}
97
98impl Capability {
99    /// Every *grantable* member of the taxonomy, in declaration order.
100    ///
101    /// [`Self::GrantAccess`] and [`Self::RevokeAccess`] are deliberately
102    /// absent: they are the never-granted markers (see their docs), so they
103    /// are excluded from [`CapabilitySet::all`], the default grant, name
104    /// parsing ([`Self::from_name`]), and set iteration — everything driven
105    /// off this array operates only over the grantable set.
106    pub const ALL: [Self; 5] = [
107        Self::LocalRead,
108        Self::LocalWrite,
109        Self::FixedConnectorRead,
110        Self::ArbitraryEgress,
111        Self::MutateExternal,
112    ];
113
114    /// Stable kebab-case name, used on the wire (the harness turn input), in
115    /// the Agent custom resource's taint-resilient list, and as a telemetry
116    /// label. Inverse of [`Self::from_name`].
117    #[must_use]
118    pub const fn as_str(self) -> &'static str {
119        match self {
120            Self::LocalRead => "local-read",
121            Self::LocalWrite => "local-write",
122            Self::FixedConnectorRead => "fixed-connector-read",
123            Self::ArbitraryEgress => "arbitrary-egress",
124            Self::MutateExternal => "mutate-external",
125            Self::GrantAccess => "grant-access",
126            Self::RevokeAccess => "revoke-access",
127            Self::ManageAdmin => "manage-admin",
128        }
129    }
130
131    /// Parse a stable kebab-case name; `None` for anything unrecognized so a
132    /// caller reading operator config fails toward granting nothing.
133    #[must_use]
134    pub fn from_name(name: &str) -> Option<Self> {
135        Self::ALL.into_iter().find(|c| c.as_str() == name)
136    }
137}
138
139/// A set of [`Capability`]s. Small, `Copy`, and closed under the usual set
140/// algebra — the decision engine works only through these operations.
141#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Hash)]
142pub struct CapabilitySet(u8);
143
144/// Scoping/audit name for the LLM provider's native web-search-grounding
145/// primitive (issue `#1226`).
146///
147/// A request-level flag the provider turns into its own built-in search tool
148/// entry mid-generation, never a model-invoked `tool_use` call. Lives here
149/// (rather than `polyc_tools`, where every other tool name lives) because
150/// both `polyc_agent` (the per-step gate that consults it) and `polyc_tools`
151/// (the `builtinTools` scoping check) need the same literal and neither crate
152/// may depend on the other.
153pub const NATIVE_SEARCH_GROUNDING: &str = "web_search_grounding";
154
155impl CapabilitySet {
156    /// The empty set.
157    pub const EMPTY: Self = Self(0);
158
159    /// The capabilities the provider's native web-search-grounding primitive
160    /// ([`NATIVE_SEARCH_GROUNDING`]) requires.
161    ///
162    /// The classic exfiltration channel, the same floor as the built-in web
163    /// fetchers (`web_fetch`, `paid_fetch`). Unlike every other tool, this
164    /// primitive is never a [`polyc_llm::ToolSpec`] the model calls
165    /// explicitly, so there is no per-call `tool_use` for the ordinary
166    /// classification path (`required_capabilities`) to inspect — the call
167    /// site that decides whether to turn grounding on for a step compares
168    /// this constant against [`granted_capabilities`] directly, via the same
169    /// [`decide`] every real tool call goes through.
170    #[must_use]
171    pub const fn native_search_grounding_requirements() -> Self {
172        Self::of(Capability::ArbitraryEgress)
173    }
174
175    /// The full privileged set — every member of the taxonomy. This is the
176    /// fail-closed requirement for an unclassifiable tool.
177    #[must_use]
178    pub const fn all() -> Self {
179        let mut bits = 0u8;
180        let mut i = 0;
181        while i < Capability::ALL.len() {
182            bits |= Capability::ALL[i] as u8;
183            i += 1;
184        }
185        Self(bits)
186    }
187
188    /// The set containing exactly `capability`.
189    #[must_use]
190    pub const fn of(capability: Capability) -> Self {
191        Self(capability as u8)
192    }
193
194    /// This set plus `capability`.
195    #[must_use]
196    pub const fn with(self, capability: Capability) -> Self {
197        Self(self.0 | capability as u8)
198    }
199
200    /// Whether `capability` is in this set.
201    #[must_use]
202    pub const fn contains(self, capability: Capability) -> bool {
203        self.0 & capability as u8 != 0
204    }
205
206    /// Whether this set has no members.
207    #[must_use]
208    pub const fn is_empty(self) -> bool {
209        self.0 == 0
210    }
211
212    /// Whether every member of this set is also in `other`.
213    #[must_use]
214    pub const fn is_subset_of(self, other: Self) -> bool {
215        self.0 & !other.0 == 0
216    }
217
218    /// Set union.
219    #[must_use]
220    pub const fn union(self, other: Self) -> Self {
221        Self(self.0 | other.0)
222    }
223
224    /// Set intersection.
225    #[must_use]
226    pub const fn intersection(self, other: Self) -> Self {
227        Self(self.0 & other.0)
228    }
229
230    /// Set difference: the members of this set that are not in `other`.
231    #[must_use]
232    pub const fn difference(self, other: Self) -> Self {
233        Self(self.0 & !other.0)
234    }
235
236    /// The members of this set, in [`Capability::ALL`] order.
237    pub fn iter(self) -> impl Iterator<Item = Capability> {
238        Capability::ALL
239            .into_iter()
240            .filter(move |c| self.contains(*c))
241    }
242
243    /// Build a set from stable kebab-case names (e.g. an Agent custom
244    /// resource's taint-resilient list or the harness wire). Unrecognized
245    /// names are NOT granted — they are returned separately so the caller can
246    /// log them — which is the fail-closed direction: a typo in operator
247    /// config grants nothing rather than something unintended.
248    pub fn from_names<'a, I: IntoIterator<Item = &'a str>>(names: I) -> (Self, Vec<String>) {
249        let mut set = Self::EMPTY;
250        let mut unknown = Vec::new();
251        for name in names {
252            match Capability::from_name(name) {
253                Some(c) => set = set.with(c),
254                None => unknown.push(name.to_owned()),
255            }
256        }
257        (set, unknown)
258    }
259
260    /// The stable kebab-case names of this set's members, in
261    /// [`Capability::ALL`] order — the inverse of [`Self::from_names`].
262    #[must_use]
263    pub fn names(self) -> Vec<&'static str> {
264        self.iter().map(Capability::as_str).collect()
265    }
266}
267
268impl FromIterator<Capability> for CapabilitySet {
269    fn from_iter<I: IntoIterator<Item = Capability>>(iter: I) -> Self {
270        iter.into_iter().fold(Self::EMPTY, Self::with)
271    }
272}
273
274/// The capabilities that untrusted content in context revokes: both channels
275/// that carry model-authored bytes to destinations an attacker may read.
276///
277/// This is the single, tested home of the containment rule that used to be
278/// the "lethal trifecta override": [`Capability::ArbitraryEgress`] (a fetch to
279/// a model-chosen destination) **and** [`Capability::MutateExternal`] (a
280/// message body or issue field is an exfiltration channel the egress rule
281/// alone would miss).
282pub const TAINT_REVOKED: CapabilitySet =
283    CapabilitySet::of(Capability::ArbitraryEgress).with(Capability::MutateExternal);
284
285// ── Requirement derivation ────────────────────────────────────────────────────
286
287/// Where a tool comes from — the registry-provenance half of classification.
288///
289/// Trust scoping is the security-load-bearing part: taint-immune
290/// classification ([`Capability::FixedConnectorRead`]) is earned only by
291/// operator registration ([`ToolOrigin::RegisteredConnector`] /
292/// [`ToolOrigin::FirstParty`]) — never by a connector's self-declared
293/// annotation hints alone. This is what the MCP specification normatively
294/// requires: clients MUST treat tool annotations as untrusted unless the
295/// server is trusted.
296#[derive(Debug, Clone, Copy, PartialEq, Eq)]
297pub enum ToolOrigin {
298    /// A sandbox-confined built-in (the coding tools): runs inside the
299    /// conversation's execution unit, which has no outbound network.
300    LocalSandbox,
301    /// A built-in fetcher (the web/paid fetchers): brokered on the trusted
302    /// side to a model-controlled destination.
303    Fetcher,
304    /// A built-in that reads or acts on the caller's own first-party state via
305    /// the control plane (the history and wallet families): a fixed,
306    /// operator-owned destination.
307    FirstParty,
308    /// A built-in that grants a third party access to the system itself — the
309    /// admin invite (`#700`). Classified apart from [`Self::FirstParty`]
310    /// because it requires [`Capability::GrantAccess`], the never-granted
311    /// marker, so it always escalates to a human before anything is minted: the
312    /// agent can only ever PROPOSE an invite, never grant access on its own.
313    AccessGrant,
314    /// A built-in that removes a third party's access to the system itself —
315    /// the admin de-admission (`#713`), the offboarding sibling of
316    /// [`Self::AccessGrant`]. Requires [`Capability::RevokeAccess`], the
317    /// never-granted marker, so it always escalates to a human before anything
318    /// is removed: the agent can only ever PROPOSE a removal, never de-admit
319    /// anyone on its own.
320    AccessRevoke,
321    /// A built-in that takes away a persona's ADMIN ROLE — the `demote`
322    /// tool (`#715`), the sibling that completes the admin-management set
323    /// alongside [`Self::AccessGrant`]/[`Self::AccessRevoke`]. Requires
324    /// [`Capability::ManageAdmin`], the never-granted marker, so it always
325    /// escalates to a human before anyone's admin role changes: the agent
326    /// can only ever PROPOSE a demote, never remove anyone's admin role on
327    /// its own.
328    AdminManage,
329    /// A connector tool whose server the operator registered (registry
330    /// provenance, e.g. the `ToolService` registry). Its annotations are
331    /// load-bearing inputs because the operator vouched for the server.
332    RegisteredConnector,
333    /// Anything else: an unregistered server's self-declared tool, an unknown
334    /// name, an unannotated spec. Fails closed to the privileged set.
335    Unknown,
336}
337
338/// The classification inputs for one tool: its spec's MCP-style annotations
339/// plus its registry provenance.
340///
341/// Built by the executor surface (`#592`) — [`ToolProfile::for_spec`] reads
342/// the annotations off the spec, and the executor supplies the origin from
343/// what it knows about the tool's source.
344#[derive(Debug, Clone, Copy, PartialEq, Eq)]
345pub struct ToolProfile {
346    /// Registry provenance — see [`ToolOrigin`].
347    pub origin: ToolOrigin,
348    /// MCP `readOnlyHint`: the tool does not modify its environment.
349    pub read_only: bool,
350    /// MCP `destructiveHint`: the tool may perform irreversible or
351    /// side-effecting changes.
352    pub destructive: bool,
353    /// MCP `openWorldHint`: the tool's RESULT may carry content of
354    /// uncontrolled provenance. An ingestion-source property: it drives
355    /// taint, not the required set (a registered connector's open-world read
356    /// still dials only its fixed, operator-vouched endpoint).
357    pub open_world: bool,
358}
359
360impl ToolProfile {
361    /// Read the classification annotations off `spec`, with the
362    /// executor-supplied registry provenance.
363    #[must_use]
364    pub const fn for_spec(spec: &ToolSpec, origin: ToolOrigin) -> Self {
365        Self {
366            origin,
367            read_only: spec.read_only,
368            destructive: spec.destructive,
369            open_world: spec.open_world,
370        }
371    }
372}
373
374/// Derive the capabilities a tool call requires from its profile — the
375/// spec's existing annotations plus registry provenance. No new hand-written
376/// per-tool metadata.
377///
378/// The mapping (see the `#587` design):
379///
380/// - sandbox-confined built-in ⇒ local read (+ local write unless read-only —
381///   destructive *inside the box* is still local);
382/// - built-in fetcher ⇒ arbitrary egress (+ external mutation when
383///   destructive, e.g. a paying fetch);
384/// - first-party / operator-registered connector ⇒ fixed-connector read
385///   (+ external mutation unless read-only and non-destructive);
386/// - access-grant (the admin invite) ⇒ the never-granted
387///   [`Capability::GrantAccess`], so it always escalates to a human;
388/// - unknown / unclassifiable ⇒ the full privileged set, fail closed: an
389///   unknown tool never slips through un-gated.
390///
391/// `open_world` is deliberately not consulted: it marks an ingestion source
392/// (drives taint when the result enters context), not an outbound
393/// capability.
394#[must_use]
395pub const fn required_capabilities(profile: ToolProfile) -> CapabilitySet {
396    match profile.origin {
397        ToolOrigin::LocalSandbox => {
398            if profile.read_only {
399                CapabilitySet::of(Capability::LocalRead)
400            } else {
401                CapabilitySet::of(Capability::LocalRead).with(Capability::LocalWrite)
402            }
403        }
404        ToolOrigin::Fetcher => {
405            if profile.destructive {
406                CapabilitySet::of(Capability::ArbitraryEgress).with(Capability::MutateExternal)
407            } else {
408                CapabilitySet::of(Capability::ArbitraryEgress)
409            }
410        }
411        ToolOrigin::FirstParty | ToolOrigin::RegisteredConnector => {
412            if profile.read_only && !profile.destructive {
413                CapabilitySet::of(Capability::FixedConnectorRead)
414            } else {
415                CapabilitySet::of(Capability::FixedConnectorRead).with(Capability::MutateExternal)
416            }
417        }
418        // The admin invite: it requires only the never-granted
419        // `GrantAccess`, so the gate escalates it in EVERY taint state and
420        // policy mode. The annotations are not consulted — proposing an
421        // access-grant always needs a person, regardless of how the tool
422        // declares itself. The actual mint's first-party mutation is enforced
423        // control-plane-side, after approval, not modeled as the agent call's
424        // granted capability.
425        ToolOrigin::AccessGrant => CapabilitySet::of(Capability::GrantAccess),
426        // The admin de-admission (#713): the offboarding sibling of the admin
427        // invite above — same reasoning, same never-granted-marker mechanism.
428        ToolOrigin::AccessRevoke => CapabilitySet::of(Capability::RevokeAccess),
429        // The admin demote (#715): completes the admin-management set —
430        // same reasoning, same never-granted-marker mechanism.
431        ToolOrigin::AdminManage => CapabilitySet::of(Capability::ManageAdmin),
432        ToolOrigin::Unknown => CapabilitySet::all(),
433    }
434}
435
436/// Clamp a re-declared profile so a connector's runtime annotation change can
437/// only ever ADD required capabilities (`#598`).
438///
439/// A connector may re-declare its tools mid-conversation (list-changed). A
440/// re-declaration never removes a requirement and never earns taint-immunity
441/// at runtime: the merged profile keeps the *less* trusted value of each
442/// annotation (loses `read_only` if either side lost it, keeps `destructive`
443/// and `open_world` if either side had it). The origin is NOT an input from
444/// the re-declaration at all — registry provenance is an operator act the
445/// executor derives, never something a connector can assert about itself —
446/// so the merged profile keeps the origin the conversation started with.
447/// The result is pinned monotonic by test:
448/// `required_capabilities(monotonic_redeclaration(old, new))` is always a
449/// superset of `required_capabilities(old)`.
450#[must_use]
451pub const fn monotonic_redeclaration(old: ToolProfile, new: ToolProfile) -> ToolProfile {
452    ToolProfile {
453        origin: old.origin,
454        read_only: old.read_only && new.read_only,
455        destructive: old.destructive || new.destructive,
456        open_world: old.open_world || new.open_world,
457    }
458}
459
460// ── Grant derivation ──────────────────────────────────────────────────────────
461
462/// Whether untrusted content is in the conversation's context at this gate
463/// decision.
464///
465/// The provenance (taint) input to grant derivation, computed from the
466/// durable seed OR the live transcript scan, never from the turn's own
467/// output.
468#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
469pub enum TaintState {
470    /// No untrusted content in context.
471    #[default]
472    Clean,
473    /// Untrusted content is in context: the granted set loses
474    /// [`TAINT_REVOKED`] except what the policy declares taint-resilient.
475    Tainted,
476}
477
478/// The agent's configured capability policy — the operator-controlled half of
479/// grant derivation.
480///
481/// Both fields come from cluster config (the Agent custom resource),
482/// unreachable from within a conversation or the request path.
483#[derive(Debug, Clone, Copy, PartialEq, Eq)]
484pub struct GrantPolicy {
485    /// The capabilities the agent's policy grants on a clean context.
486    pub base: CapabilitySet,
487    /// Operator-declared capabilities that survive the taint subtraction for
488    /// this agent's conversations (the generalization of the trusted-egress
489    /// flag: how an unattended routine keeps posting after it reads).
490    /// Default empty. Never grants beyond [`Self::base`].
491    pub taint_resilient: CapabilitySet,
492}
493
494impl Default for GrantPolicy {
495    /// Grant everything on a clean context, nothing taint-resilient — the
496    /// posture of every conversation without an operator declaration.
497    fn default() -> Self {
498        Self {
499            base: CapabilitySet::all(),
500            taint_resilient: CapabilitySet::EMPTY,
501        }
502    }
503}
504
505/// Derive the capabilities granted to one gate decision from the agent's
506/// policy and the provenance state **at that moment**.
507///
508/// Recomputed per call, because taint can enter mid-turn and must revoke for
509/// the very next call.
510///
511/// The containment rule: taint present ⇒ [`TAINT_REVOKED`] (arbitrary egress
512/// AND external mutation) removed from the granted set; the operator-declared
513/// taint-resilient set survives the subtraction, but never grants beyond the
514/// base. Monotonic under a fixed policy: adding taint never adds a
515/// capability (pinned by test).
516#[must_use]
517pub const fn granted_capabilities(policy: GrantPolicy, taint: TaintState) -> CapabilitySet {
518    match taint {
519        TaintState::Clean => policy.base,
520        TaintState::Tainted => {
521            let survivors = policy.taint_resilient.intersection(TAINT_REVOKED);
522            policy
523                .base
524                .difference(TAINT_REVOKED)
525                .union(policy.base.intersection(survivors))
526        }
527    }
528}
529
530// ── Decision engine ───────────────────────────────────────────────────────────
531
532/// An argument transform the argument-aware dispatch policy asked for, honored
533/// only when the call is otherwise allowed.
534#[derive(Debug, Clone, PartialEq, Eq, Default)]
535pub enum ArgTransform {
536    /// Run the call as proposed.
537    #[default]
538    None,
539    /// Run the call with these replacement arguments.
540    Rewrite(String),
541    /// Prepend this context as an internal-only note before the call runs.
542    InjectContext(String),
543}
544
545/// The per-call policy verdicts [`decide`] folds into the one outcome.
546///
547/// Carries the argument-aware dispatch decision plus the sandbox-escalation
548/// check, already evaluated against the call's arguments by the caller.
549#[derive(Debug, Clone, PartialEq, Eq, Default)]
550pub struct CallPolicy {
551    /// A hard policy veto with its reason: the call is blocked without a
552    /// human prompt and a human approval cannot satisfy it. Takes precedence
553    /// over everything else.
554    pub veto: Option<String>,
555    /// The argument-aware policy (or the tool's intrinsic gate) demands a
556    /// human decision for this call regardless of capabilities.
557    pub requires_human: bool,
558    /// The sandbox would deny this call before any side effect and the
559    /// deployment escalates such denials to a human instead of running into
560    /// the flat denial.
561    pub sandbox_escalation: bool,
562    /// The argument transform to honor when the call is allowed.
563    pub transform: ArgTransform,
564}
565
566/// The single unified gate result for one tool call — replaces both the
567/// argument-aware `ToolDecision` and the `needs_approval`/override booleans.
568#[derive(Debug, Clone, PartialEq, Eq)]
569pub enum GateOutcome {
570    /// Execute the call as proposed.
571    Allow,
572    /// Execute the call with rewritten arguments.
573    Modify(
574        /// The replacement arguments (JSON).
575        String,
576    ),
577    /// Prepend this context as an internal-only note, then execute.
578    InjectContext(
579        /// The note text.
580        String,
581    ),
582    /// Pause for a human decision.
583    Escalate {
584        /// Why, in plain language for the approval card. Empty for an
585        /// ordinary policy/sandbox gate (the edge renders its default
586        /// prompt); non-empty when capabilities are missing.
587        reason: String,
588        /// The required capabilities the call's granted set does not cover.
589        /// Empty when the escalation is a policy/sandbox gate rather than a
590        /// capability shortfall.
591        missing: CapabilitySet,
592    },
593    /// Block the call without a human prompt; the reason is surfaced to the
594    /// model as the tool result so it can adapt.
595    Deny(
596        /// The policy's reason.
597        String,
598    ),
599}
600
601impl GateOutcome {
602    /// Stable lowercase label for telemetry — one counter per gate outcome.
603    #[must_use]
604    pub const fn label(&self) -> &'static str {
605        match self {
606            Self::Allow => "allow",
607            Self::Modify(_) => "modify",
608            Self::InjectContext(_) => "inject_context",
609            Self::Escalate { .. } => "escalate",
610            Self::Deny(_) => "deny",
611        }
612    }
613}
614
615/// The one pure decision: compare what the call requires with what it is
616/// granted, under the argument-aware policy verdicts, and return the single
617/// [`GateOutcome`].
618///
619/// Precedence, pinned by test:
620///
621/// 1. hard policy veto ⇒ [`GateOutcome::Deny`];
622/// 2. required ⊄ granted ⇒ [`GateOutcome::Escalate`] carrying the missing
623///    set and a plain-language reason;
624/// 3. the policy demands a human (argument-aware gate or sandbox-denial
625///    escalation) ⇒ [`GateOutcome::Escalate`] with an empty missing set;
626/// 4. otherwise honor the argument transform or allow.
627///
628/// The engine is pure set algebra over the capability sets — it never
629/// matches on a specific [`Capability`], so extending the taxonomy requires
630/// no change here (pinned by test).
631#[must_use]
632pub fn decide(
633    required: CapabilitySet,
634    granted: CapabilitySet,
635    policy: &CallPolicy,
636    tool_name: &str,
637) -> GateOutcome {
638    if let Some(reason) = &policy.veto {
639        return GateOutcome::Deny(reason.clone());
640    }
641    let missing = required.difference(granted);
642    if !missing.is_empty() {
643        return GateOutcome::Escalate {
644            reason: escalation_reason(tool_name, missing),
645            missing,
646        };
647    }
648    if policy.requires_human || policy.sandbox_escalation {
649        return GateOutcome::Escalate {
650            reason: String::new(),
651            missing: CapabilitySet::EMPTY,
652        };
653    }
654    match &policy.transform {
655        ArgTransform::None => GateOutcome::Allow,
656        ArgTransform::Rewrite(args) => GateOutcome::Modify(args.clone()),
657        ArgTransform::InjectContext(note) => GateOutcome::InjectContext(note.clone()),
658    }
659}
660
661/// The plain-language reason for a missing-capability escalation, rendered
662/// verbatim on the approval card on every edge (one shared helper so the
663/// wording never differs by surface).
664///
665/// User-facing copy: no internal terms, active sentences, honest about risk
666/// without overclaiming. In the current model a capability is only ever
667/// missing because untrusted content entered the conversation (the base
668/// policy grants everything), so the copy names that cause; a future
669/// narrowed base policy reuses the same wording — the access is missing
670/// either way, and the approver's decision is the same.
671#[must_use]
672pub fn escalation_reason(tool_name: &str, missing: CapabilitySet) -> String {
673    // The access-grant marker takes precedence: an invite always needs a person
674    // to confirm the exact invitee, in every conversation state, so the wording
675    // is about the grant itself, not about any content the conversation took in.
676    if missing.contains(Capability::GrantAccess) {
677        return format!(
678            "`{tool_name}` would give someone access to Polychrome, so a person needs to \
679             confirm exactly who's being invited before it goes ahead"
680        );
681    }
682    // The revoke-access marker takes the same precedence, for the same reason:
683    // removing someone's access always needs a person to confirm exactly who,
684    // in every conversation state.
685    if missing.contains(Capability::RevokeAccess) {
686        return format!(
687            "`{tool_name}` would remove someone's access to Polychrome, so a person needs to \
688             confirm exactly who's being removed before it goes ahead"
689        );
690    }
691    // The manage-admin marker takes the same precedence: taking away someone's
692    // admin role always needs a person to confirm exactly whose, in every
693    // conversation state.
694    if missing.contains(Capability::ManageAdmin) {
695        return format!(
696            "`{tool_name}` would take away someone's admin role, so a person needs to confirm \
697             exactly whose role is being removed before it goes ahead"
698        );
699    }
700    let reaches_out = missing.contains(Capability::ArbitraryEgress);
701    let mutates = missing.contains(Capability::MutateExternal);
702    match (reaches_out, mutates) {
703        (true, true) => format!(
704            "this conversation has taken in content from outside sources, so `{tool_name}` \
705             needs a quick human check before it sends anything out or changes anything \
706             beyond this conversation"
707        ),
708        (true, false) => format!(
709            "this conversation has taken in content from outside sources, so `{tool_name}` \
710             needs a quick human check before it reaches an outside address"
711        ),
712        (false, true) => format!(
713            "this conversation has taken in content from outside sources, so `{tool_name}` \
714             needs a quick human check before it changes anything beyond this conversation"
715        ),
716        (false, false) => format!(
717            "`{tool_name}` needs more access than this conversation currently has, so a \
718             human check is needed first"
719        ),
720    }
721}
722
723#[cfg(test)]
724mod tests;