1use crate::event::EventKind;
17use serde::{Deserialize, Deserializer, Serialize, Serializer};
18use serde_json::Value;
19
20pub const HOOK_SCHEMA_VERSION: u32 = 1;
22pub const HOOK_MODULE_DEADLINE_MS: u64 = 3_000;
24
25#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
27pub enum HookModuleId {
28 #[serde(rename = "membrane.cortex-status")]
29 CortexStatus,
30 #[serde(rename = "membrane.memory-rearm")]
31 MemoryRearm,
32 #[serde(rename = "membrane.memory-recall")]
33 MemoryRecall,
34 #[serde(rename = "membrane.memory-pre-compact")]
35 MemoryPreCompact,
36 #[serde(rename = "membrane.memory-post-compact")]
37 MemoryPostCompact,
38 #[serde(rename = "membrane.memory-bump")]
39 MemoryBump,
40 #[serde(rename = "membrane.diagnostics-fence")]
41 DiagnosticsFence,
42 #[serde(rename = "membrane.memory-conflict")]
43 MemoryConflict,
44 #[serde(rename = "membrane.tool-observer")]
45 ToolObserver,
46 #[serde(rename = "membrane.memory-ingest")]
47 MemoryIngest,
48 #[serde(rename = "membrane.diagnostics-observe")]
49 DiagnosticsObserve,
50 #[serde(rename = "membrane.diagnostics-completion-fence")]
51 DiagnosticsCompletionFence,
52 #[serde(rename = "membrane.memory-nag")]
53 MemoryNag,
54 #[serde(rename = "membrane.memory-failure")]
55 MemoryFailure,
56 #[serde(rename = "membrane.memory-episode")]
57 MemoryEpisode,
58 #[serde(rename = "membrane.memory-session-end")]
59 MemorySessionEnd,
60}
61
62impl HookModuleId {
63 pub const ORDERED: [Self; 16] = [
65 Self::CortexStatus,
66 Self::MemoryRearm,
67 Self::MemoryRecall,
68 Self::MemoryPreCompact,
69 Self::MemoryPostCompact,
70 Self::MemoryBump,
71 Self::DiagnosticsFence,
72 Self::MemoryConflict,
73 Self::ToolObserver,
74 Self::MemoryIngest,
75 Self::DiagnosticsObserve,
76 Self::DiagnosticsCompletionFence,
77 Self::MemoryNag,
78 Self::MemoryFailure,
79 Self::MemoryEpisode,
80 Self::MemorySessionEnd,
81 ];
82}
83
84#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
85#[serde(rename_all = "snake_case")]
86pub enum HookModuleState {
87 Available,
88 Unavailable,
89 Skipped,
90 Blocked,
91}
92
93#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
94#[serde(rename_all = "snake_case")]
95pub enum HookInvocationStatus {
96 Ok,
97 Error,
98}
99
100#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
102#[serde(rename_all = "camelCase", deny_unknown_fields)]
103pub struct HookModuleOutputV1 {
104 pub schema_version: u32,
105 pub kind: String,
106 pub state: HookModuleState,
107 pub reason: String,
108 pub detail: Value,
109}
110
111impl HookModuleOutputV1 {
112 pub fn status(state: HookModuleState, reason: impl Into<String>, detail: Value) -> Self {
113 Self {
114 schema_version: HOOK_SCHEMA_VERSION,
115 kind: "membrane.hook.status".to_owned(),
116 state,
117 reason: reason.into(),
118 detail,
119 }
120 }
121
122 pub fn skipped() -> Self {
123 Self::status(
124 HookModuleState::Skipped,
125 "event_not_applicable",
126 Value::Null,
127 )
128 }
129}
130
131#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
132#[serde(rename_all = "camelCase", deny_unknown_fields)]
133pub struct HookModuleResultV1 {
134 pub id: HookModuleId,
135 pub status: HookInvocationStatus,
136 pub output: Option<HookModuleOutputV1>,
137 #[serde(skip_serializing_if = "Option::is_none")]
138 pub error: Option<String>,
139}
140
141impl HookModuleResultV1 {
142 pub fn ok(id: HookModuleId, output: HookModuleOutputV1) -> Self {
143 Self {
144 id,
145 status: HookInvocationStatus::Ok,
146 output: Some(output),
147 error: None,
148 }
149 }
150
151 pub fn skipped(id: HookModuleId) -> Self {
152 Self::ok(id, HookModuleOutputV1::skipped())
153 }
154
155 pub fn error(id: HookModuleId, error: impl Into<String>) -> Self {
156 Self {
157 id,
158 status: HookInvocationStatus::Error,
159 output: None,
160 error: Some(error.into()),
161 }
162 }
163}
164
165#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
166#[serde(rename_all = "camelCase", deny_unknown_fields)]
167pub struct HookDispatchResultV1 {
168 pub schema_version: u32,
169 pub event: EventKind,
170 pub status: HookInvocationStatus,
171 pub results: Vec<HookModuleResultV1>,
172}
173
174impl HookDispatchResultV1 {
175 pub fn new(event: EventKind, results: Vec<HookModuleResultV1>) -> Self {
176 let status = aggregate_hook_status(&results);
177 Self {
178 schema_version: HOOK_SCHEMA_VERSION,
179 event,
180 status,
181 results,
182 }
183 }
184}
185
186pub fn aggregate_hook_status(results: &[HookModuleResultV1]) -> HookInvocationStatus {
188 if results
189 .iter()
190 .any(|result| result.status == HookInvocationStatus::Error)
191 {
192 HookInvocationStatus::Error
193 } else {
194 HookInvocationStatus::Ok
195 }
196}
197
198#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
199#[serde(rename_all = "snake_case")]
200pub enum HookHostDecision {
201 Block,
202}
203
204#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
205#[serde(rename_all = "snake_case")]
206pub enum HookPermissionDecision {
207 Deny,
208}
209
210#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
211#[serde(rename_all = "camelCase", deny_unknown_fields)]
212pub struct HookSpecificOutputV1 {
213 pub hook_event_name: EventKind,
214 #[serde(skip_serializing_if = "Option::is_none")]
215 pub permission_decision: Option<HookPermissionDecision>,
216 #[serde(skip_serializing_if = "Option::is_none")]
217 pub permission_decision_reason: Option<String>,
218 pub additional_context: String,
219}
220
221#[derive(Clone, Debug, PartialEq)]
226pub struct HookHostResponseV1 {
227 pub decision: Option<HookHostDecision>,
228 pub reason: Option<String>,
229 pub hook_specific_output: HookSpecificOutputV1,
230 pub membrane_hook: HookDispatchResultV1,
231}
232
233#[derive(Deserialize)]
235#[serde(rename_all = "camelCase", deny_unknown_fields)]
236struct HookHostResponseWireV1 {
237 decision: Option<HookHostDecision>,
238 reason: Option<String>,
239 hook_specific_output: Option<HookSpecificOutputV1>,
240 membrane_hook: HookDispatchResultV1,
241}
242
243impl<'de> Deserialize<'de> for HookHostResponseV1 {
244 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
245 let wire = HookHostResponseWireV1::deserialize(deserializer)?;
246 let event = &wire.membrane_hook.event;
247 let hook_specific_output = match wire.hook_specific_output {
248 Some(output) => output,
249 None if !takes_host_projection(event) => HookSpecificOutputV1 {
252 hook_event_name: event.clone(),
253 permission_decision: None,
254 permission_decision_reason: None,
255 additional_context: String::new(),
256 },
257 None => {
258 return Err(<D::Error as serde::de::Error>::missing_field(
259 "hookSpecificOutput",
260 ));
261 }
262 };
263 Ok(Self {
264 decision: wire.decision,
265 reason: wire.reason,
266 hook_specific_output,
267 membrane_hook: wire.membrane_hook,
268 })
269 }
270}
271
272fn is_deny_boundary(event: &EventKind) -> bool {
274 matches!(event, EventKind::PreToolUse | EventKind::Stop)
275}
276
277fn takes_host_projection(event: &EventKind) -> bool {
279 matches!(
280 event,
281 EventKind::SessionStart
282 | EventKind::UserPromptSubmit
283 | EventKind::PreToolUse
284 | EventKind::PostToolUse
285 | EventKind::PostToolUseFailure
286 )
287}
288
289impl Serialize for HookHostResponseV1 {
293 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
294 use serde::ser::SerializeMap;
295 let event = &self.hook_specific_output.hook_event_name;
296 let omit_host_projection = !takes_host_projection(event);
297 let count = 1
298 + usize::from(!omit_host_projection)
299 + usize::from(self.decision.is_some())
300 + usize::from(self.reason.is_some());
301 let mut map = serializer.serialize_map(Some(count))?;
302 if let Some(decision) = &self.decision {
303 map.serialize_entry("decision", decision)?;
304 }
305 if let Some(reason) = &self.reason {
306 map.serialize_entry("reason", reason)?;
307 }
308 if !omit_host_projection {
309 map.serialize_entry("hookSpecificOutput", &self.hook_specific_output)?;
310 }
311 map.serialize_entry("membraneHook", &self.membrane_hook)?;
312 map.end()
313 }
314}
315
316pub fn project_hook_host_response(result: HookDispatchResultV1) -> HookHostResponseV1 {
318 let additional_context = result
319 .results
320 .iter()
321 .filter_map(|entry| {
322 entry
323 .output
324 .as_ref()
325 .and_then(|output| output.detail.get("additionalContext"))
326 .and_then(Value::as_str)
327 })
328 .filter(|context| !context.is_empty())
329 .collect::<Vec<_>>()
330 .join("\n\n");
331 let blocked = result.results.iter().find(|entry| {
332 entry
333 .output
334 .as_ref()
335 .is_some_and(|output| output.state == HookModuleState::Blocked)
336 });
337 let deny = is_deny_boundary(&result.event) && blocked.is_some();
338 let reason = blocked
339 .and_then(|entry| entry.output.as_ref())
340 .and_then(|output| {
341 output
342 .detail
343 .get("detail")
344 .and_then(Value::as_str)
345 .map(str::to_owned)
346 .or_else(|| Some(output.reason.clone()))
347 })
348 .or_else(|| deny.then(|| "semantic edit fence not cleared".to_owned()));
349 let is_stop = result.event == EventKind::Stop;
350 let is_pre_tool = result.event == EventKind::PreToolUse;
351 HookHostResponseV1 {
352 decision: (deny && is_stop).then_some(HookHostDecision::Block),
353 reason: (deny && is_stop).then(|| reason.clone()).flatten(),
354 hook_specific_output: HookSpecificOutputV1 {
355 hook_event_name: result.event.clone(),
356 permission_decision: (deny && is_pre_tool).then_some(HookPermissionDecision::Deny),
357 permission_decision_reason: (deny && is_pre_tool).then(|| reason.clone()).flatten(),
358 additional_context,
359 },
360 membrane_hook: result,
361 }
362}
363
364#[cfg(test)]
365mod tests {
366 use super::*;
367 use serde_json::json;
368
369 fn blocked(event: EventKind) -> HookDispatchResultV1 {
370 let output = HookModuleOutputV1::status(
371 HookModuleState::Blocked,
372 "fence_open",
373 json!({"detail": "tests not run", "additionalContext": "ctx"}),
374 );
375 HookDispatchResultV1::new(
376 event,
377 vec![HookModuleResultV1::ok(
378 HookModuleId::DiagnosticsFence,
379 output,
380 )],
381 )
382 }
383
384 #[test]
385 fn module_ids_use_membrane_names_in_order() {
386 assert_eq!(
387 serde_json::to_value(HookModuleId::MemoryRecall).unwrap(),
388 json!("membrane.memory-recall")
389 );
390 assert_eq!(HookModuleId::ORDERED.len(), 16);
391 assert_eq!(HookModuleId::ORDERED[0], HookModuleId::CortexStatus);
392 }
393
394 #[test]
395 fn stop_block_projects_top_level_decision_only() {
396 let response = project_hook_host_response(blocked(EventKind::Stop));
397 let value = serde_json::to_value(&response).unwrap();
398 assert_eq!(value["decision"], json!("block"));
399 assert_eq!(value["reason"], json!("tests not run"));
400 assert!(value.get("hookSpecificOutput").is_none());
401 assert_eq!(value["membraneHook"]["event"], json!("Stop"));
402 }
403
404 #[test]
405 fn pre_tool_use_block_projects_deny() {
406 let response = project_hook_host_response(blocked(EventKind::PreToolUse));
407 let value = serde_json::to_value(&response).unwrap();
408 assert!(value.get("decision").is_none());
409 assert_eq!(
410 value["hookSpecificOutput"]["permissionDecision"],
411 json!("deny")
412 );
413 assert_eq!(
414 value["hookSpecificOutput"]["permissionDecisionReason"],
415 json!("tests not run")
416 );
417 assert_eq!(
418 value["hookSpecificOutput"]["additionalContext"],
419 json!("ctx")
420 );
421 }
422
423 #[test]
424 fn unsupported_event_omits_host_projection() {
425 let response = project_hook_host_response(blocked(EventKind::PreCompact));
426 let value = serde_json::to_value(&response).unwrap();
427 assert!(value.get("hookSpecificOutput").is_none());
428 assert!(value.get("decision").is_none());
429 assert_eq!(value["membraneHook"]["event"], json!("PreCompact"));
430 }
431
432 #[test]
433 fn response_round_trips_and_rejects_unknown_fields() {
434 let response = project_hook_host_response(blocked(EventKind::SessionStart));
435 let text = serde_json::to_string(&response).unwrap();
436 let back: HookHostResponseV1 = serde_json::from_str(&text).unwrap();
437 assert_eq!(back.membrane_hook, response.membrane_hook);
438 let extra = text.replacen('{', "{\"unexpected\":1,", 1);
439 assert!(serde_json::from_str::<HookHostResponseV1>(&extra).is_err());
440 }
441
442 #[test]
443 fn response_round_trips_for_every_event_kind() {
444 let kinds = [
445 EventKind::SessionStart,
446 EventKind::UserPromptSubmit,
447 EventKind::PreToolUse,
448 EventKind::PostToolUse,
449 EventKind::PostToolUseFailure,
450 EventKind::Stop,
451 EventKind::SubagentStart,
452 EventKind::SubagentStop,
453 EventKind::PreCompact,
454 EventKind::PostCompact,
455 EventKind::SessionEnd,
456 EventKind::Notification,
457 EventKind::Other("CustomEvent".to_owned()),
458 ];
459 for kind in kinds {
460 let response = project_hook_host_response(blocked(kind.clone()));
461 let wire = serde_json::to_string(&response).unwrap();
462 let back: HookHostResponseV1 = serde_json::from_str(&wire)
463 .unwrap_or_else(|error| panic!("{kind:?} failed to read: {error}"));
464 assert_eq!(back.membrane_hook, response.membrane_hook, "{kind:?}");
465 assert_eq!(back.decision, response.decision, "{kind:?}");
466 assert_eq!(back.reason, response.reason, "{kind:?}");
467 assert_eq!(back.hook_specific_output.hook_event_name, kind);
468 assert_eq!(serde_json::to_string(&back).unwrap(), wire, "{kind:?}");
470 if takes_host_projection(&kind) {
471 assert_eq!(back, response, "{kind:?}");
472 } else {
473 let value = serde_json::to_value(&back).unwrap();
474 assert!(value.get("hookSpecificOutput").is_none(), "{kind:?}");
475 }
476 }
477 }
478
479 #[test]
480 fn projection_events_require_hook_specific_output_on_read() {
481 let response = project_hook_host_response(blocked(EventKind::PreToolUse));
482 let mut value = serde_json::to_value(&response).unwrap();
483 value.as_object_mut().unwrap().remove("hookSpecificOutput");
484 let text = value.to_string();
485 assert!(serde_json::from_str::<HookHostResponseV1>(&text).is_err());
486 }
487
488 #[test]
489 fn aggregate_status_reports_any_error() {
490 let ok = HookModuleResultV1::skipped(HookModuleId::MemoryBump);
491 let err = HookModuleResultV1::error(HookModuleId::MemoryNag, "boom");
492 assert_eq!(
493 aggregate_hook_status(std::slice::from_ref(&ok)),
494 HookInvocationStatus::Ok
495 );
496 assert_eq!(
497 aggregate_hook_status(&[ok, err]),
498 HookInvocationStatus::Error
499 );
500 }
501}