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