lib.rsannotatedlib.rssource149 lines · 5.2 KB · raw

The mod's logic, compiled to wasm32 and then to JavaScript by wasm2js (a mod's realm has no WebAssembly). The plugin's bridge.ts is the only caller: it writes a JSON question into linear memory, calls handle, and reads the JSON answer back. handle_json is the same logic as plain Rust, so it is tested natively.

The question is what to do with one hook event, answered inside Claude Code's own process in well under a millisecond: send it to the daemon and wait, send it without waiting, or leave it alone.

11mod read_only;
13use jevhooks_events::{HookEvent, Role};
14use serde::Deserialize;
15use serde_json::json;

One event, as much of it as routing needs.

18#[derive(Deserialize)]
19struct Routed {
20    event: HookEvent,

The tool, for the tool events.

22    tool: Option<String>,

The command, for a Bash tool event.

24    command: Option<String>,
25}

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}

Answers one routing question's JSON with JSON: { "role": … }, with a why when the event's usual role was overridden. An event this build does 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}
61#[cfg(target_arch = "wasm32")]
62mod abi {
63    static mut OUT_LEN: usize = 0;

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    }

Releases bytes from alloc or handle.

Safety

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    }

Answers the JSON at ptr..ptr+len; the answer's length is out_len().

Safety

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    }

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}
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}