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 wouldjev-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:

  1. Let it ask. An ask is a prompt, not a wall. Say yes.
  2. Allow the specific command, in ~/.config/jev-brig/config.toml:
    allow_commands = ["fzf", "hyperfine", "my-deploy-tool"]
    
  3. 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
    
  4. Change level for the session: export JEV_LEVEL=trusted.
  5. Turn it off for one session: export JEV_LEVEL=yolo, or comment the hook out of settings.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

commandwhat it does
jev check "<cmd>"judge a command without running it; -v shows the reasoning
jev levelslist the levels, mark the active one, show where it came from
jev gapswhat your Claude Code allow rules let through, and what jev says about it
jev logrecent decisions; --verdict deny to filter
jev installprint or --apply the Claude Code hook; --git-hooks for git's own hooks
jev hookthe 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

levelwhat it is for
readonlylook, 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
trustedwrite anywhere in $HOME, any network, still no force-push
yolono 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:

axisvalues
fs_writenone · project · project_tmp · home · any
fs_readproject · home_safe · any
execallowlist · project_scripts · any
netnone · registries · any
destructivedeny · ask · allow
gita 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 cd inside one command string is tracked; a cd in 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, $TMPDIR and ~ are expanded; anything else is unknown, and unknown writes ask.
  • A known command is judged by its usual behaviour. awk, sed and tar can 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 gaps is 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.