Skip to main content

polyhook_core/
lib.rs

1pub mod detect;
2pub mod events;
3pub mod parse;
4pub mod response;
5pub mod tools;
6pub mod types;
7mod type_impls;
8pub mod wasm;
9mod stdin_wrappers;
10
11pub use types::*;
12pub use stdin_wrappers::{read, respond};
13
14use std::cell::RefCell;
15use std::io::{Read, Write};
16
17use parse::parse_event;
18use response::serialize_response;
19
20// Store the caller from the most recently parsed event so that `respond` can
21// serialise the response in the correct format without the caller needing to
22// thread the CallerKind through their code.
23thread_local! {
24    static LAST_CALLER: RefCell<CallerKind> = RefCell::new(CallerKind::Unknown);
25}
26
27/// Read a [`HookEvent`] from an arbitrary reader.
28///
29/// Reads until EOF, then parses the JSON payload.  The detected [`CallerKind`]
30/// is stored in a thread-local so that a subsequent [`respond_to`] call can
31/// serialise the response in the correct format.
32pub fn read_from(r: &mut impl Read) -> Result<HookEvent, String> {
33    let mut buf = Vec::new();
34    r.read_to_end(&mut buf)
35        .map_err(|e| format!("read error: {e}"))?;
36
37    let event = parse_event(&buf)?;
38
39    // Persist caller so `respond_to` / `respond` can use it.
40    LAST_CALLER.with(|c| {
41        *c.borrow_mut() = event.caller.clone();
42    });
43
44    Ok(event)
45}
46
47/// Write a [`HookResponse`] to an arbitrary writer in the format expected by
48/// the agent that was detected during the most recent [`read_from`] call.
49pub fn respond_to(w: &mut impl Write, response: &HookResponse) -> Result<(), String> {
50    let caller = LAST_CALLER.with(|c| c.borrow().clone());
51    let value = serialize_response(response, &caller);
52    let json = serde_json::to_string(&value).map_err(|e| format!("JSON encode error: {e}"))?;
53
54    w.write_all(json.as_bytes())
55        .map_err(|e| format!("write error: {e}"))?;
56
57    Ok(())
58}
59
60// ---------------------------------------------------------------------------
61// Tests
62// ---------------------------------------------------------------------------
63
64#[cfg(test)]
65mod tests {
66    use super::*;
67    use std::io::Cursor;
68
69    const CLAUDE_PRE_TOOL: &str = r#"{"type":"PreToolUse","tool_name":"Bash","tool_input":{"command":"ls -la"},"session_id":"sess_test_001"}"#;
70    const CURSOR_BEFORE_TOOL: &str = r#"{"type":"BeforeToolCall","toolCall":{"name":"run_terminal_cmd","args":{"command":"echo hi"}},"sessionId":"sess_test_002"}"#;
71
72    #[test]
73    fn read_from_parses_claude_code_event() {
74        let mut cursor = Cursor::new(CLAUDE_PRE_TOOL.as_bytes());
75        let event = read_from(&mut cursor).expect("read_from should succeed");
76        assert_eq!(event.caller, CallerKind::ClaudeCode);
77        assert_eq!(event.event.to_string(), "tool:before");
78        assert_eq!(event.tool.as_deref(), Some("bash"));
79        assert_eq!(event.session_id, "sess_test_001");
80    }
81
82    #[test]
83    fn read_from_returns_error_on_invalid_json() {
84        let mut cursor = Cursor::new(b"not valid json" as &[u8]);
85        let result = read_from(&mut cursor);
86        assert!(result.is_err());
87        let msg = result.unwrap_err();
88        assert!(msg.contains("JSON parse error") || msg.contains("parse"));
89    }
90
91    #[test]
92    fn respond_to_writes_json_to_writer() {
93        // First set the LAST_CALLER via read_from so respond_to uses ClaudeCode format.
94        let mut cursor = Cursor::new(CLAUDE_PRE_TOOL.as_bytes());
95        let _ = read_from(&mut cursor).expect("read_from should succeed");
96
97        let mut output: Vec<u8> = Vec::new();
98        respond_to(&mut output, &HookResponse::approve()).expect("respond_to should succeed");
99
100        let json: serde_json::Value =
101            serde_json::from_slice(&output).expect("output should be valid JSON");
102        // ClaudeCode approve → empty object
103        assert!(json.as_object().unwrap().is_empty());
104    }
105
106    #[test]
107    fn respond_to_block_uses_detected_caller() {
108        // Parse a Cursor event so LAST_CALLER becomes Cursor.
109        let mut cursor = Cursor::new(CURSOR_BEFORE_TOOL.as_bytes());
110        let _ = read_from(&mut cursor).expect("read_from should succeed");
111
112        let mut output: Vec<u8> = Vec::new();
113        respond_to(&mut output, &HookResponse::block("stop")).expect("respond_to should succeed");
114
115        let json: serde_json::Value =
116            serde_json::from_slice(&output).expect("output should be valid JSON");
117        // Cursor block → {"action": "deny", "message": "..."}
118        assert_eq!(json["action"], "deny");
119        assert_eq!(json["message"], "stop");
120    }
121
122    #[test]
123    fn respond_to_modify_uses_detected_caller() {
124        // Parse a Claude Code event.
125        let mut cursor = Cursor::new(CLAUDE_PRE_TOOL.as_bytes());
126        let _ = read_from(&mut cursor).expect("read_from should succeed");
127
128        let new_input = serde_json::json!({"command": "echo safe"});
129        let mut output: Vec<u8> = Vec::new();
130        respond_to(&mut output, &HookResponse::modify(new_input.clone()))
131            .expect("respond_to should succeed");
132
133        let json: serde_json::Value =
134            serde_json::from_slice(&output).expect("output should be valid JSON");
135        // ClaudeCode modify → {"decision": "approve", "tool_input": {...}}
136        assert_eq!(json["decision"], "approve");
137        assert_eq!(json["tool_input"], new_input);
138    }
139
140    #[test]
141    fn last_caller_thread_local_is_updated_by_read_from() {
142        // Parse ClaudeCode event → LAST_CALLER should be ClaudeCode.
143        let mut cursor = Cursor::new(CLAUDE_PRE_TOOL.as_bytes());
144        let event = read_from(&mut cursor).expect("read_from should succeed");
145        assert_eq!(event.caller, CallerKind::ClaudeCode);
146
147        // respond_to should use ClaudeCode format.
148        let mut output: Vec<u8> = Vec::new();
149        respond_to(&mut output, &HookResponse::approve()).expect("respond_to should succeed");
150        let json: serde_json::Value = serde_json::from_slice(&output).unwrap();
151        assert!(json.as_object().unwrap().is_empty());
152
153        // Now parse Cursor event → LAST_CALLER should switch to Cursor.
154        let mut cursor2 = Cursor::new(CURSOR_BEFORE_TOOL.as_bytes());
155        let event2 = read_from(&mut cursor2).expect("read_from should succeed");
156        assert_eq!(event2.caller, CallerKind::Cursor);
157
158        let mut output2: Vec<u8> = Vec::new();
159        respond_to(&mut output2, &HookResponse::approve()).expect("respond_to should succeed");
160        let json2: serde_json::Value = serde_json::from_slice(&output2).unwrap();
161        // Cursor approve → {"action": "allow"}
162        assert_eq!(json2["action"], "allow");
163    }
164}