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//! - A tool whose spec cannot be classified requires the full privileged set
19//!   ([`CapabilitySet::all`]) — fail closed.
20//! - The model is monotonic: under a fixed policy, adding taint never adds a
21//!   capability.
22//!
23//! Everything here is a pure function over its inputs. No gate wiring, no IO,
24//! no clock: the executor surface (`#592`) derives the inputs and the agent's
25//! per-call gate (`#593`) is a thin adapter over [`decide`].
26
27use polyc_llm::ToolSpec;
28
29// ── Capability + set ─────────────────────────────────────────────────────────
30
31/// One thing a tool call can do — the unit of the containment model.
32///
33/// The taxonomy is deliberately small and rarely changes. Adding a member
34/// means extending this enum and the two derivation functions
35/// ([`required_capabilities`], [`granted_capabilities`]); the decision engine
36/// ([`decide`]) operates on sets generically and never needs to change (a
37/// pinned test demonstrates this).
38#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
39#[repr(u16)]
40pub enum Capability {
41    /// Read state confined to the conversation's sandbox (workspace files).
42    LocalRead = 1,
43    /// Mutate state confined to the conversation's sandbox (workspace writes,
44    /// sandboxed shell). Destructive *inside the box* is still local.
45    LocalWrite = 1 << 1,
46    /// Call an operator-registered connector endpoint (or a first-party
47    /// control-plane service) — a fixed destination the operator vouched for,
48    /// carrying only model-authored arguments. Taint never revokes this.
49    FixedConnectorRead = 1 << 2,
50    /// Send bytes to a model-controlled external destination — the built-in
51    /// web/paid fetchers. The classic exfiltration channel.
52    ArbitraryEgress = 1 << 3,
53    /// Perform a side effect outside the sandbox: mutate external state,
54    /// send a message, file an issue, spend money. An external mutation
55    /// carries model-authored bytes to destinations an attacker may read,
56    /// so it is an egress channel in effect even when the destination is
57    /// fixed.
58    MutateExternal = 1 << 4,
59    /// Grant a third party access to the system itself — the admin invite
60    /// (`#700`). Deliberately held OUT of [`Self::ALL`], so
61    /// it is never in [`CapabilitySet::all`], never in the default grant, and —
62    /// because [`Self::from_name`] only recognizes members of [`Self::ALL`] —
63    /// unnameable in operator config: no policy or wire input can ever seed it
64    /// into a granted set. A tool that requires it therefore always exceeds its
65    /// granted set and always escalates to a human, in every taint state and
66    /// policy mode. This is the structural mechanism behind "an access-grant is
67    /// never autonomous — a person always confirms the exact invitee".
68    GrantAccess = 1 << 5,
69    /// Remove a third party's access to the system itself — the admin
70    /// de-admission (`#713`), the offboarding sibling of [`Self::GrantAccess`].
71    /// Held OUT of [`Self::ALL`] for the identical reason: never in
72    /// [`CapabilitySet::all`], never in the default grant, and unnameable in
73    /// operator config ([`Self::from_name`] only recognizes [`Self::ALL`]
74    /// members), so a tool requiring it always exceeds its granted set and
75    /// always escalates to a human, in every taint state and policy mode. This
76    /// is the structural mechanism behind "a removal is never autonomous — a
77    /// person always confirms the exact person being removed".
78    RevokeAccess = 1 << 6,
79    /// Take away a persona's ADMIN ROLE — the `demote` tool (`#715`), and the
80    /// sibling that completes the admin-management set alongside
81    /// [`Self::GrantAccess`]/[`Self::RevokeAccess`]. Held OUT of [`Self::ALL`]
82    /// for the identical reason: never in [`CapabilitySet::all`], never in the
83    /// default grant, and unnameable in operator config ([`Self::from_name`]
84    /// only recognizes [`Self::ALL`] members), so a tool requiring it always
85    /// exceeds its granted set and always escalates to a human, in every
86    /// taint state and policy mode. This is the structural mechanism behind
87    /// "an admin's role is never removed autonomously — a person always
88    /// confirms exactly whose role is being taken away".
89    ///
90    /// This used to occupy the last bit `u8` could hold (`1 << 7`); adding
91    /// [`Self::GrantAdmin`] widened `CapabilitySet` (and this enum's
92    /// `#[repr]`) from `u8` to `u16`, so a further marker needs no more
93    /// widening — `u16` has eight bits to spare.
94    ManageAdmin = 1 << 7,
95    /// Make a persona an ADMIN — the `promote` tool (POLY-223), the
96    /// admission-granting sibling of [`Self::ManageAdmin`] that completes the
97    /// admin-management set alongside [`Self::GrantAccess`]/
98    /// [`Self::RevokeAccess`]. Held OUT of [`Self::ALL`] for the identical
99    /// reason: never in [`CapabilitySet::all`], never in the default grant,
100    /// and unnameable in operator config ([`Self::from_name`] only
101    /// recognizes [`Self::ALL`] members), so a tool requiring it always
102    /// exceeds its granted set and always escalates to a human, in every
103    /// taint state and policy mode. This is the structural mechanism behind
104    /// "an admin role is never granted autonomously — a person always
105    /// confirms exactly who is being made an admin."
106    ///
107    /// Deliberately its OWN marker rather than a reuse of
108    /// [`Self::ManageAdmin`]: the escalation copy the gate renders is
109    /// directional (see [`escalation_reason`]) — reusing `ManageAdmin` would
110    /// make a PROMOTION's approval card read "would take away someone's
111    /// admin role," the opposite of what is actually being authorized.
112    GrantAdmin = 1 << 8,
113}
114
115impl Capability {
116    /// Every *grantable* member of the taxonomy, in declaration order.
117    ///
118    /// [`Self::GrantAccess`] and [`Self::RevokeAccess`] are deliberately
119    /// absent: they are the never-granted markers (see their docs), so they
120    /// are excluded from [`CapabilitySet::all`], the default grant, name
121    /// parsing ([`Self::from_name`]), and set iteration — everything driven
122    /// off this array operates only over the grantable set.
123    pub const ALL: [Self; 5] = [
124        Self::LocalRead,
125        Self::LocalWrite,
126        Self::FixedConnectorRead,
127        Self::ArbitraryEgress,
128        Self::MutateExternal,
129    ];
130
131    /// Stable kebab-case name, used in signed approval coverage and telemetry.
132    /// Inverse of [`Self::from_name`].
133    #[must_use]
134    pub const fn as_str(self) -> &'static str {
135        match self {
136            Self::LocalRead => "local-read",
137            Self::LocalWrite => "local-write",
138            Self::FixedConnectorRead => "fixed-connector-read",
139            Self::ArbitraryEgress => "arbitrary-egress",
140            Self::MutateExternal => "mutate-external",
141            Self::GrantAccess => "grant-access",
142            Self::RevokeAccess => "revoke-access",
143            Self::ManageAdmin => "manage-admin",
144            Self::GrantAdmin => "grant-admin",
145        }
146    }
147
148    /// Parse a stable kebab-case name; `None` for anything unrecognized so a
149    /// caller reading operator config fails toward granting nothing.
150    #[must_use]
151    pub fn from_name(name: &str) -> Option<Self> {
152        Self::ALL.into_iter().find(|c| c.as_str() == name)
153    }
154}
155
156/// A set of [`Capability`]s. Small, `Copy`, and closed under the usual set
157/// algebra — the decision engine works only through these operations.
158#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Hash)]
159pub struct CapabilitySet(u16);
160
161/// Scoping/audit name for the LLM provider's native web-search-grounding
162/// primitive (issue `#1226`).
163///
164/// A request-level flag the provider turns into its own built-in search tool
165/// entry mid-generation, never a model-invoked `tool_use` call. Lives here
166/// (rather than `polyc_tools`, where every other tool name lives) because
167/// both `polyc_agent` (the per-step gate that consults it) and `polyc_tools`
168/// (the `builtinTools` scoping check) need the same literal and neither crate
169/// may depend on the other.
170pub const NATIVE_SEARCH_GROUNDING: &str = "web_search_grounding";
171
172impl CapabilitySet {
173    /// The empty set.
174    pub const EMPTY: Self = Self(0);
175
176    /// The capabilities the provider's native web-search-grounding primitive
177    /// ([`NATIVE_SEARCH_GROUNDING`]) requires.
178    ///
179    /// The classic exfiltration channel, the same floor as the built-in web
180    /// fetchers (`web_fetch`, `paid_fetch`). Unlike every other tool, this
181    /// primitive is never a [`polyc_llm::ToolSpec`] the model calls
182    /// explicitly, so there is no per-call `tool_use` for the ordinary
183    /// classification path (`required_capabilities`) to inspect — the call
184    /// site that decides whether to turn grounding on for a step compares
185    /// this constant against [`granted_capabilities`] directly, via the same
186    /// [`decide`] every real tool call goes through.
187    #[must_use]
188    pub const fn native_search_grounding_requirements() -> Self {
189        Self::of(Capability::ArbitraryEgress)
190    }
191
192    /// The full privileged set — every member of the taxonomy. This is the
193    /// fail-closed requirement for an unclassifiable tool.
194    #[must_use]
195    pub const fn all() -> Self {
196        let mut bits = 0u16;
197        let mut i = 0;
198        while i < Capability::ALL.len() {
199            bits |= Capability::ALL[i] as u16;
200            i += 1;
201        }
202        Self(bits)
203    }
204
205    /// The set containing exactly `capability`.
206    #[must_use]
207    pub const fn of(capability: Capability) -> Self {
208        Self(capability as u16)
209    }
210
211    /// This set plus `capability`.
212    #[must_use]
213    pub const fn with(self, capability: Capability) -> Self {
214        Self(self.0 | capability as u16)
215    }
216
217    /// Whether `capability` is in this set.
218    #[must_use]
219    pub const fn contains(self, capability: Capability) -> bool {
220        self.0 & capability as u16 != 0
221    }
222
223    /// Whether this set has no members.
224    #[must_use]
225    pub const fn is_empty(self) -> bool {
226        self.0 == 0
227    }
228
229    /// Whether every member of this set is also in `other`.
230    #[must_use]
231    pub const fn is_subset_of(self, other: Self) -> bool {
232        self.0 & !other.0 == 0
233    }
234
235    /// Set union.
236    #[must_use]
237    pub const fn union(self, other: Self) -> Self {
238        Self(self.0 | other.0)
239    }
240
241    /// Set intersection.
242    #[must_use]
243    pub const fn intersection(self, other: Self) -> Self {
244        Self(self.0 & other.0)
245    }
246
247    /// Set difference: the members of this set that are not in `other`.
248    #[must_use]
249    pub const fn difference(self, other: Self) -> Self {
250        Self(self.0 & !other.0)
251    }
252
253    /// The members of this set, in [`Capability::ALL`] order.
254    pub fn iter(self) -> impl Iterator<Item = Capability> {
255        Capability::ALL
256            .into_iter()
257            .filter(move |c| self.contains(*c))
258    }
259
260    /// Build a set from stable kebab-case names, such as signed approval
261    /// coverage. Unrecognized names are NOT granted — they are returned
262    /// separately so the caller can
263    /// log them — which is the fail-closed direction: a typo in operator
264    /// config grants nothing rather than something unintended.
265    pub fn from_names<'a, I: IntoIterator<Item = &'a str>>(names: I) -> (Self, Vec<String>) {
266        let mut set = Self::EMPTY;
267        let mut unknown = Vec::new();
268        for name in names {
269            match Capability::from_name(name) {
270                Some(c) => set = set.with(c),
271                None => unknown.push(name.to_owned()),
272            }
273        }
274        (set, unknown)
275    }
276
277    /// The stable kebab-case names of this set's members, in
278    /// [`Capability::ALL`] order — the inverse of [`Self::from_names`].
279    #[must_use]
280    pub fn names(self) -> Vec<&'static str> {
281        self.iter().map(Capability::as_str).collect()
282    }
283}
284
285impl FromIterator<Capability> for CapabilitySet {
286    fn from_iter<I: IntoIterator<Item = Capability>>(iter: I) -> Self {
287        iter.into_iter().fold(Self::EMPTY, Self::with)
288    }
289}
290
291/// The capabilities that untrusted content in context revokes: both channels
292/// that carry model-authored bytes to destinations an attacker may read.
293///
294/// This is the single, tested home of the containment rule that used to be
295/// the "lethal trifecta override": [`Capability::ArbitraryEgress`] (a fetch to
296/// a model-chosen destination) **and** [`Capability::MutateExternal`] (a
297/// message body or issue field is an exfiltration channel the egress rule
298/// alone would miss).
299pub const TAINT_REVOKED: CapabilitySet =
300    CapabilitySet::of(Capability::ArbitraryEgress).with(Capability::MutateExternal);
301
302// ── Requirement derivation ────────────────────────────────────────────────────
303
304/// Where a tool comes from — the registry-provenance half of classification.
305///
306/// Trust scoping is the security-load-bearing part: taint-immune
307/// classification ([`Capability::FixedConnectorRead`]) is earned only by
308/// operator registration ([`ToolOrigin::RegisteredConnector`] /
309/// [`ToolOrigin::FirstParty`]) — never by a connector's self-declared
310/// annotation hints alone. This is what the MCP specification normatively
311/// requires: clients MUST treat tool annotations as untrusted unless the
312/// server is trusted.
313#[derive(Debug, Clone, Copy, PartialEq, Eq)]
314pub enum ToolOrigin {
315    /// A process-local built-in, such as a coding tool. This origin describes
316    /// local file authority. It does not prove operating-system network
317    /// isolation. `shell_exec` can use routes allowed by the pod policy today;
318    /// issue `#2508` owns alignment with this taxonomy and D6's route-removal
319    /// checklist.
320    LocalSandbox,
321    /// A built-in fetcher (the web/paid fetchers): brokered on the trusted
322    /// side to a model-controlled destination.
323    Fetcher,
324    /// A built-in that reads or acts on the caller's own first-party state via
325    /// the control plane (the history and wallet families): a fixed,
326    /// operator-owned destination.
327    FirstParty,
328    /// A built-in that grants a third party access to the system itself — the
329    /// admin invite (`#700`). Classified apart from [`Self::FirstParty`]
330    /// because it requires [`Capability::GrantAccess`], the never-granted
331    /// marker, so it always escalates to a human before anything is minted: the
332    /// agent can only ever PROPOSE an invite, never grant access on its own.
333    AccessGrant,
334    /// A built-in that removes a third party's access to the system itself —
335    /// the admin de-admission (`#713`), the offboarding sibling of
336    /// [`Self::AccessGrant`]. Requires [`Capability::RevokeAccess`], the
337    /// never-granted marker, so it always escalates to a human before anything
338    /// is removed: the agent can only ever PROPOSE a removal, never de-admit
339    /// anyone on its own.
340    AccessRevoke,
341    /// A built-in that takes away a persona's ADMIN ROLE — the `demote`
342    /// tool (`#715`), the sibling that completes the admin-management set
343    /// alongside [`Self::AccessGrant`]/[`Self::AccessRevoke`]. Requires
344    /// [`Capability::ManageAdmin`], the never-granted marker, so it always
345    /// escalates to a human before anyone's admin role changes: the agent
346    /// can only ever PROPOSE a demote, never remove anyone's admin role on
347    /// its own.
348    AdminManage,
349    /// A built-in that makes a persona an ADMIN — the `promote` tool
350    /// (POLY-223), the admission-granting sibling that completes the
351    /// admin-management set alongside [`Self::AccessGrant`]/
352    /// [`Self::AccessRevoke`]/[`Self::AdminManage`]. Requires
353    /// [`Capability::GrantAdmin`], the never-granted marker, so it always
354    /// escalates to a human before anyone's admin role changes: the agent
355    /// can only ever PROPOSE a promotion, never grant the admin role on its
356    /// own.
357    ///
358    /// Classified apart from [`Self::AdminManage`] rather than sharing its
359    /// capability marker: [`escalation_reason`]'s copy for that marker is
360    /// directional ("would take away someone's admin role"), which would
361    /// misdescribe a promotion's approval card, so the grant direction earns
362    /// its own marker and its own wording.
363    AdminGrant,
364    /// A connector tool whose server the operator registered (registry
365    /// provenance, e.g. the `ToolService` registry). Its annotations are
366    /// load-bearing inputs because the operator vouched for the server.
367    RegisteredConnector,
368    /// Anything else: an unregistered server's self-declared tool, an unknown
369    /// name, an unannotated spec. Fails closed to the privileged set.
370    Unknown,
371}
372
373/// The classification inputs for one tool: its spec's MCP-style annotations
374/// plus its registry provenance.
375///
376/// Built by the executor surface (`#592`) — [`ToolProfile::for_spec`] reads
377/// the annotations off the spec, and the executor supplies the origin from
378/// what it knows about the tool's source.
379#[derive(Debug, Clone, Copy, PartialEq, Eq)]
380pub struct ToolProfile {
381    /// Registry provenance — see [`ToolOrigin`].
382    pub origin: ToolOrigin,
383    /// MCP `readOnlyHint`: the tool does not modify its environment.
384    pub read_only: bool,
385    /// MCP `destructiveHint`: the tool may perform irreversible or
386    /// side-effecting changes.
387    pub destructive: bool,
388    /// MCP `openWorldHint`: the tool's RESULT may carry content of
389    /// uncontrolled provenance. An ingestion-source property: it drives
390    /// taint, not the required set (a registered connector's open-world read
391    /// still dials only its fixed, operator-vouched endpoint).
392    pub open_world: bool,
393}
394
395impl ToolProfile {
396    /// Read the classification annotations off `spec`, with the
397    /// executor-supplied registry provenance.
398    #[must_use]
399    pub const fn for_spec(spec: &ToolSpec, origin: ToolOrigin) -> Self {
400        Self {
401            origin,
402            read_only: spec.read_only,
403            destructive: spec.destructive,
404            open_world: spec.open_world,
405        }
406    }
407}
408
409/// Derive the capabilities a tool call requires from its profile — the
410/// spec's existing annotations plus registry provenance. No new hand-written
411/// per-tool metadata.
412///
413/// The mapping (see the `#587` design):
414///
415/// - sandbox-confined built-in ⇒ local read (+ local write unless read-only —
416///   destructive *inside the box* is still local);
417/// - built-in fetcher ⇒ arbitrary egress (+ external mutation when
418///   destructive, e.g. a paying fetch);
419/// - first-party / operator-registered connector ⇒ fixed-connector read
420///   (+ external mutation unless read-only and non-destructive);
421/// - access-grant (the admin invite) ⇒ the never-granted
422///   [`Capability::GrantAccess`], so it always escalates to a human;
423/// - unknown / unclassifiable ⇒ the full privileged set, fail closed: an
424///   unknown tool never slips through un-gated.
425///
426/// `open_world` is deliberately not consulted: it marks an ingestion source
427/// (drives taint when the result enters context), not an outbound
428/// capability.
429#[must_use]
430pub const fn required_capabilities(profile: ToolProfile) -> CapabilitySet {
431    match profile.origin {
432        ToolOrigin::LocalSandbox => {
433            if profile.read_only {
434                CapabilitySet::of(Capability::LocalRead)
435            } else {
436                CapabilitySet::of(Capability::LocalRead).with(Capability::LocalWrite)
437            }
438        }
439        ToolOrigin::Fetcher => {
440            if profile.destructive {
441                CapabilitySet::of(Capability::ArbitraryEgress).with(Capability::MutateExternal)
442            } else {
443                CapabilitySet::of(Capability::ArbitraryEgress)
444            }
445        }
446        ToolOrigin::FirstParty | ToolOrigin::RegisteredConnector => {
447            if profile.read_only && !profile.destructive {
448                CapabilitySet::of(Capability::FixedConnectorRead)
449            } else {
450                CapabilitySet::of(Capability::FixedConnectorRead).with(Capability::MutateExternal)
451            }
452        }
453        // The admin invite: it requires only the never-granted
454        // `GrantAccess`, so the gate escalates it in EVERY taint state and
455        // policy mode. The annotations are not consulted — proposing an
456        // access-grant always needs a person, regardless of how the tool
457        // declares itself. The actual mint's first-party mutation is enforced
458        // control-plane-side, after approval, not modeled as the agent call's
459        // granted capability.
460        ToolOrigin::AccessGrant => CapabilitySet::of(Capability::GrantAccess),
461        // The admin de-admission (#713): the offboarding sibling of the admin
462        // invite above — same reasoning, same never-granted-marker mechanism.
463        ToolOrigin::AccessRevoke => CapabilitySet::of(Capability::RevokeAccess),
464        // The admin demote (#715): completes the admin-management set —
465        // same reasoning, same never-granted-marker mechanism.
466        ToolOrigin::AdminManage => CapabilitySet::of(Capability::ManageAdmin),
467        // The admin promote (POLY-223): the admission-granting sibling of
468        // the demote arm above — same reasoning, same never-granted-marker
469        // mechanism, its own marker so the escalation copy stays directional.
470        ToolOrigin::AdminGrant => CapabilitySet::of(Capability::GrantAdmin),
471        ToolOrigin::Unknown => CapabilitySet::all(),
472    }
473}
474
475/// Clamp a re-declared profile so a connector's runtime annotation change can
476/// only ever ADD required capabilities (`#598`).
477///
478/// A connector may re-declare its tools mid-conversation (list-changed). A
479/// re-declaration never removes a requirement and never earns taint-immunity
480/// at runtime: the merged profile keeps the *less* trusted value of each
481/// annotation (loses `read_only` if either side lost it, keeps `destructive`
482/// and `open_world` if either side had it). The origin is NOT an input from
483/// the re-declaration at all — registry provenance is an operator act the
484/// executor derives, never something a connector can assert about itself —
485/// so the merged profile keeps the origin the conversation started with.
486/// The result is pinned monotonic by test:
487/// `required_capabilities(monotonic_redeclaration(old, new))` is always a
488/// superset of `required_capabilities(old)`.
489#[must_use]
490pub const fn monotonic_redeclaration(old: ToolProfile, new: ToolProfile) -> ToolProfile {
491    ToolProfile {
492        origin: old.origin,
493        read_only: old.read_only && new.read_only,
494        destructive: old.destructive || new.destructive,
495        open_world: old.open_world || new.open_world,
496    }
497}
498
499// ── Deployment viability (#1415) ────────────────────────────────────────────────
500
501/// A browser-facing ceremony page a built-in mints a one-time link to.
502///
503/// Closed and small on purpose: only ceremonies that actually gate a
504/// chat-callable built-in belong here. `wallet-passkey-login` (the web app's
505/// own sign-in) is a real deployment ceremony too, but it backs no built-in
506/// tool call — it is a pure browser flow a person reaches directly, never
507/// something the model mints a link to — so adding it here would leave a
508/// [`Requirement`] variant no built-in ever resolves to, which the harness's
509/// dead-variant guard exists to catch.
510#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
511pub enum Ceremony {
512    /// The wallet-link card (`POLYCHROME_WALLET_LINK_URL`): mints the link a
513    /// caller signs to attach an external wallet, and the shared page the
514    /// spending-limit-update and hard-revoke ceremonies reuse.
515    WalletLink,
516    /// The email-verification magic-link page (`POLYCHROME_EMAIL_LINK_URL`):
517    /// mints the link a caller clicks to verify an email address.
518    EmailMagicLink,
519}
520
521/// A deployment prerequisite a built-in's calls depend on.
522///
523/// A closed, exhaustively matchable taxonomy of the STATIC, versioned facts a
524/// built-in family needs configured before any call of its own can do
525/// anything, sibling to [`ToolOrigin`] in the same "static shape of the
526/// built-in surface" sense. Carries only the fact, never remedy text: the
527/// copy a person reads about a missing prerequisite belongs to the surface
528/// that renders it (an approval card, a status tool), not to this taxonomy.
529///
530/// Extending the taxonomy means adding a variant here and teaching
531/// `polyc_tools::capability::builtin_requirements` (`crates/tools`) which
532/// built-ins need it, and [`DeploymentCapabilities::is_viable`] which
533/// deployment fact answers it — the harness's `builtin_surface_guard` fails
534/// closed if either side is left out (a requirement no built-in resolves to,
535/// or a built-in a requirement can't classify).
536#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
537pub enum Requirement {
538    /// A usable outbound mail relay (credentials/token valid, not merely a
539    /// relay URL set — a missing URL alone falls back to a hosted default).
540    /// `link_email` needs this to actually deliver a verification message.
541    MailRelay,
542    /// A configured browser-facing ceremony page — see [`Ceremony`].
543    CeremonyPage(Ceremony),
544    /// The outbound-payments proxy: a settlement currency and a way to sign
545    /// (a deployment env signer or a caller's own delegated wallet key).
546    /// `paid_fetch` needs this to ever send a payment.
547    PaymentsProxy,
548}
549
550impl Requirement {
551    /// Every concrete [`Requirement`] value, in declaration order — the
552    /// enumeration [`DeploymentCapabilities::viable_names`] and
553    /// [`Self::from_name`] iterate over, and the harness guard's
554    /// dead-variant check walks.
555    pub const ALL: [Self; 4] = [
556        Self::MailRelay,
557        Self::CeremonyPage(Ceremony::WalletLink),
558        Self::CeremonyPage(Ceremony::EmailMagicLink),
559        Self::PaymentsProxy,
560    ];
561
562    /// Stable kebab-case name, used on the wire
563    /// (`TurnInput.viable_requirements`) and as a telemetry label. Inverse of
564    /// [`Self::from_name`].
565    #[must_use]
566    pub const fn as_str(self) -> &'static str {
567        match self {
568            Self::MailRelay => "mail-relay",
569            Self::CeremonyPage(Ceremony::WalletLink) => "ceremony-page:wallet-link",
570            Self::CeremonyPage(Ceremony::EmailMagicLink) => "ceremony-page:email-magic-link",
571            Self::PaymentsProxy => "payments-proxy",
572        }
573    }
574
575    /// Parse a stable kebab-case name; `None` for anything unrecognized so a
576    /// caller reading the wire fails toward treating the requirement as
577    /// unmet rather than guessing.
578    #[must_use]
579    pub fn from_name(name: &str) -> Option<Self> {
580        Self::ALL.into_iter().find(|r| r.as_str() == name)
581    }
582}
583
584/// The deployment-configuration facts [`Requirement`]s are checked against.
585///
586/// Resolved ONCE at control-plane startup from the same `Option`/`Arc`
587/// configuration each ceremony/proxy already builds for its own use (never a
588/// second, independent env read that could drift from what the ceremony
589/// itself decided), mirroring `ConfiguredBackends` in the harness's
590/// `main.rs`: a struct of independent configuration facts, not states of one
591/// state machine — hence the flat bools below rather than a nested
592/// enum/state-machine shape.
593///
594/// Carried across the control-plane/harness wire as
595/// `TurnInput.viable_requirements` (the [`Requirement::as_str`] names this
596/// deployment satisfies) because the facts live in control-plane-only
597/// configuration (mail relay credentials, ceremony URLs, the payments
598/// signer) the harness sandbox cannot read for itself — the harness never
599/// resolves this struct locally; it reconstructs the viable subset with
600/// [`Self::from_names`] from the wire strings each turn.
601#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
602#[allow(clippy::struct_excessive_bools)] // four INDEPENDENT config facts (see doc above), not a state machine
603pub struct DeploymentCapabilities {
604    /// [`Requirement::MailRelay`] is met.
605    pub mail_relay: bool,
606    /// <code>[Requirement::CeremonyPage]([Ceremony::WalletLink])</code> is met.
607    pub wallet_link_ceremony: bool,
608    /// <code>[Requirement::CeremonyPage]([Ceremony::EmailMagicLink])</code> is met.
609    pub email_magic_link_ceremony: bool,
610    /// [`Requirement::PaymentsProxy`] is met.
611    pub payments_proxy: bool,
612}
613
614impl DeploymentCapabilities {
615    /// Every [`Requirement`] viable — the standalone/dev/test posture, and
616    /// the explicit choice a caller that genuinely does not care about
617    /// deployment viability (a test scoping only `builtin_allow`, the
618    /// in-process whole-conversation test transport) reaches for by name
619    /// instead of hand-listing every field `true`. NOT the type's
620    /// [`Default`] — [`Default`] stays fail-closed (nothing viable), so a
621    /// caller that forgets to thread the real wire-resolved value hides
622    /// every requirement-gated built-in instead of silently over-advertising
623    /// one whose deployment prerequisite is actually unmet.
624    #[must_use]
625    pub const fn all() -> Self {
626        Self {
627            mail_relay: true,
628            wallet_link_ceremony: true,
629            email_magic_link_ceremony: true,
630            payments_proxy: true,
631        }
632    }
633
634    /// Whether this deployment currently satisfies `requirement`.
635    #[must_use]
636    pub const fn is_viable(self, requirement: Requirement) -> bool {
637        match requirement {
638            Requirement::MailRelay => self.mail_relay,
639            Requirement::CeremonyPage(Ceremony::WalletLink) => self.wallet_link_ceremony,
640            Requirement::CeremonyPage(Ceremony::EmailMagicLink) => self.email_magic_link_ceremony,
641            Requirement::PaymentsProxy => self.payments_proxy,
642        }
643    }
644
645    /// Whether every member of `requirements` is viable — the join
646    /// `build_tool_executor` folds into `granted ∩ owned ∩ viable`. An empty
647    /// slice (a built-in with no deployment prerequisite) is always viable.
648    #[must_use]
649    pub fn all_viable(self, requirements: &[Requirement]) -> bool {
650        requirements.iter().all(|r| self.is_viable(*r))
651    }
652
653    /// The stable kebab-case names of every [`Requirement`] this deployment
654    /// satisfies, in [`Requirement::ALL`] order — what the control plane
655    /// puts on the wire. Inverse of [`Self::from_names`].
656    #[must_use]
657    pub fn viable_names(self) -> Vec<&'static str> {
658        Requirement::ALL
659            .into_iter()
660            .filter(|r| self.is_viable(*r))
661            .map(Requirement::as_str)
662            .collect()
663    }
664
665    /// Reconstruct from the wire's stable-name list (`TurnInput.viable_requirements`)
666    /// — the harness's side of [`Self::viable_names`]. An unrecognized name
667    /// (a newer control plane's requirement an older harness doesn't know)
668    /// is silently ignored rather than failing the turn: an unknown
669    /// requirement can never be satisfied by an older binary's `is_viable`
670    /// match anyway, so any built-in that needs it stays hidden either way.
671    #[must_use]
672    pub fn from_names<'a, I: IntoIterator<Item = &'a str>>(names: I) -> Self {
673        let mut caps = Self::default();
674        for name in names {
675            match Requirement::from_name(name) {
676                Some(Requirement::MailRelay) => caps.mail_relay = true,
677                Some(Requirement::CeremonyPage(Ceremony::WalletLink)) => {
678                    caps.wallet_link_ceremony = true;
679                }
680                Some(Requirement::CeremonyPage(Ceremony::EmailMagicLink)) => {
681                    caps.email_magic_link_ceremony = true;
682                }
683                Some(Requirement::PaymentsProxy) => caps.payments_proxy = true,
684                None => {}
685            }
686        }
687        caps
688    }
689}
690
691// ── Grant derivation ──────────────────────────────────────────────────────────
692
693/// Whether untrusted content is in the conversation's context at this gate
694/// decision.
695///
696/// The provenance (taint) input to grant derivation, computed from the
697/// durable seed OR the live transcript scan, never from the turn's own
698/// output.
699#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
700pub enum TaintState {
701    /// No untrusted content in context.
702    #[default]
703    Clean,
704    /// Untrusted content is in context: the granted set loses
705    /// [`TAINT_REVOKED`].
706    Tainted,
707}
708
709/// The agent's configured capability policy — the operator-controlled half of
710/// grant derivation.
711///
712/// The policy comes from cluster config (the Agent custom resource),
713/// unreachable from within a conversation or the request path.
714#[derive(Debug, Clone, Copy, PartialEq, Eq)]
715pub struct GrantPolicy {
716    /// The capabilities the agent's policy grants on a clean context.
717    pub base: CapabilitySet,
718}
719
720impl Default for GrantPolicy {
721    /// Grant everything on a clean context.
722    fn default() -> Self {
723        Self {
724            base: CapabilitySet::all(),
725        }
726    }
727}
728
729/// Derive the capabilities granted to one gate decision from the agent's
730/// policy and the provenance state **at that moment**.
731///
732/// Recomputed per call, because taint can enter mid-turn and must revoke for
733/// the very next call.
734///
735/// The containment rule: taint present ⇒ [`TAINT_REVOKED`] (arbitrary egress
736/// AND external mutation) removed from the granted set. Monotonic under a fixed
737/// policy: adding taint never adds a capability (pinned by test).
738#[must_use]
739pub const fn granted_capabilities(policy: GrantPolicy, taint: TaintState) -> CapabilitySet {
740    match taint {
741        TaintState::Clean => policy.base,
742        TaintState::Tainted => policy.base.difference(TAINT_REVOKED),
743    }
744}
745
746// ── Decision engine ───────────────────────────────────────────────────────────
747
748/// An argument transform the argument-aware dispatch policy asked for, honored
749/// only when the call is otherwise allowed.
750#[derive(Debug, Clone, PartialEq, Eq, Default)]
751pub enum ArgTransform {
752    /// Run the call as proposed.
753    #[default]
754    None,
755    /// Run the call with these replacement arguments.
756    Rewrite(String),
757    /// Prepend this context as an internal-only note before the call runs.
758    InjectContext(String),
759}
760
761/// The per-call policy verdicts [`decide`] folds into the one outcome.
762///
763/// Carries the argument-aware dispatch decision plus the sandbox-escalation
764/// check, already evaluated against the call's arguments by the caller.
765#[derive(Debug, Clone, PartialEq, Eq, Default)]
766pub struct CallPolicy {
767    /// A hard policy veto with its reason: the call is blocked without a
768    /// human prompt and a human approval cannot satisfy it. Takes precedence
769    /// over everything else.
770    pub veto: Option<String>,
771    /// The argument-aware policy (or the tool's intrinsic gate) demands a
772    /// human decision for this call regardless of capabilities.
773    pub requires_human: bool,
774    /// The sandbox would deny this call before any side effect and the
775    /// deployment escalates such denials to a human instead of running into
776    /// the flat denial.
777    pub sandbox_escalation: bool,
778    /// The argument transform to honor when the call is allowed.
779    pub transform: ArgTransform,
780}
781
782/// The single unified gate result for one tool call — replaces both the
783/// argument-aware `ToolDecision` and the `needs_approval`/override booleans.
784#[derive(Debug, Clone, PartialEq, Eq)]
785pub enum GateOutcome {
786    /// Execute the call as proposed.
787    Allow,
788    /// Execute the call with rewritten arguments.
789    Modify(
790        /// The replacement arguments (JSON).
791        String,
792    ),
793    /// Prepend this context as an internal-only note, then execute.
794    InjectContext(
795        /// The note text.
796        String,
797    ),
798    /// Pause for a human decision.
799    Escalate {
800        /// Why, in plain language for the approval card. Empty for an
801        /// ordinary policy/sandbox gate (the edge renders its default
802        /// prompt); non-empty when capabilities are missing.
803        reason: String,
804        /// The required capabilities the call's granted set does not cover.
805        /// Empty when the escalation is a policy/sandbox gate rather than a
806        /// capability shortfall.
807        missing: CapabilitySet,
808    },
809    /// Block the call without a human prompt; the reason is surfaced to the
810    /// model as the tool result so it can adapt.
811    Deny(
812        /// The policy's reason.
813        String,
814    ),
815}
816
817impl GateOutcome {
818    /// Stable lowercase label for telemetry — one counter per gate outcome.
819    #[must_use]
820    pub const fn label(&self) -> &'static str {
821        match self {
822            Self::Allow => "allow",
823            Self::Modify(_) => "modify",
824            Self::InjectContext(_) => "inject_context",
825            Self::Escalate { .. } => "escalate",
826            Self::Deny(_) => "deny",
827        }
828    }
829}
830
831/// The one pure decision: compare what the call requires with what it is
832/// granted, under the argument-aware policy verdicts, and return the single
833/// [`GateOutcome`].
834///
835/// Precedence, pinned by test:
836///
837/// 1. hard policy veto ⇒ [`GateOutcome::Deny`];
838/// 2. required ⊄ granted ⇒ [`GateOutcome::Escalate`] carrying the missing
839///    set and a plain-language reason;
840/// 3. the policy demands a human (argument-aware gate or sandbox-denial
841///    escalation) ⇒ [`GateOutcome::Escalate`] with an empty missing set;
842/// 4. otherwise honor the argument transform or allow.
843///
844/// The engine is pure set algebra over the capability sets — it never
845/// matches on a specific [`Capability`], so extending the taxonomy requires
846/// no change here (pinned by test).
847#[must_use]
848pub fn decide(
849    required: CapabilitySet,
850    granted: CapabilitySet,
851    policy: &CallPolicy,
852    tool_name: &str,
853) -> GateOutcome {
854    if let Some(reason) = &policy.veto {
855        return GateOutcome::Deny(reason.clone());
856    }
857    let missing = required.difference(granted);
858    if !missing.is_empty() {
859        return GateOutcome::Escalate {
860            reason: escalation_reason(tool_name, missing),
861            missing,
862        };
863    }
864    if policy.requires_human || policy.sandbox_escalation {
865        return GateOutcome::Escalate {
866            reason: String::new(),
867            missing: CapabilitySet::EMPTY,
868        };
869    }
870    match &policy.transform {
871        ArgTransform::None => GateOutcome::Allow,
872        ArgTransform::Rewrite(args) => GateOutcome::Modify(args.clone()),
873        ArgTransform::InjectContext(note) => GateOutcome::InjectContext(note.clone()),
874    }
875}
876
877/// The plain-language reason for a missing-capability escalation, rendered
878/// verbatim on the approval card on every edge (one shared helper so the
879/// wording never differs by surface).
880///
881/// User-facing copy: no internal terms, active sentences, honest about risk
882/// without overclaiming. In the current model a capability is only ever
883/// missing because untrusted content entered the conversation (the base
884/// policy grants everything), so the copy names that cause; a future
885/// narrowed base policy reuses the same wording — the access is missing
886/// either way, and the approver's decision is the same.
887///
888/// The `MutateExternal`-missing arms in particular name a possibility being
889/// checked, not a fact about the call: the gate has no way to confirm a call
890/// is read-only here (an unannotated registered tool, or any unregistered
891/// tool falling back to [`CapabilitySet::all`], lands on this arm whether or
892/// not it ever changes anything), so the copy says the check runs before the
893/// tool "could" reach out or mutate, never that it will.
894#[must_use]
895pub fn escalation_reason(tool_name: &str, missing: CapabilitySet) -> String {
896    // The access-grant marker takes precedence: an invite always needs a person
897    // to confirm the exact invitee, in every conversation state, so the wording
898    // is about the grant itself, not about any content the conversation took in.
899    if missing.contains(Capability::GrantAccess) {
900        return format!(
901            "`{tool_name}` would give someone access to Polychrome, so a person needs to \
902             confirm exactly who's being invited before it goes ahead"
903        );
904    }
905    // The revoke-access marker takes the same precedence, for the same reason:
906    // removing someone's access always needs a person to confirm exactly who,
907    // in every conversation state.
908    if missing.contains(Capability::RevokeAccess) {
909        return format!(
910            "`{tool_name}` would remove someone's access to Polychrome, so a person needs to \
911             confirm exactly who's being removed before it goes ahead"
912        );
913    }
914    // The grant-admin marker takes the same precedence: making someone an
915    // admin always needs a person to confirm exactly who, in every
916    // conversation state.
917    if missing.contains(Capability::GrantAdmin) {
918        return format!(
919            "`{tool_name}` would make someone an admin, so a person needs to confirm exactly \
920             who's being given the admin role before it goes ahead"
921        );
922    }
923    // The manage-admin marker takes the same precedence: taking away someone's
924    // admin role always needs a person to confirm exactly whose, in every
925    // conversation state.
926    if missing.contains(Capability::ManageAdmin) {
927        return format!(
928            "`{tool_name}` would take away someone's admin role, so a person needs to confirm \
929             exactly whose role is being removed before it goes ahead"
930        );
931    }
932    let reaches_out = missing.contains(Capability::ArbitraryEgress);
933    let mutates = missing.contains(Capability::MutateExternal);
934    match (reaches_out, mutates) {
935        // Missing `MutateExternal` does not mean `tool_name` mutates — see
936        // this function's doc comment for why.
937        (true, true) => format!(
938            "this conversation has taken in content from outside sources, so `{tool_name}` \
939             needs a quick human check before it could send anything out or change anything \
940             beyond this conversation"
941        ),
942        (true, false) => format!(
943            "this conversation has taken in content from outside sources, so `{tool_name}` \
944             needs a quick human check before it reaches an outside address"
945        ),
946        (false, true) => format!(
947            "this conversation has taken in content from outside sources, so `{tool_name}` \
948             needs a quick human check before it could change anything beyond this conversation"
949        ),
950        (false, false) => format!(
951            "`{tool_name}` needs more access than this conversation currently has, so a \
952             human check is needed first"
953        ),
954    }
955}
956
957#[cfg(test)]
958mod tests;