jev-brig
September 20, 2026 · View on GitHub
A Claude Code PreToolUse hook that judges every Bash command against a
session policy level, and answers allow, ask or deny with a reason
the agent can act on.
$ jev check "git push --force origin main"
DENY git push --force origin main
git push --force overwrites the remote branch; level nopush allows commit, rewrite, worktree, not force_push, push
What this is for
Two very different things get called "sandboxing an agent":
- A careless agent. It force-pushes, commits to
main,rm -rfs the wrong directory, pip-installs into the system Python. This guard catches those, and that is what it is designed for. - An adversarial agent, or prompt injection. Something that is trying to
get out. No hook that inspects command text can win that fight:
eval "$(curl …)", base64, a script written and then run,make,git config core.pager=…. For that you need a container and an environment with no credentials in it.
jev-brig is a guardrail, not a boundary. It is deliberately honest about which.
What gets checked
Only the commands Claude Code's own settings wave through without a prompt.
Anything not covered by an allow rule already stops for the user, and a
second opinion on a prompt that is already happening is noise. So the guard
looks at exactly the gap those rules open:
| Claude Code would | jev-brig |
|---|---|
allow it silently (an allow rule matches) | judges it |
prompt (no rule matches, or an ask rule) | stays silent |
deny it (a deny rule matches) | stays silent |
skip permissions entirely (bypassPermissions) | judges everything |
A compound line is only "waved through" if every part of it matches an allow
rule, which is how Claude Code treats a && b.
This is where the real exposure is. Rules like Bash(bash *), Bash(python3 *)
or Bash(source *) are universal bypasses — Claude Code matches on a prefix
and cannot see that bash -c '…' contains anything at all:
$ jev gaps
9 Bash allow rule(s). These are what runs without a prompt,
so they are the only commands jev-brig judges (level nopush):
Bash(bash *) lets through:
DENY bash -c 'rm -rf ~'
deletes /home/me, outside the project; fs_write=project_tmp
Bash(find *) lets through:
DENY find / -name x -delete
deletes under /, outside the project; fs_write=project_tmp
Bash(grep *): no probe matches this rule
jev check says when it is deliberately staying quiet:
$ jev check "git push --force origin main"
SKIP git push --force origin main
no settings.json rule waves this through, so Claude Code asks anyway
(would be DENY: git push --force overwrites the remote branch; …)
Set check_scope = "everything" to judge every command regardless. That is
worth it only if you want a hard refusal where Claude Code would have offered
you a prompt — a prompt you might say yes to by reflex.
Note the consequence of the default: with no allow rules in settings.json,
jev-brig stays silent on everything, because there is nothing it can add.
jev gaps says so plainly rather than looking broken.
Install
Needs Python 3.12+ and uv.
git clone https://github.com/uberto/jev-brig.git
cd jev-brig
uv sync
Wire it into Claude Code:
uv run jev install # prints the settings.json block to paste
uv run jev install --apply # or merges it for you, keeping a backup
Pick a level:
mkdir -p ~/.config/jev-brig
echo 'level = "nopush"' > ~/.config/jev-brig/config.toml
See what that actually buys you, given your own allow rules:
uv run jev gaps
Optionally, the enforcement floor for git (fires however git is invoked, even from a script the hook never sees):
cd ~/my-project && uv run --project ~/path/to/jev-brig jev install --git-hooks
Restart Claude Code, or start a new session, for the hook to load.
To put jev on your PATH instead of typing uv run:
uv tool install --editable .
Only the Bash tool is inspected. Write and Edit have their own permission
rules in Claude Code and are left alone.
Using it
Once installed there is nothing to do: it runs before every Bash command. What you notice is a refusal, in Claude Code, with the reason attached:
[jev-brig nopush] git push; level nopush allows commit, rewrite, worktree, not push
Claude reads that reason, so it usually does the sensible next thing — asks you to push, or picks a different approach — instead of retrying the same command.
Check a command without running it
The fastest way to understand a verdict, and the thing to reach for when something surprises you:
jev check "git push --force origin main" # what would happen
jev check -v "cd build && rm -rf ." # ...and why: parsed commands and effects
jev check -l folder "pip install requests" # try a different level
jev check --json "rm -rf /etc" # for scripts; exit 1 if not allowed
-v prints every simple command the line contains, the working directory each
one runs in, and the effects each produces. That is the whole reasoning, so a
wrong verdict is usually obvious from it.
Change the level
For one session, before starting Claude Code:
export JEV_LEVEL=folder
Permanently, in ~/.config/jev-brig/config.toml:
level = "pr"
Per project, in the same file (the project cannot set this itself — see Why the level is not in the repo):
[projects."/home/me/work/prod-api"]
level = "pr"
When it gets in the way
In order of preference:
- Let it ask. An
askis a prompt, not a wall. Say yes. - Allow the specific command, in
~/.config/jev-brig/config.toml:allow_commands = ["fzf", "hyperfine", "my-deploy-tool"] - Widen one axis rather than jumping a whole level:
level = "nopush" net = "any" # this project needs to hit a real API write_paths = ["~/notes"] # and write here - Change level for the session:
export JEV_LEVEL=trusted. - Turn it off for one session:
export JEV_LEVEL=yolo, or comment the hook out ofsettings.json.
Read the log
Every decision is recorded, which is what makes tightening a level an informed choice rather than a guess:
jev log # last 20 decisions
jev log -n 100 --verdict deny --verdict ask
Before changing anything, spend a week in audit mode — everything is allowed, every verdict is still logged:
export JEV_AUDIT=1
Then jev log --verdict deny shows exactly what a stricter level would have
cost you.
Command reference
| command | what it does |
|---|---|
jev check "<cmd>" | judge a command without running it; -v shows the reasoning |
jev levels | list the levels, mark the active one, show where it came from |
jev gaps | what your Claude Code allow rules let through, and what jev says about it |
jev log | recent decisions; --verdict deny to filter |
jev install | print or --apply the Claude Code hook; --git-hooks for git's own hooks |
jev hook | the hook itself; reads a payload on stdin (Claude Code calls this) |
Useful environment variables: JEV_LEVEL, JEV_AUDIT=1, JEV_CONFIG,
JEV_LOG, JEV_CLAUDE_SETTINGS.
Troubleshooting
It never says anything. Expected, if your settings.json has no allow
rules — Claude Code prompts for everything already. Run jev gaps: it says so
in plain words, and lists the rules it does judge. To check every command
regardless, set check_scope = "everything".
Is it installed at all? jev levels shows the active level and which file
it came from. To test the hook end to end:
echo '{"tool_name":"Bash","tool_input":{"command":"git push"},"cwd":"'$PWD'"}' | jev hook
Nothing printed means "no opinion". A JSON block means it would act.
It blocked something it shouldn't. jev check -v "<the command>" prints the
effects it derived. If one of them is wrong, that is a bug in handlers.py —
the fix is one or two lines there, and it cannot change how a level is enforced.
It asks too often. Look at what: jev log --verdict ask. Repeated asks for
the same tool belong in allow_commands; repeated asks about one directory
belong in write_paths.
Levels
| level | what it is for |
|---|---|
readonly | look, don't touch |
folder | "everything is ok in the folder, only read outside" |
nopush | "all git ok but not push" — the default |
pr | "no direct commits or rewriting history" — branch-and-PR work |
trusted | write anywhere in $HOME, any network, still no force-push |
yolo | no opinion on anything |
jev levels prints them with the axis values and marks the active one.
A level is not a point on one ladder. It is a setting for each of these:
| axis | values |
|---|---|
fs_write | none · project · project_tmp · home · any |
fs_read | project · home_safe · any |
exec | allowlist · project_scripts · any |
net | none · registries · any |
destructive | deny · ask · allow |
git | a set: worktree, commit, rewrite, push, force_push, remote_write |
git is a set rather than a ladder because "rewrite history freely but never
push" and "push freely but never rewrite" are both normal ways to work, and a
single ladder cannot express both. Protected branches (main, master, …) are
a separate list: they are what makes pr stricter than nopush, which has
none, because nothing has left the machine yet.
How it decides
command text → shell AST → simple commands → effects → one verdict per effect → the strictest wins
The parsing step is the point of the whole exercise. rm -r -f x,
cd /etc && rm -rf conf.d, cat notes > /etc/passwd, bash -c 'rm -rf ~',
echo $(curl http://x) and find /etc -exec rm {} \; all have to arrive as
the same kind of fact, and a regex cannot do that. bashlex gives a real
AST; every simple command in it is judged, including the ones inside
substitutions, loops, bash -c strings and literal eval.
Handlers say what a command does (effects.py: read, write, destroy, run,
reach, git, unclear). Rules say what the policy thinks of that (rules.py).
Nothing in the command table knows what a level is, and nothing in the rules
knows what rm is, so adding a command cannot change how a policy is enforced.
If the command cannot be parsed, or a path resolves at runtime, the answer is
ask — never a silent allow.
ask is the answer to "risky but normal"
source is the example everyone hits. Sourcing a file runs its contents, so it
is exactly as dangerous as running that file — no more. A level that already
allows project scripts has nothing extra to fear from source .venv/bin/activate,
so that is allowed; source ~/.bashrc asks; source "$SOMEWHERE/env" asks,
with the reason saying why. The same reframing removes most special cases: the
question is never the verb, it is whose code executes.
The same applies to deletes. rm -rf build inside the project is ordinary work
and is allowed; rm -rf ., the project root itself, .git, and any wildcard
delete still ask, because those are the undo button.
Audit mode
Before tightening anything, run for a week with:
export JEV_AUDIT=1
Everything is allowed, every verdict is logged. Then jev log --verdict deny
shows what a level would actually have cost you, which beats guessing.
git hooks
jev install --git-hooks adds pre-commit and pre-push. The Claude Code
hook is fast, explainable feedback to the agent; the git hooks are the actual
floor, because they fire however git was invoked — from a Makefile, a script,
or a tool this guard has never heard of. The pre-push hook also catches
non-fast-forward pushes, which the command line does not always reveal.
Configuration
~/.config/jev-brig/config.toml:
level = "nopush"
check_scope = "allowed" # "allowed" (default) or "everything" -- see "What gets checked"
# Override individual axes on top of the preset.
fs_write = "project"
protected_branches = ["main", "release/*"]
allow_commands = ["fzf", "hyperfine"] # never ask about these
deny_commands = ["ncftp"]
write_paths = ["~/notes"] # extra writable roots
unknown_command = "ask" # ask | allow | deny
auto_approve = false # see below
audit_only = false
[projects."/home/me/work/prod-api"]
level = "pr"
Unknown keys are an error rather than being ignored: a typo in a security config should be loud.
auto_approve is off by default, and that is deliberate. With it off, an
allowed command produces no output, which means "no opinion" — Claude Code's
own permission rules then apply as usual, and this guard can only ever
restrict. Turn it on and an allowed command also skips the normal prompt, which
is fewer interruptions but hands this tool the final say.
Why the level is not in the repo
JEV_LEVEL or ~/.config/jev-brig/config.toml, and nothing else. A .jev.toml
inside the project would be editable by the agent it constrains — one
well-meaning "fix the config" and the leash is off. For the same reason,
writing to .claude/settings.json, to this tool's own config and to its source
is denied at every level except yolo.
Limits worth knowing
- Text inspection loses to a determined agent. Stated again because it matters: this catches accidents.
- The working directory can drift. A
cdinside one command string is tracked; acdin a previous Bash call is not, because the hook only sees the session cwd. Relative paths are resolved against that. - Only the resolvable is resolved.
$HOME,$PWD,$TMPDIRand~are expanded; anything else is unknown, and unknown writes ask. - A known command is judged by its usual behaviour.
awk,sedandtarcan all shell out; the obvious forms are caught, exotic ones are not. - Speed. ~40ms for a command with no shell metacharacters (a fast path that skips the parser, tested for equivalence against it), ~105ms otherwise, most of it Python start-up and bashlex building its grammar.
- The allow-rule matcher is a reimplementation. It follows the documented
forms (
Bash,Bash(*), exact,Bash(cmd:*),Bash(cmd *)) and merges enterprise, user and project settings, but it is our reading of Claude Code's matcher, not Claude Code's own code. When the two disagree the effect is either a redundant check or a missed one, never a wrongly-blocked command that Claude Code would have allowed silently —jev gapsis the way to see what it thinks. - A command inside a substitution is offered to the policy but not to the gate, since Claude Code handles those itself. That can mean checking something it would have prompted for anyway.
Development
uv run pytest # 99 tests
uv run jev check -v "cd build && rm -rf ."
Planned: more levels to choose from. The six presets here are a starting point, not the full set of ways people want to work. What will not change is how a rule is written — it names what to block logically ("writes outside the project", "moves a protected branch"), never a pattern the command text has to match.
tests/test_parsing.py covers the shapes that break regex-based guards;
tests/test_claude_settings.py covers the allow/ask/deny rule matching;
tests/test_rules.py is the level-by-level verdict matrix;
tests/test_hook.py covers the Claude Code contract, including that a broken
config asks rather than allows.