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/// The small configured core set plus exact activated deferred tools for one
175/// session, pinned to the catalog generation it was built against. Core
176/// tools are pinned with the schema digest AND trust provenance of their
177/// catalog record at build time, so later drift is detectable per tool, not
178/// just per generation.
179#[derive(Clone, Debug)]
180pub struct SessionToolSet {
181 session: SessionId,
182 catalog_generation: CatalogGeneration,
183 core: BTreeMap<ToolId, CorePin>,
184 activated: BTreeMap<ToolId, ActivatedTool>,
185 /// Session schema-generation counter (spec §7.7): advances by exactly
186 /// one for every successful nonempty activation batch (a single
187 /// activation is a batch of one). Deterministic bookkeeping only; it
188 /// exposes nothing and grants nothing.
189 schema_generation: u64,
190}
191
192impl SessionToolSet {
193 /// Build a fresh set for one session. Every configured core id must
194 /// exist in the catalog (typed failure otherwise) and its schema digest
195 /// AND trust provenance are pinned from the catalog record; the set
196 /// starts with zero activations — nothing is inherited from any other
197 /// session.
198 pub fn new(
199 session: SessionId,
200 core: impl IntoIterator<Item = ToolId>,
201 catalog: &ToolCatalog,
202 ) -> Result<Self, SessionToolSetError> {
203 let mut validated = BTreeMap::new();
204 for id in core {
205 let Some(record) = catalog.get(&id) else {
206 return Err(SessionToolSetError::UnknownCoreTool(id));
207 };
208 validated.insert(
209 id,
210 CorePin {
211 schema_digest: record.schema_digest().clone(),
212 provenance: record.provenance().clone(),
213 },
214 );
215 }
216 Ok(Self {
217 session,
218 catalog_generation: catalog.generation(),
219 core: validated,
220 activated: BTreeMap::new(),
221 schema_generation: 0,
222 })
223 }
224
225 pub fn session(&self) -> &SessionId {
226 &self.session
227 }
228
229 /// Default per-stream-session set (Task 16 behavior preservation): the
230 /// core set is EXACTLY every currently cataloged capability with
231 /// verified provenance — builtins plus already-loaded trusted
232 /// extension/MCP/plugin tools. `Unverified` capabilities are excluded
233 /// (denied-by-default at the gate), and the set carries zero
234 /// activations. Deterministic: ids come straight from the catalog in
235 /// `ToolId` order, so rebuilding after a catalog mutation yields the
236 /// same set for the same catalog state.
237 pub fn default_core_for_catalog(session: SessionId, catalog: &ToolCatalog) -> Self {
238 let core: Vec<ToolId> = catalog
239 .iter()
240 .filter(|record| record.provenance() != &TrustProvenance::Unverified)
241 .map(|record| record.id().clone())
242 .collect();
243 Self::new(session, core, catalog)
244 .expect("core ids are drawn from the catalog itself and must all resolve")
245 }
246
247 /// Deterministic minimal core for Task 18 progressive disclosure. Only
248 /// essential local operations plus discovery/authorization gateways are
249 /// eligible; missing/disabled gateways are skipped rather than invented.
250 /// Specialized subagent lifecycle, extension, MCP, shell-session, and
251 /// dynamically registered tools remain deferred until exact activation.
252 pub fn progressive_core_for_catalog(session: SessionId, catalog: &ToolCatalog) -> Self {
253 const ESSENTIAL_BUILTINS: &[&str] = &[
254 "bash",
255 "read",
256 "write",
257 "edit",
258 "grep",
259 "find",
260 "ls",
261 "search_tools",
262 "activate_tools",
263 "search_skills",
264 "load_skill",
265 ];
266 let core = ESSENTIAL_BUILTINS
267 .iter()
268 .map(|name| ToolId::builtin(name))
269 .filter(|id| catalog.get(id).is_some())
270 .collect::<Vec<_>>();
271 Self::new(session, core, catalog)
272 .expect("progressive core ids are filtered through the catalog")
273 }
274
275 /// Round-top rebuild against a NEWER catalog (`runtime/stream.rs`):
276 /// the core is re-derived exactly as a fresh build would derive it, and
277 /// every prior exact activation whose CURRENT catalog record still
278 /// matches its pinned (schema digest, trust provenance) is re-issued a
279 /// grant at the new generation. Activations whose record is gone or
280 /// drifted are dropped and reported.
281 ///
282 /// This is NOT a gate change: a carried activation is exactly what a
283 /// user re-activation of the same tool would produce against this
284 /// catalog, and `ExecutionGate::authorize` still checks digest and
285 /// provenance against the current record on every call.
286 pub fn rebuilt_for_catalog(
287 &self,
288 catalog: &ToolCatalog,
289 progressive: bool,
290 ) -> (Self, Vec<DroppedActivation>) {
291 let mut next = if progressive {
292 Self::progressive_core_for_catalog(self.session.clone(), catalog)
293 } else {
294 Self::default_core_for_catalog(self.session.clone(), catalog)
295 };
296 let mut dropped = Vec::new();
297 for old in self.activated.values() {
298 let id = old.grant().tool_id().clone();
299 match catalog.get(&id) {
300 // Promoted to core by the new catalog — nothing to carry.
301 Some(_) if next.core.contains_key(&id) => {}
302 // Same checks a re-activation runs (`activate_many`): pinned
303 // digest + provenance unchanged AND the record's source still
304 // agrees with its provenance (`check_source_trust`). A record
305 // whose source drifted under a stable provenance is dropped
306 // as `Drifted` rather than carried into the schema.
307 Some(rec)
308 if rec.schema_digest() == old.schema_digest()
309 && rec.provenance() == old.provenance()
310 && check_source_trust(rec).is_ok() =>
311 {
312 let grant = SessionActivationGrant::new(
313 self.session.as_str(),
314 id.clone(),
315 catalog.generation(),
316 rec.schema_digest().clone(),
317 )
318 .expect("session id was valid when the set was built");
319 next.activated.insert(
320 id,
321 ActivatedTool {
322 grant,
323 provenance: rec.provenance().clone(),
324 lease: RuntimeLease::NotAcquired,
325 },
326 );
327 }
328 Some(_) => dropped.push(DroppedActivation {
329 id,
330 reason: DropReason::Drifted,
331 }),
332 None => dropped.push(DroppedActivation {
333 id,
334 reason: DropReason::Removed,
335 }),
336 }
337 }
338 // Session schema-generation is bookkeeping for "activation batches
339 // applied"; carrying is not a batch: keep the old counter, +1 iff
340 // anything was dropped (the exposed schema changed).
341 next.schema_generation = self.schema_generation + u64::from(!dropped.is_empty());
342 (next, dropped)
343 }
344
345 /// Typed EXACT revocation (Task 19 grant invalidation): remove one
346 /// non-core exact activation from this session's set. The session
347 /// schema generation advances by exactly one IFF an activation was
348 /// removed; core tools and unknown/never-activated ids fail typed with
349 /// zero mutation. Never touches the catalog or sibling activations.
350 pub fn revoke_exact(&mut self, id: &ToolId) -> Result<(), ExactRevocationError> {
351 if self.core.contains_key(id) {
352 return Err(ExactRevocationError::CoreTool(id.clone()));
353 }
354 if self.activated.remove(id).is_none() {
355 return Err(ExactRevocationError::NotActivated(id.clone()));
356 }
357 self.schema_generation += 1;
358 Ok(())
359 }
360
361 /// The catalog generation this set snapshot was built against.
362 pub fn catalog_generation(&self) -> CatalogGeneration {
363 self.catalog_generation
364 }
365
366 /// True when the catalog has mutated since this set was built. A stale
367 /// set must be rebuilt/revalidated, not silently used.
368 pub fn is_stale(&self, catalog: &ToolCatalog) -> bool {
369 self.catalog_generation != catalog.generation()
370 }
371
372 /// Deterministic projection of configured core ids (ToolId order).
373 pub fn core_ids(&self) -> impl Iterator<Item = &ToolId> {
374 self.core.keys()
375 }
376
377 pub fn is_core(&self, id: &ToolId) -> bool {
378 self.core.contains_key(id)
379 }
380
381 /// The schema digest pinned for a configured core tool at build time,
382 /// or `None` when the id is not in this session's core set.
383 pub fn core_schema_digest(&self, id: &ToolId) -> Option<&SchemaDigest> {
384 self.core.get(id).map(CorePin::schema_digest)
385 }
386
387 /// The full (digest, provenance) pin for a configured core tool, or
388 /// `None` when the id is not in this session's core set.
389 pub fn core_pin(&self, id: &ToolId) -> Option<&CorePin> {
390 self.core.get(id)
391 }
392
393 /// Deterministic projection of exact activations (ToolId order).
394 pub fn activated(&self) -> impl Iterator<Item = &ActivatedTool> {
395 self.activated.values()
396 }
397
398 pub fn activation(&self, id: &ToolId) -> Option<&ActivatedTool> {
399 self.activated.get(id)
400 }
401
402 /// Session schema-generation counter (spec §7.7): the number of
403 /// successful nonempty activation batches applied to this set. A
404 /// deterministic bulk `activate_many` advances it by exactly one.
405 pub fn schema_generation(&self) -> u64 {
406 self.schema_generation
407 }
408
409 /// Validate one grant against this set and the current catalog without
410 /// mutating anything. Shared by [`Self::activate`] and
411 /// [`Self::activate_many`] so single and bulk paths can never drift.
412 fn validate_grant(
413 &self,
414 grant: &SessionActivationGrant,
415 catalog: &ToolCatalog,
416 ) -> Result<(), ActivationError> {
417 if grant.session_id() != self.session.as_str() {
418 return Err(ActivationError::SessionMismatch);
419 }
420 let tool_id = grant.tool_id().clone();
421 let record = catalog
422 .get(&tool_id)
423 .ok_or_else(|| ActivationError::UnknownTool(tool_id.clone()))?;
424 if grant.catalog_generation() != catalog.generation() {
425 return Err(ActivationError::StaleGeneration {
426 grant: grant.catalog_generation(),
427 catalog: catalog.generation(),
428 });
429 }
430 if grant.schema_digest() != record.schema_digest() {
431 return Err(ActivationError::DigestMismatch(tool_id));
432 }
433 // A fresh grant cannot rescue a stale set snapshot: the set itself
434 // must be rebuilt against the current catalog first.
435 if self.catalog_generation != catalog.generation() {
436 return Err(ActivationError::StaleSnapshot {
437 set: self.catalog_generation,
438 catalog: catalog.generation(),
439 });
440 }
441 // Exact-tuple re-check through the grant's own covers() so the two
442 // validation paths can never drift apart.
443 debug_assert!(grant.covers(
444 self.session.as_str(),
445 &tool_id,
446 catalog.generation(),
447 record.schema_digest(),
448 ));
449 if self.activated.contains_key(&tool_id) {
450 return Err(ActivationError::AlreadyActivated(tool_id));
451 }
452 // Core tools are always available to the session; re-recording one
453 // as a deferred activation would create two conflicting bookkeeping
454 // entries for the same capability.
455 if self.core.contains_key(&tool_id) {
456 return Err(ActivationError::AlreadyCore(tool_id));
457 }
458 Ok(())
459 }
460
461 /// Record one exact activation. This is set/test/bootstrap plumbing, not
462 /// a model-facing activation flow: the grant must already exist and must
463 /// match this session, a cataloged tool, the CURRENT catalog generation,
464 /// and the CURRENT schema digest exactly. Any drift — foreign session,
465 /// unknown tool, stale generation, changed digest, stale set snapshot,
466 /// duplicate activation, core-set membership — fails typed before any
467 /// mutation, so a failed call leaves the set byte-for-byte unchanged. No
468 /// implementation is constructed, no process started, no schema exposed.
469 /// A successful call is a nonempty batch of one: the session
470 /// schema-generation advances by exactly one.
471 pub fn activate(
472 &mut self,
473 grant: SessionActivationGrant,
474 catalog: &ToolCatalog,
475 ) -> Result<(), ActivationError> {
476 self.validate_grant(&grant, catalog)?;
477 let provenance = catalog
478 .get(grant.tool_id())
479 .expect("validate_grant verified the record exists")
480 .provenance()
481 .clone();
482 self.activated.insert(
483 grant.tool_id().clone(),
484 ActivatedTool {
485 grant,
486 provenance,
487 lease: RuntimeLease::NotAcquired,
488 },
489 );
490 self.schema_generation += 1;
491 Ok(())
492 }
493
494 /// Deterministic atomic bulk activation (spec §7.7): validate EVERY
495 /// requested grant first — duplicates within the batch, unknown ids,
496 /// core-set ids, stale generations, digest mismatches, stale set
497 /// snapshots, and untrusted/inconsistent source provenance all fail
498 /// typed with ZERO partial mutation — then apply in stable `ToolId`
499 /// order and advance the session schema-generation by exactly one. An
500 /// empty batch is a no-op that advances nothing. Returns the number of
501 /// activations applied.
502 pub fn activate_many(
503 &mut self,
504 grants: Vec<SessionActivationGrant>,
505 catalog: &ToolCatalog,
506 ) -> Result<usize, BulkActivationError> {
507 if grants.is_empty() {
508 return Ok(0);
509 }
510 let mut seen: std::collections::BTreeSet<ToolId> = std::collections::BTreeSet::new();
511 for grant in &grants {
512 if !seen.insert(grant.tool_id().clone()) {
513 return Err(BulkActivationError::DuplicateRequest(
514 grant.tool_id().clone(),
515 ));
516 }
517 self.validate_grant(grant, catalog)
518 .map_err(BulkActivationError::Grant)?;
519 // Source trust re-check before ANY grant is applied: an
520 // unverified or provenance-inconsistent capability rejects the
521 // whole batch (validate_grant proved the record exists).
522 let record = catalog
523 .get(grant.tool_id())
524 .expect("validate_grant verified the record exists");
525 check_source_trust(record).map_err(|err| match err {
526 ToolAuthorizationError::SourceProvenanceMismatch(id) => {
527 BulkActivationError::SourceProvenanceMismatch(id)
528 }
529 // check_source_trust only produces UntrustedSource or
530 // SourceProvenanceMismatch; anything else maps to the
531 // conservative untrusted denial for this id.
532 _ => BulkActivationError::UntrustedSource(grant.tool_id().clone()),
533 })?;
534 }
535 // All grants validated: apply in stable ToolId order.
536 let mut ordered = grants;
537 ordered.sort_by(|a, b| a.tool_id().cmp(b.tool_id()));
538 let applied = ordered.len();
539 for grant in ordered {
540 let provenance = catalog
541 .get(grant.tool_id())
542 .expect("validate_grant verified the record exists")
543 .provenance()
544 .clone();
545 self.activated.insert(
546 grant.tool_id().clone(),
547 ActivatedTool {
548 grant,
549 provenance,
550 lease: RuntimeLease::NotAcquired,
551 },
552 );
553 }
554 // Exactly ONE schema-generation advance for the whole batch.
555 self.schema_generation += 1;
556 Ok(applied)
557 }
558}
559
560// ── ExecutionGate (Task 16, spec §7.1 / §4.2) ───────────────────────────────
561
562/// Byte budget for wire/tool names echoed inside typed denials. Denials are
563/// static, bounded, metadata-only strings: hostile oversize wire names must
564/// never be reflected verbatim.
565pub const WIRE_NAME_MAX_BYTES: usize = 128;
566
567fn bounded_wire_name(raw: &str) -> String {
568 BoundedText::new(raw, WIRE_NAME_MAX_BYTES).text
569}
570
571/// Typed resolution of an incoming API/wire tool name to the exact live
572/// runtime tool identity: the wire name as received (bounded), the runtime
573/// name it deterministically maps to (via the registry's sanitized-name
574/// reverse mapping), and the exact catalog [`ToolId`] derived from the live
575/// tool instance itself — never from the alias. Aliases and collision
576/// suffixes therefore cannot select a different identity than the tool they
577/// deterministically resolve to.
578///
579/// This is identity data only; it grants nothing. Authorization happens in
580/// [`ExecutionGate::authorize`], which is the only constructor of
581/// [`AuthorizedToolCall`].
582#[derive(Clone, Debug, PartialEq, Eq)]
583pub struct ResolvedToolCall {
584 wire_name: String,
585 runtime_name: String,
586 tool_id: ToolId,
587}
588
589impl ResolvedToolCall {
590 pub fn new(wire_name: &str, runtime_name: &str, tool_id: ToolId) -> Self {
591 Self {
592 wire_name: bounded_wire_name(wire_name),
593 runtime_name: runtime_name.to_string(),
594 tool_id,
595 }
596 }
597
598 /// The API/wire name as received (bounded for echo safety).
599 pub fn wire_name(&self) -> &str {
600 &self.wire_name
601 }
602
603 /// The exact runtime tool name the wire name resolves to.
604 pub fn runtime_name(&self) -> &str {
605 &self.runtime_name
606 }
607
608 /// The exact catalog identity of the resolved live tool.
609 pub fn tool_id(&self) -> &ToolId {
610 &self.tool_id
611 }
612}
613
614#[derive(Clone, Copy, Debug, PartialEq, Eq)]
615pub enum ActivationBasis {
616 Core,
617 Exact {
618 catalog_generation: CatalogGeneration,
619 },
620}
621
622/// A fully authorized tool call: exact resolved identity plus the acquired
623/// implementation. The implementation field is private and the ONLY
624/// constructor is [`ExecutionGate::authorize`], which acquires it strictly
625/// after every check passes — the safe path cannot reach an implementation
626/// without passing the gate.
627pub struct AuthorizedToolCall {
628 resolved: ResolvedToolCall,
629 implementation: Arc<dyn Tool>,
630 activation_basis: ActivationBasis,
631}
632
633impl AuthorizedToolCall {
634 pub fn resolved(&self) -> &ResolvedToolCall {
635 &self.resolved
636 }
637
638 pub fn wire_name(&self) -> &str {
639 self.resolved.wire_name()
640 }
641
642 pub fn runtime_name(&self) -> &str {
643 self.resolved.runtime_name()
644 }
645
646 pub fn tool_id(&self) -> &ToolId {
647 self.resolved.tool_id()
648 }
649
650 pub fn activation_basis(&self) -> ActivationBasis {
651 self.activation_basis
652 }
653
654 /// The authorized implementation handle.
655 pub fn implementation(&self) -> Arc<dyn Tool> {
656 Arc::clone(&self.implementation)
657 }
658}
659
660impl std::fmt::Debug for AuthorizedToolCall {
661 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
662 f.debug_struct("AuthorizedToolCall")
663 .field("resolved", &self.resolved)
664 .finish_non_exhaustive()
665 }
666}
667
668/// Typed execution-gate denial. Every variant is static, bounded, and
669/// metadata-only: no tool input, no schema bytes, no provider body content —
670/// only bounded identities and generation counters.
671#[derive(Clone, Debug, Error, PartialEq, Eq)]
672pub enum ToolAuthorizationError {
673 /// The wire name resolves to no live registered tool. Message shape kept
674 /// compatible with the legacy tool-loop denial; the echoed name is
675 /// bounded at resolution time.
676 #[error("Unknown tool: {wire_name}")]
677 UnknownTool { wire_name: String },
678 /// The live tool exists but the catalog holds no record for its
679 /// identity — the registry/catalog invariant is broken; fail closed.
680 #[error("Tool call denied: capability is not cataloged: {0}")]
681 NotCataloged(ToolId),
682 /// Known capability, but neither core for this session nor covered by an
683 /// exact activation grant (the forged deferred-call case).
684 #[error("Tool call denied: tool is not activated for this session: {0}")]
685 NotActivated(ToolId),
686 /// The capability's current schema digest differs from the digest the
687 /// session pinned at core-build/activation time — a changed tool is
688 /// never silently blessed.
689 #[error("Tool call denied: schema digest changed since authorization was pinned: {0}")]
690 SchemaDigestMismatch(ToolId),
691 /// Unverified/unknown provenance is denied by default, even if the
692 /// capability was (mis)configured as core, until an explicit later
693 /// policy exists.
694 #[error("Tool call denied: source provenance is unverified: {0}")]
695 UntrustedSource(ToolId),
696 /// Source/trust provenance failure: either the catalog record's source
697 /// and trust provenance disagree (an internally inconsistent record must
698 /// never authorize), or the record's CURRENT provenance differs from the
699 /// provenance the session pinned at core-build/activation time (a tool
700 /// removed and re-added under a different — even internally coherent —
701 /// provenance must never authorize).
702 #[error("Tool call denied: source/trust provenance is inconsistent or drifted from the session's pin: {0}")]
703 SourceProvenanceMismatch(ToolId),
704}
705
706/// The spec §7.1 execution gate: a pure, reusable authorization component
707/// evaluated immediately before tool implementation lookup/execution.
708///
709/// Order of checks (each fails typed, closed, without acquiring anything):
710/// 1. resolve wire name → exact live `ToolId` (deterministic reverse
711/// mapping; aliases cannot pick a different identity);
712/// 2. the identity must be cataloged;
713/// 3. the identity must be core (pinned digest intact) or hold an exact
714/// session activation grant covering (session, tool, digest) — see
715/// [`grant_covers_execution`]: catalog generation drift alone does NOT
716/// deny at execution time; only a real change to the called tool's
717/// record (digest, presence, provenance) does;
718/// 4. the record's CURRENT trust provenance must equal the provenance the
719/// session PINNED at core-build/activation time — this closes the
720/// coherent-imposter hole (same `ToolId`, schema-identical body,
721/// different but internally consistent source/provenance);
722/// 5. source trust is re-evaluated conservatively — see the honesty note
723/// on [`check_source_trust`]: this re-checks typed source/provenance
724/// consistency and denies Unknown/Unverified, but does NOT yet consult
725/// live manifest permission/revocation state (Task 20 integrates the
726/// permission/revocation policy);
727/// 6. side-effect/confirmation policy (interim: `Unclassified` is allowed
728/// only for verified-provenance capabilities — Task 24 adds real effect
729/// classes);
730/// 7. only then is the implementation acquired.
731///
732/// TOCTOU: callers must resolve, authorize, and acquire against ONE
733/// consistent registry borrow/lock guard — [`ExecutionGate::authorize_wire_call`]
734/// does exactly that for a `&ToolRegistry` held under the caller's read lock.
735pub struct ExecutionGate;
736
737impl ExecutionGate {
738 /// Resolve an incoming API/wire name against one registry snapshot.
739 pub fn resolve(
740 registry: &ToolRegistry,
741 wire_name: &str,
742 ) -> Result<ResolvedToolCall, ToolAuthorizationError> {
743 registry
744 .resolve_wire_call(wire_name)
745 .ok_or_else(|| ToolAuthorizationError::UnknownTool {
746 wire_name: bounded_wire_name(wire_name),
747 })
748 }
749
750 /// Authorize a resolved identity against one catalog snapshot and one
751 /// session tool set. On success — and only then — the implementation is
752 /// acquired from the catalog record's factory and returned inside the
753 /// typed [`AuthorizedToolCall`]. Failure acquires nothing and leaves the
754 /// session set untouched (the gate never mutates it).
755 ///
756 /// SECURITY INVARIANT (per-tool digest + provenance validation): no
757 /// tool executes unless its CURRENT catalog record — presence of the
758 /// exact `ToolId`, schema digest, and source/trust provenance — exactly
759 /// matches what the session pinned at core-build/activation time.
760 /// Catalog generation inequality ALONE is no longer a denial reason at
761 /// execution time: an unrelated background mutation (e.g. a plugin
762 /// load mid-round) must not kill in-flight calls to schema-identical
763 /// tools. Any REAL drift of the called tool's record — changed digest,
764 /// removal, changed provenance (even to an internally coherent one) —
765 /// still denies typed and closed.
766 ///
767 /// ACCEPTED RESIDUAL RISK: [`SchemaDigest`] hashes the schema JSON
768 /// only, not the implementation factory. A record replaced in place
769 /// with an identical schema AND identical pinned provenance but a
770 /// different implementation passes this gate; the exposure is bounded
771 /// by the deterministic round-top rebuild (runtime/stream.rs), which
772 /// re-pins the set against the current catalog between provider rounds.
773 pub fn authorize(
774 catalog: &ToolCatalog,
775 session: &SessionToolSet,
776 resolved: ResolvedToolCall,
777 ) -> Result<AuthorizedToolCall, ToolAuthorizationError> {
778 let tool_id = resolved.tool_id().clone();
779 let record = catalog
780 .get(&tool_id)
781 .ok_or_else(|| ToolAuthorizationError::NotCataloged(tool_id.clone()))?;
782
783 // Generation drift is observed (for logging) but NOT a wholesale
784 // denial: the per-tool checks below are unconditional and judge the
785 // called tool's CURRENT record against the session's pins. The
786 // round-top rebuild (runtime/stream.rs) still uses `is_stale` to
787 // refresh the set deterministically between rounds.
788 let generation_drift = session.is_stale(catalog);
789
790 // Core status or exact activation grant, with pinned-digest
791 // verification either way — unconditional, drift or not. The pinned
792 // trust provenance is carried out of each branch for the
793 // unconditional pinned-provenance check below.
794 let activation_basis;
795 let pinned_provenance: &TrustProvenance;
796 if let Some(pin) = session.core_pin(&tool_id) {
797 if pin.schema_digest() != record.schema_digest() {
798 if generation_drift {
799 tracing::warn!(
800 tool = %tool_id,
801 set_generation = session.catalog_generation().value(),
802 catalog_generation = catalog.generation().value(),
803 "tool denied under catalog generation drift: pinned core schema digest \
804 no longer matches the current catalog record"
805 );
806 }
807 return Err(ToolAuthorizationError::SchemaDigestMismatch(tool_id));
808 }
809 pinned_provenance = pin.provenance();
810 activation_basis = ActivationBasis::Core;
811 } else if let Some(activated) = session.activation(&tool_id) {
812 if activated.schema_digest() != record.schema_digest() {
813 if generation_drift {
814 tracing::warn!(
815 tool = %tool_id,
816 set_generation = session.catalog_generation().value(),
817 catalog_generation = catalog.generation().value(),
818 "tool denied under catalog generation drift: activation grant schema \
819 digest no longer matches the current catalog record"
820 );
821 }
822 return Err(ToolAuthorizationError::SchemaDigestMismatch(tool_id));
823 }
824 // Execution-time grant re-check on the (session, tool, digest)
825 // tuple — deliberately WITHOUT the generation term (see
826 // `grant_covers_execution`). Grant ISSUANCE and activation stay
827 // generation-strict (`validate_grant`, `covers()`).
828 if !grant_covers_execution(
829 activated.grant(),
830 session.session().as_str(),
831 &tool_id,
832 record.schema_digest(),
833 ) {
834 return Err(ToolAuthorizationError::NotActivated(tool_id));
835 }
836 pinned_provenance = activated.provenance();
837 activation_basis = ActivationBasis::Exact {
838 catalog_generation: activated.catalog_generation(),
839 };
840 } else {
841 return Err(ToolAuthorizationError::NotActivated(tool_id));
842 }
843
844 // Unconditional pinned-provenance equality (BLOCKER fix): the
845 // record's CURRENT trust provenance must equal the provenance the
846 // session pinned at core-build/activation time. This — not the
847 // self-consistency check below — is what catches the coherent
848 // imposter: a tool removed and re-added under the same `ToolId`
849 // with a schema-identical body but a different, internally
850 // consistent source/provenance pair.
851 if pinned_provenance != record.provenance() {
852 if generation_drift {
853 tracing::warn!(
854 tool = %tool_id,
855 set_generation = session.catalog_generation().value(),
856 catalog_generation = catalog.generation().value(),
857 "tool denied under catalog generation drift: current record provenance \
858 no longer matches the provenance the session pinned"
859 );
860 }
861 return Err(ToolAuthorizationError::SourceProvenanceMismatch(tool_id));
862 }
863
864 // Conservative source trust re-check immediately before
865 // acquisition: typed source/provenance SELF-consistency plus the
866 // deny-by-default for Unknown/Unverified (see check_source_trust) —
867 // live manifest permission/revocation state is not consulted here
868 // yet (Task 20). This complements (does not replace) the
869 // pinned-provenance equality above.
870 check_source_trust(record)?;
871
872 if generation_drift {
873 tracing::warn!(
874 tool = %tool_id,
875 set_generation = session.catalog_generation().value(),
876 catalog_generation = catalog.generation().value(),
877 "tool call survived catalog generation drift: current record digest, presence \
878 and provenance exactly match the session's pins"
879 );
880 }
881
882 // Effect classes (Task 24) are execution-SCHEDULING policy, not
883 // authorization policy: every class may execute once authorized —
884 // the stream scheduler decides concurrency/ordering from the
885 // recorded class. The exhaustive match keeps this decision loud if
886 // a future class needs gate-level treatment.
887 match record.effect() {
888 ToolEffect::ReadOnly | ToolEffect::IdempotentWrite | ToolEffect::NonIdempotent => {}
889 }
890
891 // Acquisition happens strictly after authorization succeeds.
892 let implementation = record.implementation();
893 Ok(AuthorizedToolCall {
894 resolved,
895 implementation,
896 activation_basis,
897 })
898 }
899
900 /// Resolve + authorize + acquire against ONE registry borrow (one
901 /// consistent snapshot under the caller's read lock — no TOCTOU between
902 /// resolution and implementation acquisition).
903 pub fn authorize_wire_call(
904 registry: &ToolRegistry,
905 session: &SessionToolSet,
906 wire_name: &str,
907 ) -> Result<AuthorizedToolCall, ToolAuthorizationError> {
908 let resolved = Self::resolve(registry, wire_name)?;
909 Self::authorize(registry.catalog(), session, resolved)
910 }
911}
912
913/// Execution-time grant coverage: (session, tool, digest) equality WITHOUT
914/// the generation term of [`SessionActivationGrant::covers`]. Rationale: at
915/// execution time the tool's identity and schema are already re-validated
916/// against the CURRENT catalog record, so requiring the pinned generation to
917/// equal the live one only makes unrelated catalog mutations (background
918/// plugin loads) kill in-flight calls to schema-identical tools. `covers()`
919/// itself (agent-core) stays exact-tuple and is still what grant ISSUANCE
920/// and activation validation (`validate_grant`) enforce — this relaxation
921/// applies ONLY to re-validation of an already-established authorization.
922fn grant_covers_execution(
923 grant: &SessionActivationGrant,
924 session_id: &str,
925 tool_id: &ToolId,
926 schema_digest: &SchemaDigest,
927) -> bool {
928 grant.session_id() == session_id
929 && grant.tool_id() == tool_id
930 && grant.schema_digest() == schema_digest
931}
932
933/// Conservative per-source trust policy — CONSISTENCY check, not a live
934/// permission lookup: `BuiltinRuntime` provenance is valid only for
935/// builtin-sourced records; extension/MCP/plugin provenance must match the
936/// catalog source identity exactly; `Unverified` (and any unknown source)
937/// is denied by default even if configured core.
938///
939/// Honesty note (Task 16): this validates the typed provenance recorded at
940/// catalog time against the record's source identity. It does NOT consult
941/// live manifest permission state or revocation — a capability whose
942/// extension permissions were revoked after cataloging still passes this
943/// check until the catalog entry is removed/rebuilt. Live
944/// permission/revocation integration is Task 20.
945fn check_source_trust(record: &CapabilityRecord) -> Result<(), ToolAuthorizationError> {
946 let deny_untrusted = || ToolAuthorizationError::UntrustedSource(record.id().clone());
947 let deny_mismatch = || ToolAuthorizationError::SourceProvenanceMismatch(record.id().clone());
948 match (record.provenance(), record.source()) {
949 (TrustProvenance::Unverified, _) => Err(deny_untrusted()),
950 (_, CapabilitySource::Unknown { .. }) => Err(deny_untrusted()),
951 (TrustProvenance::BuiltinRuntime, CapabilitySource::Builtin) => Ok(()),
952 (
953 TrustProvenance::ExtensionManifest { extension_id },
954 CapabilitySource::Extension {
955 extension_id: source_extension_id,
956 ..
957 },
958 ) if extension_id == source_extension_id => Ok(()),
959 (
960 TrustProvenance::McpConfig { server_id },
961 CapabilitySource::Mcp {
962 server_id: source_server_id,
963 ..
964 },
965 ) if server_id == source_server_id => Ok(()),
966 (
967 TrustProvenance::PluginConfig { plugin_id },
968 CapabilitySource::Plugin {
969 plugin_id: source_plugin_id,
970 ..
971 },
972 ) if plugin_id == source_plugin_id => Ok(()),
973 _ => Err(deny_mismatch()),
974 }
975}
976
977// ── Task 17: host grant issuance, typed confirmation authority, and the
978// retained shared session set (spec §7.2, §7.3, §7.7) ─────────────────────
979
980/// One retained per-stream-session handle: `activate_tools` mutates the SAME
981/// set the `ExecutionGate` and the next provider round consume. Interior
982/// critical sections are short and never held across `.await`.
983pub type SharedSessionToolSet = Arc<std::sync::RwLock<SessionToolSet>>;
984
985/// Typed failure for deterministic bulk activation ([`SessionToolSet::activate_many`]).
986/// Every variant fails the WHOLE batch with zero partial mutation.
987#[derive(Clone, Debug, Error, PartialEq, Eq)]
988pub enum BulkActivationError {
989 #[error("duplicate tool id in one activation batch: {0}")]
990 DuplicateRequest(ToolId),
991 #[error("activation batch rejected: {0}")]
992 Grant(#[from] ActivationError),
993 #[error("activation batch rejected: source provenance is unverified: {0}")]
994 UntrustedSource(ToolId),
995 #[error("activation batch rejected: catalog source and trust provenance disagree: {0}")]
996 SourceProvenanceMismatch(ToolId),
997}
998
999/// Host-supplied activation authority for MODEL-INITIATED activation.
1000///
1001/// `ModelConfirmed` may be populated ONLY by host policy (explicit server
1002/// auto-approve or an interactive confirmation mechanism) — never from
1003/// model-authored JSON arguments. Explicit user-requested activation goes
1004/// through [`activate_exact_for_user`], a host Rust API that involves no
1005/// authority value at all (PR #63 ergonomics: a user asking for an exact
1006/// known tool needs no redundant prompt).
1007#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1008pub enum ActivationAuthority {
1009 /// No confirmation authority: model-initiated activation is denied
1010 /// before any grant issuance or set/schema mutation.
1011 Unauthorized,
1012 /// Host confirmation policy satisfied for this session/turn.
1013 ModelConfirmed,
1014}
1015
1016/// Typed failure for host grant issuance. Issuance validates the exact
1017/// known `ToolId`, the current catalog generation/digest (pinned into the
1018/// grant), and source trust — before any grant exists.
1019#[derive(Clone, Debug, Error, PartialEq, Eq)]
1020pub enum GrantIssuanceError {
1021 #[error("cannot issue activation grant: tool is not in the catalog: {0}")]
1022 UnknownTool(ToolId),
1023 #[error("cannot issue activation grant: source provenance is unverified: {0}")]
1024 UntrustedSource(ToolId),
1025 #[error("cannot issue activation grant: catalog source and trust provenance disagree: {0}")]
1026 SourceProvenanceMismatch(ToolId),
1027 #[error("cannot issue activation grant: {0}")]
1028 Grant(#[from] agent_core::orchestration::capability::ActivationGrantError),
1029}
1030
1031/// Issue one exact session-scoped activation grant against the CURRENT
1032/// catalog state. Validates: the id is a known exact cataloged capability
1033/// and its source trust/provenance is consistent and verified. The grant
1034/// pins the current catalog generation and schema digest. Pure: no factory
1035/// invocation, no process start, no network, no set mutation.
1036pub fn issue_exact_grant(
1037 catalog: &ToolCatalog,
1038 session: &SessionId,
1039 tool_id: &ToolId,
1040) -> Result<SessionActivationGrant, GrantIssuanceError> {
1041 let record = catalog
1042 .get(tool_id)
1043 .ok_or_else(|| GrantIssuanceError::UnknownTool(tool_id.clone()))?;
1044 check_source_trust(record).map_err(|err| match err {
1045 ToolAuthorizationError::SourceProvenanceMismatch(id) => {
1046 GrantIssuanceError::SourceProvenanceMismatch(id)
1047 }
1048 _ => GrantIssuanceError::UntrustedSource(tool_id.clone()),
1049 })?;
1050 Ok(SessionActivationGrant::new(
1051 session.as_str(),
1052 tool_id.clone(),
1053 catalog.generation(),
1054 record.schema_digest().clone(),
1055 )?)
1056}
1057
1058/// Typed failure for the host activation entry points.
1059#[derive(Clone, Debug, Error, PartialEq, Eq)]
1060pub enum HostActivationError {
1061 /// Model-initiated activation without host confirmation authority.
1062 /// Denied BEFORE grant issuance and before any set/schema mutation.
1063 #[error(
1064 "tool activation requires host confirmation authority; \
1065 model-initiated activation was not confirmed"
1066 )]
1067 ConfirmationRequired,
1068 #[error(transparent)]
1069 Issuance(#[from] GrantIssuanceError),
1070 #[error(transparent)]
1071 Bulk(#[from] BulkActivationError),
1072}
1073
1074/// Explicit USER-REQUESTED exact activation (spec §7.3 / PR #63 ergonomics):
1075/// the host may authorize one exact known local identity without a
1076/// redundant confirmation prompt. This is a host Rust API — it is never
1077/// reachable from model-authored JSON and takes no confirmation boolean.
1078/// Grants are session-scoped and never persisted.
1079pub fn activate_exact_for_user(
1080 set: &mut SessionToolSet,
1081 catalog: &ToolCatalog,
1082 tool_id: &ToolId,
1083) -> Result<(), HostActivationError> {
1084 let grant = issue_exact_grant(catalog, set.session(), tool_id)?;
1085 set.activate_many(vec![grant], catalog)?;
1086 Ok(())
1087}
1088
1089/// MODEL-INITIATED exact activation: requires host-supplied
1090/// [`ActivationAuthority::ModelConfirmed`]. Without it, the request is
1091/// denied before any grant issuance, set mutation, or schema-generation
1092/// advance. All requested ids are validated and issued first; the batch
1093/// applies atomically in stable `ToolId` order with exactly one session
1094/// schema-generation advance. Grants are session-scoped, never persisted.
1095pub fn activate_model_initiated(
1096 authority: ActivationAuthority,
1097 set: &mut SessionToolSet,
1098 catalog: &ToolCatalog,
1099 tool_ids: &[ToolId],
1100) -> Result<usize, HostActivationError> {
1101 match authority {
1102 ActivationAuthority::ModelConfirmed => {}
1103 ActivationAuthority::Unauthorized => {
1104 return Err(HostActivationError::ConfirmationRequired);
1105 }
1106 }
1107 let session = set.session().clone();
1108 let mut grants = Vec::with_capacity(tool_ids.len());
1109 for tool_id in tool_ids {
1110 grants.push(issue_exact_grant(catalog, &session, tool_id)?);
1111 }
1112 Ok(set.activate_many(grants, catalog)?)
1113}
1114
1115/// Resolve the session tool set for the extension-provider route (Task 17,
1116/// closing the Task 16 policy divergence): when the runtime threads its
1117/// RETAINED per-stream set, the route consumes THAT set's current state —
1118/// including exact activations — at its pinned generation. The route must
1119/// never silently mint a fresh default-core set for the same round. Only
1120/// callers with no retained handle at all (internal/sync helpers) fall back
1121/// to a fresh default-core set with zero activations under a locally minted
1122/// session.
1123///
1124/// Generation drift is deliberately SURVIVED here (per-tool digest
1125/// validation fix): the retained set is served as-is even when the catalog
1126/// generation moved past its snapshot, because every actual execution still
1127/// passes through [`ExecutionGate::authorize`], which unconditionally
1128/// re-validates each called tool's current record (presence, digest,
1129/// provenance) against the session's pins. Denying the whole retained set
1130/// wholesale would let an unrelated background catalog mutation kill an
1131/// entire in-flight round of schema-identical tools. Infallible: neither
1132/// path can be denied anymore, so the return type is the plain set.
1133pub fn route_session_set(
1134 retained: Option<&SharedSessionToolSet>,
1135 catalog: &ToolCatalog,
1136 fallback_session: impl FnOnce() -> SessionId,
1137) -> SessionToolSet {
1138 match retained {
1139 Some(shared) => {
1140 let guard = shared
1141 .read()
1142 .unwrap_or_else(std::sync::PoisonError::into_inner);
1143 if guard.is_stale(catalog) {
1144 tracing::warn!(
1145 set_generation = guard.catalog_generation().value(),
1146 catalog_generation = catalog.generation().value(),
1147 "serving retained session tool set across catalog generation drift; \
1148 per-call ExecutionGate::authorize still protects execution"
1149 );
1150 }
1151 guard.clone()
1152 }
1153 None => SessionToolSet::default_core_for_catalog(fallback_session(), catalog),
1154 }
1155}