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-no with 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.”
    Two of them are 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-no with 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 that gti is 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 from tool.found_in_path instead, which is also what keeps ls from scoring 0.58 on its own two letters.
  • leaks_secret has its criteria.false in code for the same reason. The original excludes a credential the line only refers to — $TOKEN, --password-file=, a bare -p that prompts — and no wording carries that. Every version tried scores the reference at or above a real key: echo $OPENAI_API_KEY answers 0.91 where Bearer sk-live-… answers 0.50, because the variable’s name is what reads as a secret; one wording puts a bare ls at 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.
  • /classify with 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’s verdict choice 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.