1use 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 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 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 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 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}
166fn 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 assert_eq!(error.raw_os_error(), Some(92)); 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}