Skip to main content

mati_core/hooks/decide/
instructions_loaded.rs

1//! Pure parsing for Claude Code's `InstructionsLoaded` hook event.
2
3use serde::{Deserialize, Serialize};
4
5/// The payload captured from Claude Code 2.1.223's `InstructionsLoaded` event.
6///
7/// Keep this shape aligned with the installed platform contract. The adapter
8/// records the payload as received; it does not make an enforcement decision.
9///
10/// Unlike the MCP input DTOs, this deliberately does NOT `deny_unknown_fields`:
11/// those parse untrusted client input, this parses a platform payload that gains
12/// fields between Claude Code releases. Rejecting one would stop the audit.
13#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14pub struct InstructionsLoadedPayload {
15    pub session_id: String,
16    pub transcript_path: String,
17    pub cwd: String,
18    pub hook_event_name: String,
19    pub file_path: String,
20    pub memory_type: String,
21    pub load_reason: String,
22}
23
24/// Parse one raw `InstructionsLoaded` hook payload.
25///
26/// Invalid or unrelated payloads return `None` so the hook can fail open
27/// without recording a misleading event.
28pub fn parse_instructions_loaded(input: &serde_json::Value) -> Option<InstructionsLoadedPayload> {
29    let payload: InstructionsLoadedPayload = serde_json::from_value(input.clone()).ok()?;
30    (payload.hook_event_name == "InstructionsLoaded").then_some(payload)
31}
32
33#[cfg(test)]
34mod tests {
35    use super::*;
36    use serde_json::json;
37
38    fn payload() -> serde_json::Value {
39        json!({
40            "session_id": "session-123",
41            "transcript_path": "/tmp/transcript.jsonl",
42            "cwd": "/repo",
43            "hook_event_name": "InstructionsLoaded",
44            "file_path": "/repo/.claude/CLAUDE.md",
45            "memory_type": "Project",
46            "load_reason": "session_start"
47        })
48    }
49
50    #[test]
51    fn parses_captured_platform_shape() {
52        let parsed = parse_instructions_loaded(&payload()).expect("payload should parse");
53        assert_eq!(parsed.file_path, "/repo/.claude/CLAUDE.md");
54        assert_eq!(parsed.load_reason, "session_start");
55    }
56
57    #[test]
58    fn rejects_other_hook_events() {
59        let mut value = payload();
60        value["hook_event_name"] = json!("SessionStart");
61        assert!(parse_instructions_loaded(&value).is_none());
62    }
63
64    #[test]
65    fn rejects_missing_or_wrongly_typed_fields() {
66        let mut missing = payload();
67        missing.as_object_mut().unwrap().remove("file_path");
68        assert!(parse_instructions_loaded(&missing).is_none());
69
70        let mut wrong_type = payload();
71        wrong_type["load_reason"] = json!(42);
72        assert!(parse_instructions_loaded(&wrong_type).is_none());
73    }
74
75    /// A Claude Code release that adds a field must not silence the audit.
76    #[test]
77    fn accepts_unknown_fields() {
78        let mut value = payload();
79        value["future_platform_field"] = json!(true);
80        let parsed = parse_instructions_loaded(&value).expect("unknown fields must not reject");
81        assert_eq!(parsed.file_path, "/repo/.claude/CLAUDE.md");
82    }
83}