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}