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