agent_engine/tools/activation.rs
1//! `SessionToolSet` — per-session core + exact activated tools (Task 15,
2//! spec §7.1, §4.2).
3//!
4//! This is bookkeeping only, not yet an execution gate: it records which
5//! configured core tools and which exactly-activated deferred tools belong to
6//! one session, pinned to the catalog generation and schema digests they were
7//! granted against. It never issues model-facing activations, exposes
8//! schemas, initializes implementations, or enforces execution.
9//!
10//! Isolation invariants:
11//! - a new session's set starts with zero activations and inherits nothing
12//! from any other set;
13//! - activation requires an exact [`SessionActivationGrant`] for this
14//! session, tool, current catalog generation, and current schema digest;
15//! - every rejection is typed and leaves the set unchanged (no partial
16//! mutation);
17//! - catalog drift is represented (`is_stale`), never silently absorbed.
18
19use std::collections::BTreeMap;
20use std::sync::Arc;
21
22use agent_core::BoundedText;
23use thiserror::Error;
24
25use super::catalog::{
26 CapabilityRecord, CapabilitySource, CatalogGeneration, SchemaDigest, SessionActivationGrant,
27 ToolCatalog, ToolEffect, ToolId, TrustProvenance,
28};
29use super::{Tool, ToolRegistry};
30
31pub use agent_core::orchestration::capability::{SessionId, SessionIdError};
32
33/// Runtime-lease placeholder metadata (spec §7.4 territory). Real lease
34/// acquisition/expiry arrives with MCP/extension activation work; until then
35/// every activation records that no runtime has been acquired.
36#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
37#[serde(rename_all = "snake_case")]
38pub enum RuntimeLease {
39 NotAcquired,
40}
41
42/// One exact activation held by a session: the validated grant plus lease
43/// placeholder metadata. The grant carries the pinned catalog generation and
44/// schema digest; the trust provenance of the catalog record is pinned
45/// engine-side at activation time (the grant type in agent-core is
46/// deliberately untouched) so execution can detect a record that was
47/// removed and re-added under a different — even internally coherent —
48/// source/provenance.
49#[derive(Clone, Debug)]
50pub struct ActivatedTool {
51 grant: SessionActivationGrant,
52 provenance: TrustProvenance,
53 lease: RuntimeLease,
54}
55
56impl ActivatedTool {
57 pub fn grant(&self) -> &SessionActivationGrant {
58 &self.grant
59 }
60
61 pub fn schema_digest(&self) -> &SchemaDigest {
62 self.grant.schema_digest()
63 }
64
65 /// The trust provenance of the catalog record at activation time.
66 pub fn provenance(&self) -> &TrustProvenance {
67 &self.provenance
68 }
69
70 pub fn catalog_generation(&self) -> CatalogGeneration {
71 self.grant.catalog_generation()
72 }
73
74 pub fn lease(&self) -> RuntimeLease {
75 self.lease
76 }
77}
78
79/// Typed failure for [`SessionToolSet::revoke_exact`].
80#[derive(Clone, Debug, Error, PartialEq, Eq)]
81pub enum ExactRevocationError {
82 #[error("cannot revoke a configured core tool: {0}")]
83 CoreTool(ToolId),
84 #[error("tool is not activated in this session: {0}")]
85 NotActivated(ToolId),
86}
87
88/// Typed failure for constructing a [`SessionToolSet`].
89#[derive(Clone, Debug, Error, PartialEq, Eq)]
90pub enum SessionToolSetError {
91 #[error("configured core tool is not in the catalog: {0}")]
92 UnknownCoreTool(ToolId),
93}
94
95/// Typed failure for activating a tool in a [`SessionToolSet`]. Every
96/// variant fails closed without mutating the set.
97#[derive(Clone, Debug, Error, PartialEq, Eq)]
98pub enum ActivationError {
99 #[error("activation grant was issued for another session")]
100 SessionMismatch,
101 #[error("activation grant names a tool the catalog does not know: {0}")]
102 UnknownTool(ToolId),
103 #[error(
104 "activation grant generation {} does not match current catalog generation {}",
105 grant.value(),
106 catalog.value()
107 )]
108 StaleGeneration {
109 grant: CatalogGeneration,
110 catalog: CatalogGeneration,
111 },
112 #[error("activation grant schema digest does not match the catalog record for {0}")]
113 DigestMismatch(ToolId),
114 #[error("tool is already activated for this session: {0}")]
115 AlreadyActivated(ToolId),
116 #[error("tool is already in this session's configured core set: {0}")]
117 AlreadyCore(ToolId),
118 #[error(
119 "session tool set snapshot at generation {} is stale against catalog generation {}; rebuild it",
120 set.value(),
121 catalog.value()
122 )]
123 StaleSnapshot {
124 set: CatalogGeneration,
125 catalog: CatalogGeneration,
126 },
127}
128
129/// Per-tool pins captured when a core tool enters a session's set: the
130/// schema digest AND the trust provenance of the catalog record at build
131/// time. Pinning provenance (not just the digest) closes the coherent-
132/// imposter hole: a tool removed and re-added under the SAME `ToolId` with a
133/// schema-identical body but a different, internally consistent
134/// source/provenance must still be denied at execution time.
135#[derive(Clone, Debug, PartialEq, Eq)]
136pub struct CorePin {
137 schema_digest: SchemaDigest,
138 provenance: TrustProvenance,
139}
140
141impl CorePin {
142 pub fn schema_digest(&self) -> &SchemaDigest {
143 &self.schema_digest
144 }
145
146 pub fn provenance(&self) -> &TrustProvenance {
147 &self.provenance
148 }
149}
150
151/// Why an activation was not carried across a round-top rebuild.
152#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
153#[serde(rename_all = "snake_case")]
154pub enum DropReason {
155 /// The tool is no longer in the catalog.
156 Removed,
157 /// The record's schema digest or trust provenance changed.
158 Drifted,
159}
160
161/// One activation dropped by [`SessionToolSet::rebuilt_for_catalog`].
162#[derive(Clone, Debug, PartialEq, Eq)]
163pub struct DroppedActivation {
164 pub id: ToolId,
165 pub reason: DropReason,
166}
167
168/// Kill-switch for carry-forward: `SYNAPS_TOOLSET_CARRY_FORWARD=0` restores
169/// the zero-inherit rebuild (every catalog mutation wipes activations).
170pub fn carry_forward_enabled() -> bool {
171 std::env::var("SYNAPS_TOOLSET_CARRY_FORWARD").as_deref() != Ok("0")
172}
173
174/// One exact activation as plain, serializable data: what a session hands
175/// to its next runtime across a daemon reload (`SessionReloadRecord`) or a
176/// park. It carries the PINS the activation was granted against — never a
177/// grant: the next runtime has a new tool-session id and re-issues a grant
178/// only after [`SessionToolSet::restore_carried`] re-validated the pins
179/// against its own catalog. Untrusted on the way back in (it round-trips
180/// through a file), which is why every field is re-checked, not believed.
181#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
182pub struct CarriedActivation {
183 /// Canonical `ToolId` (`namespace:name`).
184 pub tool_id: String,
185 /// Lowercase hex schema digest pinned at activation time.
186 pub schema_digest: String,
187 /// Trust provenance pinned at activation time.
188 pub provenance: CarriedProvenance,
189}
190
191/// Serializable mirror of [`TrustProvenance`] for [`CarriedActivation`].
192/// Only ever COMPARED with the current catalog record's provenance; never
193/// converted back into a `TrustProvenance`, so wire data cannot mint one.
194#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
195#[serde(tag = "kind", rename_all = "snake_case")]
196pub enum CarriedProvenance {
197 BuiltinRuntime,
198 ExtensionManifest { extension_id: String },
199 McpConfig { server_id: String },
200 PluginConfig { plugin_id: String },
201 Unverified,
202}
203
204impl From<&TrustProvenance> for CarriedProvenance {
205 fn from(p: &TrustProvenance) -> Self {
206 match p {
207 TrustProvenance::BuiltinRuntime => Self::BuiltinRuntime,
208 TrustProvenance::ExtensionManifest { extension_id } => Self::ExtensionManifest {
209 extension_id: extension_id.clone(),
210 },
211 TrustProvenance::McpConfig { server_id } => Self::McpConfig {
212 server_id: server_id.clone(),
213 },
214 TrustProvenance::PluginConfig { plugin_id } => Self::PluginConfig {
215 plugin_id: plugin_id.clone(),
216 },
217 TrustProvenance::Unverified => Self::Unverified,
218 }
219 }
220}
221
222/// Why a carried activation was not restored.
223#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
224#[serde(rename_all = "snake_case")]
225pub enum RestoreDropReason {
226 /// Not in the current catalog (or not a valid `ToolId`).
227 Removed,
228 /// Schema digest, trust provenance or source trust changed.
229 Drifted,
230 /// `tools.activation_confirm = deny` in the restoring runtime.
231 Denied,
232 /// The target set was not built against the current catalog.
233 StaleSet,
234}
235
236/// Outcome of [`SessionToolSet::restore_carried`]: what was restored and
237/// what was dropped (raw id + reason), for logging and the session notice.
238#[derive(Clone, Debug, Default, PartialEq, Eq)]
239pub struct RestoreReport {
240 pub restored: Vec<ToolId>,
241 pub dropped: Vec<(String, RestoreDropReason)>,
242}
243
244/// The small configured core set plus exact activated deferred tools for one
245/// session, pinned to the catalog generation it was built against. Core
246/// tools are pinned with the schema digest AND trust provenance of their
247/// catalog record at build time, so later drift is detectable per tool, not
248/// just per generation.
249#[derive(Clone, Debug)]
250pub struct SessionToolSet {
251 session: SessionId,
252 catalog_generation: CatalogGeneration,
253 core: BTreeMap<ToolId, CorePin>,
254 activated: BTreeMap<ToolId, ActivatedTool>,
255 /// Session schema-generation counter (spec §7.7): advances by exactly
256 /// one for every successful nonempty activation batch (a single
257 /// activation is a batch of one). Deterministic bookkeeping only; it
258 /// exposes nothing and grants nothing.
259 schema_generation: u64,
260}
261
262impl SessionToolSet {
263 /// Build a fresh set for one session. Every configured core id must
264 /// exist in the catalog (typed failure otherwise) and its schema digest
265 /// AND trust provenance are pinned from the catalog record; the set
266 /// starts with zero activations — nothing is inherited from any other
267 /// session.
268 pub fn new(
269 session: SessionId,
270 core: impl IntoIterator<Item = ToolId>,
271 catalog: &ToolCatalog,
272 ) -> Result<Self, SessionToolSetError> {
273 let mut validated = BTreeMap::new();
274 for id in core {
275 let Some(record) = catalog.get(&id) else {
276 return Err(SessionToolSetError::UnknownCoreTool(id));
277 };
278 validated.insert(
279 id,
280 CorePin {
281 schema_digest: record.schema_digest().clone(),
282 provenance: record.provenance().clone(),
283 },
284 );
285 }
286 Ok(Self {
287 session,
288 catalog_generation: catalog.generation(),
289 core: validated,
290 activated: BTreeMap::new(),
291 schema_generation: 0,
292 })
293 }
294
295 pub fn session(&self) -> &SessionId {
296 &self.session
297 }
298
299 /// Default per-stream-session set (Task 16 behavior preservation): the
300 /// core set is EXACTLY every currently cataloged capability with
301 /// verified provenance — builtins plus already-loaded trusted
302 /// extension/MCP/plugin tools. `Unverified` capabilities are excluded
303 /// (denied-by-default at the gate), and the set carries zero
304 /// activations. Deterministic: ids come straight from the catalog in
305 /// `ToolId` order, so rebuilding after a catalog mutation yields the
306 /// same set for the same catalog state.
307 pub fn default_core_for_catalog(session: SessionId, catalog: &ToolCatalog) -> Self {
308 let core: Vec<ToolId> = catalog
309 .iter()
310 .filter(|record| record.provenance() != &TrustProvenance::Unverified)
311 .map(|record| record.id().clone())
312 .collect();
313 Self::new(session, core, catalog)
314 .expect("core ids are drawn from the catalog itself and must all resolve")
315 }
316
317 /// Deterministic minimal core for Task 18 progressive disclosure. Only
318 /// essential local operations plus discovery/authorization gateways are
319 /// eligible; missing/disabled gateways are skipped rather than invented.
320 /// Specialized subagent lifecycle, extension, MCP, shell-session, and
321 /// dynamically registered tools remain deferred until exact activation.
322 pub fn progressive_core_for_catalog(session: SessionId, catalog: &ToolCatalog) -> Self {
323 const ESSENTIAL_BUILTINS: &[&str] = &[
324 "bash",
325 "read",
326 "write",
327 "edit",
328 "multi_edit",
329 "grep",
330 "find",
331 "ls",
332 "search_tools",
333 "activate_tools",
334 "search_skills",
335 "load_skill",
336 ];
337 let core = ESSENTIAL_BUILTINS
338 .iter()
339 .map(|name| ToolId::builtin(name))
340 .filter(|id| catalog.get(id).is_some())
341 .collect::<Vec<_>>();
342 Self::new(session, core, catalog)
343 .expect("progressive core ids are filtered through the catalog")
344 }
345
346 /// Round-top rebuild against a NEWER catalog (`runtime/stream.rs`):
347 /// the core is re-derived exactly as a fresh build would derive it, and
348 /// every prior exact activation whose CURRENT catalog record still
349 /// matches its pinned (schema digest, trust provenance) is re-issued a
350 /// grant at the new generation. Activations whose record is gone or
351 /// drifted are dropped and reported.
352 ///
353 /// This is NOT a gate change: a carried activation is exactly what a
354 /// user re-activation of the same tool would produce against this
355 /// catalog, and `ExecutionGate::authorize` still checks digest and
356 /// provenance against the current record on every call.
357 pub fn rebuilt_for_catalog(
358 &self,
359 catalog: &ToolCatalog,
360 progressive: bool,
361 ) -> (Self, Vec<DroppedActivation>) {
362 let next = if progressive {
363 Self::progressive_core_for_catalog(self.session.clone(), catalog)
364 } else {
365 Self::default_core_for_catalog(self.session.clone(), catalog)
366 };
367 self.carry_activations_into(next, catalog)
368 }
369
370 /// Carry this set's exact activations into `next` — a freshly built core
371 /// for the SAME session against `catalog` — with exactly the checks
372 /// [`Self::rebuilt_for_catalog`] applies (pinned digest + provenance
373 /// unchanged and source trust intact; promoted-to-core ids are not
374 /// carried; drifted/removed ones are dropped and reported). Shared by the
375 /// round-top rebuild and the turn-start reconciliation of a retained
376 /// set, so the two paths can never drift.
377 ///
378 /// A `next` built for a different session inherits nothing: grants never
379 /// cross sessions.
380 pub fn carry_activations_into(
381 &self,
382 mut next: SessionToolSet,
383 catalog: &ToolCatalog,
384 ) -> (Self, Vec<DroppedActivation>) {
385 if next.session != self.session {
386 return (next, Vec::new());
387 }
388 let mut dropped = Vec::new();
389 for old in self.activated.values() {
390 let id = old.grant().tool_id().clone();
391 if let Err(reason) = next.admit_pinned(&id, catalog, |rec| {
392 rec.schema_digest() == old.schema_digest() && rec.provenance() == old.provenance()
393 }) {
394 dropped.push(DroppedActivation { id, reason });
395 }
396 }
397 // Session schema-generation is bookkeeping for "activation batches
398 // applied"; carrying is not a batch: keep the old counter, +1 iff
399 // anything was dropped (the exposed schema changed).
400 next.schema_generation = self.schema_generation + u64::from(!dropped.is_empty());
401 (next, dropped)
402 }
403
404 /// Admit one prior activation of THIS session into this set against
405 /// `catalog` — the single check shared by turn-to-turn carrying
406 /// ([`Self::carry_activations_into`]) and cross-image restore
407 /// ([`Self::restore_carried`]), so the two can never drift:
408 ///
409 /// - uncataloged → `Removed`;
410 /// - already core → `Ok` (still on the wire, nothing to record);
411 /// - `pin_matches(record)` (the pinned schema digest AND trust
412 /// provenance are unchanged) AND the record's source still agrees with
413 /// its provenance (`check_source_trust`, as `activate_many`) → a grant
414 /// at the CURRENT catalog generation is recorded;
415 /// - anything else → `Drifted`, never silently re-granted.
416 ///
417 /// Already-activated ids are left as they are (`Ok`). Does not touch the
418 /// schema-generation counter; callers own that bookkeeping.
419 fn admit_pinned(
420 &mut self,
421 id: &ToolId,
422 catalog: &ToolCatalog,
423 pin_matches: impl FnOnce(&CapabilityRecord) -> bool,
424 ) -> Result<(), DropReason> {
425 let Some(rec) = catalog.get(id) else {
426 return Err(DropReason::Removed);
427 };
428 if self.core.contains_key(id) || self.activated.contains_key(id) {
429 return Ok(());
430 }
431 if !(pin_matches(rec) && check_source_trust(rec).is_ok()) {
432 return Err(DropReason::Drifted);
433 }
434 let grant = SessionActivationGrant::new(
435 self.session.as_str(),
436 id.clone(),
437 catalog.generation(),
438 rec.schema_digest().clone(),
439 )
440 .expect("session id was valid when the set was built");
441 self.activated.insert(
442 id.clone(),
443 ActivatedTool {
444 grant,
445 provenance: rec.provenance().clone(),
446 lease: RuntimeLease::NotAcquired,
447 },
448 );
449 Ok(())
450 }
451
452 /// This session's exact activations as plain data, in `ToolId` order —
453 /// what a daemon reload or a park hands to the session's next runtime
454 /// (see [`CarriedActivation`]). Core tools are not included: the next
455 /// runtime re-derives its core from its own catalog.
456 pub fn carried_activations(&self) -> Vec<CarriedActivation> {
457 self.activated
458 .values()
459 .map(|a| CarriedActivation {
460 tool_id: a.grant().tool_id().as_str().to_string(),
461 schema_digest: a.schema_digest().as_hex().to_string(),
462 provenance: CarriedProvenance::from(a.provenance()),
463 })
464 .collect()
465 }
466
467 /// Restore activations this SAME conversation held in a previous
468 /// runtime (a daemon reload's previous image, or the runtime a park
469 /// dropped) into this set, which must be freshly built against
470 /// `catalog` for the new runtime's tool session.
471 ///
472 /// This continues grants the session already had; it is not a new
473 /// grant. Each entry is re-validated against the CURRENT catalog with
474 /// exactly the pins [`ToolRegistry::session_tools_schema`] and the
475 /// execution gate check — schema digest AND trust provenance — plus
476 /// source trust; an entry that is unparsable, uncataloged, or drifted
477 /// is dropped fail-closed and reported, never re-granted. Restored
478 /// grants are issued for THIS set's session at the current generation,
479 /// so the execution gate authorizes them like any other activation.
480 ///
481 /// Activation policy (`tools.activation_confirm`) is the caller's
482 /// decision (see `runtime::stream::restore_pending_activations`).
483 /// Advances the schema-generation counter by one iff anything was
484 /// restored (one batch), like `activate_many`.
485 pub fn restore_carried(
486 &mut self,
487 carried: &[CarriedActivation],
488 catalog: &ToolCatalog,
489 ) -> RestoreReport {
490 let mut report = RestoreReport::default();
491 if self.catalog_generation != catalog.generation() {
492 // A stale snapshot cannot take grants (as `validate_grant`):
493 // the caller rebuilds against `catalog` first.
494 report.dropped = carried
495 .iter()
496 .map(|c| (c.tool_id.clone(), RestoreDropReason::StaleSet))
497 .collect();
498 return report;
499 }
500 for c in carried {
501 let Ok(id) = ToolId::parse(&c.tool_id) else {
502 report
503 .dropped
504 .push((c.tool_id.clone(), RestoreDropReason::Removed));
505 continue;
506 };
507 let was_new = !self.core.contains_key(&id) && !self.activated.contains_key(&id);
508 match self.admit_pinned(&id, catalog, |rec| {
509 rec.schema_digest().as_hex() == c.schema_digest
510 && CarriedProvenance::from(rec.provenance()) == c.provenance
511 }) {
512 Ok(()) if was_new && self.activated.contains_key(&id) => {
513 report.restored.push(id);
514 }
515 Ok(()) => {}
516 Err(DropReason::Removed) => {
517 report
518 .dropped
519 .push((c.tool_id.clone(), RestoreDropReason::Removed));
520 }
521 Err(DropReason::Drifted) => {
522 report
523 .dropped
524 .push((c.tool_id.clone(), RestoreDropReason::Drifted));
525 }
526 }
527 }
528 if !report.restored.is_empty() {
529 self.schema_generation += 1;
530 }
531 report
532 }
533
534 /// Typed EXACT revocation (Task 19 grant invalidation): remove one
535 /// non-core exact activation from this session's set. The session
536 /// schema generation advances by exactly one IFF an activation was
537 /// removed; core tools and unknown/never-activated ids fail typed with
538 /// zero mutation. Never touches the catalog or sibling activations.
539 pub fn revoke_exact(&mut self, id: &ToolId) -> Result<(), ExactRevocationError> {
540 if self.core.contains_key(id) {
541 return Err(ExactRevocationError::CoreTool(id.clone()));
542 }
543 if self.activated.remove(id).is_none() {
544 return Err(ExactRevocationError::NotActivated(id.clone()));
545 }
546 self.schema_generation += 1;
547 Ok(())
548 }
549
550 /// The catalog generation this set snapshot was built against.
551 pub fn catalog_generation(&self) -> CatalogGeneration {
552 self.catalog_generation
553 }
554
555 /// True when the catalog has mutated since this set was built. A stale
556 /// set must be rebuilt/revalidated, not silently used.
557 pub fn is_stale(&self, catalog: &ToolCatalog) -> bool {
558 self.catalog_generation != catalog.generation()
559 }
560
561 /// Deterministic projection of configured core ids (ToolId order).
562 pub fn core_ids(&self) -> impl Iterator<Item = &ToolId> {
563 self.core.keys()
564 }
565
566 pub fn is_core(&self, id: &ToolId) -> bool {
567 self.core.contains_key(id)
568 }
569
570 /// The schema digest pinned for a configured core tool at build time,
571 /// or `None` when the id is not in this session's core set.
572 pub fn core_schema_digest(&self, id: &ToolId) -> Option<&SchemaDigest> {
573 self.core.get(id).map(CorePin::schema_digest)
574 }
575
576 /// The full (digest, provenance) pin for a configured core tool, or
577 /// `None` when the id is not in this session's core set.
578 pub fn core_pin(&self, id: &ToolId) -> Option<&CorePin> {
579 self.core.get(id)
580 }
581
582 /// Deterministic projection of exact activations (ToolId order).
583 pub fn activated(&self) -> impl Iterator<Item = &ActivatedTool> {
584 self.activated.values()
585 }
586
587 pub fn activation(&self, id: &ToolId) -> Option<&ActivatedTool> {
588 self.activated.get(id)
589 }
590
591 /// Session schema-generation counter (spec §7.7): the number of
592 /// successful nonempty activation batches applied to this set. A
593 /// deterministic bulk `activate_many` advances it by exactly one.
594 pub fn schema_generation(&self) -> u64 {
595 self.schema_generation
596 }
597
598 /// Validate one grant against this set and the current catalog without
599 /// mutating anything. Shared by [`Self::activate`] and
600 /// [`Self::activate_many`] so single and bulk paths can never drift.
601 fn validate_grant(
602 &self,
603 grant: &SessionActivationGrant,
604 catalog: &ToolCatalog,
605 ) -> Result<(), ActivationError> {
606 if grant.session_id() != self.session.as_str() {
607 return Err(ActivationError::SessionMismatch);
608 }
609 let tool_id = grant.tool_id().clone();
610 let record = catalog
611 .get(&tool_id)
612 .ok_or_else(|| ActivationError::UnknownTool(tool_id.clone()))?;
613 if grant.catalog_generation() != catalog.generation() {
614 return Err(ActivationError::StaleGeneration {
615 grant: grant.catalog_generation(),
616 catalog: catalog.generation(),
617 });
618 }
619 if grant.schema_digest() != record.schema_digest() {
620 return Err(ActivationError::DigestMismatch(tool_id));
621 }
622 // A fresh grant cannot rescue a stale set snapshot: the set itself
623 // must be rebuilt against the current catalog first.
624 if self.catalog_generation != catalog.generation() {
625 return Err(ActivationError::StaleSnapshot {
626 set: self.catalog_generation,
627 catalog: catalog.generation(),
628 });
629 }
630 // Exact-tuple re-check through the grant's own covers() so the two
631 // validation paths can never drift apart.
632 debug_assert!(grant.covers(
633 self.session.as_str(),
634 &tool_id,
635 catalog.generation(),
636 record.schema_digest(),
637 ));
638 if self.activated.contains_key(&tool_id) {
639 return Err(ActivationError::AlreadyActivated(tool_id));
640 }
641 // Core tools are always available to the session; re-recording one
642 // as a deferred activation would create two conflicting bookkeeping
643 // entries for the same capability.
644 if self.core.contains_key(&tool_id) {
645 return Err(ActivationError::AlreadyCore(tool_id));
646 }
647 Ok(())
648 }
649
650 /// Record one exact activation. This is set/test/bootstrap plumbing, not
651 /// a model-facing activation flow: the grant must already exist and must
652 /// match this session, a cataloged tool, the CURRENT catalog generation,
653 /// and the CURRENT schema digest exactly. Any drift — foreign session,
654 /// unknown tool, stale generation, changed digest, stale set snapshot,
655 /// duplicate activation, core-set membership — fails typed before any
656 /// mutation, so a failed call leaves the set byte-for-byte unchanged. No
657 /// implementation is constructed, no process started, no schema exposed.
658 /// A successful call is a nonempty batch of one: the session
659 /// schema-generation advances by exactly one.
660 pub fn activate(
661 &mut self,
662 grant: SessionActivationGrant,
663 catalog: &ToolCatalog,
664 ) -> Result<(), ActivationError> {
665 self.validate_grant(&grant, catalog)?;
666 let provenance = catalog
667 .get(grant.tool_id())
668 .expect("validate_grant verified the record exists")
669 .provenance()
670 .clone();
671 self.activated.insert(
672 grant.tool_id().clone(),
673 ActivatedTool {
674 grant,
675 provenance,
676 lease: RuntimeLease::NotAcquired,
677 },
678 );
679 self.schema_generation += 1;
680 Ok(())
681 }
682
683 /// Deterministic atomic bulk activation (spec §7.7): validate EVERY
684 /// requested grant first — duplicates within the batch, unknown ids,
685 /// core-set ids, stale generations, digest mismatches, stale set
686 /// snapshots, and untrusted/inconsistent source provenance all fail
687 /// typed with ZERO partial mutation — then apply in stable `ToolId`
688 /// order and advance the session schema-generation by exactly one. An
689 /// empty batch is a no-op that advances nothing. Returns the number of
690 /// activations applied.
691 pub fn activate_many(
692 &mut self,
693 grants: Vec<SessionActivationGrant>,
694 catalog: &ToolCatalog,
695 ) -> Result<usize, BulkActivationError> {
696 if grants.is_empty() {
697 return Ok(0);
698 }
699 let mut seen: std::collections::BTreeSet<ToolId> = std::collections::BTreeSet::new();
700 for grant in &grants {
701 if !seen.insert(grant.tool_id().clone()) {
702 return Err(BulkActivationError::DuplicateRequest(
703 grant.tool_id().clone(),
704 ));
705 }
706 self.validate_grant(grant, catalog)
707 .map_err(BulkActivationError::Grant)?;
708 // Source trust re-check before ANY grant is applied: an
709 // unverified or provenance-inconsistent capability rejects the
710 // whole batch (validate_grant proved the record exists).
711 let record = catalog
712 .get(grant.tool_id())
713 .expect("validate_grant verified the record exists");
714 check_source_trust(record).map_err(|err| match err {
715 ToolAuthorizationError::SourceProvenanceMismatch(id) => {
716 BulkActivationError::SourceProvenanceMismatch(id)
717 }
718 // check_source_trust only produces UntrustedSource or
719 // SourceProvenanceMismatch; anything else maps to the
720 // conservative untrusted denial for this id.
721 _ => BulkActivationError::UntrustedSource(grant.tool_id().clone()),
722 })?;
723 }
724 // All grants validated: apply in stable ToolId order.
725 let mut ordered = grants;
726 ordered.sort_by(|a, b| a.tool_id().cmp(b.tool_id()));
727 let applied = ordered.len();
728 for grant in ordered {
729 let provenance = catalog
730 .get(grant.tool_id())
731 .expect("validate_grant verified the record exists")
732 .provenance()
733 .clone();
734 self.activated.insert(
735 grant.tool_id().clone(),
736 ActivatedTool {
737 grant,
738 provenance,
739 lease: RuntimeLease::NotAcquired,
740 },
741 );
742 }
743 // Exactly ONE schema-generation advance for the whole batch.
744 self.schema_generation += 1;
745 Ok(applied)
746 }
747}
748
749// ── ExecutionGate (Task 16, spec §7.1 / §4.2) ───────────────────────────────
750
751/// Byte budget for wire/tool names echoed inside typed denials. Denials are
752/// static, bounded, metadata-only strings: hostile oversize wire names must
753/// never be reflected verbatim.
754pub const WIRE_NAME_MAX_BYTES: usize = 128;
755
756fn bounded_wire_name(raw: &str) -> String {
757 BoundedText::new(raw, WIRE_NAME_MAX_BYTES).text
758}
759
760/// Typed resolution of an incoming API/wire tool name to the exact live
761/// runtime tool identity: the wire name as received (bounded), the runtime
762/// name it deterministically maps to (via the registry's sanitized-name
763/// reverse mapping), and the exact catalog [`ToolId`] derived from the live
764/// tool instance itself — never from the alias. Aliases and collision
765/// suffixes therefore cannot select a different identity than the tool they
766/// deterministically resolve to.
767///
768/// This is identity data only; it grants nothing. Authorization happens in
769/// [`ExecutionGate::authorize`], which is the only constructor of
770/// [`AuthorizedToolCall`].
771#[derive(Clone, Debug, PartialEq, Eq)]
772pub struct ResolvedToolCall {
773 wire_name: String,
774 runtime_name: String,
775 tool_id: ToolId,
776}
777
778impl ResolvedToolCall {
779 pub fn new(wire_name: &str, runtime_name: &str, tool_id: ToolId) -> Self {
780 Self {
781 wire_name: bounded_wire_name(wire_name),
782 runtime_name: runtime_name.to_string(),
783 tool_id,
784 }
785 }
786
787 /// The API/wire name as received (bounded for echo safety).
788 pub fn wire_name(&self) -> &str {
789 &self.wire_name
790 }
791
792 /// The exact runtime tool name the wire name resolves to.
793 pub fn runtime_name(&self) -> &str {
794 &self.runtime_name
795 }
796
797 /// The exact catalog identity of the resolved live tool.
798 pub fn tool_id(&self) -> &ToolId {
799 &self.tool_id
800 }
801}
802
803#[derive(Clone, Copy, Debug, PartialEq, Eq)]
804pub enum ActivationBasis {
805 Core,
806 Exact {
807 catalog_generation: CatalogGeneration,
808 },
809}
810
811/// A fully authorized tool call: exact resolved identity plus the acquired
812/// implementation. The implementation field is private and the ONLY
813/// constructor is [`ExecutionGate::authorize`], which acquires it strictly
814/// after every check passes — the safe path cannot reach an implementation
815/// without passing the gate.
816pub struct AuthorizedToolCall {
817 resolved: ResolvedToolCall,
818 implementation: Arc<dyn Tool>,
819 activation_basis: ActivationBasis,
820}
821
822impl AuthorizedToolCall {
823 pub fn resolved(&self) -> &ResolvedToolCall {
824 &self.resolved
825 }
826
827 pub fn wire_name(&self) -> &str {
828 self.resolved.wire_name()
829 }
830
831 pub fn runtime_name(&self) -> &str {
832 self.resolved.runtime_name()
833 }
834
835 pub fn tool_id(&self) -> &ToolId {
836 self.resolved.tool_id()
837 }
838
839 pub fn activation_basis(&self) -> ActivationBasis {
840 self.activation_basis
841 }
842
843 /// The authorized implementation handle.
844 pub fn implementation(&self) -> Arc<dyn Tool> {
845 Arc::clone(&self.implementation)
846 }
847}
848
849impl std::fmt::Debug for AuthorizedToolCall {
850 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
851 f.debug_struct("AuthorizedToolCall")
852 .field("resolved", &self.resolved)
853 .finish_non_exhaustive()
854 }
855}
856
857/// Typed execution-gate denial. Every variant is static, bounded, and
858/// metadata-only: no tool input, no schema bytes, no provider body content —
859/// only bounded identities and generation counters.
860#[derive(Clone, Debug, Error, PartialEq, Eq)]
861pub enum ToolAuthorizationError {
862 /// The wire name resolves to no live registered tool. Message shape kept
863 /// compatible with the legacy tool-loop denial; the echoed name is
864 /// bounded at resolution time.
865 #[error("Unknown tool: {wire_name}")]
866 UnknownTool { wire_name: String },
867 /// The live tool exists but the catalog holds no record for its
868 /// identity — the registry/catalog invariant is broken; fail closed.
869 #[error("Tool call denied: capability is not cataloged: {0}")]
870 NotCataloged(ToolId),
871 /// Known capability, but neither core for this session nor covered by an
872 /// exact activation grant (the forged deferred-call case).
873 #[error("Tool call denied: tool is not activated for this session: {0}")]
874 NotActivated(ToolId),
875 /// The capability's current schema digest differs from the digest the
876 /// session pinned at core-build/activation time — a changed tool is
877 /// never silently blessed.
878 #[error("Tool call denied: schema digest changed since authorization was pinned: {0}")]
879 SchemaDigestMismatch(ToolId),
880 /// Unverified/unknown provenance is denied by default, even if the
881 /// capability was (mis)configured as core, until an explicit later
882 /// policy exists.
883 #[error("Tool call denied: source provenance is unverified: {0}")]
884 UntrustedSource(ToolId),
885 /// Source/trust provenance failure: either the catalog record's source
886 /// and trust provenance disagree (an internally inconsistent record must
887 /// never authorize), or the record's CURRENT provenance differs from the
888 /// provenance the session pinned at core-build/activation time (a tool
889 /// removed and re-added under a different — even internally coherent —
890 /// provenance must never authorize).
891 #[error("Tool call denied: source/trust provenance is inconsistent or drifted from the session's pin: {0}")]
892 SourceProvenanceMismatch(ToolId),
893}
894
895/// The spec §7.1 execution gate: a pure, reusable authorization component
896/// evaluated immediately before tool implementation lookup/execution.
897///
898/// Order of checks (each fails typed, closed, without acquiring anything):
899/// 1. resolve wire name → exact live `ToolId` (deterministic reverse
900/// mapping; aliases cannot pick a different identity);
901/// 2. the identity must be cataloged;
902/// 3. the identity must be core (pinned digest intact) or hold an exact
903/// session activation grant covering (session, tool, digest) — see
904/// [`grant_covers_execution`]: catalog generation drift alone does NOT
905/// deny at execution time; only a real change to the called tool's
906/// record (digest, presence, provenance) does;
907/// 4. the record's CURRENT trust provenance must equal the provenance the
908/// session PINNED at core-build/activation time — this closes the
909/// coherent-imposter hole (same `ToolId`, schema-identical body,
910/// different but internally consistent source/provenance);
911/// 5. source trust is re-evaluated conservatively — see the honesty note
912/// on [`check_source_trust`]: this re-checks typed source/provenance
913/// consistency and denies Unknown/Unverified, but does NOT yet consult
914/// live manifest permission/revocation state (Task 20 integrates the
915/// permission/revocation policy);
916/// 6. side-effect/confirmation policy (interim: `Unclassified` is allowed
917/// only for verified-provenance capabilities — Task 24 adds real effect
918/// classes);
919/// 7. only then is the implementation acquired.
920///
921/// TOCTOU: callers must resolve, authorize, and acquire against ONE
922/// consistent registry borrow/lock guard — [`ExecutionGate::authorize_wire_call`]
923/// does exactly that for a `&ToolRegistry` held under the caller's read lock.
924pub struct ExecutionGate;
925
926impl ExecutionGate {
927 /// Resolve an incoming API/wire name against one registry snapshot.
928 pub fn resolve(
929 registry: &ToolRegistry,
930 wire_name: &str,
931 ) -> Result<ResolvedToolCall, ToolAuthorizationError> {
932 registry
933 .resolve_wire_call(wire_name)
934 .ok_or_else(|| ToolAuthorizationError::UnknownTool {
935 wire_name: bounded_wire_name(wire_name),
936 })
937 }
938
939 /// Authorize a resolved identity against one catalog snapshot and one
940 /// session tool set. On success — and only then — the implementation is
941 /// acquired from the catalog record's factory and returned inside the
942 /// typed [`AuthorizedToolCall`]. Failure acquires nothing and leaves the
943 /// session set untouched (the gate never mutates it).
944 ///
945 /// SECURITY INVARIANT (per-tool digest + provenance validation): no
946 /// tool executes unless its CURRENT catalog record — presence of the
947 /// exact `ToolId`, schema digest, and source/trust provenance — exactly
948 /// matches what the session pinned at core-build/activation time.
949 /// Catalog generation inequality ALONE is no longer a denial reason at
950 /// execution time: an unrelated background mutation (e.g. a plugin
951 /// load mid-round) must not kill in-flight calls to schema-identical
952 /// tools. Any REAL drift of the called tool's record — changed digest,
953 /// removal, changed provenance (even to an internally coherent one) —
954 /// still denies typed and closed.
955 ///
956 /// ACCEPTED RESIDUAL RISK: [`SchemaDigest`] hashes the schema JSON
957 /// only, not the implementation factory. A record replaced in place
958 /// with an identical schema AND identical pinned provenance but a
959 /// different implementation passes this gate; the exposure is bounded
960 /// by the deterministic round-top rebuild (runtime/stream.rs), which
961 /// re-pins the set against the current catalog between provider rounds.
962 pub fn authorize(
963 catalog: &ToolCatalog,
964 session: &SessionToolSet,
965 resolved: ResolvedToolCall,
966 ) -> Result<AuthorizedToolCall, ToolAuthorizationError> {
967 let tool_id = resolved.tool_id().clone();
968 let record = catalog
969 .get(&tool_id)
970 .ok_or_else(|| ToolAuthorizationError::NotCataloged(tool_id.clone()))?;
971
972 // Generation drift is observed (for logging) but NOT a wholesale
973 // denial: the per-tool checks below are unconditional and judge the
974 // called tool's CURRENT record against the session's pins. The
975 // round-top rebuild (runtime/stream.rs) still uses `is_stale` to
976 // refresh the set deterministically between rounds.
977 let generation_drift = session.is_stale(catalog);
978
979 // Core status or exact activation grant, with pinned-digest
980 // verification either way — unconditional, drift or not. The pinned
981 // trust provenance is carried out of each branch for the
982 // unconditional pinned-provenance check below.
983 let activation_basis;
984 let pinned_provenance: &TrustProvenance;
985 if let Some(pin) = session.core_pin(&tool_id) {
986 if pin.schema_digest() != record.schema_digest() {
987 if generation_drift {
988 tracing::warn!(
989 tool = %tool_id,
990 set_generation = session.catalog_generation().value(),
991 catalog_generation = catalog.generation().value(),
992 "tool denied under catalog generation drift: pinned core schema digest \
993 no longer matches the current catalog record"
994 );
995 }
996 return Err(ToolAuthorizationError::SchemaDigestMismatch(tool_id));
997 }
998 pinned_provenance = pin.provenance();
999 activation_basis = ActivationBasis::Core;
1000 } else if let Some(activated) = session.activation(&tool_id) {
1001 if activated.schema_digest() != record.schema_digest() {
1002 if generation_drift {
1003 tracing::warn!(
1004 tool = %tool_id,
1005 set_generation = session.catalog_generation().value(),
1006 catalog_generation = catalog.generation().value(),
1007 "tool denied under catalog generation drift: activation grant schema \
1008 digest no longer matches the current catalog record"
1009 );
1010 }
1011 return Err(ToolAuthorizationError::SchemaDigestMismatch(tool_id));
1012 }
1013 // Execution-time grant re-check on the (session, tool, digest)
1014 // tuple — deliberately WITHOUT the generation term (see
1015 // `grant_covers_execution`). Grant ISSUANCE and activation stay
1016 // generation-strict (`validate_grant`, `covers()`).
1017 if !grant_covers_execution(
1018 activated.grant(),
1019 session.session().as_str(),
1020 &tool_id,
1021 record.schema_digest(),
1022 ) {
1023 return Err(ToolAuthorizationError::NotActivated(tool_id));
1024 }
1025 pinned_provenance = activated.provenance();
1026 activation_basis = ActivationBasis::Exact {
1027 catalog_generation: activated.catalog_generation(),
1028 };
1029 } else {
1030 return Err(ToolAuthorizationError::NotActivated(tool_id));
1031 }
1032
1033 // Unconditional pinned-provenance equality (BLOCKER fix): the
1034 // record's CURRENT trust provenance must equal the provenance the
1035 // session pinned at core-build/activation time. This — not the
1036 // self-consistency check below — is what catches the coherent
1037 // imposter: a tool removed and re-added under the same `ToolId`
1038 // with a schema-identical body but a different, internally
1039 // consistent source/provenance pair.
1040 if pinned_provenance != record.provenance() {
1041 if generation_drift {
1042 tracing::warn!(
1043 tool = %tool_id,
1044 set_generation = session.catalog_generation().value(),
1045 catalog_generation = catalog.generation().value(),
1046 "tool denied under catalog generation drift: current record provenance \
1047 no longer matches the provenance the session pinned"
1048 );
1049 }
1050 return Err(ToolAuthorizationError::SourceProvenanceMismatch(tool_id));
1051 }
1052
1053 // Conservative source trust re-check immediately before
1054 // acquisition: typed source/provenance SELF-consistency plus the
1055 // deny-by-default for Unknown/Unverified (see check_source_trust) —
1056 // live manifest permission/revocation state is not consulted here
1057 // yet (Task 20). This complements (does not replace) the
1058 // pinned-provenance equality above.
1059 check_source_trust(record)?;
1060
1061 if generation_drift {
1062 tracing::warn!(
1063 tool = %tool_id,
1064 set_generation = session.catalog_generation().value(),
1065 catalog_generation = catalog.generation().value(),
1066 "tool call survived catalog generation drift: current record digest, presence \
1067 and provenance exactly match the session's pins"
1068 );
1069 }
1070
1071 // Effect classes (Task 24) are execution-SCHEDULING policy, not
1072 // authorization policy: every class may execute once authorized —
1073 // the stream scheduler decides concurrency/ordering from the
1074 // recorded class. The exhaustive match keeps this decision loud if
1075 // a future class needs gate-level treatment.
1076 match record.effect() {
1077 ToolEffect::ReadOnly | ToolEffect::IdempotentWrite | ToolEffect::NonIdempotent => {}
1078 }
1079
1080 // Acquisition happens strictly after authorization succeeds.
1081 let implementation = record.implementation();
1082 Ok(AuthorizedToolCall {
1083 resolved,
1084 implementation,
1085 activation_basis,
1086 })
1087 }
1088
1089 /// Resolve + authorize + acquire against ONE registry borrow (one
1090 /// consistent snapshot under the caller's read lock — no TOCTOU between
1091 /// resolution and implementation acquisition).
1092 pub fn authorize_wire_call(
1093 registry: &ToolRegistry,
1094 session: &SessionToolSet,
1095 wire_name: &str,
1096 ) -> Result<AuthorizedToolCall, ToolAuthorizationError> {
1097 let resolved = Self::resolve(registry, wire_name)?;
1098 Self::authorize(registry.catalog(), session, resolved)
1099 }
1100}
1101
1102/// Execution-time grant coverage: (session, tool, digest) equality WITHOUT
1103/// the generation term of [`SessionActivationGrant::covers`]. Rationale: at
1104/// execution time the tool's identity and schema are already re-validated
1105/// against the CURRENT catalog record, so requiring the pinned generation to
1106/// equal the live one only makes unrelated catalog mutations (background
1107/// plugin loads) kill in-flight calls to schema-identical tools. `covers()`
1108/// itself (agent-core) stays exact-tuple and is still what grant ISSUANCE
1109/// and activation validation (`validate_grant`) enforce — this relaxation
1110/// applies ONLY to re-validation of an already-established authorization.
1111fn grant_covers_execution(
1112 grant: &SessionActivationGrant,
1113 session_id: &str,
1114 tool_id: &ToolId,
1115 schema_digest: &SchemaDigest,
1116) -> bool {
1117 grant.session_id() == session_id
1118 && grant.tool_id() == tool_id
1119 && grant.schema_digest() == schema_digest
1120}
1121
1122/// Conservative per-source trust policy — CONSISTENCY check, not a live
1123/// permission lookup: `BuiltinRuntime` provenance is valid only for
1124/// builtin-sourced records; extension/MCP/plugin provenance must match the
1125/// catalog source identity exactly; `Unverified` (and any unknown source)
1126/// is denied by default even if configured core.
1127///
1128/// Honesty note (Task 16): this validates the typed provenance recorded at
1129/// catalog time against the record's source identity. It does NOT consult
1130/// live manifest permission state or revocation — a capability whose
1131/// extension permissions were revoked after cataloging still passes this
1132/// check until the catalog entry is removed/rebuilt. Live
1133/// permission/revocation integration is Task 20.
1134/// Whether `activate_tools` could ever grant this record: the same source
1135/// trust check activation applies (verified provenance that matches its
1136/// declared source). Used to decide which dormant tools are advertised as
1137/// deferred definitions; it never authorizes anything by itself.
1138pub(crate) fn is_activatable_record(record: &CapabilityRecord) -> bool {
1139 check_source_trust(record).is_ok()
1140}
1141
1142fn check_source_trust(record: &CapabilityRecord) -> Result<(), ToolAuthorizationError> {
1143 let deny_untrusted = || ToolAuthorizationError::UntrustedSource(record.id().clone());
1144 let deny_mismatch = || ToolAuthorizationError::SourceProvenanceMismatch(record.id().clone());
1145 match (record.provenance(), record.source()) {
1146 (TrustProvenance::Unverified, _) => Err(deny_untrusted()),
1147 (_, CapabilitySource::Unknown { .. }) => Err(deny_untrusted()),
1148 (TrustProvenance::BuiltinRuntime, CapabilitySource::Builtin) => Ok(()),
1149 (
1150 TrustProvenance::ExtensionManifest { extension_id },
1151 CapabilitySource::Extension {
1152 extension_id: source_extension_id,
1153 ..
1154 },
1155 ) if extension_id == source_extension_id => Ok(()),
1156 (
1157 TrustProvenance::McpConfig { server_id },
1158 CapabilitySource::Mcp {
1159 server_id: source_server_id,
1160 ..
1161 },
1162 ) if server_id == source_server_id => Ok(()),
1163 (
1164 TrustProvenance::PluginConfig { plugin_id },
1165 CapabilitySource::Plugin {
1166 plugin_id: source_plugin_id,
1167 ..
1168 },
1169 ) if plugin_id == source_plugin_id => Ok(()),
1170 _ => Err(deny_mismatch()),
1171 }
1172}
1173
1174// ── Task 17: host grant issuance, typed confirmation authority, and the
1175// retained shared session set (spec §7.2, §7.3, §7.7) ─────────────────────
1176
1177/// One retained per-stream-session handle: `activate_tools` mutates the SAME
1178/// set the `ExecutionGate` and the next provider round consume. Interior
1179/// critical sections are short and never held across `.await`.
1180pub type SharedSessionToolSet = Arc<std::sync::RwLock<SessionToolSet>>;
1181
1182/// The per-runtime slot that RETAINS a session's [`SharedSessionToolSet`]
1183/// across stream turns. Exact activations are session-scoped (the
1184/// `activate_tools` contract says so), so the set that holds them must outlive
1185/// a single provider turn: every turn of the same runtime tool session
1186/// reconciles and reuses the retained set instead of minting a fresh,
1187/// zero-activation one. Shared by `Runtime` clones exactly like
1188/// `host_tool_session`; empty until the first progressive turn.
1189pub type RetainedSessionToolSet = Arc<std::sync::Mutex<Option<SharedSessionToolSet>>>;
1190
1191/// Activations a runtime was handed from the same conversation's previous
1192/// runtime (daemon reload, unpark) and has not applied yet. Consumed by the
1193/// first progressive turn start (or projected read-only by a keep-warm
1194/// ping before it), against THAT moment's catalog — as late as possible, so
1195/// tools registered after the runtime was built are seen. Shared by
1196/// `Runtime` clones exactly like [`RetainedSessionToolSet`].
1197pub type PendingToolRestore = Arc<std::sync::Mutex<Option<Vec<CarriedActivation>>>>;
1198
1199/// Typed failure for deterministic bulk activation ([`SessionToolSet::activate_many`]).
1200/// Every variant fails the WHOLE batch with zero partial mutation.
1201#[derive(Clone, Debug, Error, PartialEq, Eq)]
1202pub enum BulkActivationError {
1203 #[error("duplicate tool id in one activation batch: {0}")]
1204 DuplicateRequest(ToolId),
1205 #[error("activation batch rejected: {0}")]
1206 Grant(#[from] ActivationError),
1207 #[error("activation batch rejected: source provenance is unverified: {0}")]
1208 UntrustedSource(ToolId),
1209 #[error("activation batch rejected: catalog source and trust provenance disagree: {0}")]
1210 SourceProvenanceMismatch(ToolId),
1211}
1212
1213/// Host-supplied activation authority for MODEL-INITIATED activation.
1214///
1215/// `ModelConfirmed` may be populated ONLY by host policy (explicit server
1216/// auto-approve or an interactive confirmation mechanism) — never from
1217/// model-authored JSON arguments. Explicit user-requested activation goes
1218/// through [`activate_exact_for_user`], a host Rust API that involves no
1219/// authority value at all (PR #63 ergonomics: a user asking for an exact
1220/// known tool needs no redundant prompt).
1221#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1222pub enum ActivationAuthority {
1223 /// No confirmation authority: model-initiated activation is denied
1224 /// before any grant issuance or set/schema mutation.
1225 Unauthorized,
1226 /// Host confirmation policy satisfied for this session/turn.
1227 ModelConfirmed,
1228}
1229
1230/// Typed failure for host grant issuance. Issuance validates the exact
1231/// known `ToolId`, the current catalog generation/digest (pinned into the
1232/// grant), and source trust — before any grant exists.
1233#[derive(Clone, Debug, Error, PartialEq, Eq)]
1234pub enum GrantIssuanceError {
1235 #[error("cannot issue activation grant: tool is not in the catalog: {0}")]
1236 UnknownTool(ToolId),
1237 #[error("cannot issue activation grant: source provenance is unverified: {0}")]
1238 UntrustedSource(ToolId),
1239 #[error("cannot issue activation grant: catalog source and trust provenance disagree: {0}")]
1240 SourceProvenanceMismatch(ToolId),
1241 #[error("cannot issue activation grant: {0}")]
1242 Grant(#[from] agent_core::orchestration::capability::ActivationGrantError),
1243}
1244
1245/// Issue one exact session-scoped activation grant against the CURRENT
1246/// catalog state. Validates: the id is a known exact cataloged capability
1247/// and its source trust/provenance is consistent and verified. The grant
1248/// pins the current catalog generation and schema digest. Pure: no factory
1249/// invocation, no process start, no network, no set mutation.
1250pub fn issue_exact_grant(
1251 catalog: &ToolCatalog,
1252 session: &SessionId,
1253 tool_id: &ToolId,
1254) -> Result<SessionActivationGrant, GrantIssuanceError> {
1255 let record = catalog
1256 .get(tool_id)
1257 .ok_or_else(|| GrantIssuanceError::UnknownTool(tool_id.clone()))?;
1258 check_source_trust(record).map_err(|err| match err {
1259 ToolAuthorizationError::SourceProvenanceMismatch(id) => {
1260 GrantIssuanceError::SourceProvenanceMismatch(id)
1261 }
1262 _ => GrantIssuanceError::UntrustedSource(tool_id.clone()),
1263 })?;
1264 Ok(SessionActivationGrant::new(
1265 session.as_str(),
1266 tool_id.clone(),
1267 catalog.generation(),
1268 record.schema_digest().clone(),
1269 )?)
1270}
1271
1272/// Typed failure for the host activation entry points.
1273#[derive(Clone, Debug, Error, PartialEq, Eq)]
1274pub enum HostActivationError {
1275 /// Model-initiated activation without host confirmation authority.
1276 /// Denied BEFORE grant issuance and before any set/schema mutation.
1277 #[error(
1278 "tool activation requires host confirmation authority; \
1279 model-initiated activation was not confirmed"
1280 )]
1281 ConfirmationRequired,
1282 #[error(transparent)]
1283 Issuance(#[from] GrantIssuanceError),
1284 #[error(transparent)]
1285 Bulk(#[from] BulkActivationError),
1286}
1287
1288/// Explicit USER-REQUESTED exact activation (spec §7.3 / PR #63 ergonomics):
1289/// the host may authorize one exact known local identity without a
1290/// redundant confirmation prompt. This is a host Rust API — it is never
1291/// reachable from model-authored JSON and takes no confirmation boolean.
1292/// Grants are session-scoped and never persisted.
1293pub fn activate_exact_for_user(
1294 set: &mut SessionToolSet,
1295 catalog: &ToolCatalog,
1296 tool_id: &ToolId,
1297) -> Result<(), HostActivationError> {
1298 let grant = issue_exact_grant(catalog, set.session(), tool_id)?;
1299 set.activate_many(vec![grant], catalog)?;
1300 Ok(())
1301}
1302
1303/// MODEL-INITIATED exact activation: requires host-supplied
1304/// [`ActivationAuthority::ModelConfirmed`]. Without it, the request is
1305/// denied before any grant issuance, set mutation, or schema-generation
1306/// advance. All requested ids are validated and issued first; the batch
1307/// applies atomically in stable `ToolId` order with exactly one session
1308/// schema-generation advance. Grants are session-scoped, never persisted.
1309pub fn activate_model_initiated(
1310 authority: ActivationAuthority,
1311 set: &mut SessionToolSet,
1312 catalog: &ToolCatalog,
1313 tool_ids: &[ToolId],
1314) -> Result<usize, HostActivationError> {
1315 match authority {
1316 ActivationAuthority::ModelConfirmed => {}
1317 ActivationAuthority::Unauthorized => {
1318 return Err(HostActivationError::ConfirmationRequired);
1319 }
1320 }
1321 let session = set.session().clone();
1322 let mut grants = Vec::with_capacity(tool_ids.len());
1323 for tool_id in tool_ids {
1324 grants.push(issue_exact_grant(catalog, &session, tool_id)?);
1325 }
1326 Ok(set.activate_many(grants, catalog)?)
1327}
1328
1329/// Resolve the session tool set for the extension-provider route (Task 17,
1330/// closing the Task 16 policy divergence): when the runtime threads its
1331/// RETAINED per-stream set, the route consumes THAT set's current state —
1332/// including exact activations — at its pinned generation. The route must
1333/// never silently mint a fresh default-core set for the same round. Only
1334/// callers with no retained handle at all (internal/sync helpers) fall back
1335/// to a fresh default-core set with zero activations under a locally minted
1336/// session.
1337///
1338/// Generation drift is deliberately SURVIVED here (per-tool digest
1339/// validation fix): the retained set is served as-is even when the catalog
1340/// generation moved past its snapshot, because every actual execution still
1341/// passes through [`ExecutionGate::authorize`], which unconditionally
1342/// re-validates each called tool's current record (presence, digest,
1343/// provenance) against the session's pins. Denying the whole retained set
1344/// wholesale would let an unrelated background catalog mutation kill an
1345/// entire in-flight round of schema-identical tools. Infallible: neither
1346/// path can be denied anymore, so the return type is the plain set.
1347pub fn route_session_set(
1348 retained: Option<&SharedSessionToolSet>,
1349 catalog: &ToolCatalog,
1350 fallback_session: impl FnOnce() -> SessionId,
1351) -> SessionToolSet {
1352 match retained {
1353 Some(shared) => {
1354 let guard = shared
1355 .read()
1356 .unwrap_or_else(std::sync::PoisonError::into_inner);
1357 if guard.is_stale(catalog) {
1358 tracing::warn!(
1359 set_generation = guard.catalog_generation().value(),
1360 catalog_generation = catalog.generation().value(),
1361 "serving retained session tool set across catalog generation drift; \
1362 per-call ExecutionGate::authorize still protects execution"
1363 );
1364 }
1365 guard.clone()
1366 }
1367 None => SessionToolSet::default_core_for_catalog(fallback_session(), catalog),
1368 }
1369}