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