Skip to main content

bamboo_plugin_protocol/
projected_tool_event_v1.rs

1use serde::{Deserialize, Serialize};
2
3use crate::{
4    ToolEventBuildError, ToolEventSubscriptionId, ToolEventTypeV1, MAX_TOOL_EVENT_CALL_ID_BYTES,
5    MAX_TOOL_EVENT_JSON_BYTES, MAX_TOOL_EVENT_PATH_BYTES, MAX_TOOL_EVENT_ROOT_SESSION_ID_BYTES,
6    MAX_TOOL_EVENT_SESSION_ID_BYTES, MAX_TOOL_EVENT_TOOL_NAME_BYTES, TOOL_EVENT_V1_SCHEMA_VERSION,
7};
8
9/// Per-field payload bounds applied by the host projection before it performs
10/// the exact complete-event serialization/limit check.
11pub const MAX_PROJECTED_TOOL_EVENT_DIFF_BYTES: usize = 4 * 1024;
12pub const MAX_PROJECTED_TOOL_EVENT_CONTENT_BYTES: usize = 8 * 1024;
13pub const TOOL_EVENT_PATH_REDACTION_PERMISSION_NOT_GRANTED: &str = "permission_not_granted";
14pub const TOOL_EVENT_PATH_REDACTION_SENSITIVE: &str = "sensitive_path";
15pub const TOOL_EVENT_PATH_REDACTION_UNSAFE: &str = "unsafe_path";
16
17/// Permission-aware context delivered to plugin services. Metadata is the
18/// safe baseline; `tool_name` is absent unless both requested and host-granted.
19#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
20pub struct ProjectedToolEventContextV1 {
21    pub session_id: String,
22    pub root_session_id: String,
23    #[serde(default, skip_serializing_if = "Option::is_none")]
24    pub tool_name: Option<String>,
25    pub tool_call_id: String,
26}
27
28/// Permission-aware `file_changed` data. An absent/redacted path never leaves
29/// a placeholder path field; the stable reason is safe metadata. Diff/content
30/// are bounded strings and carry an explicit truncation bit when shortened.
31#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
32pub struct ProjectedFileChangedV1 {
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub path: Option<String>,
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub path_redaction_reason: Option<String>,
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub diff: Option<String>,
39    #[serde(default, skip_serializing_if = "is_false")]
40    pub diff_truncated: bool,
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    pub content: Option<String>,
43    #[serde(default, skip_serializing_if = "is_false")]
44    pub content_truncated: bool,
45}
46
47fn is_false(value: &bool) -> bool {
48    !*value
49}
50
51/// Irreversible host projection admitted to a plugin sink queue. This is
52/// intentionally a distinct type from producer [`crate::ToolEventV1`], making
53/// raw-event queueing a type error.
54#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
55pub struct ProjectedToolEventV1 {
56    pub schema_version: u16,
57    pub event_type: ToolEventTypeV1,
58    pub subscription_id: ToolEventSubscriptionId,
59    pub context: ProjectedToolEventContextV1,
60    pub data: ProjectedFileChangedV1,
61    /// Present on host-projected delivery. Optional during deserialization so
62    /// the original full-observation v1 golden remains backward compatible.
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub observation_policy_generation: Option<u64>,
65}
66
67impl ProjectedToolEventV1 {
68    pub fn file_changed(
69        context: ProjectedToolEventContextV1,
70        data: ProjectedFileChangedV1,
71        observation_policy_generation: u64,
72    ) -> Self {
73        Self {
74            schema_version: TOOL_EVENT_V1_SCHEMA_VERSION,
75            event_type: ToolEventTypeV1::file_changed(),
76            subscription_id: ToolEventSubscriptionId::file_changed_v1(),
77            context,
78            data,
79            observation_policy_generation: Some(observation_policy_generation),
80        }
81    }
82
83    /// Revalidate a received projection against the public v1 contract.
84    pub fn validate_bounds(&self) -> Result<(), ToolEventBuildError> {
85        if self.schema_version != TOOL_EVENT_V1_SCHEMA_VERSION {
86            return Err(ToolEventBuildError::UnsupportedSchemaVersion {
87                actual: self.schema_version,
88                supported: TOOL_EVENT_V1_SCHEMA_VERSION,
89            });
90        }
91        if !self.event_type.is_file_changed() || !self.subscription_id.is_file_changed_v1() {
92            return Err(ToolEventBuildError::KnownVariantMismatch);
93        }
94        validate_projected_field(
95            "context.session_id",
96            &self.context.session_id,
97            MAX_TOOL_EVENT_SESSION_ID_BYTES,
98        )?;
99        validate_projected_field(
100            "context.root_session_id",
101            &self.context.root_session_id,
102            MAX_TOOL_EVENT_ROOT_SESSION_ID_BYTES,
103        )?;
104        validate_projected_field(
105            "context.tool_call_id",
106            &self.context.tool_call_id,
107            MAX_TOOL_EVENT_CALL_ID_BYTES,
108        )?;
109        if let Some(tool_name) = &self.context.tool_name {
110            validate_projected_field(
111                "context.tool_name",
112                tool_name,
113                MAX_TOOL_EVENT_TOOL_NAME_BYTES,
114            )?;
115        }
116        match (&self.data.path, &self.data.path_redaction_reason) {
117            (Some(path), None) => {
118                validate_projected_field("data.path", path, MAX_TOOL_EVENT_PATH_BYTES)?;
119            }
120            (None, Some(reason))
121                if matches!(
122                    reason.as_str(),
123                    TOOL_EVENT_PATH_REDACTION_PERMISSION_NOT_GRANTED
124                        | TOOL_EVENT_PATH_REDACTION_SENSITIVE
125                        | TOOL_EVENT_PATH_REDACTION_UNSAFE
126                ) =>
127            {
128                if self.data.diff.is_some()
129                    || self.data.content.is_some()
130                    || self.data.diff_truncated
131                    || self.data.content_truncated
132                {
133                    return Err(ToolEventBuildError::InvalidKnownPayload(
134                        "redacted path projection must not carry diff/content".to_string(),
135                    ));
136                }
137            }
138            _ => {
139                return Err(ToolEventBuildError::InvalidKnownPayload(
140                    "file_changed projection requires exactly one of path or path_redaction_reason"
141                        .to_string(),
142                ));
143            }
144        }
145        if let Some(diff) = &self.data.diff {
146            validate_projected_field("data.diff", diff, MAX_PROJECTED_TOOL_EVENT_DIFF_BYTES)?;
147        } else if self.data.diff_truncated {
148            return Err(ToolEventBuildError::InvalidKnownPayload(
149                "diff_truncated requires diff".to_string(),
150            ));
151        }
152        if let Some(content) = &self.data.content {
153            validate_projected_field(
154                "data.content",
155                content,
156                MAX_PROJECTED_TOOL_EVENT_CONTENT_BYTES,
157            )?;
158        } else if self.data.content_truncated {
159            return Err(ToolEventBuildError::InvalidKnownPayload(
160                "content_truncated requires content".to_string(),
161            ));
162        }
163        if self.observation_policy_generation == Some(0) {
164            return Err(ToolEventBuildError::InvalidKnownPayload(
165                "observation_policy_generation must be positive".to_string(),
166            ));
167        }
168        let actual = serde_json::to_vec(self)
169            .map_err(|error| ToolEventBuildError::Serialization(error.to_string()))?
170            .len();
171        if actual > MAX_TOOL_EVENT_JSON_BYTES {
172            return Err(ToolEventBuildError::EventTooLarge {
173                actual,
174                max: MAX_TOOL_EVENT_JSON_BYTES,
175            });
176        }
177        Ok(())
178    }
179}
180
181fn validate_projected_field(
182    field: &'static str,
183    value: &str,
184    max: usize,
185) -> Result<(), ToolEventBuildError> {
186    if value.trim().is_empty() {
187        return Err(ToolEventBuildError::EmptyField { field });
188    }
189    if value.len() > max {
190        return Err(ToolEventBuildError::FieldTooLarge {
191            field,
192            actual: value.len(),
193            max,
194        });
195    }
196    Ok(())
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202    use crate::ToolEventSubscriptionId;
203
204    #[test]
205    fn absent_observation_fields_are_absent_on_the_wire() {
206        let projected = ProjectedToolEventV1::file_changed(
207            ProjectedToolEventContextV1 {
208                session_id: "session".to_string(),
209                root_session_id: "root".to_string(),
210                tool_name: None,
211                tool_call_id: "call".to_string(),
212            },
213            ProjectedFileChangedV1 {
214                path_redaction_reason: Some("permission_not_granted".to_string()),
215                ..ProjectedFileChangedV1::default()
216            },
217            1,
218        );
219        let value = serde_json::to_value(&projected).unwrap();
220        assert!(value["context"].get("tool_name").is_none());
221        assert!(value["data"].get("path").is_none());
222        assert_eq!(
223            value["data"]["path_redaction_reason"],
224            "permission_not_granted"
225        );
226        projected.validate_bounds().unwrap();
227    }
228
229    #[test]
230    fn original_full_observation_golden_remains_deserializable() {
231        let projected: ProjectedToolEventV1 = serde_json::from_str(include_str!(
232            "../tests/golden/tool_event_v1.file_changed.json"
233        ))
234        .unwrap();
235        assert_eq!(projected.context.tool_name.as_deref(), Some("Write"));
236        assert_eq!(
237            projected.data.path.as_deref(),
238            Some("/workspace/zenith/src/lib.rs")
239        );
240        assert_eq!(projected.observation_policy_generation, None);
241        projected.validate_bounds().unwrap();
242    }
243
244    #[test]
245    fn invalid_identifier_and_mixed_path_projection_are_rejected() {
246        let mut projected: ProjectedToolEventV1 = serde_json::from_str(include_str!(
247            "../tests/golden/tool_event_v1.file_changed.json"
248        ))
249        .unwrap();
250        projected.subscription_id = ToolEventSubscriptionId::new("tool.future.v1");
251        assert_eq!(
252            projected.validate_bounds(),
253            Err(ToolEventBuildError::KnownVariantMismatch)
254        );
255
256        projected.subscription_id = ToolEventSubscriptionId::file_changed_v1();
257        projected.data.path_redaction_reason =
258            Some(TOOL_EVENT_PATH_REDACTION_SENSITIVE.to_string());
259        assert!(matches!(
260            projected.validate_bounds(),
261            Err(ToolEventBuildError::InvalidKnownPayload(_))
262        ));
263    }
264}