1//! The mod's logic, compiled to wasm32 and then to JavaScript by `wasm2js` 2//! (a mod's realm has no `WebAssembly`). The plugin's `bridge.ts` is the only 3//! caller: it writes a JSON question into linear memory, calls `handle`, and 4//! reads the JSON answer back. `handle_json` is the same logic as plain Rust, 5//! so it is tested natively. 6//! 7//! The question is what to do with one hook event, answered inside Claude 8//! Code's own process in well under a millisecond: send it to the daemon and 9//! wait, send it without waiting, or leave it alone. 10 11mod read_only; 12 13use jevhooks_events::{HookEvent, Role}; 14use serde::Deserialize; 15use serde_json::json; 16 17/// One event, as much of it as routing needs. 18#[derive(Deserialize)] 19struct Routed { 20 event: HookEvent, 21 /// The tool, for the tool events. 22 tool: Option<String>, 23 /// The command, for a Bash tool event. 24 command: Option<String>, 25} 26 27/// What to do with `routed`, and why when it is not the event's usual role. 28fn route(routed: &Routed) -> (Role, &'static str) { 29 let role = routed.event.role(); 30 let about_a_tool = matches!( 31 routed.event, 32 HookEvent::PreToolUse | HookEvent::PostToolUse | HookEvent::PostToolUseFailure | HookEvent::PermissionDenied 33 ); 34 if !about_a_tool { 35 return (role, ""); 36 } 37 // Only Bash is judged, so only Bash's outcomes are worth reporting. 38 if routed.tool.as_deref() != Some("Bash") { 39 return (Role::Ignore, "only Bash commands are judged"); 40 } 41 match routed.command.as_deref() { 42 Some(command) if read_only::is_read_only(command) => (Role::Ignore, "plainly read-only"), 43 _ => (role, ""), 44 } 45} 46 47/// Answers one routing question's JSON with JSON: `{ "role": … }`, with a 48/// `why` when the event's usual role was overridden. An event this build does 49/// not know is ignored, and the answer says so. 50pub fn handle_json(input: &[u8]) -> Vec<u8> { 51 let answer = match serde_json::from_slice::<Routed>(input) { 52 Ok(routed) => { 53 let (role, why) = route(&routed); 54 json!({ "role": role, "why": why }) 55 } 56 Err(error) => json!({ "role": Role::Ignore, "why": format!("not routed: {error}") }), 57 }; 58 serde_json::to_vec(&answer).unwrap_or_default() 59} 60 61#[cfg(target_arch = "wasm32")] 62mod abi { 63 static mut OUT_LEN: usize = 0; 64 65 /// Reserves `len` bytes for the caller to fill. 66 #[unsafe(no_mangle)] 67 pub extern "C" fn alloc(len: usize) -> *mut u8 { 68 let mut buffer = Vec::<u8>::with_capacity(len); 69 let ptr = buffer.as_mut_ptr(); 70 std::mem::forget(buffer); 71 ptr 72 } 73 74 /// Releases bytes from `alloc` or `handle`. 75 /// 76 /// # Safety 77 /// `ptr` and `len` must come from one `alloc(len)`, or from `handle` and `out_len`. 78 #[unsafe(no_mangle)] 79 pub unsafe extern "C" fn dealloc(ptr: *mut u8, len: usize) { 80 drop(unsafe { Vec::from_raw_parts(ptr, 0, len) }) 81 } 82 83 /// Answers the JSON at `ptr..ptr+len`; the answer's length is `out_len()`. 84 /// 85 /// # Safety 86 /// `ptr..ptr+len` must be initialised bytes the caller owns. 87 #[unsafe(no_mangle)] 88 pub unsafe extern "C" fn handle(ptr: *const u8, len: usize) -> *mut u8 { 89 let input = unsafe { std::slice::from_raw_parts(ptr, len) }; 90 let mut bytes = super::handle_json(input).into_boxed_slice(); 91 unsafe { OUT_LEN = bytes.len() }; 92 let out = bytes.as_mut_ptr(); 93 std::mem::forget(bytes); 94 out 95 } 96 97 /// Length of the last answer from `handle`. 98 #[unsafe(no_mangle)] 99 pub extern "C" fn out_len() -> usize { 100 unsafe { OUT_LEN } 101 } 102} 103 104#[cfg(test)] 105mod tests { 106 use super::handle_json; 107 use serde_json::{Value, json}; 108 109 fn role(input: Value) -> Value { 110 let answer: Value = serde_json::from_slice(&handle_json(input.to_string().as_bytes())).unwrap(); 111 answer["role"].clone() 112 } 113 114 #[test] 115 fn a_risky_bash_command_is_decided() { 116 assert_eq!(role(json!({ "event": "PreToolUse", "tool": "Bash", "command": "rm -rf target" })), "decide"); 117 } 118 119 #[test] 120 fn a_read_only_bash_command_is_never_sent() { 121 assert_eq!(role(json!({ "event": "PreToolUse", "tool": "Bash", "command": "git status" })), "ignore"); 122 assert_eq!(role(json!({ "event": "PostToolUse", "tool": "Bash", "command": "git status" })), "ignore"); 123 } 124 125 #[test] 126 fn other_tools_are_not_judged() { 127 assert_eq!(role(json!({ "event": "PreToolUse", "tool": "Read" })), "ignore"); 128 assert_eq!(role(json!({ "event": "PostToolUse", "tool": "Edit" })), "ignore"); 129 } 130 131 #[test] 132 fn a_judged_commands_outcome_is_observed() { 133 assert_eq!(role(json!({ "event": "PostToolUse", "tool": "Bash", "command": "rm -rf target" })), "observe"); 134 assert_eq!(role(json!({ "event": "PermissionDenied", "tool": "Bash", "command": "rm -rf target" })), "observe"); 135 } 136 137 #[test] 138 fn events_keep_their_roles() { 139 assert_eq!(role(json!({ "event": "Stop" })), "decide"); 140 assert_eq!(role(json!({ "event": "UserPromptSubmit" })), "observe"); 141 assert_eq!(role(json!({ "event": "MessageDisplay" })), "ignore"); 142 } 143 144 #[test] 145 fn an_unknown_event_is_ignored() { 146 assert_eq!(role(json!({ "event": "SomethingNew" })), "ignore"); 147 assert_eq!(role(json!("text")), "ignore"); 148 } 149}