Skip to main content

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