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