Skip to main content

tea_tools/
audit.rs

1use serde::{Deserialize, Deserializer, Serialize};
2use serde_json::to_value;
3use tea_protocol::{ProtocolMetadata, ProtocolMetadataError};
4use thiserror::Error;
5
6use crate::{ToolEffect, ToolResourceAccess, ToolSource, ToolVersion};
7
8const MAX_AUDIT_EFFECTS: usize = 64;
9const MAX_AUDIT_RESOURCES: usize = 128;
10const MAX_RESOURCE_PRESENTATION_BYTES: usize = 2048;
11
12/// Metadata namespace used on durable tool-call request envelopes.
13pub const TOOL_AUDIT_METADATA_NAMESPACE: &str = "dev.tea-rs.tool-audit";
14
15/// Already-redacted resource presentation safe for durable audit metadata.
16#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
17#[serde(rename_all = "camelCase")]
18pub struct ToolAuditResource {
19    scheme: String,
20    redacted_presentation: String,
21    access: ToolResourceAccess,
22}
23
24impl ToolAuditResource {
25    /// Creates a bounded audit resource from an already-redacted presentation.
26    ///
27    /// # Errors
28    ///
29    /// Returns an error for a non-canonical scheme or an empty, oversized, or
30    /// control-containing presentation.
31    pub fn new(
32        scheme: impl Into<String>,
33        redacted_presentation: impl Into<String>,
34        access: ToolResourceAccess,
35    ) -> Result<Self, ToolAuditMetadataError> {
36        let scheme = scheme.into();
37        let redacted_presentation = redacted_presentation.into();
38        let mut bytes = scheme.bytes();
39        if scheme.len() > 64
40            || !bytes.next().is_some_and(|byte| byte.is_ascii_lowercase())
41            || !bytes.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
42        {
43            return Err(ToolAuditMetadataError::InvalidResource);
44        }
45        if redacted_presentation.is_empty()
46            || redacted_presentation.len() > MAX_RESOURCE_PRESENTATION_BYTES
47            || redacted_presentation.chars().any(char::is_control)
48        {
49            return Err(ToolAuditMetadataError::InvalidResource);
50        }
51        Ok(Self {
52            scheme,
53            redacted_presentation,
54            access,
55        })
56    }
57
58    /// Returns the canonical resource scheme.
59    #[must_use]
60    pub fn scheme(&self) -> &str {
61        &self.scheme
62    }
63
64    /// Returns the already-redacted resource presentation.
65    #[must_use]
66    pub fn redacted_presentation(&self) -> &str {
67        &self.redacted_presentation
68    }
69
70    /// Returns requested resource access.
71    #[must_use]
72    pub const fn access(&self) -> ToolResourceAccess {
73        self.access
74    }
75}
76
77#[derive(Deserialize)]
78#[serde(rename_all = "camelCase", deny_unknown_fields)]
79struct RawToolAuditResource {
80    scheme: String,
81    redacted_presentation: String,
82    access: ToolResourceAccess,
83}
84
85impl<'de> Deserialize<'de> for ToolAuditResource {
86    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
87    where
88        D: Deserializer<'de>,
89    {
90        let raw = RawToolAuditResource::deserialize(deserializer)?;
91        Self::new(raw.scheme, raw.redacted_presentation, raw.access)
92            .map_err(serde::de::Error::custom)
93    }
94}
95
96/// Bounded tool provenance and declared-capability metadata for durable audit.
97#[derive(Debug, Clone, PartialEq, Serialize)]
98#[serde(rename_all = "camelCase")]
99pub struct ToolAuditMetadata {
100    tool_version: ToolVersion,
101    source: ToolSource,
102    effects: Vec<ToolEffect>,
103    resources: Vec<ToolAuditResource>,
104}
105
106impl ToolAuditMetadata {
107    /// Creates canonical audit metadata from already-validated tool context.
108    ///
109    /// # Errors
110    ///
111    /// Returns an error for missing or oversized effects, or too many resources.
112    pub fn new(
113        tool_version: ToolVersion,
114        source: ToolSource,
115        effects: impl IntoIterator<Item = ToolEffect>,
116        resources: impl IntoIterator<Item = ToolAuditResource>,
117    ) -> Result<Self, ToolAuditMetadataError> {
118        let mut effects = effects.into_iter().collect::<Vec<_>>();
119        effects.sort();
120        effects.dedup();
121        if effects.is_empty() || effects.len() > MAX_AUDIT_EFFECTS {
122            return Err(ToolAuditMetadataError::InvalidEffects);
123        }
124        let mut resources = resources.into_iter().collect::<Vec<_>>();
125        resources.sort();
126        resources.dedup();
127        if resources.len() > MAX_AUDIT_RESOURCES {
128            return Err(ToolAuditMetadataError::InvalidResources);
129        }
130        Ok(Self {
131            tool_version,
132            source,
133            effects,
134            resources,
135        })
136    }
137
138    /// Converts this value to one bounded, namespaced protocol metadata entry.
139    ///
140    /// # Errors
141    ///
142    /// Returns an error if serialization or protocol metadata bounds fail.
143    pub fn to_protocol_metadata(&self) -> Result<ProtocolMetadata, ToolAuditMetadataError> {
144        let value = to_value(self).map_err(ToolAuditMetadataError::Serialization)?;
145        ProtocolMetadata::from_entries([(TOOL_AUDIT_METADATA_NAMESPACE, value)])
146            .map_err(ToolAuditMetadataError::ProtocolMetadata)
147    }
148
149    /// Returns the frozen semantic tool version.
150    #[must_use]
151    pub const fn tool_version(&self) -> &ToolVersion {
152        &self.tool_version
153    }
154
155    /// Returns the frozen tool-source provenance.
156    #[must_use]
157    pub const fn source(&self) -> &ToolSource {
158        &self.source
159    }
160
161    /// Returns sorted, deduplicated effect names.
162    #[must_use]
163    pub fn effects(&self) -> &[ToolEffect] {
164        &self.effects
165    }
166
167    /// Returns sorted, deduplicated redacted resources.
168    #[must_use]
169    pub fn resources(&self) -> &[ToolAuditResource] {
170        &self.resources
171    }
172}
173
174#[derive(Deserialize)]
175#[serde(rename_all = "camelCase", deny_unknown_fields)]
176struct RawToolAuditMetadata {
177    tool_version: ToolVersion,
178    source: ToolSource,
179    effects: Vec<ToolEffect>,
180    resources: Vec<ToolAuditResource>,
181}
182
183impl<'de> Deserialize<'de> for ToolAuditMetadata {
184    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
185    where
186        D: Deserializer<'de>,
187    {
188        let raw = RawToolAuditMetadata::deserialize(deserializer)?;
189        Self::new(raw.tool_version, raw.source, raw.effects, raw.resources)
190            .map_err(serde::de::Error::custom)
191    }
192}
193
194/// Error returned when validating or encoding tool audit metadata.
195#[derive(Debug, Error)]
196pub enum ToolAuditMetadataError {
197    /// Declared effects are empty or exceed the deterministic bound.
198    #[error("tool audit effects are invalid")]
199    InvalidEffects,
200    /// Redacted resources exceed the deterministic bound.
201    #[error("tool audit resources are invalid")]
202    InvalidResources,
203    /// One redacted resource is malformed or exceeds its text bound.
204    #[error("tool audit resource is invalid")]
205    InvalidResource,
206    /// Audit serialization unexpectedly failed.
207    #[error("tool audit metadata could not be encoded: {0}")]
208    Serialization(serde_json::Error),
209    /// Audit metadata exceeds protocol metadata constraints.
210    #[error("tool audit metadata exceeds protocol bounds: {0}")]
211    ProtocolMetadata(ProtocolMetadataError),
212}