lib.rsannotatedlib.rssource149 lines · 5.2 KB · raw
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}