pi-jev-permit
September 20, 2026 · View on GitHub
The single place in pi where Jev (TypeSafe's System One decision model) is used: one core and one consumer — a permission gate that judges every bash / write / edit call before it runs.
Jev returns numbers, not prose: one probability per question, plus the model name and token counts.
Two earlier consumers, jev_evaluate and ask_advisor, were removed. They were thin wrappers over the same call, had never been invoked in live use, and cost a permanent entry in every prompt's tool list; asking Jev a question the agent could answer itself is not worth that.
First run
-
Install and restart.
pi install npm:pi-jev-permit— orpi install /path/to/pi-jev-permitwhen you are working on the source — then quit and relaunch pi: extensions load at startup and the command list is fixed for the session. -
Get a key. The official route is the TypeSafe console:
console.typesafe.ai→ API Keys. -
Only if you bill a gateway or a router, point the package at it before logging in, in
~/.pi/agent/pi-jev-permit.json:{ "provider": { "preset": "tapsvc" } }Official TypeSafe users need no config at all; the default preset is
typesafe. -
Log in.
/jev-permit logintargets the protocol of the global provider;/jev-permit login decisionsorsystemonepicks one explicitly. The key is verified against the endpoint the gate actually uses before it is stored, then written 0600 to~/.pi/agent/secrets/pi-jev-permit-<protocol>-api-key. -
Check it. Run anything that is not read-only and read the line above the editor — it names the model the call was judged by.
/jev-permit statsshows the same traffic afterwards.
With no key the package still works: read-only commands take the local fast path, and everything else is blocked with a message telling you to log in. That is deliberate — silence is never consent.
How a call is judged
0. hard deny fixed list, raw argv, never reaches the model
1. your rules gate.allow / gate.deny, matched per shell segment
2. read-only every segment is known read-only -> local, zero network
3. Jev one question: should this call be allowed?
Layer 0 is not configurable and is checked on the raw text before anything else. Layers 1 and 2 cost nothing (no network). Layer 3 asks exactly one question — should this call be allowed to run — and the considerations it weighs are ranked, not merely conjoined:
- Authorisation. A direct, specific instruction in
latest_user_messageis decisive: the call is allowed even when it is otherwise risky or hard to undo — a user asking for a git history rewrite is authorisation, not a reason to refuse. Without such an instruction the call must still fit the work inuser_intent, or be a routine step of it. - Credentials. A call that sends secrets anywhere, or reads a credential file into the conversation, is refused — and an instruction does not override it; only a human pausing the gate can.
- Irreversibility — data outside its target, uncommitted work, repository history — weighs rather than vetoes: it lowers the probability for a call nobody asked for, and does not block one the user explicitly asked for.
Allowed when p >= thresholds.allow (default 0.6). Everything else — including "unclear" and "no answer" — is blocked: silence is never consent. A block is not a dead end: /jev-permit allow grants one retry of one refused call, and it reaches exactly as far as the model's own refusals — never a hard deny, never a deny rule, never a credential.
The newest user message travels separately as latest_user_message, because the intent window alone is a conversation rather than an instruction. A blocked call then gets exactly one follow-up question — unauthorised, credential risk, or irreversible risk — so the block message names the reason instead of leaving three different next moves to guess from.
Three details matter more than they look:
- Segments, not strings.
cd /repo && npm testis decomposed into segments and each is matched on its own, so an allow rule can actually express it (a whole-string matcher cannot: one;would disable every rule). Transparent wrappers (rtk) are stripped and a leadingVAR=valueassignment is treated as inert, so a wrapped read-only command still takes the fast path. Hard deny, by contrast, always looks at the raw text. - Redaction before anything leaves the machine. Secrets in the command text (PEM blocks, JWTs,
sk-…,ghp_…,AKIA…,Bearer …,api_key=…) become<redacted>. File contents, diffs and tool output are never sent; a write/edit contributes only its path.
The widget above the input box
jev-permit allow bash · ls -la /tmp <- verdict + tool + the call being judged
fast path · 0ms <- how it was decided (route/model · reading · latency)
jev-permit deny bash · npm publish
typesafe/jev-1.13 · allow 0.04 · 792ms
not clearly allowed (p=0.04 < 0.6) <- only when blocked
The command text is redacted and truncated to 80 characters before it is displayed; a write/edit shows the path only. records: "off" silences the widget, "full" additionally appends one entry per decision to the transcript.
Configuration
Global ~/.pi/agent/pi-jev-permit.json, project <cwd>/.pi/pi-jev-permit.json (only for a trusted project). Changes take effect on the next judgment; /jev-permit reload re-reads and reports warnings.
{
"provider": { "preset": "tapsvc", "timeoutMs": 4000, "maxRetries": 1 },
"budget": { "requestsPerDay": 2000, "usdPerDay": 1.0 },
"gate": {
"records": "status", // status | full | off
"allow": ["cd *", "git commit *"], // matched per segment
"deny": ["sudo *", "chmod 777 *"],
"transparentWrappers": ["rtk"],
"protectedPaths": ["/etc/", "~/.ssh/"]
},
"thresholds": { "allow": 0.6 },
"onUnavailable": { "mode": "degraded", "breakerAfter": 3, "cooldownMs": 60000 }
}
Presets: typesafe (official /v1/systemone), tapsvc and openrouter (the decisions contract at /api/alpha/decisions). The two protocols differ only in URL, key verification and model id — the request and response bodies are the same JSON, so one parser serves both. gate.provider may override the global provider field by field, which is how the gate can bill a different account than the global default.
Invalid values are dropped with a warning rather than silently defaulted; a missing field keeps its default (absent is not the same as wrong).
Commands
| command | what it does |
|---|---|
/jev-permit allow [id] | lists the calls the model refused this session, newest first; with an id it authorises one retry of that exact call — bound to the tool and the redacted command, valid 60 seconds, spent by the retry. It cannot cover a hard deny (never listed) or a credential refusal (marked pause only) |
/jev-permit login [systemone|decisions] | verifies a key against the endpoint the gate actually uses, then stores it 0600 in that protocol's own slot |
/jev-permit pause [30m] | allows everything until the deadline, then recovers by itself |
/jev-permit resume | ends a pause |
/jev-permit stats | usage, plus how the judged calls actually scored |
/jev-permit explain | the last few decisions: command, layer, reason, readings |
/jev-permit reload | re-reads the config and reports warnings |
When Jev is unavailable
| state | layers 1–2 | layer 3 | widget |
|---|---|---|---|
ok | normal | asks Jev | the last verdict |
degraded | still pass | blocked, with the reason | jev-permit DEGRADED |
paused | pass | pass | jev-permit PAUSED 30m |
After three consecutive failures the gate degrades: the model layer blocks, but the read-only fast path and your allow rules keep working, so a broken key or a dead endpoint does not stop ordinary work. A failed judgment is never treated as approval.
Design notes
The package replaces pi-jev-auto-mode, which judged the whole command string. Five measured problems drove the rewrite:
- Allow rules could not match a chain. Any
;or&&disabled every allow pattern, socd x && npm testcould never be allowlisted — that is whyrtk'sexport RTK_DB_PATH=…; rtk …wrapper broke the fast path entirely. - One card per judgment flooded the transcript (120+ in a day).
- Rules lived in code — 33 dangerous patterns, 8 hard denies and the read-only table were constants.
- A dead Jev blocked everything, including
ls. rmwas judged on the raw string, so a filename containing-rtripped a hard deny.
Four problems only showed up in live use, each caught by an end-to-end run against the real endpoint rather than by a unit test:
- A threshold that could never fire.
no_secret_egresswas set to0.97, whose reject line isp <= 0.03— a region the model never produces (reading a private key scored0.10). The rule was effectively dead and a credential read passed the gate. - The intent window kept the oldest messages.
intent_coveragewas truncated from the front, so in a long conversation the most recent authorisation was dropped and the gate blocked work the user had just approved. rtktranslates verbs, not just prefixes:tail -2 fbecomesrtk read f. Stripping the wrapper left an unrecognised command name, so a purely read-only chain was sent to the model. Hard deny now looks through transparent wrappers too.ctx.ui.setStatuswrites to the footer and did not render at all here;ctx.ui.setWidgetdefaults to just above the editor, which is where the status belongs.
The single-question form of layer 3 followed from the same measurement: the model's per-condition readings for a compound decision are not separable by threshold tuning, so one number with one knob is easier to reason about and to calibrate from the log.
Development
node --test --experimental-strip-types test/chain.test.ts test/policy.test.ts test/config.test.ts \
test/jev.test.ts test/gate.test.ts test/intent.test.ts test/rtk-compat.test.ts test/status.test.ts test/authorization.test.ts
node_modules/.bin/tsc --noEmit -p tsconfig.json
src/policy.ts is pure (no IO, no clock, no network) so the safety-sensitive predicates can be exercised without pi. test/diagnose.ts runs one command through the layers and prints what each segment resolved to; test/e2e.ts exercises the whole pipeline against the real endpoint with a stored key.