Skip to main content

bamboo_plugin/
hooks.rs

1//! Strict, deliberately small portable command-hook contract.
2use crate::{PluginError, PluginManifest, PluginResult};
3use serde::{Deserialize, Serialize};
4use sha2::{Digest, Sha256};
5use std::{
6    collections::BTreeMap,
7    path::{Path, PathBuf},
8};
9
10#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
11#[serde(deny_unknown_fields)]
12pub struct HookDeclaration {
13    pub config: String,
14    /// Every executable script/dependency the reviewer must inspect. The receipt
15    /// additionally hashes the complete bundle; undeclared changes cannot hide.
16    pub scripts: Vec<String>,
17}
18#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
19pub enum PortableEvent {
20    UserPromptSubmit,
21    PreToolUse,
22    PostToolUse,
23    Stop,
24    /// Bamboo extension; this event is observational and has no control output.
25    BambooModelCallUsage,
26}
27#[derive(Debug, Clone, Serialize, Deserialize)]
28#[serde(deny_unknown_fields)]
29pub struct PortableConfig {
30    #[serde(default)]
31    pub description: Option<String>,
32    #[serde(deserialize_with = "unique_events")]
33    pub hooks: BTreeMap<PortableEvent, Vec<PortableGroup>>,
34}
35#[derive(Debug, Clone, Serialize, Deserialize)]
36#[serde(deny_unknown_fields)]
37pub struct PortableGroup {
38    #[serde(default)]
39    pub matcher: Option<String>,
40    pub hooks: Vec<PortableCommand>,
41}
42#[derive(Debug, Clone, Serialize, Deserialize)]
43#[serde(deny_unknown_fields)]
44pub struct PortableCommand {
45    #[serde(rename = "type")]
46    pub kind: CommandKind,
47    pub command: String,
48    /// Mandatory seconds, unlike the upstream defaults.
49    pub timeout: u64,
50}
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub enum CommandKind {
53    #[serde(rename = "command")]
54    Command,
55}
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
57#[serde(rename_all = "kebab-case")]
58pub enum HookState {
59    NeedsReview,
60    Disabled,
61    Active,
62    Unsupported,
63}
64#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
65#[serde(deny_unknown_fields)]
66pub struct HookRegistration {
67    pub plugin_id: String,
68    pub version: String,
69    pub config: String,
70    pub digest: String,
71    #[serde(default)]
72    pub trusted_digest: Option<String>,
73    #[serde(default)]
74    pub enabled: bool,
75}
76impl HookRegistration {
77    pub fn state(&self, current_digest: &str) -> HookState {
78        if self.digest != current_digest || self.trusted_digest.as_deref() != Some(current_digest) {
79            HookState::NeedsReview
80        } else if self.enabled {
81            HookState::Active
82        } else {
83            HookState::Disabled
84        }
85    }
86    /// Caller must obtain an explicit review confirmation for this exact digest.
87    /// A stale UI/request cannot approve replacement bytes.
88    pub fn confirm_review(&mut self, reviewed_digest: &str) -> PluginResult<()> {
89        if self.digest != reviewed_digest {
90            return Err(invalid("stale hook review digest"));
91        }
92        self.trusted_digest = Some(reviewed_digest.to_owned());
93        self.enabled = true;
94        Ok(())
95    }
96}
97fn invalid(message: impl Into<String>) -> PluginError {
98    PluginError::InvalidManifest(message.into())
99}
100pub fn bundle_path(root: &Path, relative: &str) -> PluginResult<PathBuf> {
101    let path = Path::new(relative);
102    if path.as_os_str().is_empty()
103        || path.is_absolute()
104        || path.components().any(|c| {
105            !matches!(
106                c,
107                std::path::Component::Normal(_) | std::path::Component::CurDir
108            )
109        })
110    {
111        return Err(invalid(format!("unsafe hook bundle path: {relative}")));
112    }
113    let canonical_root = root.canonicalize().map_err(|e| invalid(e.to_string()))?;
114    let target = root
115        .join(path)
116        .canonicalize()
117        .map_err(|e| invalid(e.to_string()))?;
118    if !target.starts_with(canonical_root) {
119        return Err(invalid("hook path escapes plugin root"));
120    }
121    Ok(target)
122}
123impl PortableConfig {
124    pub fn parse(bytes: &[u8]) -> PluginResult<Self> {
125        if bytes.len() > 64 * 1024 {
126            return Err(invalid("hook config exceeds 64 KiB"));
127        }
128        let config: Self = serde_json::from_slice(bytes)
129            .map_err(|e| invalid(format!("unsupported hook config: {e}")))?;
130        let mut count = 0;
131        for (event, groups) in &config.hooks {
132            for group in groups {
133                if let Some(matcher) = group
134                    .matcher
135                    .as_deref()
136                    .filter(|s| !s.is_empty() && *s != "*")
137                {
138                    if !matches!(
139                        event,
140                        PortableEvent::PreToolUse | PortableEvent::PostToolUse
141                    ) {
142                        return Err(invalid("matcher is only supported for tool events"));
143                    }
144                    regex::Regex::new(matcher)
145                        .map_err(|e| invalid(format!("unsupported portable matcher: {e}")))?;
146                }
147                if group.hooks.is_empty() {
148                    return Err(invalid("empty hook group"));
149                }
150                for command in &group.hooks {
151                    count += 1;
152                    if command.command.trim().is_empty() || !(1..=600).contains(&command.timeout) {
153                        return Err(invalid(
154                            "command and explicit timeout (1..600 seconds) required",
155                        ));
156                    }
157                }
158            }
159        }
160        if count == 0 || count > 64 {
161            return Err(invalid("hook config requires 1..64 commands"));
162        }
163        Ok(config)
164    }
165}
166/// Hash entry types, paths and file bytes with length framing, in sorted order. Symlinks and
167/// special files are rejected; plugin data lives outside the reviewed bundle.
168fn hash_permissions(metadata: &std::fs::Metadata, hash: &mut Sha256) {
169    #[cfg(unix)]
170    {
171        use std::os::unix::fs::PermissionsExt;
172        hash.update((metadata.permissions().mode() & 0o7777).to_le_bytes());
173    }
174    #[cfg(not(unix))]
175    hash.update([u8::from(metadata.permissions().readonly())]);
176}
177
178fn hash_tree(root: &Path, dir: &Path, hash: &mut Sha256, total: &mut u64) -> PluginResult<()> {
179    let directory = std::fs::symlink_metadata(dir).map_err(|e| invalid(e.to_string()))?;
180    if !directory.is_dir() {
181        return Err(invalid(
182            "hook bundle directory cannot be a symlink or special file",
183        ));
184    }
185    hash_permissions(&directory, hash);
186    let mut paths = std::fs::read_dir(dir)
187        .map_err(|e| invalid(e.to_string()))?
188        .map(|entry| entry.map(|e| e.path()))
189        .collect::<Result<Vec<_>, _>>()
190        .map_err(|e| invalid(e.to_string()))?;
191    paths.sort();
192    for path in paths {
193        let metadata = std::fs::symlink_metadata(&path).map_err(|e| invalid(e.to_string()))?;
194        if metadata.is_dir() {
195            let name = path
196                .strip_prefix(root)
197                .unwrap()
198                .to_str()
199                .ok_or_else(|| invalid("hook bundle paths must be Unicode"))?;
200            hash.update(b"directory");
201            hash.update((name.len() as u64).to_le_bytes());
202            hash.update(name.as_bytes());
203            hash_tree(root, &path, hash, total)?;
204        } else if metadata.is_file() {
205            *total = total.saturating_add(metadata.len());
206            if *total > 64 * 1024 * 1024 {
207                return Err(invalid("reviewed hook bundle exceeds 64 MiB"));
208            }
209            let name = path
210                .strip_prefix(root)
211                .unwrap()
212                .to_str()
213                .ok_or_else(|| invalid("hook bundle paths must be Unicode"))?;
214            let bytes = std::fs::read(&path).map_err(|e| invalid(e.to_string()))?;
215            hash.update(b"file");
216            hash_permissions(&metadata, hash);
217            hash.update((name.len() as u64).to_le_bytes());
218            hash.update(name.as_bytes());
219            hash.update((bytes.len() as u64).to_le_bytes());
220            hash.update(bytes);
221        } else {
222            return Err(invalid(
223                "hook bundles cannot contain symlinks or special files",
224            ));
225        }
226    }
227    Ok(())
228}
229pub fn registrations(
230    manifest: &PluginManifest,
231    root: &Path,
232) -> PluginResult<Vec<HookRegistration>> {
233    if manifest.provides.hooks.len() > 16 {
234        return Err(invalid("at most 16 hook configs per plugin"));
235    }
236    if manifest.provides.hooks.is_empty() {
237        return Ok(vec![]);
238    }
239    let mut hash = Sha256::new();
240    hash.update(manifest.id.as_bytes());
241    hash.update([0]);
242    hash.update(manifest.version.as_bytes());
243    hash_tree(root, root, &mut hash, &mut 0)?;
244    let digest = format!("{:x}", hash.finalize());
245    let mut registrations = vec![];
246    for declaration in &manifest.provides.hooks {
247        if declaration.scripts.is_empty() {
248            return Err(invalid("hook declaration requires reviewed scripts"));
249        }
250        for script in &declaration.scripts {
251            if !bundle_path(root, script)?.is_file() {
252                return Err(invalid("reviewed script is not a file"));
253            }
254        }
255        let path = bundle_path(root, &declaration.config)?;
256        PortableConfig::parse(&std::fs::read(path).map_err(|e| invalid(e.to_string()))?)?;
257        if registrations
258            .iter()
259            .any(|r: &HookRegistration| r.config == declaration.config)
260        {
261            return Err(invalid("duplicate hook config"));
262        }
263        registrations.push(HookRegistration {
264            plugin_id: manifest.id.clone(),
265            version: manifest.version.clone(),
266            config: declaration.config.clone(),
267            digest: digest.clone(),
268            trusted_digest: None,
269            enabled: false,
270        });
271    }
272    Ok(registrations)
273}
274
275fn unique_events<'de, D: serde::Deserializer<'de>>(
276    deserializer: D,
277) -> Result<BTreeMap<PortableEvent, Vec<PortableGroup>>, D::Error> {
278    struct Events;
279    impl<'de> serde::de::Visitor<'de> for Events {
280        type Value = BTreeMap<PortableEvent, Vec<PortableGroup>>;
281        fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
282            f.write_str("unique portable event entries")
283        }
284        fn visit_map<A: serde::de::MapAccess<'de>>(
285            self,
286            mut map: A,
287        ) -> Result<Self::Value, A::Error> {
288            let mut events = BTreeMap::new();
289            while let Some((key, value)) = map.next_entry()? {
290                if events.insert(key, value).is_some() {
291                    return Err(serde::de::Error::custom("duplicate hook event"));
292                }
293            }
294            Ok(events)
295        }
296    }
297    deserializer.deserialize_map(Events)
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303    #[cfg(unix)]
304    #[test]
305    fn executable_and_directory_permissions_invalidate_digest() {
306        use std::os::unix::fs::PermissionsExt;
307        let temp = tempfile::tempdir().unwrap();
308        let script = temp.path().join("policy");
309        std::fs::write(&script, "#!/bin/sh\ntrue\n").unwrap();
310        std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o644)).unwrap();
311        let digest = || {
312            let mut hash = Sha256::new();
313            hash_tree(temp.path(), temp.path(), &mut hash, &mut 0).unwrap();
314            format!("{:x}", hash.finalize())
315        };
316        let original = digest();
317        let mut receipt = HookRegistration {
318            plugin_id: "fixture".into(),
319            version: "0.1.0".into(),
320            config: "hooks.json".into(),
321            digest: original.clone(),
322            trusted_digest: None,
323            enabled: false,
324        };
325        receipt.confirm_review(&original).unwrap();
326        std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
327        assert_eq!(receipt.state(&digest()), HookState::NeedsReview);
328        std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o644)).unwrap();
329        assert_eq!(digest(), original);
330        let mode = std::fs::metadata(temp.path()).unwrap().permissions().mode();
331        std::fs::set_permissions(temp.path(), std::fs::Permissions::from_mode(mode ^ 0o010))
332            .unwrap();
333        assert_ne!(digest(), original);
334        std::fs::set_permissions(temp.path(), std::fs::Permissions::from_mode(mode)).unwrap();
335    }
336
337    #[cfg(unix)]
338    #[test]
339    fn rejects_non_unicode_file_and_directory_names() {
340        use std::os::unix::ffi::OsStringExt;
341        let temp = tempfile::tempdir().unwrap();
342        let created = |result: std::io::Result<()>| {
343            #[cfg(target_os = "macos")]
344            if let Err(error) = &result {
345                if error.raw_os_error() == Some(92) {
346                    // macOS can reject the name before hash_tree can inspect it.
347                    assert_eq!(error.raw_os_error(), Some(92)); // Darwin EILSEQ
348                    assert!(std::fs::read_dir(temp.path()).unwrap().next().is_none());
349                    return false;
350                }
351            }
352            result.unwrap();
353            true
354        };
355        for byte in [0xff, 0xfe] {
356            let path = temp.path().join(std::ffi::OsString::from_vec(vec![byte]));
357            if created(std::fs::create_dir(&path)) {
358                assert!(hash_tree(temp.path(), temp.path(), &mut Sha256::new(), &mut 0).is_err());
359                std::fs::remove_dir(&path).unwrap();
360            }
361            if created(std::fs::write(&path, "policy")) {
362                assert!(hash_tree(temp.path(), temp.path(), &mut Sha256::new(), &mut 0).is_err());
363                std::fs::remove_file(&path).unwrap();
364            }
365        }
366    }
367
368    #[test]
369    fn empty_directory_changes_invalidate_reviewed_tree() {
370        let temp = tempfile::tempdir().unwrap();
371        std::fs::write(temp.path().join("script.sh"), "test -d flag").unwrap();
372        let digest = || {
373            let mut hash = Sha256::new();
374            hash_tree(temp.path(), temp.path(), &mut hash, &mut 0).unwrap();
375            format!("{:x}", hash.finalize())
376        };
377        let original = digest();
378        let mut receipt = HookRegistration {
379            plugin_id: "fixture".into(),
380            version: "0.1.0".into(),
381            config: "hooks.json".into(),
382            digest: original.clone(),
383            trusted_digest: None,
384            enabled: false,
385        };
386        receipt.confirm_review(&original).unwrap();
387        std::fs::create_dir(temp.path().join("flag")).unwrap();
388        let added = digest();
389        assert_eq!(receipt.state(&added), HookState::NeedsReview);
390        std::fs::rename(temp.path().join("flag"), temp.path().join("renamed")).unwrap();
391        assert_ne!(digest(), added);
392        std::fs::remove_dir(temp.path().join("renamed")).unwrap();
393        assert_eq!(digest(), original);
394        assert_eq!(receipt.state(&digest()), HookState::Active);
395    }
396
397    #[test]
398    fn rejects_unsupported_protocol_surface_and_regex() {
399        for input in [
400            r#"{"hooks":{"SessionStart":[]}}"#,
401            r#"{"hooks":{"Stop":[],"Stop":[]}}"#,
402            r#"{"hooks":{"PreToolUse":[{"hooks":[{"type":"command","command":"true"}]}]}}"#,
403            r#"{"hooks":{"Stop":[{"hooks":[{"type":"agent","command":"true","timeout":1}]}]}}"#,
404            r#"{"hooks":{"Stop":[{"hooks":[{"type":"command","command":"true","timeout":1,"async":true}]}]}}"#,
405            r#"{"hooks":{"PreToolUse":[{"matcher":"(?<=x)y","hooks":[{"type":"command","command":"true","timeout":1}]}]}}"#,
406            r#"{"hooks":{"PreToolUse":[{"matcher":"(x)\\1","hooks":[{"type":"command","command":"true","timeout":1}]}]}}"#,
407        ] {
408            assert!(PortableConfig::parse(input.as_bytes()).is_err(), "{input}");
409        }
410    }
411
412    #[test]
413    fn bamboo_model_usage_is_an_explicit_portable_extension() {
414        let bytes = br#"{"hooks":{"BambooModelCallUsage":[{"hooks":[{"type":"command","command":"true","timeout":1}]}]}}"#;
415        let config = PortableConfig::parse(bytes).unwrap();
416        assert!(config
417            .hooks
418            .contains_key(&PortableEvent::BambooModelCallUsage));
419        let invalid = br#"{"hooks":{"BambooModelCallUsage":[{"matcher":"tool","hooks":[{"type":"command","command":"true","timeout":1}]}]}}"#;
420        assert!(PortableConfig::parse(invalid).is_err());
421    }
422    #[test]
423    fn explicit_trust_is_exact_and_changes_invalidate_it() {
424        let mut r = HookRegistration {
425            plugin_id: "test".into(),
426            version: "1.0.0".into(),
427            config: "hooks.json".into(),
428            digest: "old".into(),
429            trusted_digest: None,
430            enabled: false,
431        };
432        assert_eq!(r.state("old"), HookState::NeedsReview);
433        assert!(r.confirm_review("new").is_err());
434        r.confirm_review("old").unwrap();
435        assert_eq!(r.state("old"), HookState::Active);
436        assert_eq!(r.state("new"), HookState::NeedsReview);
437        r.enabled = false;
438        assert_eq!(r.state("old"), HookState::Disabled);
439    }
440    #[test]
441    fn rejects_escape_paths_and_symlinks() {
442        let temp = tempfile::tempdir().unwrap();
443        assert!(bundle_path(temp.path(), "../outside").is_err());
444        assert!(bundle_path(temp.path(), "/etc/passwd").is_err());
445        #[cfg(unix)]
446        {
447            std::os::unix::fs::symlink("/etc/passwd", temp.path().join("escape")).unwrap();
448            assert!(bundle_path(temp.path(), "escape").is_err());
449            let outside = tempfile::tempdir().unwrap();
450            let linked_root = outside.path().join("linked-bundle");
451            std::os::unix::fs::symlink(temp.path(), &linked_root).unwrap();
452            assert!(hash_tree(&linked_root, &linked_root, &mut Sha256::new(), &mut 0).is_err());
453        }
454    }
455}