Chapter 10: jevhooks-mod/src, routing and a filter that is wrong in only one direction

Every event Claude Code raises comes through here first, as a little JSON question: { "event": "PreToolUse", "tool": "Bash", "command": "git status" }. The answer is one of three roles, from chapter 8: decide (send it to the daemon and wait), observe (send it and do not wait) or ignore. It has to be answered inside Claude Code's own process, every time, in well under a millisecond, so it does no I/O and asks no one.

route in lib.rs starts from the event's role and narrows it for the four tool events (PreToolUse, PostToolUse, PostToolUseFailure, PermissionDenied):

flowchart TD
  E["an event"] --> T{"a tool event?"}
  T -- no --> R["its role<br/>(chapter 8)"]
  T -- yes --> B{"tool is Bash?"}
  B -- no --> I1["ignore<br/>only Bash commands are judged"]
  B -- yes --> RO{"plainly read-only?"}
  RO -- yes --> I2["ignore<br/>plainly read-only"]
  RO -- no --> R

Only Bash is judged, so only Bash's outcomes are worth reporting; and a command the filter waves through is never judged, so its outcome is not reported either. handle_json wraps route with the JSON on both sides, and answers anything it cannot read (an event this build does not know, or not an object at all) with ignore and a why.

The read-only filter

read_only.rs answers one question: can this command line only read? A yes means the command never leaves the process: no round trip, no question to Jev, no cost. It is the most important function in the mod, and it is deliberately dim.

It does not parse shell. Shell is a language in which ls can delete your home directory, given the right $(...), and a parser that understands every way would be a large thing to get right. So the filter refuses to understand anything clever. If a command contains any character that could chain, redirect, substitute, glob, escape or quote (; | & < > $ ` ( ) { } * ? ~ ! # \ ' ", or a newline), it is not plainly read-only. What is left is a program name and plain words, and the program has to be on a short list:

ProgramRead-only when
ls, pwd, cat, head, tail, wc, file, stat, du, df, whoami, which, echo, grepalways (with the rules below)
rgno --pre, which runs a command on every file
treeno -o, which writes a file
dateno -s or --set, which set the clock
gitthe subcommand comes first, and is status, log, diff, show, blame, rev-parse, describe or ls-files with no --output or -o; branch with only listing flags; or remote -v

And no word may point outside the project: nothing starting with /, no .. segment, no =/. A command that reaches outside the project is not waved through, however harmless it looks: cat /etc/hostname goes to Jev like any other.

Aside: wrong in only one direction. Every filter is wrong sometimes. This one is designed so that all its mistakes are the cheap kind. If it says "read-only" about a command that is not, a command that changes something runs unjudged: that must never happen. If it says "not read-only" about a command that is (cat 'my notes.txt', quoted, is refused), the only cost is that Jev is asked, a quarter of a second and a fraction of a cent. So every doubt is resolved towards "ask Jev", and the tests are mostly a list of commands that must be refused: ls; rm -rf target, ls $(rm x), git -c core.pager=sh log, git diff --output=x, rg --pre sh x, date -s tomorrow.

Try it. cargo test -p jevhooks-mod. plain_reads_are_read_only, anything_that_could_write_is_not and reading_outside_the_project_is_left_to_jev are the filter; the rest of the tests are the routing. Add a command you are unsure about to the second list and see whether it stays green.

For the people who maintain it

The abi module, compiled only for wasm32, is what plugin/hooks/bridge.ts calls: alloc(len) reserves bytes for the question, handle(ptr, len) answers it and returns a pointer to the answer, out_len() gives the answer's length, and dealloc(ptr, len) frees either. OUT_LEN is a static mut, which is sound because the mod is single-threaded and reads it straight after handle.

In this folder

PathWhat
lib.rsRouted, route, handle_json, the wasm abi (alloc, dealloc, handle, out_len), and the routing tests.
read_only.rsis_read_only, SHELL_SYNTAX, leaves_project, git_reads, and the filter's tests.

← Previous: Chapter 9, jevhooks-mod/ · Up: jevhooks-mod · Next: Chapter 11, jevhooks-daemon/ →