1use serde::{Deserialize, Serialize};
67use serde_json::Value;
68
69use super::events::{HookEvent, MemoryDelta};
70
71#[derive(Debug, Clone, PartialEq, Serialize)]
86#[serde(tag = "action", rename_all = "snake_case")]
87pub enum HookDecision {
88 Allow,
91 Modify(ModifyPayload),
95 Deny {
100 reason: String,
101 #[serde(default = "default_deny_code")]
102 code: i32,
103 },
104 AskUser {
109 prompt: String,
110 options: Vec<String>,
111 #[serde(skip_serializing_if = "Option::is_none")]
112 default: Option<String>,
113 },
114}
115
116#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
123pub struct ModifyPayload {
124 pub delta: MemoryDelta,
125}
126
127fn default_deny_code() -> i32 {
128 403
129}
130
131#[derive(Debug)]
143pub enum DecisionParseError {
144 NotAnObject,
146 MissingAction,
152 UnknownAction(String),
154 MissingField {
158 action: &'static str,
159 field: &'static str,
160 },
161 Malformed(String),
163}
164
165impl std::fmt::Display for DecisionParseError {
166 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
167 match self {
168 DecisionParseError::NotAnObject => {
169 write!(f, "hook decision must be a JSON object")
170 }
171 DecisionParseError::MissingAction => {
172 write!(f, "hook decision missing required \"action\" field")
173 }
174 DecisionParseError::UnknownAction(a) => {
175 write!(f, "hook decision has unknown action \"{a}\"")
176 }
177 DecisionParseError::MissingField { action, field } => {
178 write!(
179 f,
180 "hook decision action=\"{action}\" missing required field \"{field}\""
181 )
182 }
183 DecisionParseError::Malformed(msg) => {
184 write!(f, "hook decision malformed: {msg}")
185 }
186 }
187 }
188}
189
190impl std::error::Error for DecisionParseError {}
191
192impl HookDecision {
197 fn malformed_must_be_string(field: &str) -> DecisionParseError {
214 DecisionParseError::Malformed(format!("\"{field}\" must be a string"))
215 }
216
217 pub fn parse(line: &str) -> Result<Self, DecisionParseError> {
218 let trimmed = line.trim();
219 if trimmed.is_empty() || trimmed == "{}" {
220 return Ok(HookDecision::Allow);
221 }
222
223 let value: Value = serde_json::from_str(trimmed)
224 .map_err(|e| DecisionParseError::Malformed(e.to_string()))?;
225 let obj = value.as_object().ok_or(DecisionParseError::NotAnObject)?;
226
227 if obj.is_empty() {
230 return Ok(HookDecision::Allow);
231 }
232
233 let action = obj
234 .get("action")
235 .ok_or(DecisionParseError::MissingAction)?
236 .as_str()
237 .ok_or_else(|| Self::malformed_must_be_string("action"))?;
238
239 match action {
240 "allow" => Ok(HookDecision::Allow),
241 "modify" => {
242 let delta_v = obj.get("delta").ok_or(DecisionParseError::MissingField {
243 action: "modify",
244 field: "delta",
245 })?;
246 let delta: MemoryDelta = serde_json::from_value(delta_v.clone())
247 .map_err(|e| DecisionParseError::Malformed(e.to_string()))?;
248 Ok(HookDecision::Modify(ModifyPayload { delta }))
249 }
250 "deny" => {
251 let reason = obj
252 .get("reason")
253 .ok_or(DecisionParseError::MissingField {
254 action: "deny",
255 field: "reason",
256 })?
257 .as_str()
258 .ok_or_else(|| Self::malformed_must_be_string("reason"))?
259 .to_string();
260 let code = obj
261 .get("code")
262 .and_then(serde_json::Value::as_i64)
263 .map_or_else(default_deny_code, |c| {
264 i32::try_from(c).unwrap_or(default_deny_code())
265 });
266 Ok(HookDecision::Deny { reason, code })
267 }
268 "ask_user" => {
269 let prompt = obj
270 .get("prompt")
271 .ok_or(DecisionParseError::MissingField {
272 action: "ask_user",
273 field: "prompt",
274 })?
275 .as_str()
276 .ok_or_else(|| Self::malformed_must_be_string("prompt"))?
277 .to_string();
278 let options_v = obj.get("options").ok_or(DecisionParseError::MissingField {
279 action: "ask_user",
280 field: "options",
281 })?;
282 let options: Vec<String> = serde_json::from_value(options_v.clone())
283 .map_err(|e| DecisionParseError::Malformed(e.to_string()))?;
284 let default = match obj.get("default") {
285 None => None,
286 Some(Value::Null) => None,
287 Some(v) => Some(
288 v.as_str()
289 .ok_or_else(|| Self::malformed_must_be_string("default"))?
290 .to_string(),
291 ),
292 };
293 Ok(HookDecision::AskUser {
294 prompt,
295 options,
296 default,
297 })
298 }
299 other => Err(DecisionParseError::UnknownAction(other.to_string())),
300 }
301 }
302
303 #[must_use]
312 pub fn degrade_modify_for_post_event(self, event: HookEvent) -> Self {
313 if matches!(self, HookDecision::Modify(_)) && !is_pre_event(event) {
314 tracing::warn!(
315 event = ?event,
316 "hooks: Modify decision returned for post- event; degrading to Allow"
317 );
318 return HookDecision::Allow;
319 }
320 self
321 }
322}
323
324#[must_use]
343#[deny(unreachable_patterns)]
344pub fn is_pre_event(event: HookEvent) -> bool {
345 match event {
346 HookEvent::PreStore
348 | HookEvent::PreRecall
349 | HookEvent::PreSearch
350 | HookEvent::PreDelete
351 | HookEvent::PrePromote
352 | HookEvent::PreLink
353 | HookEvent::PreConsolidate
354 | HookEvent::PreGovernanceDecision
355 | HookEvent::PreArchive
356 | HookEvent::PreTranscriptStore
357 | HookEvent::PreRecallExpand
360 | HookEvent::PreReflect
364 | HookEvent::PreCompaction => true,
367
368 HookEvent::PostStore
370 | HookEvent::PostRecall
371 | HookEvent::PostSearch
372 | HookEvent::PostDelete
373 | HookEvent::PostPromote
374 | HookEvent::PostLink
375 | HookEvent::PostConsolidate
376 | HookEvent::PostGovernanceDecision
377 | HookEvent::OnIndexEviction
378 | HookEvent::PostTranscriptStore
379 | HookEvent::PostReflect
380 | HookEvent::OnCompactionRollback => false,
381 }
382}
383
384impl<'de> Deserialize<'de> for HookDecision {
389 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
390 where
391 D: serde::Deserializer<'de>,
392 {
393 let value = Value::deserialize(deserializer)?;
397 let as_text = serde_json::to_string(&value).map_err(serde::de::Error::custom)?;
398 HookDecision::parse(&as_text).map_err(serde::de::Error::custom)
399 }
400}
401
402#[cfg(test)]
407mod tests {
408 use super::*;
409 use serde_json::json;
410
411 #[test]
414 fn allow_round_trips() {
415 let d = HookDecision::Allow;
416 let json = serde_json::to_string(&d).expect("encode");
417 assert_eq!(json, r#"{"action":"allow"}"#);
418 let back: HookDecision = serde_json::from_str(&json).expect("decode");
419 assert_eq!(back, HookDecision::Allow);
420 }
421
422 #[test]
423 fn modify_round_trips_with_delta() {
424 let delta = MemoryDelta {
425 tags: Some(vec!["redacted".into()]),
426 priority: Some(5),
427 ..Default::default()
428 };
429 let d = HookDecision::Modify(ModifyPayload {
430 delta: delta.clone(),
431 });
432 let json = serde_json::to_string(&d).expect("encode");
433 let v: Value = serde_json::from_str(&json).expect("parse");
435 assert_eq!(v["action"], json!("modify"));
436 assert_eq!(v["delta"]["tags"], json!(["redacted"]));
437 assert_eq!(v["delta"]["priority"], json!(5));
438
439 let back: HookDecision = serde_json::from_str(&json).expect("decode");
440 assert_eq!(back, HookDecision::Modify(ModifyPayload { delta }));
441 }
442
443 #[test]
444 fn deny_round_trips_with_explicit_code() {
445 let d = HookDecision::Deny {
446 reason: "redact required".into(),
447 code: 451,
448 };
449 let json = serde_json::to_string(&d).expect("encode");
450 let back: HookDecision = serde_json::from_str(&json).expect("decode");
451 assert_eq!(back, d);
452 }
453
454 #[test]
455 fn deny_default_code_when_omitted() {
456 let d = HookDecision::parse(r#"{"action":"deny","reason":"nope"}"#).expect("parse");
457 match d {
458 HookDecision::Deny { reason, code } => {
459 assert_eq!(reason, "nope");
460 assert_eq!(code, 403, "missing code defaults to 403");
461 }
462 other => panic!("expected Deny, got {other:?}"),
463 }
464 }
465
466 #[test]
467 fn ask_user_round_trips() {
468 let d = HookDecision::AskUser {
469 prompt: "Promote to long-term?".into(),
470 options: vec!["yes".into(), "no".into()],
471 default: Some("no".into()),
472 };
473 let json = serde_json::to_string(&d).expect("encode");
474 let v: Value = serde_json::from_str(&json).expect("parse");
475 assert_eq!(v["action"], json!("ask_user"));
476 assert_eq!(v["options"], json!(["yes", "no"]));
477 assert_eq!(v["default"], json!("no"));
478
479 let back: HookDecision = serde_json::from_str(&json).expect("decode");
480 assert_eq!(back, d);
481 }
482
483 #[test]
484 fn ask_user_default_optional() {
485 let raw = r#"{"action":"ask_user","prompt":"continue?","options":["a","b"]}"#;
486 let d = HookDecision::parse(raw).expect("parse");
487 match d {
488 HookDecision::AskUser {
489 prompt,
490 options,
491 default,
492 } => {
493 assert_eq!(prompt, "continue?");
494 assert_eq!(options, vec!["a".to_string(), "b".to_string()]);
495 assert!(default.is_none());
496 }
497 other => panic!("expected AskUser, got {other:?}"),
498 }
499 }
500
501 #[test]
504 fn empty_payload_treated_as_allow() {
505 assert_eq!(HookDecision::parse("").unwrap(), HookDecision::Allow);
506 assert_eq!(HookDecision::parse(" ").unwrap(), HookDecision::Allow);
507 assert_eq!(HookDecision::parse("{}").unwrap(), HookDecision::Allow);
508 assert_eq!(HookDecision::parse("{ }").unwrap(), HookDecision::Allow);
509 }
510
511 #[test]
514 fn unknown_action_rejected_with_named_error() {
515 let err = HookDecision::parse(r#"{"action":"explode"}"#).unwrap_err();
516 match err {
517 DecisionParseError::UnknownAction(a) => assert_eq!(a, "explode"),
518 other => panic!("expected UnknownAction, got {other:?}"),
519 }
520 }
521
522 #[test]
523 fn missing_action_rejected() {
524 let err = HookDecision::parse(r#"{"reason":"why"}"#).unwrap_err();
525 assert!(matches!(err, DecisionParseError::MissingAction));
526 }
527
528 #[test]
529 fn deny_missing_reason_rejected() {
530 let err = HookDecision::parse(r#"{"action":"deny"}"#).unwrap_err();
531 match err {
532 DecisionParseError::MissingField { action, field } => {
533 assert_eq!(action, "deny");
534 assert_eq!(field, "reason");
535 }
536 other => panic!("expected MissingField, got {other:?}"),
537 }
538 }
539
540 #[test]
541 fn modify_missing_delta_rejected() {
542 let err = HookDecision::parse(r#"{"action":"modify"}"#).unwrap_err();
543 match err {
544 DecisionParseError::MissingField { action, field } => {
545 assert_eq!(action, "modify");
546 assert_eq!(field, "delta");
547 }
548 other => panic!("expected MissingField, got {other:?}"),
549 }
550 }
551
552 #[test]
553 fn ask_user_missing_prompt_rejected() {
554 let err = HookDecision::parse(r#"{"action":"ask_user","options":["a"]}"#).unwrap_err();
555 match err {
556 DecisionParseError::MissingField { action, field } => {
557 assert_eq!(action, "ask_user");
558 assert_eq!(field, "prompt");
559 }
560 other => panic!("expected MissingField, got {other:?}"),
561 }
562 }
563
564 #[test]
565 fn ask_user_missing_options_rejected() {
566 let err = HookDecision::parse(r#"{"action":"ask_user","prompt":"?"}"#).unwrap_err();
567 match err {
568 DecisionParseError::MissingField { action, field } => {
569 assert_eq!(action, "ask_user");
570 assert_eq!(field, "options");
571 }
572 other => panic!("expected MissingField, got {other:?}"),
573 }
574 }
575
576 #[test]
577 fn non_object_payload_rejected() {
578 let err = HookDecision::parse(r#"["allow"]"#).unwrap_err();
579 assert!(matches!(err, DecisionParseError::NotAnObject));
580 }
581
582 #[test]
583 fn malformed_json_rejected() {
584 let err = HookDecision::parse(r"not json at all").unwrap_err();
585 assert!(matches!(err, DecisionParseError::Malformed(_)));
586 }
587
588 fn dispatch(event: HookEvent, raw: &str) -> HookDecision {
594 let parsed = HookDecision::parse(raw).expect("parse");
595 parsed.degrade_modify_for_post_event(event)
596 }
597
598 #[test]
599 fn modify_on_pre_event_passes_through() {
600 let raw = r#"{"action":"modify","delta":{"priority":9}}"#;
601 let d = dispatch(HookEvent::PreStore, raw);
602 match d {
603 HookDecision::Modify(m) => assert_eq!(m.delta.priority, Some(9)),
604 other => panic!("expected Modify, got {other:?}"),
605 }
606 }
607
608 #[test]
609 fn modify_on_post_event_degrades_to_allow() {
610 let raw = r#"{"action":"modify","delta":{"priority":9}}"#;
611 assert_eq!(
613 dispatch(HookEvent::PostStore, raw),
614 HookDecision::Allow,
615 "Modify on post_store must degrade to Allow"
616 );
617 assert_eq!(
618 dispatch(HookEvent::PostRecall, raw),
619 HookDecision::Allow,
620 "Modify on post_recall must degrade to Allow"
621 );
622 assert_eq!(
623 dispatch(HookEvent::OnIndexEviction, raw),
624 HookDecision::Allow,
625 "Modify on on_index_eviction must degrade to Allow"
626 );
627 }
628
629 #[test]
630 fn allow_on_post_event_unchanged() {
631 assert_eq!(
633 dispatch(HookEvent::PostStore, r#"{"action":"allow"}"#),
634 HookDecision::Allow
635 );
636 }
637
638 #[test]
639 fn deny_on_post_event_unchanged() {
640 let raw = r#"{"action":"deny","reason":"x","code":500}"#;
641 assert_eq!(
642 dispatch(HookEvent::PostStore, raw),
643 HookDecision::Deny {
644 reason: "x".into(),
645 code: 500
646 }
647 );
648 }
649
650 #[test]
653 fn is_pre_event_classifies_all_variants() {
654 for ev in [
657 HookEvent::PreStore,
658 HookEvent::PreRecall,
659 HookEvent::PreSearch,
660 HookEvent::PreDelete,
661 HookEvent::PrePromote,
662 HookEvent::PreLink,
663 HookEvent::PreConsolidate,
664 HookEvent::PreGovernanceDecision,
665 HookEvent::PreArchive,
666 HookEvent::PreTranscriptStore,
667 HookEvent::PreRecallExpand,
668 HookEvent::PreReflect,
669 HookEvent::PreCompaction,
670 ] {
671 assert!(is_pre_event(ev), "expected {ev:?} to be a pre- event");
672 }
673 for ev in [
676 HookEvent::PostStore,
677 HookEvent::PostRecall,
678 HookEvent::PostSearch,
679 HookEvent::PostDelete,
680 HookEvent::PostPromote,
681 HookEvent::PostLink,
682 HookEvent::PostConsolidate,
683 HookEvent::PostGovernanceDecision,
684 HookEvent::OnIndexEviction,
685 HookEvent::PostTranscriptStore,
686 HookEvent::PostReflect,
687 HookEvent::OnCompactionRollback,
688 ] {
689 assert!(!is_pre_event(ev), "expected {ev:?} to be a post-/on- event");
690 }
691 }
692
693 #[test]
696 fn parse_error_display_is_descriptive() {
697 let cases = [
698 DecisionParseError::NotAnObject,
699 DecisionParseError::MissingAction,
700 DecisionParseError::UnknownAction("foo".into()),
701 DecisionParseError::MissingField {
702 action: "deny",
703 field: "reason",
704 },
705 DecisionParseError::Malformed("expected `,`".into()),
706 ];
707 for e in &cases {
708 let s = e.to_string();
709 assert!(!s.is_empty(), "Display empty for {e:?}");
710 assert!(
711 s.contains("hook decision"),
712 "Display missing context for {e:?}: {s}"
713 );
714 }
715 }
716
717 #[test]
718 fn parse_action_must_be_string() {
719 let err = HookDecision::parse(r#"{"action": 42}"#).unwrap_err();
720 match err {
721 DecisionParseError::Malformed(m) => assert!(m.contains("must be a string")),
722 other => panic!("expected Malformed, got {other:?}"),
723 }
724 }
725
726 #[test]
727 fn parse_deny_reason_must_be_string() {
728 let err = HookDecision::parse(r#"{"action":"deny","reason": 99}"#).unwrap_err();
729 match err {
730 DecisionParseError::Malformed(m) => assert!(m.contains("reason")),
731 other => panic!("expected Malformed, got {other:?}"),
732 }
733 }
734
735 #[test]
736 fn parse_ask_user_prompt_must_be_string() {
737 let err =
738 HookDecision::parse(r#"{"action":"ask_user","prompt":1,"options":["a"]}"#).unwrap_err();
739 match err {
740 DecisionParseError::Malformed(m) => assert!(m.contains("prompt")),
741 other => panic!("expected Malformed, got {other:?}"),
742 }
743 }
744
745 #[test]
746 fn parse_ask_user_default_must_be_string_when_present() {
747 let err = HookDecision::parse(
748 r#"{"action":"ask_user","prompt":"p","options":["a"],"default":42}"#,
749 )
750 .unwrap_err();
751 match err {
752 DecisionParseError::Malformed(m) => assert!(m.contains("default")),
753 other => panic!("expected Malformed, got {other:?}"),
754 }
755 }
756
757 #[test]
758 fn parse_ask_user_default_null_is_none() {
759 let d = HookDecision::parse(
760 r#"{"action":"ask_user","prompt":"q","options":["yes","no"],"default":null}"#,
761 )
762 .expect("parse");
763 match d {
764 HookDecision::AskUser { default, .. } => assert!(default.is_none()),
765 other => panic!("expected AskUser, got {other:?}"),
766 }
767 }
768
769 #[test]
770 fn parse_modify_with_invalid_delta_returns_malformed() {
771 let err = HookDecision::parse(r#"{"action":"modify","delta": 7}"#).unwrap_err();
774 match err {
775 DecisionParseError::Malformed(_) => {}
776 other => panic!("expected Malformed, got {other:?}"),
777 }
778 }
779
780 #[test]
781 fn parse_ask_user_options_must_be_array_of_strings() {
782 let err = HookDecision::parse(r#"{"action":"ask_user","prompt":"p","options":"nope"}"#)
783 .unwrap_err();
784 match err {
785 DecisionParseError::Malformed(_) => {}
786 other => panic!("expected Malformed, got {other:?}"),
787 }
788 }
789
790 #[test]
791 fn parse_deny_code_out_of_i32_range_falls_back_to_default() {
792 let raw = r#"{"action":"deny","reason":"big code","code": 9999999999}"#;
794 let d = HookDecision::parse(raw).expect("parse");
795 match d {
796 HookDecision::Deny { code, .. } => assert_eq!(code, 403),
797 other => panic!("expected Deny, got {other:?}"),
798 }
799 }
800
801 #[test]
802 fn hook_decision_deserialize_via_serde_routes_through_parse() {
803 let raw = r#"{"action":"allow"}"#;
805 let d: HookDecision = serde_json::from_str(raw).expect("decode");
806 assert_eq!(d, HookDecision::Allow);
807 }
808
809 #[test]
810 fn hook_decision_deserialize_unknown_action_returns_serde_error() {
811 let raw = r#"{"action":"explode"}"#;
812 let r: Result<HookDecision, _> = serde_json::from_str(raw);
813 assert!(r.is_err());
814 }
815
816 #[test]
817 fn hook_decision_partial_eq_modify_with_equal_deltas() {
818 let a = HookDecision::Modify(ModifyPayload {
819 delta: MemoryDelta {
820 tags: Some(vec!["x".into()]),
821 ..Default::default()
822 },
823 });
824 let b = HookDecision::Modify(ModifyPayload {
825 delta: MemoryDelta {
826 tags: Some(vec!["x".into()]),
827 ..Default::default()
828 },
829 });
830 assert_eq!(a, b);
831 }
832
833 #[test]
834 fn hook_decision_partial_eq_modify_with_different_deltas() {
835 let a = HookDecision::Modify(ModifyPayload {
836 delta: MemoryDelta {
837 tags: Some(vec!["x".into()]),
838 ..Default::default()
839 },
840 });
841 let b = HookDecision::Modify(ModifyPayload {
842 delta: MemoryDelta {
843 tags: Some(vec!["y".into()]),
844 ..Default::default()
845 },
846 });
847 assert_ne!(a, b);
848 }
849
850 #[test]
851 fn hook_decision_partial_eq_distinct_variants() {
852 assert_ne!(
853 HookDecision::Allow,
854 HookDecision::Deny {
855 reason: "x".into(),
856 code: 403,
857 }
858 );
859 assert_ne!(
860 HookDecision::Deny {
861 reason: "a".into(),
862 code: 403,
863 },
864 HookDecision::AskUser {
865 prompt: "p".into(),
866 options: vec!["a".into()],
867 default: None,
868 }
869 );
870 }
871
872 #[test]
873 fn parse_array_payload_rejected_as_not_object() {
874 let err = HookDecision::parse("[1,2,3]").unwrap_err();
875 assert!(matches!(err, DecisionParseError::NotAnObject));
876 }
877}