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// ── Deployment viability (#1415) ────────────────────────────────────────────────
461
462/// A browser-facing ceremony page a built-in mints a one-time link to.
463///
464/// Closed and small on purpose: only ceremonies that actually gate a
465/// chat-callable built-in belong here. `wallet-signup` (new-account creation)
466/// and `wallet-passkey-login` (the web app's own sign-in) are real deployment
467/// ceremonies too, but neither one backs any built-in tool call — they are
468/// pure browser flows a person reaches directly, never something the model
469/// mints a link to — so adding them here would leave a [`Requirement`]
470/// variant no built-in ever resolves to, which the harness's dead-variant
471/// guard exists to catch.
472#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
473pub enum Ceremony {
474 /// The wallet-link card (`POLYCHROME_WALLET_LINK_URL`): mints the link a
475 /// caller signs to attach an external wallet, and the shared page the
476 /// spending-limit-update and hard-revoke ceremonies reuse.
477 WalletLink,
478 /// The email-verification magic-link page (`POLYCHROME_EMAIL_LINK_URL`):
479 /// mints the link a caller clicks to verify an email address.
480 EmailMagicLink,
481}
482
483/// A deployment prerequisite a built-in's calls depend on.
484///
485/// A closed, exhaustively matchable taxonomy of the STATIC, versioned facts a
486/// built-in family needs configured before any call of its own can do
487/// anything, sibling to [`ToolOrigin`] in the same "static shape of the
488/// built-in surface" sense. Carries only the fact, never remedy text: the
489/// copy a person reads about a missing prerequisite belongs to the surface
490/// that renders it (an approval card, a status tool), not to this taxonomy.
491///
492/// Extending the taxonomy means adding a variant here and teaching
493/// `polyc_tools::capability::builtin_requirements` (`crates/tools`) which
494/// built-ins need it, and [`DeploymentCapabilities::is_viable`] which
495/// deployment fact answers it — the harness's `builtin_surface_guard` fails
496/// closed if either side is left out (a requirement no built-in resolves to,
497/// or a built-in a requirement can't classify).
498#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
499pub enum Requirement {
500 /// A usable outbound mail relay (credentials/token valid, not merely a
501 /// relay URL set — a missing URL alone falls back to a hosted default).
502 /// `link_email` needs this to actually deliver a verification message.
503 MailRelay,
504 /// A configured browser-facing ceremony page — see [`Ceremony`].
505 CeremonyPage(Ceremony),
506 /// The outbound-payments proxy: a settlement currency and a way to sign
507 /// (a deployment env signer or a caller's own delegated wallet key).
508 /// `paid_fetch` needs this to ever send a payment.
509 PaymentsProxy,
510 /// The persona-credential (mandate-signing passkey) enrollment ceremony
511 /// — what the persona passkey `persona_credential_setup` mints actually
512 /// buys: the ability for a later signature to authorize spend on the
513 /// caller's behalf.
514 MandateIssuer,
515}
516
517impl Requirement {
518 /// Every concrete [`Requirement`] value, in declaration order — the
519 /// enumeration [`DeploymentCapabilities::viable_names`] and
520 /// [`Self::from_name`] iterate over, and the harness guard's
521 /// dead-variant check walks.
522 pub const ALL: [Self; 5] = [
523 Self::MailRelay,
524 Self::CeremonyPage(Ceremony::WalletLink),
525 Self::CeremonyPage(Ceremony::EmailMagicLink),
526 Self::PaymentsProxy,
527 Self::MandateIssuer,
528 ];
529
530 /// Stable kebab-case name, used on the wire
531 /// (`TurnInput.viable_requirements`) and as a telemetry label. Inverse of
532 /// [`Self::from_name`].
533 #[must_use]
534 pub const fn as_str(self) -> &'static str {
535 match self {
536 Self::MailRelay => "mail-relay",
537 Self::CeremonyPage(Ceremony::WalletLink) => "ceremony-page:wallet-link",
538 Self::CeremonyPage(Ceremony::EmailMagicLink) => "ceremony-page:email-magic-link",
539 Self::PaymentsProxy => "payments-proxy",
540 Self::MandateIssuer => "mandate-issuer",
541 }
542 }
543
544 /// Parse a stable kebab-case name; `None` for anything unrecognized so a
545 /// caller reading the wire fails toward treating the requirement as
546 /// unmet rather than guessing.
547 #[must_use]
548 pub fn from_name(name: &str) -> Option<Self> {
549 Self::ALL.into_iter().find(|r| r.as_str() == name)
550 }
551}
552
553/// The deployment-configuration facts [`Requirement`]s are checked against.
554///
555/// Resolved ONCE at control-plane startup from the same `Option`/`Arc`
556/// configuration each ceremony/proxy already builds for its own use (never a
557/// second, independent env read that could drift from what the ceremony
558/// itself decided), mirroring `ConfiguredBackends` in the harness's
559/// `main.rs`: a struct of independent configuration facts, not states of one
560/// state machine — hence the flat bools below rather than a nested
561/// enum/state-machine shape.
562///
563/// Carried across the control-plane/harness wire as
564/// `TurnInput.viable_requirements` (the [`Requirement::as_str`] names this
565/// deployment satisfies) because the facts live in control-plane-only
566/// configuration (mail relay credentials, ceremony URLs, the payments
567/// signer) the harness sandbox cannot read for itself — the harness never
568/// resolves this struct locally; it reconstructs the viable subset with
569/// [`Self::from_names`] from the wire strings each turn.
570#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
571#[allow(clippy::struct_excessive_bools)] // five INDEPENDENT config facts (see doc above), not a state machine
572pub struct DeploymentCapabilities {
573 /// [`Requirement::MailRelay`] is met.
574 pub mail_relay: bool,
575 /// <code>[Requirement::CeremonyPage]([Ceremony::WalletLink])</code> is met.
576 pub wallet_link_ceremony: bool,
577 /// <code>[Requirement::CeremonyPage]([Ceremony::EmailMagicLink])</code> is met.
578 pub email_magic_link_ceremony: bool,
579 /// [`Requirement::PaymentsProxy`] is met.
580 pub payments_proxy: bool,
581 /// [`Requirement::MandateIssuer`] is met.
582 pub mandate_issuer: bool,
583}
584
585impl DeploymentCapabilities {
586 /// Every [`Requirement`] viable — the standalone/dev/test posture, and
587 /// the explicit choice a caller that genuinely does not care about
588 /// deployment viability (a test scoping only `builtin_allow`, the
589 /// in-process whole-conversation test transport) reaches for by name
590 /// instead of hand-listing every field `true`. NOT the type's
591 /// [`Default`] — [`Default`] stays fail-closed (nothing viable), so a
592 /// caller that forgets to thread the real wire-resolved value hides
593 /// every requirement-gated built-in instead of silently over-advertising
594 /// one whose deployment prerequisite is actually unmet.
595 #[must_use]
596 pub const fn all() -> Self {
597 Self {
598 mail_relay: true,
599 wallet_link_ceremony: true,
600 email_magic_link_ceremony: true,
601 payments_proxy: true,
602 mandate_issuer: true,
603 }
604 }
605
606 /// Whether this deployment currently satisfies `requirement`.
607 #[must_use]
608 pub const fn is_viable(self, requirement: Requirement) -> bool {
609 match requirement {
610 Requirement::MailRelay => self.mail_relay,
611 Requirement::CeremonyPage(Ceremony::WalletLink) => self.wallet_link_ceremony,
612 Requirement::CeremonyPage(Ceremony::EmailMagicLink) => self.email_magic_link_ceremony,
613 Requirement::PaymentsProxy => self.payments_proxy,
614 Requirement::MandateIssuer => self.mandate_issuer,
615 }
616 }
617
618 /// Whether every member of `requirements` is viable — the join
619 /// `build_tool_executor` folds into `granted ∩ owned ∩ viable`. An empty
620 /// slice (a built-in with no deployment prerequisite) is always viable.
621 #[must_use]
622 pub fn all_viable(self, requirements: &[Requirement]) -> bool {
623 requirements.iter().all(|r| self.is_viable(*r))
624 }
625
626 /// The stable kebab-case names of every [`Requirement`] this deployment
627 /// satisfies, in [`Requirement::ALL`] order — what the control plane
628 /// puts on the wire. Inverse of [`Self::from_names`].
629 #[must_use]
630 pub fn viable_names(self) -> Vec<&'static str> {
631 Requirement::ALL
632 .into_iter()
633 .filter(|r| self.is_viable(*r))
634 .map(Requirement::as_str)
635 .collect()
636 }
637
638 /// Reconstruct from the wire's stable-name list (`TurnInput.viable_requirements`)
639 /// — the harness's side of [`Self::viable_names`]. An unrecognized name
640 /// (a newer control plane's requirement an older harness doesn't know)
641 /// is silently ignored rather than failing the turn: an unknown
642 /// requirement can never be satisfied by an older binary's `is_viable`
643 /// match anyway, so any built-in that needs it stays hidden either way.
644 #[must_use]
645 pub fn from_names<'a, I: IntoIterator<Item = &'a str>>(names: I) -> Self {
646 let mut caps = Self::default();
647 for name in names {
648 match Requirement::from_name(name) {
649 Some(Requirement::MailRelay) => caps.mail_relay = true,
650 Some(Requirement::CeremonyPage(Ceremony::WalletLink)) => {
651 caps.wallet_link_ceremony = true;
652 }
653 Some(Requirement::CeremonyPage(Ceremony::EmailMagicLink)) => {
654 caps.email_magic_link_ceremony = true;
655 }
656 Some(Requirement::PaymentsProxy) => caps.payments_proxy = true,
657 Some(Requirement::MandateIssuer) => caps.mandate_issuer = true,
658 None => {}
659 }
660 }
661 caps
662 }
663}
664
665// ── Grant derivation ──────────────────────────────────────────────────────────
666
667/// Whether untrusted content is in the conversation's context at this gate
668/// decision.
669///
670/// The provenance (taint) input to grant derivation, computed from the
671/// durable seed OR the live transcript scan, never from the turn's own
672/// output.
673#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
674pub enum TaintState {
675 /// No untrusted content in context.
676 #[default]
677 Clean,
678 /// Untrusted content is in context: the granted set loses
679 /// [`TAINT_REVOKED`] except what the policy declares taint-resilient.
680 Tainted,
681}
682
683/// The agent's configured capability policy — the operator-controlled half of
684/// grant derivation.
685///
686/// Both fields come from cluster config (the Agent custom resource),
687/// unreachable from within a conversation or the request path.
688#[derive(Debug, Clone, Copy, PartialEq, Eq)]
689pub struct GrantPolicy {
690 /// The capabilities the agent's policy grants on a clean context.
691 pub base: CapabilitySet,
692 /// Operator-declared capabilities that survive the taint subtraction for
693 /// this agent's conversations (the generalization of the trusted-egress
694 /// flag: how an unattended routine keeps posting after it reads).
695 /// Default empty. Never grants beyond [`Self::base`].
696 pub taint_resilient: CapabilitySet,
697}
698
699impl Default for GrantPolicy {
700 /// Grant everything on a clean context, nothing taint-resilient — the
701 /// posture of every conversation without an operator declaration.
702 fn default() -> Self {
703 Self {
704 base: CapabilitySet::all(),
705 taint_resilient: CapabilitySet::EMPTY,
706 }
707 }
708}
709
710/// Derive the capabilities granted to one gate decision from the agent's
711/// policy and the provenance state **at that moment**.
712///
713/// Recomputed per call, because taint can enter mid-turn and must revoke for
714/// the very next call.
715///
716/// The containment rule: taint present ⇒ [`TAINT_REVOKED`] (arbitrary egress
717/// AND external mutation) removed from the granted set; the operator-declared
718/// taint-resilient set survives the subtraction, but never grants beyond the
719/// base. Monotonic under a fixed policy: adding taint never adds a
720/// capability (pinned by test).
721#[must_use]
722pub const fn granted_capabilities(policy: GrantPolicy, taint: TaintState) -> CapabilitySet {
723 match taint {
724 TaintState::Clean => policy.base,
725 TaintState::Tainted => {
726 let survivors = policy.taint_resilient.intersection(TAINT_REVOKED);
727 policy
728 .base
729 .difference(TAINT_REVOKED)
730 .union(policy.base.intersection(survivors))
731 }
732 }
733}
734
735// ── Decision engine ───────────────────────────────────────────────────────────
736
737/// An argument transform the argument-aware dispatch policy asked for, honored
738/// only when the call is otherwise allowed.
739#[derive(Debug, Clone, PartialEq, Eq, Default)]
740pub enum ArgTransform {
741 /// Run the call as proposed.
742 #[default]
743 None,
744 /// Run the call with these replacement arguments.
745 Rewrite(String),
746 /// Prepend this context as an internal-only note before the call runs.
747 InjectContext(String),
748}
749
750/// The per-call policy verdicts [`decide`] folds into the one outcome.
751///
752/// Carries the argument-aware dispatch decision plus the sandbox-escalation
753/// check, already evaluated against the call's arguments by the caller.
754#[derive(Debug, Clone, PartialEq, Eq, Default)]
755pub struct CallPolicy {
756 /// A hard policy veto with its reason: the call is blocked without a
757 /// human prompt and a human approval cannot satisfy it. Takes precedence
758 /// over everything else.
759 pub veto: Option<String>,
760 /// The argument-aware policy (or the tool's intrinsic gate) demands a
761 /// human decision for this call regardless of capabilities.
762 pub requires_human: bool,
763 /// The sandbox would deny this call before any side effect and the
764 /// deployment escalates such denials to a human instead of running into
765 /// the flat denial.
766 pub sandbox_escalation: bool,
767 /// The argument transform to honor when the call is allowed.
768 pub transform: ArgTransform,
769}
770
771/// The single unified gate result for one tool call — replaces both the
772/// argument-aware `ToolDecision` and the `needs_approval`/override booleans.
773#[derive(Debug, Clone, PartialEq, Eq)]
774pub enum GateOutcome {
775 /// Execute the call as proposed.
776 Allow,
777 /// Execute the call with rewritten arguments.
778 Modify(
779 /// The replacement arguments (JSON).
780 String,
781 ),
782 /// Prepend this context as an internal-only note, then execute.
783 InjectContext(
784 /// The note text.
785 String,
786 ),
787 /// Pause for a human decision.
788 Escalate {
789 /// Why, in plain language for the approval card. Empty for an
790 /// ordinary policy/sandbox gate (the edge renders its default
791 /// prompt); non-empty when capabilities are missing.
792 reason: String,
793 /// The required capabilities the call's granted set does not cover.
794 /// Empty when the escalation is a policy/sandbox gate rather than a
795 /// capability shortfall.
796 missing: CapabilitySet,
797 },
798 /// Block the call without a human prompt; the reason is surfaced to the
799 /// model as the tool result so it can adapt.
800 Deny(
801 /// The policy's reason.
802 String,
803 ),
804}
805
806impl GateOutcome {
807 /// Stable lowercase label for telemetry — one counter per gate outcome.
808 #[must_use]
809 pub const fn label(&self) -> &'static str {
810 match self {
811 Self::Allow => "allow",
812 Self::Modify(_) => "modify",
813 Self::InjectContext(_) => "inject_context",
814 Self::Escalate { .. } => "escalate",
815 Self::Deny(_) => "deny",
816 }
817 }
818}
819
820/// The one pure decision: compare what the call requires with what it is
821/// granted, under the argument-aware policy verdicts, and return the single
822/// [`GateOutcome`].
823///
824/// Precedence, pinned by test:
825///
826/// 1. hard policy veto ⇒ [`GateOutcome::Deny`];
827/// 2. required ⊄ granted ⇒ [`GateOutcome::Escalate`] carrying the missing
828/// set and a plain-language reason;
829/// 3. the policy demands a human (argument-aware gate or sandbox-denial
830/// escalation) ⇒ [`GateOutcome::Escalate`] with an empty missing set;
831/// 4. otherwise honor the argument transform or allow.
832///
833/// The engine is pure set algebra over the capability sets — it never
834/// matches on a specific [`Capability`], so extending the taxonomy requires
835/// no change here (pinned by test).
836#[must_use]
837pub fn decide(
838 required: CapabilitySet,
839 granted: CapabilitySet,
840 policy: &CallPolicy,
841 tool_name: &str,
842) -> GateOutcome {
843 if let Some(reason) = &policy.veto {
844 return GateOutcome::Deny(reason.clone());
845 }
846 let missing = required.difference(granted);
847 if !missing.is_empty() {
848 return GateOutcome::Escalate {
849 reason: escalation_reason(tool_name, missing),
850 missing,
851 };
852 }
853 if policy.requires_human || policy.sandbox_escalation {
854 return GateOutcome::Escalate {
855 reason: String::new(),
856 missing: CapabilitySet::EMPTY,
857 };
858 }
859 match &policy.transform {
860 ArgTransform::None => GateOutcome::Allow,
861 ArgTransform::Rewrite(args) => GateOutcome::Modify(args.clone()),
862 ArgTransform::InjectContext(note) => GateOutcome::InjectContext(note.clone()),
863 }
864}
865
866/// The plain-language reason for a missing-capability escalation, rendered
867/// verbatim on the approval card on every edge (one shared helper so the
868/// wording never differs by surface).
869///
870/// User-facing copy: no internal terms, active sentences, honest about risk
871/// without overclaiming. In the current model a capability is only ever
872/// missing because untrusted content entered the conversation (the base
873/// policy grants everything), so the copy names that cause; a future
874/// narrowed base policy reuses the same wording — the access is missing
875/// either way, and the approver's decision is the same.
876///
877/// The `MutateExternal`-missing arms in particular name a possibility being
878/// checked, not a fact about the call: the gate has no way to confirm a call
879/// is read-only here (an unannotated registered tool, or any unregistered
880/// tool falling back to [`CapabilitySet::all`], lands on this arm whether or
881/// not it ever changes anything), so the copy says the check runs before the
882/// tool "could" reach out or mutate, never that it will.
883#[must_use]
884pub fn escalation_reason(tool_name: &str, missing: CapabilitySet) -> String {
885 // The access-grant marker takes precedence: an invite always needs a person
886 // to confirm the exact invitee, in every conversation state, so the wording
887 // is about the grant itself, not about any content the conversation took in.
888 if missing.contains(Capability::GrantAccess) {
889 return format!(
890 "`{tool_name}` would give someone access to Polychrome, so a person needs to \
891 confirm exactly who's being invited before it goes ahead"
892 );
893 }
894 // The revoke-access marker takes the same precedence, for the same reason:
895 // removing someone's access always needs a person to confirm exactly who,
896 // in every conversation state.
897 if missing.contains(Capability::RevokeAccess) {
898 return format!(
899 "`{tool_name}` would remove someone's access to Polychrome, so a person needs to \
900 confirm exactly who's being removed before it goes ahead"
901 );
902 }
903 // The manage-admin marker takes the same precedence: taking away someone's
904 // admin role always needs a person to confirm exactly whose, in every
905 // conversation state.
906 if missing.contains(Capability::ManageAdmin) {
907 return format!(
908 "`{tool_name}` would take away someone's admin role, so a person needs to confirm \
909 exactly whose role is being removed before it goes ahead"
910 );
911 }
912 let reaches_out = missing.contains(Capability::ArbitraryEgress);
913 let mutates = missing.contains(Capability::MutateExternal);
914 match (reaches_out, mutates) {
915 // Missing `MutateExternal` does not mean `tool_name` mutates — see
916 // this function's doc comment for why.
917 (true, true) => format!(
918 "this conversation has taken in content from outside sources, so `{tool_name}` \
919 needs a quick human check before it could send anything out or change anything \
920 beyond this conversation"
921 ),
922 (true, false) => format!(
923 "this conversation has taken in content from outside sources, so `{tool_name}` \
924 needs a quick human check before it reaches an outside address"
925 ),
926 (false, true) => format!(
927 "this conversation has taken in content from outside sources, so `{tool_name}` \
928 needs a quick human check before it could change anything beyond this conversation"
929 ),
930 (false, false) => format!(
931 "`{tool_name}` needs more access than this conversation currently has, so a \
932 human check is needed first"
933 ),
934 }
935}
936
937#[cfg(test)]
938mod tests;