use serde::{Deserialize, Serialize};
use serde_json::Value;
use super::events::{HookEvent, MemoryDelta};
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(tag = "action", rename_all = "snake_case")]
pub enum HookDecision {
Allow,
Modify(ModifyPayload),
Deny {
reason: String,
#[serde(default = "default_deny_code")]
code: i32,
},
AskUser {
prompt: String,
options: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
default: Option<String>,
},
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ModifyPayload {
pub delta: MemoryDelta,
}
fn default_deny_code() -> i32 {
403
}
#[derive(Debug)]
pub enum DecisionParseError {
NotAnObject,
MissingAction,
UnknownAction(String),
MissingField {
action: &'static str,
field: &'static str,
},
Malformed(String),
}
impl std::fmt::Display for DecisionParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
DecisionParseError::NotAnObject => {
write!(f, "hook decision must be a JSON object")
}
DecisionParseError::MissingAction => {
write!(f, "hook decision missing required \"action\" field")
}
DecisionParseError::UnknownAction(a) => {
write!(f, "hook decision has unknown action \"{a}\"")
}
DecisionParseError::MissingField { action, field } => {
write!(
f,
"hook decision action=\"{action}\" missing required field \"{field}\""
)
}
DecisionParseError::Malformed(msg) => {
write!(f, "hook decision malformed: {msg}")
}
}
}
}
impl std::error::Error for DecisionParseError {}
impl HookDecision {
fn malformed_must_be_string(field: &str) -> DecisionParseError {
DecisionParseError::Malformed(format!("\"{field}\" must be a string"))
}
pub fn parse(line: &str) -> Result<Self, DecisionParseError> {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed == "{}" {
return Ok(HookDecision::Allow);
}
let value: Value = serde_json::from_str(trimmed)
.map_err(|e| DecisionParseError::Malformed(e.to_string()))?;
let obj = value.as_object().ok_or(DecisionParseError::NotAnObject)?;
if obj.is_empty() {
return Ok(HookDecision::Allow);
}
let action = obj
.get("action")
.ok_or(DecisionParseError::MissingAction)?
.as_str()
.ok_or_else(|| Self::malformed_must_be_string("action"))?;
match action {
"allow" => Ok(HookDecision::Allow),
"modify" => {
let delta_v = obj.get("delta").ok_or(DecisionParseError::MissingField {
action: "modify",
field: "delta",
})?;
let delta: MemoryDelta = serde_json::from_value(delta_v.clone())
.map_err(|e| DecisionParseError::Malformed(e.to_string()))?;
Ok(HookDecision::Modify(ModifyPayload { delta }))
}
"deny" => {
let reason = obj
.get("reason")
.ok_or(DecisionParseError::MissingField {
action: "deny",
field: "reason",
})?
.as_str()
.ok_or_else(|| Self::malformed_must_be_string("reason"))?
.to_string();
let code = obj
.get("code")
.and_then(serde_json::Value::as_i64)
.map_or_else(default_deny_code, |c| {
i32::try_from(c).unwrap_or(default_deny_code())
});
Ok(HookDecision::Deny { reason, code })
}
"ask_user" => {
let prompt = obj
.get("prompt")
.ok_or(DecisionParseError::MissingField {
action: "ask_user",
field: "prompt",
})?
.as_str()
.ok_or_else(|| Self::malformed_must_be_string("prompt"))?
.to_string();
let options_v = obj.get("options").ok_or(DecisionParseError::MissingField {
action: "ask_user",
field: "options",
})?;
let options: Vec<String> = serde_json::from_value(options_v.clone())
.map_err(|e| DecisionParseError::Malformed(e.to_string()))?;
let default = match obj.get("default") {
None => None,
Some(Value::Null) => None,
Some(v) => Some(
v.as_str()
.ok_or_else(|| Self::malformed_must_be_string("default"))?
.to_string(),
),
};
Ok(HookDecision::AskUser {
prompt,
options,
default,
})
}
other => Err(DecisionParseError::UnknownAction(other.to_string())),
}
}
#[must_use]
pub fn degrade_modify_for_post_event(self, event: HookEvent) -> Self {
if matches!(self, HookDecision::Modify(_)) && !is_pre_event(event) {
tracing::warn!(
event = ?event,
"hooks: Modify decision returned for post- event; degrading to Allow"
);
return HookDecision::Allow;
}
self
}
}
#[must_use]
#[deny(unreachable_patterns)]
pub fn is_pre_event(event: HookEvent) -> bool {
match event {
HookEvent::PreStore
| HookEvent::PreRecall
| HookEvent::PreSearch
| HookEvent::PreDelete
| HookEvent::PrePromote
| HookEvent::PreLink
| HookEvent::PreConsolidate
| HookEvent::PreGovernanceDecision
| HookEvent::PreArchive
| HookEvent::PreTranscriptStore
| HookEvent::PreRecallExpand
| HookEvent::PreReflect
| HookEvent::PreCompaction => true,
HookEvent::PostStore
| HookEvent::PostRecall
| HookEvent::PostSearch
| HookEvent::PostDelete
| HookEvent::PostPromote
| HookEvent::PostLink
| HookEvent::PostConsolidate
| HookEvent::PostGovernanceDecision
| HookEvent::OnIndexEviction
| HookEvent::PostTranscriptStore
| HookEvent::PostReflect
| HookEvent::OnCompactionRollback => false,
}
}
impl<'de> Deserialize<'de> for HookDecision {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = Value::deserialize(deserializer)?;
let as_text = serde_json::to_string(&value).map_err(serde::de::Error::custom)?;
HookDecision::parse(&as_text).map_err(serde::de::Error::custom)
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn allow_round_trips() {
let d = HookDecision::Allow;
let json = serde_json::to_string(&d).expect("encode");
assert_eq!(json, r#"{"action":"allow"}"#);
let back: HookDecision = serde_json::from_str(&json).expect("decode");
assert_eq!(back, HookDecision::Allow);
}
#[test]
fn modify_round_trips_with_delta() {
let delta = MemoryDelta {
tags: Some(vec!["redacted".into()]),
priority: Some(5),
..Default::default()
};
let d = HookDecision::Modify(ModifyPayload {
delta: delta.clone(),
});
let json = serde_json::to_string(&d).expect("encode");
let v: Value = serde_json::from_str(&json).expect("parse");
assert_eq!(v["action"], json!("modify"));
assert_eq!(v["delta"]["tags"], json!(["redacted"]));
assert_eq!(v["delta"]["priority"], json!(5));
let back: HookDecision = serde_json::from_str(&json).expect("decode");
assert_eq!(back, HookDecision::Modify(ModifyPayload { delta }));
}
#[test]
fn deny_round_trips_with_explicit_code() {
let d = HookDecision::Deny {
reason: "redact required".into(),
code: 451,
};
let json = serde_json::to_string(&d).expect("encode");
let back: HookDecision = serde_json::from_str(&json).expect("decode");
assert_eq!(back, d);
}
#[test]
fn deny_default_code_when_omitted() {
let d = HookDecision::parse(r#"{"action":"deny","reason":"nope"}"#).expect("parse");
match d {
HookDecision::Deny { reason, code } => {
assert_eq!(reason, "nope");
assert_eq!(code, 403, "missing code defaults to 403");
}
other => panic!("expected Deny, got {other:?}"),
}
}
#[test]
fn ask_user_round_trips() {
let d = HookDecision::AskUser {
prompt: "Promote to long-term?".into(),
options: vec!["yes".into(), "no".into()],
default: Some("no".into()),
};
let json = serde_json::to_string(&d).expect("encode");
let v: Value = serde_json::from_str(&json).expect("parse");
assert_eq!(v["action"], json!("ask_user"));
assert_eq!(v["options"], json!(["yes", "no"]));
assert_eq!(v["default"], json!("no"));
let back: HookDecision = serde_json::from_str(&json).expect("decode");
assert_eq!(back, d);
}
#[test]
fn ask_user_default_optional() {
let raw = r#"{"action":"ask_user","prompt":"continue?","options":["a","b"]}"#;
let d = HookDecision::parse(raw).expect("parse");
match d {
HookDecision::AskUser {
prompt,
options,
default,
} => {
assert_eq!(prompt, "continue?");
assert_eq!(options, vec!["a".to_string(), "b".to_string()]);
assert!(default.is_none());
}
other => panic!("expected AskUser, got {other:?}"),
}
}
#[test]
fn empty_payload_treated_as_allow() {
assert_eq!(HookDecision::parse("").unwrap(), HookDecision::Allow);
assert_eq!(HookDecision::parse(" ").unwrap(), HookDecision::Allow);
assert_eq!(HookDecision::parse("{}").unwrap(), HookDecision::Allow);
assert_eq!(HookDecision::parse("{ }").unwrap(), HookDecision::Allow);
}
#[test]
fn unknown_action_rejected_with_named_error() {
let err = HookDecision::parse(r#"{"action":"explode"}"#).unwrap_err();
match err {
DecisionParseError::UnknownAction(a) => assert_eq!(a, "explode"),
other => panic!("expected UnknownAction, got {other:?}"),
}
}
#[test]
fn missing_action_rejected() {
let err = HookDecision::parse(r#"{"reason":"why"}"#).unwrap_err();
assert!(matches!(err, DecisionParseError::MissingAction));
}
#[test]
fn deny_missing_reason_rejected() {
let err = HookDecision::parse(r#"{"action":"deny"}"#).unwrap_err();
match err {
DecisionParseError::MissingField { action, field } => {
assert_eq!(action, "deny");
assert_eq!(field, "reason");
}
other => panic!("expected MissingField, got {other:?}"),
}
}
#[test]
fn modify_missing_delta_rejected() {
let err = HookDecision::parse(r#"{"action":"modify"}"#).unwrap_err();
match err {
DecisionParseError::MissingField { action, field } => {
assert_eq!(action, "modify");
assert_eq!(field, "delta");
}
other => panic!("expected MissingField, got {other:?}"),
}
}
#[test]
fn ask_user_missing_prompt_rejected() {
let err = HookDecision::parse(r#"{"action":"ask_user","options":["a"]}"#).unwrap_err();
match err {
DecisionParseError::MissingField { action, field } => {
assert_eq!(action, "ask_user");
assert_eq!(field, "prompt");
}
other => panic!("expected MissingField, got {other:?}"),
}
}
#[test]
fn ask_user_missing_options_rejected() {
let err = HookDecision::parse(r#"{"action":"ask_user","prompt":"?"}"#).unwrap_err();
match err {
DecisionParseError::MissingField { action, field } => {
assert_eq!(action, "ask_user");
assert_eq!(field, "options");
}
other => panic!("expected MissingField, got {other:?}"),
}
}
#[test]
fn non_object_payload_rejected() {
let err = HookDecision::parse(r#"["allow"]"#).unwrap_err();
assert!(matches!(err, DecisionParseError::NotAnObject));
}
#[test]
fn malformed_json_rejected() {
let err = HookDecision::parse(r"not json at all").unwrap_err();
assert!(matches!(err, DecisionParseError::Malformed(_)));
}
fn dispatch(event: HookEvent, raw: &str) -> HookDecision {
let parsed = HookDecision::parse(raw).expect("parse");
parsed.degrade_modify_for_post_event(event)
}
#[test]
fn modify_on_pre_event_passes_through() {
let raw = r#"{"action":"modify","delta":{"priority":9}}"#;
let d = dispatch(HookEvent::PreStore, raw);
match d {
HookDecision::Modify(m) => assert_eq!(m.delta.priority, Some(9)),
other => panic!("expected Modify, got {other:?}"),
}
}
#[test]
fn modify_on_post_event_degrades_to_allow() {
let raw = r#"{"action":"modify","delta":{"priority":9}}"#;
assert_eq!(
dispatch(HookEvent::PostStore, raw),
HookDecision::Allow,
"Modify on post_store must degrade to Allow"
);
assert_eq!(
dispatch(HookEvent::PostRecall, raw),
HookDecision::Allow,
"Modify on post_recall must degrade to Allow"
);
assert_eq!(
dispatch(HookEvent::OnIndexEviction, raw),
HookDecision::Allow,
"Modify on on_index_eviction must degrade to Allow"
);
}
#[test]
fn allow_on_post_event_unchanged() {
assert_eq!(
dispatch(HookEvent::PostStore, r#"{"action":"allow"}"#),
HookDecision::Allow
);
}
#[test]
fn deny_on_post_event_unchanged() {
let raw = r#"{"action":"deny","reason":"x","code":500}"#;
assert_eq!(
dispatch(HookEvent::PostStore, raw),
HookDecision::Deny {
reason: "x".into(),
code: 500
}
);
}
#[test]
fn is_pre_event_classifies_all_variants() {
for ev in [
HookEvent::PreStore,
HookEvent::PreRecall,
HookEvent::PreSearch,
HookEvent::PreDelete,
HookEvent::PrePromote,
HookEvent::PreLink,
HookEvent::PreConsolidate,
HookEvent::PreGovernanceDecision,
HookEvent::PreArchive,
HookEvent::PreTranscriptStore,
HookEvent::PreRecallExpand,
HookEvent::PreReflect,
HookEvent::PreCompaction,
] {
assert!(is_pre_event(ev), "expected {ev:?} to be a pre- event");
}
for ev in [
HookEvent::PostStore,
HookEvent::PostRecall,
HookEvent::PostSearch,
HookEvent::PostDelete,
HookEvent::PostPromote,
HookEvent::PostLink,
HookEvent::PostConsolidate,
HookEvent::PostGovernanceDecision,
HookEvent::OnIndexEviction,
HookEvent::PostTranscriptStore,
HookEvent::PostReflect,
HookEvent::OnCompactionRollback,
] {
assert!(!is_pre_event(ev), "expected {ev:?} to be a post-/on- event");
}
}
#[test]
fn parse_error_display_is_descriptive() {
let cases = [
DecisionParseError::NotAnObject,
DecisionParseError::MissingAction,
DecisionParseError::UnknownAction("foo".into()),
DecisionParseError::MissingField {
action: "deny",
field: "reason",
},
DecisionParseError::Malformed("expected `,`".into()),
];
for e in &cases {
let s = e.to_string();
assert!(!s.is_empty(), "Display empty for {e:?}");
assert!(
s.contains("hook decision"),
"Display missing context for {e:?}: {s}"
);
}
}
#[test]
fn parse_action_must_be_string() {
let err = HookDecision::parse(r#"{"action": 42}"#).unwrap_err();
match err {
DecisionParseError::Malformed(m) => assert!(m.contains("must be a string")),
other => panic!("expected Malformed, got {other:?}"),
}
}
#[test]
fn parse_deny_reason_must_be_string() {
let err = HookDecision::parse(r#"{"action":"deny","reason": 99}"#).unwrap_err();
match err {
DecisionParseError::Malformed(m) => assert!(m.contains("reason")),
other => panic!("expected Malformed, got {other:?}"),
}
}
#[test]
fn parse_ask_user_prompt_must_be_string() {
let err =
HookDecision::parse(r#"{"action":"ask_user","prompt":1,"options":["a"]}"#).unwrap_err();
match err {
DecisionParseError::Malformed(m) => assert!(m.contains("prompt")),
other => panic!("expected Malformed, got {other:?}"),
}
}
#[test]
fn parse_ask_user_default_must_be_string_when_present() {
let err = HookDecision::parse(
r#"{"action":"ask_user","prompt":"p","options":["a"],"default":42}"#,
)
.unwrap_err();
match err {
DecisionParseError::Malformed(m) => assert!(m.contains("default")),
other => panic!("expected Malformed, got {other:?}"),
}
}
#[test]
fn parse_ask_user_default_null_is_none() {
let d = HookDecision::parse(
r#"{"action":"ask_user","prompt":"q","options":["yes","no"],"default":null}"#,
)
.expect("parse");
match d {
HookDecision::AskUser { default, .. } => assert!(default.is_none()),
other => panic!("expected AskUser, got {other:?}"),
}
}
#[test]
fn parse_modify_with_invalid_delta_returns_malformed() {
let err = HookDecision::parse(r#"{"action":"modify","delta": 7}"#).unwrap_err();
match err {
DecisionParseError::Malformed(_) => {}
other => panic!("expected Malformed, got {other:?}"),
}
}
#[test]
fn parse_ask_user_options_must_be_array_of_strings() {
let err = HookDecision::parse(r#"{"action":"ask_user","prompt":"p","options":"nope"}"#)
.unwrap_err();
match err {
DecisionParseError::Malformed(_) => {}
other => panic!("expected Malformed, got {other:?}"),
}
}
#[test]
fn parse_deny_code_out_of_i32_range_falls_back_to_default() {
let raw = r#"{"action":"deny","reason":"big code","code": 9999999999}"#;
let d = HookDecision::parse(raw).expect("parse");
match d {
HookDecision::Deny { code, .. } => assert_eq!(code, 403),
other => panic!("expected Deny, got {other:?}"),
}
}
#[test]
fn hook_decision_deserialize_via_serde_routes_through_parse() {
let raw = r#"{"action":"allow"}"#;
let d: HookDecision = serde_json::from_str(raw).expect("decode");
assert_eq!(d, HookDecision::Allow);
}
#[test]
fn hook_decision_deserialize_unknown_action_returns_serde_error() {
let raw = r#"{"action":"explode"}"#;
let r: Result<HookDecision, _> = serde_json::from_str(raw);
assert!(r.is_err());
}
#[test]
fn hook_decision_partial_eq_modify_with_equal_deltas() {
let a = HookDecision::Modify(ModifyPayload {
delta: MemoryDelta {
tags: Some(vec!["x".into()]),
..Default::default()
},
});
let b = HookDecision::Modify(ModifyPayload {
delta: MemoryDelta {
tags: Some(vec!["x".into()]),
..Default::default()
},
});
assert_eq!(a, b);
}
#[test]
fn hook_decision_partial_eq_modify_with_different_deltas() {
let a = HookDecision::Modify(ModifyPayload {
delta: MemoryDelta {
tags: Some(vec!["x".into()]),
..Default::default()
},
});
let b = HookDecision::Modify(ModifyPayload {
delta: MemoryDelta {
tags: Some(vec!["y".into()]),
..Default::default()
},
});
assert_ne!(a, b);
}
#[test]
fn hook_decision_partial_eq_distinct_variants() {
assert_ne!(
HookDecision::Allow,
HookDecision::Deny {
reason: "x".into(),
code: 403,
}
);
assert_ne!(
HookDecision::Deny {
reason: "a".into(),
code: 403,
},
HookDecision::AskUser {
prompt: "p".into(),
options: vec!["a".into()],
default: None,
}
);
}
#[test]
fn parse_array_payload_rejected_as_not_object() {
let err = HookDecision::parse("[1,2,3]").unwrap_err();
assert!(matches!(err, DecisionParseError::NotAnObject));
}
}