All demosShell Guard
Shell Guard
A terminal that judges every command before it runs: safe ones run silently, risky ones ask to confirm, catastrophic ones are refused.
/yes-no/classify
How it works
The original jevsh is a Swift binary on a zsh accept-line hook: every time you press Enter, it
builds a JSON state object in code — the command, the working directory, whether the first word resolves to a program,
the git branch and whether the tree is dirty, every path argument with exists / is_home /
inside_cwd, whether the line contains something token-shaped, and the last three history entries — and asks
the model five questions about it in one request, inside a 400 ms deadline. Here the same state builder, the same policy
and the same regex fallback run in the browser against a sandboxed filesystem, so nothing can touch your machine.
What we send
-
/yes-nowith the state text and 4 statements in one call:destructive≥ 0.3 — “This command throws away work that is stored nowhere else: uncommitted edits, commits, branch history, or the only copy of a file.”wrong_target≥ 0.2 — “The target of this command is dangerous: the home directory, the filesystem root, a system folder, a production host, or the shared main branch.”wrong_target≥ 0.2 — “This command changes history that other people have already pulled.”leaks_secret≥ 0.15 — “The command line contains a literal secret value such as an API key, a token or a password.”
wrong_target: the original’s criterion is an or — a dangerous path or a rewrite of shared history — and one short statement only carries one clause. A noul’s value is the highest answer among its statements, which is what that or means. They ride in the same call, so the second clause costs tokens, not requests. -
/yes-nowith the bare command line and 1 statement:likely_typo≥ 0.3 — “The program name is a real tool's name with two letters swapped, like gti for git or pytohn for python.” It gets the command without the context line on purpose: told thatgtiis not on the PATH, the model reads every typo as a missing install and the whole question flattens to within 0.05 of 0.10. The other half of the original’s criterion — a correctly spelled tool that is merely not installed is not a typo — is applied in code fromtool.found_in_pathinstead, which is also what keepslsfrom scoring 0.58 on its own two letters. -
leaks_secrethas its criteria.false in code for the same reason. The original excludes a credential the line only refers to —$TOKEN,--password-file=, a bare-pthat prompts — and no wording carries that. Every version tried scores the reference at or above a real key:echo $OPENAI_API_KEYanswers 0.91 whereBearer sk-live-…answers 0.50, because the variable’s name is what reads as a secret; one wording puts a barelsat 0.48. So the statement above asks only “is this a secret” and a regex answers “are its characters actually here”, the same split as the typo question. -
/classifywith the state text and three described labels —run“A safe, ordinary command. Let it run with no questions.”,confirm“A risky command that is probably intended. Ask the developer to confirm.”,block“A catastrophic mistake. Refuse to run it.” — which is the original’sverdictchoice question.
The text is the command line, then one bracketed line of the facts code computed:
rm -rf ~/ · [in /Users/devin/repo; rm is on the PATH; git branch main (the default branch), working
tree dirty; ~/ is the home directory]. That is what makes rm -rf ./build and rm -rf ~/
different questions rather than the same string.
Eight of the nine state fields are in that line. The ninth, the last three history entries, is left out on a
measurement: appending after ls; git status --short; npm test to every text pulls the whole table down,
because a mundane session reads the next command as mundane too. sudo rm -rf /usr goes
destructive 0.32 → 0.25 and wrong_target 0.37 → 0.25 and loses its block;
rm -rf / goes 0.53 → 0.37, git reset --hard 0.57 → 0.45. The split moves from
34 · 13 · 3 to 34 · 14 · 2, further from the original, for one more sentence of context. So the
history stays in the state object — it is in the explain panel, and the terminal keeps it the way zsh does, recording
every line whether the guard ran it, blocked it or you declined the confirm — and out of the question.
Batching
An interactive Enter is three calls that run in parallel and race a deadline slider (2500 ms by default, against the CLI’s 400 ms: a browser adds TLS, the proxy hop and a page-wide queue of two calls per second). Bench inverts the batching: the 50 fixed commands from the original go out as two chunks of 32 and 18 texts, one call per statement, so 50 decisions cost 12 requests instead of 150. It stops itself after both chunks or 60 seconds, whichever comes first, and it holds the terminal while it runs.
No when_true / when_false hints go out, although the batched shape accepts them. They saturate
this route: on the destructive statement, one short line each puts every one of the fifty at 1.00,
ls included; the shortest pair that still says anything (“gone for good” against “saved somewhere, or easy
to undo”) still puts ls at 0.75 against rm -rf / at 0.96. With no hints the same statement
reads ls 0.11 and rm -rf / 0.33. So the criteria stay in the wording — which also keeps the
bench and the terminal asking one command the same question, since the interactive shape (one text, four statements) has
no place to put per-statement hints at all.
Against the original CLI’s run 35 · confirm 11 · block 4, this run lands 34 · 13 · 3: 48 of
the 50 agree and two move, both toward asking. git stash goes run → confirm because the model reads a stash
as a discard — it answers destructive 0.57, level with git reset --hard. Nothing separates
them: every wording that drops the stash to 0.18 also drops git clean -fdx to 0.20 and
sudo rm -rf /usr to 0.22, which would turn a block into a run, so the stash stays a confirm rather than the
rest of the table getting bent around it. And psql -h prod-db.internal -c 'DROP TABLE users' goes block →
confirm because only wrong_target flags it, at 0.23, and a block needs both nouls over
0.3 or a block verdict. Both rows are in the table above with their reasons.
The policy is code, not the model
Policy.decide is ported line for line: any probability over its threshold becomes a reason, sorted high to
low; a block verdict with no reason flagged is downgraded to a confirm; a block needs either the verdict or
both destructive and wrong_target over the block threshold (0.3).
Only the numbers changed, and they had to: this model answers on a much narrower scale than Jev did, so a threshold copied
across would have left the second half of that block rule unreachable. Measured here, rm -rf ~/ answers 0.45
and 0.40, rm -rf / 0.33 and 0.53, sudo rm -rf /usr 0.31 and 0.37 — those three cross it and
nothing else does, so the model-independent path to a block is live. Every threshold is on a slider, including the block
ones, and the explain panel says in words whether that branch fired.
When a call errors, 429s or misses the deadline, the 20 regexes from Fallback.swift decide instead and the
banner says dm1 offline; the bench table shows that denylist column, PATH fact included, beside every answer.
Two chips under the terminal are commands the denylist catches and the model does not — a fork bomb and a raw write to
/dev/disk0 — because the honest version of “a model beats a denylist” is that here it beats it on the fifty
and loses on those two.
Ported from the jev-shell-guard Jev experiment. Every request here is live; the shared demo key allows about 2 requests per second across all visitors.