Installing jev-kit on WSL

September 19, 2026 ยท View on GitHub

This guide is for a Windows PC running WSL2 Ubuntu, where Claude Code and its config live inside the WSL distribution rather than on the Windows side. Everything below runs from a WSL terminal (wsl from PowerShell, or a Windows Terminal WSL tab) unless a step says otherwise.

If you are installing on a headless Linux server instead, the same steps apply except section 1, which exists only because WSL does not start systemd by default.

A workstation often has several Claude config directories rather than one, and may use per-repository git hooks. See Finding every Claude config directory before you get to wiring the hook in. Do that step once you know the list, not before.

1. Turn on systemd

WSL2 does not run systemd unless told to. Without it there is no warm daemon, no health timer, no tuning timer and no hourly filesearch index. install/install.sh still installs and the guard still works, slower per call and with nothing scheduled. To get systemd:

Inside the distribution, edit (or create) /etc/wsl.conf:

[boot]
systemd=true

Then, from Windows (PowerShell, not inside WSL):

wsl --shutdown

and start the distribution again. Confirm it took:

systemctl --user show-environment

If that fails, install/install.sh detects the missing session itself, prints the same instructions, and degrades to no-systemd mode rather than failing. Install on WSL in the install guide says exactly what no-systemd mode costs you. It is a legitimate way to run this for a while if you are not ready to touch /etc/wsl.conf yet.

2. Clone the repository

mkdir -p ~/code
git clone https://github.com/jonathanavis96/jev-kit.git ~/code/jev-kit
cd ~/code/jev-kit

Nothing in this repository needs a credential to clone, and nothing in it should ever hold one. If you are installing from a private fork instead, clone that; the rest of this guide is unchanged.

3. Create the key file

The guard, and every optional component that talks to TypeSafe, reads TYPESAFE_API_KEY from a key file rather than a hard-coded value.

This step is not optional. Check whether a key is already in place before you install, because install/install.sh --check-only reports a missing key and then still installs happily:

warn  no TYPESAFE_API_KEY found (env, or /home/<you>/.config/jev-kit/env).
warn    the guard still installs and still fails open; it just judges nothing.

Without the key the install is close to pointless, and it is mis-weighted. Every rule with a fuzzy half asks Jev and so fails open: R1 secret exposure, R4, R10. The code-only rules still hard-deny, and there are five of them: R5 sudo, R6 GUI, R9 commit secret, and the two legacy R8 guards. Arming enforce in that state blocks ordinary workstation actions while doing nothing at all about secrets. Get the key in place first, or stay in shadow.

Create it mode 600 at the default path. One key serves every component in the kit, so it is kit-level and not under the guard's own directory:

mkdir -p ~/.config/jev-kit
chmod 700 ~/.config/jev-kit
touch ~/.config/jev-kit/env
chmod 600 ~/.config/jev-kit/env

If you installed an earlier version and your key is already at ~/.config/airlock/env, leave it there. That path is still resolved, for good, and nothing needs moving.

If your machine already keeps keys somewhere else, put it there instead and set AIRLOCK_KEY_FILE in install/config.env to that path. The installer records the path (never the value) so the hook can find it.

Type the key into the file with an editor (nano ~/.config/jev-kit/env, or $EDITOR) as a line reading TYPESAFE_API_KEY=.... Do not paste it into a Claude session, an issue, a chat message or a command line. Anything a transcript or shell history retains is a place a key can leak from. Never cat the file afterwards to check it; if you need to confirm it is there, check for the line without printing its value:

grep -q '^TYPESAFE_API_KEY=' ~/.config/jev-kit/env && echo "line present"

4. Configure and install

cp install/config.env.example install/config.env
$EDITOR install/config.env      # set AIRLOCK_KEY_FILE if you put it
                                 # somewhere other than the default above,
                                 # and which Claude account the tuning judge
                                 # and bench sessions spend

Recommended flags for a fresh machine:

install/install.sh --guard --daemon --monitoring --filesearch \
                   --claude-update --belay
install/doctor.sh

--claude-update installs the hourly idle-only Claude Code updater (claude-update/README.md). It checks once an hour, and it updates only when no headless run is alive and no session transcript has changed in the last 30 minutes, so it never lands mid-thought. There is no reason to run a stale CLI on this machine, so it is part of the recommended set rather than an extra.

--belay installs the Stop hook. When an agent says it has finished, belay looks at whether that turn changed files and whether any check has passed since.

If files changed with no passing check behind the claim, belay asks Jev a few yes/no questions about what the transcript shows. Where the claim looks unsupported, it sends the agent back to verify rather than letting the turn end. It does that at most three times in a session, and it fails open: no key, a slow answer or any error means the turn ends normally.

What leaves the machine is small and redacted: the task text, the final message and the check command lines, through a 13-rule secret redactor, capped at a few thousand characters. No diffs, no file contents. belay/install.sh prints the Stop hook block to add; nothing is written to a settings.json for you.

Add --tuning once you are comfortable with what the tuning loop does (tuning/README.md); leave --browser, --review and --shim for later, deliberate decisions. Compaction is its own numbered step below, because install.sh refuses to install it for you.

install.sh never edits a settings.json unless you pass --wire; without it, the hook edit is only printed. Do not pass --wire yet. Wire each config directory by hand in the next step, because there is more than one.

5. Finding every Claude config directory

A workstation often has several Claude config directories, not one, because it runs per-repository git hooks alongside interactive sessions on more than one account.

A bare ls -d ~/.claude* is not the list. On the machine this guide was written against it returned 11 entries, most of which were files (.claude.json, .claude.json.bak*, .claude-remote-control.log, .claude-usage-notify.env) or directories with no settings.json in them. Only a directory that actually has a settings.json can be wired. Filter for that:

for d in ~/.claude*/; do [ -f "$d/settings.json" ] && echo "$d"; done

On that machine it yielded exactly two of the eleven. The others existed but carried no settings.json, so there was nothing to wire in them. Do not create one just to have somewhere to put the hook.

Also check any repo-level .envrc, wrapper script, or shell alias that sets CLAUDE_CONFIG_DIR itself before assuming the ~/.claude* glob is the whole list.

6. Wire the hook into each one

For every directory the previous step found:

install/wire.sh --print ~/.claude/settings.json ~/.claude-<account>/settings.json ...

wire.sh --apply both ADDS a missing airlock hook and repoints an existing one. Earlier releases only repointed. Against a settings.json that had never had the guard in it, which was the case for both files on that machine, wire.sh printed no airlock.py hook command found, skipping and changed nothing. A first install needed a hand-edit before wire.sh had anything to do.

That gap is fixed. --apply now adds the missing PreToolUse entry itself (matcher "*", timeout 5, command <absolute python3> $AIRLOCK_HOME/current/hooks/airlock.py), idempotently, backing the file up first and leaving every other key and hook untouched. It creates the file outright if it does not exist yet. --print shows exactly what --apply would add or repoint.

So on a first install the order is simply:

install/install.sh --guard --wire ~/.claude/settings.json ~/.claude-<account>/settings.json ...

which adds the hook to every file named (each backed up first, or created if missing), and on every later run just repoints them at the newest release after a deploy.sh.

Pass --belay to also add the Stop hook to the same files in the same pass: matcher "*", absolute path to <home>/bin/airlock-belay-run, timeout 25. Use install/wire.sh --apply --belay ..., or install.sh --wire when --belay is one of the selected components. It is in the default set. It is only added when that wrapper file actually exists on this machine; otherwise wire.sh says so and leaves Stop alone. Pass --function-hooks to also set "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1" in each file's env block, if compaction is going in.

Repeat for any further config directory the glob or your check turned up. Missing one is not catastrophic: that account's sessions run without the guard. But "installed" is then not "wired everywhere".

6a. Per-machine rule overrides

The rules table is written against a headless server. A workstation has a desktop, so at least one rule is simply wrong there. Overrides live in ~/.config/airlock/rules.json, one entry per rule id from airlock/rules.py, with values deny | ask | warn | log | off:

mkdir -p ~/.config/airlock
cat > ~/.config/airlock/rules.json <<'JSON'
{"R6-gui-or-browser": "off", "R5-sudo": "warn"}
JSON

R6-gui-or-browser denies xdg-open, wslview and explorer.exe, because a headless server has no desktop. On a workstation those are ordinary. R6 is off by default on every platform now, and the installer never turns it on inside WSL, which reaches a Windows desktop. The "off" entry above therefore restates the default rather than changing it.

R5-sudo is a workstation's own machine, so it warns rather than blocks. R3 and R7 already default to warn and need no entry. So does R4-long-work-bare-shell, but note that its advice text still talks about a box "reached only over SSH", which is accurate for a server and not for a workstation.

Watch out: once rules.json exists, re-running install/install.sh --guard fails. Its pre-deploy python3 -m unittest discover -s tests reads the real $HOME, so the machine's own overrides leak into tests/test_user_requested.py (which expects R5-sudo to deny) and 5 tests fail with refusing to deploy it. Verified 2026-09-19: the same suite passes with env HOME=$(mktemp -d). Until the tests are isolated from $HOME, either set rules.json after the guard install, or re-run the install with a throwaway HOME.

7. Start in shadow mode

The default mode is already shadow on a fresh install: it logs what enforce would have done and blocks nothing. Leave it there for a day and read the log before arming it:

cat "$(python3 -c 'from airlock import paths; print(paths.state_dir())')/shadow.jsonl" | tail -50

Then arm it:

echo enforce > ~/.config/airlock/mode

and go back to shadow at any time the same way:

echo shadow > ~/.config/airlock/mode

8. The kill switch

Either of these makes the hook a complete no-op, immediately, on every account it is wired into:

export AIRLOCK_DISABLE=1                  # this shell only
touch ~/.config/airlock/disabled          # this machine, until removed

Removing the disabled file, or unsetting the variable, restores whatever mode was set before.

9. Prove it actually runs

install/doctor.sh

doctor.sh runs real hook processes against a throwaway HOME, and pins R6 on inside it. So its deny case, an xdg-open blocked by R6, passes on any machine whatever this one's rules.json says about R6. This machine's own R6 setting is reported separately.

It proves the hook works, not what this machine's rules do. Check those by piping an event through the deployed hook with the real HOME.

doctor.sh runs one real deny, one real allow, a fail-open check and a kill-switch check. It also checks the daemon over its Unix socket, runs a real health check, queries the real plocate index, and reports every installed systemd timer's actual state. It does not merely check that files exist.

10. The health timer

Installed by --monitoring. It runs every five minutes and writes health.jsonl; nothing is pushed to Uptime Kuma until GS_KUMA_AIRLOCK_PUSH_URL is set in the key file (it is a capability token, so it is treated like a secret, not put in install/config.env). Create the push monitor first, then add the URL, then the health check will start pushing on its next run.

11. The plocate index, under WSL

--filesearch indexes $HOME inside the WSL distribution, the Linux side only. It does not, and cannot usefully, see the Windows filesystem. Windows files under /mnt/c/... are visible from WSL, but plocate's Ubuntu package does not index NTFS efficiently and nothing here tries.

For Windows files, Everything's es.exe command-line tool is the better fit. It is a separate Windows-native tool, not something this repository builds or installs. If the Windows-side files need searching, install Everything and use es.exe there. This section indexes the Linux home only.

12. The compaction plugin

Recommended, and a separate step on purpose: install/install.sh never installs it, even with --compaction. That flag only prints the warning and stops. Run the component's own installer yourself, after reading its README:

less compaction/README.md
compaction/install.sh

What it sends off the machine: up to roughly 25,000 tokens of raw tool inputs and tool-result text per request. File contents, command output, fetched pages, truncated only for size, with no redaction pass anywhere in the plugin's source.

That is a much larger exposure than anything else installed here, which is why this step is its own decision rather than a flag.

What you get for it: a long session stays inside its context window instead of degrading. A measured manual compaction on this box took a 49,288-token session down to 23,111 tokens in 906 ms. It needs Claude Code 2.1.274 or later with function hooks enabled.

If the exposure is not acceptable for the work this machine does, skip this step and say so in the machine notes. Everything else above still works without it.

13. Updating later

cd ~/code/airlock
git pull
install/deploy.sh

deploy.sh finds the main checkout, runs the full unit test suite against a clean export of its current main commit (never the working tree), deploys it to a new immutable release under $AIRLOCK_HOME/releases/<sha>/, and flips current at it atomically. Nothing observes current missing or half-written, and settings.json and the systemd units already point at current, so an update takes effect on the next tool call (or daemon restart) with no further edits.

Rollback:

install/rollback.sh

points current at the release immediately before the one it currently points at.

Troubleshooting

SymptomCause
Guard judgements take ~0.9 s instead of ~0.3 sThe daemon is down, or was never installed (no systemd). The client falls back to a direct HTTPS call per judgement automatically -- it still works, just slower.
A hook does not seem to run at all, in any modeNot usually the ~. Measured under WSL with Claude Code 2.1.278, 2026-09-19: hook commands are run through a shell, so a leading ~/ in bash ~/.claude/hooks/... is expanded and the hook does run -- another hook on that machine was written that way and wrote its audit log from a real session the same day. Write absolute paths anyway (a hook invoked without a shell, or with a different HOME, has nothing to expand against), but look elsewhere first: the wrong config directory, a non-executable script, or a matcher that does not match. $HOME and $AIRLOCK_HOME in a hook command are a different matter -- do not rely on those.
An ask rule never seems to ask, just blocksCorrect, in a headless run. ask is honoured in an attended session, but a headless one has nobody to answer it, so it degrades to an honest deny (verified against Claude Code 2.1.272). No rule ships with ask as its default action for exactly this reason.
Guard installs fine but "fails open" on everythingThis is the intended behaviour whenever something is wrong: no key, no daemon, a malformed payload, an internal error. A broken guard must never block or slow a tool call. Check install/doctor.sh's output for which specific thing is missing.
A session on one account is guarded, another is notStep 6 was not repeated for every directory ls -d ~/.claude* found. Re-run install/wire.sh --print against the full list.