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    // serde_json::Value is always serializable; expect is safe here.
53    let json = serde_json::to_string(&value).expect("serde_json::Value is always serializable");
54
55    w.write_all(json.as_bytes())
56        .map_err(|e| format!("write error: {e}"))?;
57
58    Ok(())
59}
60
61// ---------------------------------------------------------------------------
62// Tests
63// ---------------------------------------------------------------------------
64
65#[cfg(test)]
66mod tests {
67    use super::*;
68    use std::io::Cursor;
69
70    const CLAUDE_PRE_TOOL: &str = r#"{"type":"PreToolUse","tool_name":"Bash","tool_input":{"command":"ls -la"},"session_id":"sess_test_001"}"#;
71    const CURSOR_BEFORE_TOOL: &str = r#"{"type":"BeforeToolCall","toolCall":{"name":"run_terminal_cmd","args":{"command":"echo hi"}},"sessionId":"sess_test_002"}"#;
72
73    #[test]
74    fn read_from_parses_claude_code_event() {
75        let mut cursor = Cursor::new(CLAUDE_PRE_TOOL.as_bytes());
76        let event = read_from(&mut cursor).expect("read_from should succeed");
77        assert_eq!(event.caller, CallerKind::ClaudeCode);
78        assert_eq!(event.event.to_string(), "tool:before");
79        assert_eq!(event.tool.as_deref(), Some("bash"));
80        assert_eq!(event.session_id, "sess_test_001");
81    }
82
83    #[test]
84    fn read_from_returns_error_on_invalid_json() {
85        let mut cursor = Cursor::new(b"not valid json" as &[u8]);
86        let result = read_from(&mut cursor);
87        assert!(result.is_err());
88        let msg = result.unwrap_err();
89        assert!(msg.contains("JSON parse error") || msg.contains("parse"));
90    }
91
92    #[test]
93    fn respond_to_writes_json_to_writer() {
94        // First set the LAST_CALLER via read_from so respond_to uses ClaudeCode format.
95        let mut cursor = Cursor::new(CLAUDE_PRE_TOOL.as_bytes());
96        let _ = read_from(&mut cursor).expect("read_from should succeed");
97
98        let mut output: Vec<u8> = Vec::new();
99        respond_to(&mut output, &HookResponse::approve()).expect("respond_to should succeed");
100
101        let json: serde_json::Value =
102            serde_json::from_slice(&output).expect("output should be valid JSON");
103        // ClaudeCode approve → empty object
104        assert!(json.as_object().unwrap().is_empty());
105    }
106
107    #[test]
108    fn respond_to_block_uses_detected_caller() {
109        // Parse a Cursor event so LAST_CALLER becomes Cursor.
110        let mut cursor = Cursor::new(CURSOR_BEFORE_TOOL.as_bytes());
111        let _ = read_from(&mut cursor).expect("read_from should succeed");
112
113        let mut output: Vec<u8> = Vec::new();
114        respond_to(&mut output, &HookResponse::block("stop")).expect("respond_to should succeed");
115
116        let json: serde_json::Value =
117            serde_json::from_slice(&output).expect("output should be valid JSON");
118        // Cursor block → {"action": "deny", "message": "..."}
119        assert_eq!(json["action"], "deny");
120        assert_eq!(json["message"], "stop");
121    }
122
123    #[test]
124    fn respond_to_modify_uses_detected_caller() {
125        // Parse a Claude Code event.
126        let mut cursor = Cursor::new(CLAUDE_PRE_TOOL.as_bytes());
127        let _ = read_from(&mut cursor).expect("read_from should succeed");
128
129        let new_input = serde_json::json!({"command": "echo safe"});
130        let mut output: Vec<u8> = Vec::new();
131        respond_to(&mut output, &HookResponse::modify(new_input.clone()))
132            .expect("respond_to should succeed");
133
134        let json: serde_json::Value =
135            serde_json::from_slice(&output).expect("output should be valid JSON");
136        // ClaudeCode modify → {"decision": "approve", "tool_input": {...}}
137        assert_eq!(json["decision"], "approve");
138        assert_eq!(json["tool_input"], new_input);
139    }
140
141    #[test]
142    fn last_caller_thread_local_is_updated_by_read_from() {
143        // Parse ClaudeCode event → LAST_CALLER should be ClaudeCode.
144        let mut cursor = Cursor::new(CLAUDE_PRE_TOOL.as_bytes());
145        let event = read_from(&mut cursor).expect("read_from should succeed");
146        assert_eq!(event.caller, CallerKind::ClaudeCode);
147
148        // respond_to should use ClaudeCode format.
149        let mut output: Vec<u8> = Vec::new();
150        respond_to(&mut output, &HookResponse::approve()).expect("respond_to should succeed");
151        let json: serde_json::Value = serde_json::from_slice(&output).unwrap();
152        assert!(json.as_object().unwrap().is_empty());
153
154        // Now parse Cursor event → LAST_CALLER should switch to Cursor.
155        let mut cursor2 = Cursor::new(CURSOR_BEFORE_TOOL.as_bytes());
156        let event2 = read_from(&mut cursor2).expect("read_from should succeed");
157        assert_eq!(event2.caller, CallerKind::Cursor);
158
159        let mut output2: Vec<u8> = Vec::new();
160        respond_to(&mut output2, &HookResponse::approve()).expect("respond_to should succeed");
161        let json2: serde_json::Value = serde_json::from_slice(&output2).unwrap();
162        // Cursor approve → {"action": "allow"}
163        assert_eq!(json2["action"], "allow");
164    }
165
166    // Test the respond() thin wrapper (writes to stdout — captured by test harness).
167    #[test]
168    fn respond_delegates_to_stdout() {
169        // Prime LAST_CALLER.
170        let mut cursor = Cursor::new(CLAUDE_PRE_TOOL.as_bytes());
171        let _ = read_from(&mut cursor).expect("prime");
172        // respond() writes to stdout; in tests the harness captures it.
173        let result = respond(&HookResponse::approve());
174        assert!(result.is_ok());
175    }
176
177    // Test the read() thin wrapper via an OS pipe (Unix only).
178    #[cfg(unix)]
179    #[test]
180    fn read_delegates_to_stdin() {
181        extern "C" {
182            fn pipe(fds: *mut i32) -> i32;
183            fn dup(fd: i32) -> i32;
184            fn dup2(oldfd: i32, newfd: i32) -> i32;
185            fn close(fd: i32) -> i32;
186            fn write(fd: i32, buf: *const u8, count: usize) -> isize;
187        }
188
189        let json = CLAUDE_PRE_TOOL.as_bytes();
190
191        unsafe {
192            let mut fds = [0i32; 2];
193            assert_eq!(pipe(fds.as_mut_ptr()), 0);
194            let (read_fd, write_fd) = (fds[0], fds[1]);
195
196            write(write_fd, json.as_ptr(), json.len());
197            close(write_fd);
198
199            let saved = dup(0);
200            dup2(read_fd, 0);
201            close(read_fd);
202
203            let result = read();
204
205            dup2(saved, 0);
206            close(saved);
207
208            result.expect("read() should succeed");
209        }
210    }
211
212    // Cover the error branch in read_from where the reader fails.
213    #[test]
214    fn read_from_io_error_returns_err() {
215        struct FailReader;
216        impl std::io::Read for FailReader {
217            fn read(&mut self, _: &mut [u8]) -> std::io::Result<usize> {
218                Err(std::io::Error::new(std::io::ErrorKind::BrokenPipe, "pipe broken"))
219            }
220        }
221        let result = read_from(&mut FailReader);
222        assert!(result.is_err());
223        assert!(result.unwrap_err().contains("read error"));
224    }
225
226    // Cover the error branch in respond_to where the writer fails.
227    #[test]
228    fn respond_to_write_error_returns_err() {
229        struct FailWriter;
230        impl std::io::Write for FailWriter {
231            fn write(&mut self, _: &[u8]) -> std::io::Result<usize> {
232                Err(std::io::Error::new(std::io::ErrorKind::BrokenPipe, "pipe broken"))
233            }
234            fn flush(&mut self) -> std::io::Result<()> { Ok(()) }
235        }
236        assert!(FailWriter.flush().is_ok());
237        let mut cursor = Cursor::new(CLAUDE_PRE_TOOL.as_bytes());
238        let _ = read_from(&mut cursor).expect("prime");
239        let result = respond_to(&mut FailWriter, &HookResponse::approve());
240        assert!(result.is_err());
241        assert!(result.unwrap_err().contains("write error"));
242    }
243}