Skip to main content

agent_core/orchestration/
capability.rs

1//! Typed capability identities shared across catalog/activation surfaces
2//! (spec §4.1, §12).
3//!
4//! External identifiers become typed values before policy checks. Parsing is
5//! strict and canonical: no sanitization or aliasing happens here, so two
6//! distinct raw spellings can never collapse into one identity. Malformed and
7//! oversized input fails closed with typed errors.
8
9use serde::Serialize;
10use sha2::{Digest, Sha256};
11use thiserror::Error;
12
13/// Maximum total byte length of a serialized `ToolId` (`namespace:name`).
14pub const TOOL_ID_MAX_BYTES: usize = 200;
15/// Maximum byte length of the namespace segment.
16pub const TOOL_ID_NAMESPACE_MAX_BYTES: usize = 64;
17/// Maximum byte length of the name segment.
18pub const TOOL_ID_NAME_MAX_BYTES: usize = 128;
19/// Maximum byte length of a session identifier inside an activation grant.
20pub const SESSION_ID_MAX_BYTES: usize = 256;
21
22/// Typed failure for boundary parsing of [`ToolId`].
23#[derive(Clone, Debug, Error, PartialEq, Eq)]
24pub enum ToolIdError {
25    #[error("tool id is empty")]
26    Empty,
27    #[error("tool id is oversized: {actual} bytes exceeds limit {limit}")]
28    Oversized { actual: usize, limit: usize },
29    #[error("tool id is missing a `namespace:name` separator")]
30    MissingNamespace,
31    #[error("tool id namespace segment is empty, oversized, or non-canonical")]
32    InvalidNamespace,
33    #[error("tool id name segment is empty, oversized, or non-canonical")]
34    InvalidName,
35}
36
37/// Stable, validated capability identity of the form `namespace:name`
38/// (e.g. `builtin:bash`, `mcp.server-1:list_issues`).
39///
40/// Only canonical lowercase segments of `[a-z0-9_.-]` starting with an
41/// alphanumeric character are accepted, which removes case/sanitization
42/// alias ambiguity by construction.
43#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
44#[serde(transparent)]
45pub struct ToolId(String);
46
47fn is_canonical_segment(segment: &str, max_bytes: usize) -> bool {
48    if segment.is_empty() || segment.len() > max_bytes {
49        return false;
50    }
51    let mut chars = segment.chars();
52    let first_ok = chars
53        .next()
54        .is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit());
55    first_ok
56        && chars
57            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '-' | '.'))
58}
59
60/// Reserved marker for hex-encoded segments. Verbatim segments never start
61/// with it, so encoded and verbatim forms cannot collide.
62const ENCODED_SEGMENT_PREFIX: &str = "enc-";
63/// Reserved marker for digest-compressed oversized segments.
64const DIGEST_SEGMENT_PREFIX: &str = "sha-";
65/// Hex characters kept from the SHA-256 of an oversized segment (160 bits).
66const DIGEST_SEGMENT_HEX_LEN: usize = 40;
67
68fn hex_lower(bytes: &[u8]) -> String {
69    let mut out = String::with_capacity(bytes.len() * 2);
70    for b in bytes {
71        use std::fmt::Write as _;
72        let _ = write!(out, "{b:02x}");
73    }
74    out
75}
76
77/// Deterministic alias-safe canonical encoding of one raw runtime segment.
78///
79/// - Already-canonical segments that do not spell a reserved prefix pass
80///   through verbatim, so existing canonical identities are unchanged.
81/// - Anything else (uppercase, Unicode, whitespace, `:`, empty, reserved
82///   spellings) becomes `enc-<lowercase hex of the raw bytes>`, which is
83///   injective: two distinct raw spellings can never collapse.
84/// - Segments whose hex form exceeds the byte budget compress to
85///   `sha-<truncated sha256 hex>` (deterministic, 160-bit collision
86///   resistant, always within budget).
87fn encode_segment(raw: &str, max_bytes: usize) -> String {
88    debug_assert!(max_bytes >= DIGEST_SEGMENT_PREFIX.len() + DIGEST_SEGMENT_HEX_LEN);
89    if is_canonical_segment(raw, max_bytes)
90        && !raw.starts_with(ENCODED_SEGMENT_PREFIX)
91        && !raw.starts_with(DIGEST_SEGMENT_PREFIX)
92    {
93        return raw.to_string();
94    }
95    let encoded = format!("{ENCODED_SEGMENT_PREFIX}{}", hex_lower(raw.as_bytes()));
96    if encoded.len() <= max_bytes {
97        return encoded;
98    }
99    let digest = Sha256::digest(raw.as_bytes());
100    let mut hex = hex_lower(&digest);
101    hex.truncate(DIGEST_SEGMENT_HEX_LEN);
102    format!("{DIGEST_SEGMENT_PREFIX}{hex}")
103}
104
105impl ToolId {
106    /// Parse an untrusted string into a canonical `ToolId`, failing closed on
107    /// anything malformed, oversized, or non-canonical.
108    pub fn parse(raw: &str) -> Result<Self, ToolIdError> {
109        if raw.is_empty() {
110            return Err(ToolIdError::Empty);
111        }
112        if raw.len() > TOOL_ID_MAX_BYTES {
113            return Err(ToolIdError::Oversized {
114                actual: raw.len(),
115                limit: TOOL_ID_MAX_BYTES,
116            });
117        }
118        let (namespace, name) = raw.split_once(':').ok_or(ToolIdError::MissingNamespace)?;
119        if !is_canonical_segment(namespace, TOOL_ID_NAMESPACE_MAX_BYTES) {
120            return Err(ToolIdError::InvalidNamespace);
121        }
122        if !is_canonical_segment(name, TOOL_ID_NAME_MAX_BYTES) {
123            return Err(ToolIdError::InvalidName);
124        }
125        Ok(Self(raw.to_string()))
126    }
127
128    /// Namespace-family prefixes used by the source-aware constructors below.
129    /// They are pairwise non-overlapping, so identities from different
130    /// sources cannot collide even for identical raw names.
131    const BUILTIN_NAMESPACE: &'static str = "builtin";
132    const UNKNOWN_NAMESPACE: &'static str = "unknown";
133    const EXTENSION_NAMESPACE_PREFIX: &'static str = "ext.";
134    const MCP_NAMESPACE_PREFIX: &'static str = "mcp.";
135    const PLUGIN_NAMESPACE_PREFIX: &'static str = "plugin.";
136
137    fn from_source(namespace: String, raw_name: &str) -> Self {
138        debug_assert!(namespace.len() <= TOOL_ID_NAMESPACE_MAX_BYTES);
139        let name = encode_segment(raw_name, TOOL_ID_NAME_MAX_BYTES);
140        Self(format!("{namespace}:{name}"))
141    }
142
143    fn from_prefixed_source(prefix: &str, raw_source: &str, raw_name: &str) -> Self {
144        let budget = TOOL_ID_NAMESPACE_MAX_BYTES - prefix.len();
145        let namespace = format!("{prefix}{}", encode_segment(raw_source, budget));
146        Self::from_source(namespace, raw_name)
147    }
148
149    /// Identity of a capability compiled into this runtime.
150    pub fn builtin(runtime_name: &str) -> Self {
151        Self::from_source(Self::BUILTIN_NAMESPACE.to_string(), runtime_name)
152    }
153
154    /// Identity of a capability declared by a locally installed extension.
155    /// The extension id and tool name are encoded independently, so existing
156    /// uppercase/Unicode/colon-bearing runtime identities are representable
157    /// exactly without alias collapse.
158    pub fn extension(extension_id: &str, tool_name: &str) -> Self {
159        Self::from_prefixed_source(Self::EXTENSION_NAMESPACE_PREFIX, extension_id, tool_name)
160    }
161
162    /// Identity of a capability served by a configured MCP server, keyed by
163    /// the server id and the tool name as the server knows it.
164    pub fn mcp(server_id: &str, server_tool_name: &str) -> Self {
165        Self::from_prefixed_source(Self::MCP_NAMESPACE_PREFIX, server_id, server_tool_name)
166    }
167
168    /// Identity of a capability declared by a plugin definition.
169    pub fn plugin(plugin_id: &str, tool_name: &str) -> Self {
170        Self::from_prefixed_source(Self::PLUGIN_NAMESPACE_PREFIX, plugin_id, tool_name)
171    }
172
173    /// Identity of a dynamically registered capability with no declared
174    /// origin. Kept in an explicit `unknown` namespace so it can never be
175    /// mistaken for a trusted builtin.
176    pub fn unclassified(runtime_name: &str) -> Self {
177        Self::from_source(Self::UNKNOWN_NAMESPACE.to_string(), runtime_name)
178    }
179
180    pub fn as_str(&self) -> &str {
181        &self.0
182    }
183
184    /// The source namespace segment (before the `:`).
185    pub fn namespace(&self) -> &str {
186        self.0.split_once(':').map(|(ns, _)| ns).unwrap_or("")
187    }
188
189    /// The capability name segment (after the `:`).
190    pub fn name(&self) -> &str {
191        self.0.split_once(':').map(|(_, name)| name).unwrap_or("")
192    }
193}
194
195impl std::fmt::Display for ToolId {
196    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
197        f.write_str(&self.0)
198    }
199}
200
201/// Monotonic catalog mutation counter. Every catalog mutation advances the
202/// generation so stale activations can be detected and invalidated.
203#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
204#[serde(transparent)]
205pub struct CatalogGeneration(u64);
206
207/// Typed failure for exhausting the generation counter. Mutations observing
208/// this must fail closed: no mutation may succeed without a new generation.
209#[derive(Clone, Copy, Debug, Error, PartialEq, Eq)]
210#[error("catalog generation counter exhausted at u64::MAX")]
211pub struct CatalogGenerationOverflow;
212
213impl CatalogGeneration {
214    pub const fn initial() -> Self {
215        Self(0)
216    }
217
218    /// Construct a generation at an explicit value (boundary tests, resuming
219    /// persisted counters). Constructing a generation grants nothing.
220    pub const fn new(value: u64) -> Self {
221        Self(value)
222    }
223
224    /// Fail-closed advancement: at `u64::MAX` there is no new generation, so
225    /// the caller must abort its mutation instead of wrapping or sticking
226    /// (either of which could let stale grants keep validating).
227    #[must_use = "checked_next() returns the advanced generation without mutating self"]
228    pub fn checked_next(self) -> Result<Self, CatalogGenerationOverflow> {
229        self.0
230            .checked_add(1)
231            .map(Self)
232            .ok_or(CatalogGenerationOverflow)
233    }
234
235    pub const fn value(self) -> u64 {
236        self.0
237    }
238}
239
240/// Deterministic SHA-256 digest (lowercase hex) of a tool's full JSON schema.
241///
242/// The digest hashes a canonical structural encoding — explicit type tags,
243/// length framing, and lexicographically sorted object keys — computed
244/// iteratively. It is therefore independent of serde_json's `preserve_order`
245/// feature and map insertion order, never allocates a serialized copy of the
246/// schema, performs no fallible serialization (`expect`-free on external
247/// data), and cannot overflow the stack on deeply nested values.
248#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize)]
249#[serde(transparent)]
250pub struct SchemaDigest(String);
251
252const DIGEST_TAG_NULL: u8 = 0;
253const DIGEST_TAG_BOOL: u8 = 1;
254const DIGEST_TAG_NUMBER: u8 = 2;
255const DIGEST_TAG_STRING: u8 = 3;
256const DIGEST_TAG_ARRAY: u8 = 4;
257const DIGEST_TAG_OBJECT: u8 = 5;
258const DIGEST_TAG_KEY: u8 = 6;
259
260impl SchemaDigest {
261    pub fn of_schema(schema: &serde_json::Value) -> Self {
262        use serde_json::Value;
263
264        enum Task<'a> {
265            Value(&'a Value),
266            Key(&'a str),
267        }
268
269        let mut hasher = Sha256::new();
270        let mut stack: Vec<Task<'_>> = vec![Task::Value(schema)];
271        while let Some(task) = stack.pop() {
272            match task {
273                Task::Key(key) => {
274                    hasher.update([DIGEST_TAG_KEY]);
275                    hasher.update((key.len() as u64).to_le_bytes());
276                    hasher.update(key.as_bytes());
277                }
278                Task::Value(value) => match value {
279                    Value::Null => hasher.update([DIGEST_TAG_NULL]),
280                    Value::Bool(b) => hasher.update([DIGEST_TAG_BOOL, u8::from(*b)]),
281                    Value::Number(n) => {
282                        let repr = n.to_string();
283                        hasher.update([DIGEST_TAG_NUMBER]);
284                        hasher.update((repr.len() as u64).to_le_bytes());
285                        hasher.update(repr.as_bytes());
286                    }
287                    Value::String(s) => {
288                        hasher.update([DIGEST_TAG_STRING]);
289                        hasher.update((s.len() as u64).to_le_bytes());
290                        hasher.update(s.as_bytes());
291                    }
292                    Value::Array(items) => {
293                        hasher.update([DIGEST_TAG_ARRAY]);
294                        hasher.update((items.len() as u64).to_le_bytes());
295                        for item in items.iter().rev() {
296                            stack.push(Task::Value(item));
297                        }
298                    }
299                    Value::Object(map) => {
300                        hasher.update([DIGEST_TAG_OBJECT]);
301                        hasher.update((map.len() as u64).to_le_bytes());
302                        // Sort explicitly: with `preserve_order` enabled the
303                        // map iterates in insertion order, which must not
304                        // change the digest.
305                        let mut entries: Vec<(&String, &Value)> = map.iter().collect();
306                        entries.sort_by_key(|(key, _)| *key);
307                        for (key, child) in entries.into_iter().rev() {
308                            stack.push(Task::Value(child));
309                            stack.push(Task::Key(key));
310                        }
311                    }
312                },
313            }
314        }
315        Self(format!("{:x}", hasher.finalize()))
316    }
317
318    pub fn as_hex(&self) -> &str {
319        &self.0
320    }
321}
322
323/// Typed failure for boundary parsing of [`SessionId`].
324#[derive(Clone, Debug, Error, PartialEq, Eq)]
325pub enum SessionIdError {
326    #[error("session id is empty")]
327    Empty,
328    #[error("session id is oversized: {actual} bytes exceeds limit {limit}")]
329    Oversized { actual: usize, limit: usize },
330    #[error("session id contains control characters")]
331    ControlCharacters,
332}
333
334/// Typed, validated session identity (Task 15). Freely accepted unbounded
335/// strings must not name sessions; parse at the boundary instead. The same
336/// limits back [`SessionActivationGrant`] session validation, so a grant's
337/// session id and a `SessionId` can never diverge in what they accept.
338#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
339#[serde(transparent)]
340pub struct SessionId(String);
341
342impl SessionId {
343    pub fn parse(raw: &str) -> Result<Self, SessionIdError> {
344        if raw.is_empty() {
345            return Err(SessionIdError::Empty);
346        }
347        if raw.len() > SESSION_ID_MAX_BYTES {
348            return Err(SessionIdError::Oversized {
349                actual: raw.len(),
350                limit: SESSION_ID_MAX_BYTES,
351            });
352        }
353        // Reject C0/C1/DEL control values (newline, carriage return, NUL,
354        // ESC/ANSI, NEL, …): session ids reach `Display`/log output and
355        // grant comparisons, so raw control bytes must fail closed at the
356        // boundary instead of being smuggled through.
357        if raw.chars().any(char::is_control) {
358            return Err(SessionIdError::ControlCharacters);
359        }
360        Ok(Self(raw.to_string()))
361    }
362
363    pub fn as_str(&self) -> &str {
364        &self.0
365    }
366}
367
368impl std::fmt::Display for SessionId {
369    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
370        f.write_str(&self.0)
371    }
372}
373
374/// Typed failure for constructing a [`SessionActivationGrant`].
375#[derive(Clone, Debug, Error, PartialEq, Eq)]
376pub enum ActivationGrantError {
377    #[error("activation grant session id is empty")]
378    EmptySessionId,
379    #[error("activation grant session id is oversized: {actual} bytes exceeds limit {limit}")]
380    OversizedSessionId { actual: usize, limit: usize },
381    #[error("activation grant session id contains control characters")]
382    ControlCharacterSessionId,
383}
384
385/// An exact, session-scoped activation of one catalog capability, pinned to
386/// the catalog generation and schema digest it was granted against.
387///
388/// Introduced in Task 14 as a shared identity type; issuing and enforcing
389/// grants is later work (`SessionToolSet` / `ExecutionGate`). No code path
390/// issues grants at catalog insertion time.
391#[derive(Clone, Debug, Serialize)]
392pub struct SessionActivationGrant {
393    session_id: String,
394    tool_id: ToolId,
395    catalog_generation: CatalogGeneration,
396    schema_digest: SchemaDigest,
397}
398
399impl SessionActivationGrant {
400    pub fn new(
401        session_id: &str,
402        tool_id: ToolId,
403        catalog_generation: CatalogGeneration,
404        schema_digest: SchemaDigest,
405    ) -> Result<Self, ActivationGrantError> {
406        // Single source of truth for session identity limits: delegate to
407        // `SessionId` so grant and session parsing can never diverge.
408        let session_id = SessionId::parse(session_id).map_err(|err| match err {
409            SessionIdError::Empty => ActivationGrantError::EmptySessionId,
410            SessionIdError::Oversized { actual, limit } => {
411                ActivationGrantError::OversizedSessionId { actual, limit }
412            }
413            SessionIdError::ControlCharacters => ActivationGrantError::ControlCharacterSessionId,
414        })?;
415        Ok(Self {
416            session_id: session_id.0,
417            tool_id,
418            catalog_generation,
419            schema_digest,
420        })
421    }
422
423    pub fn session_id(&self) -> &str {
424        &self.session_id
425    }
426
427    pub fn tool_id(&self) -> &ToolId {
428        &self.tool_id
429    }
430
431    pub fn catalog_generation(&self) -> CatalogGeneration {
432        self.catalog_generation
433    }
434
435    pub fn schema_digest(&self) -> &SchemaDigest {
436        &self.schema_digest
437    }
438
439    /// True only for the exact (session, tool, generation, digest) tuple the
440    /// grant was issued for. Any drift — stale generation, changed schema,
441    /// different session or tool — fails closed.
442    pub fn covers(
443        &self,
444        session_id: &str,
445        tool_id: &ToolId,
446        catalog_generation: CatalogGeneration,
447        schema_digest: &SchemaDigest,
448    ) -> bool {
449        self.session_id == session_id
450            && &self.tool_id == tool_id
451            && self.catalog_generation == catalog_generation
452            && &self.schema_digest == schema_digest
453    }
454}