ohmyjev 0.3.2 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -8
- package/adapters/omp.ts +34 -10
- package/adapters/pi.ts +31 -10
- package/hooks/dashboard.ts +4 -4
- package/hooks/policy.ts +94 -14
- package/package.json +6 -1
package/README.md
CHANGED
|
@@ -58,12 +58,13 @@ If `/omj` ends with `key: env TYPESAFE_API_KEY · typesafe · jev-1.13.0`, you'r
|
|
|
58
58
|
|
|
59
59
|
| Feature | What it does |
|
|
60
60
|
|---|---|
|
|
61
|
-
| **Bash gate** | Denies a command when Jev rates it irreversible at 0.6 or more, or
|
|
61
|
+
| **Bash gate** | Denies a command when Jev rates it irreversible at 0.6 or more, or when it sends local files or secrets to an outside host (0.7), such as `curl` uploading `~/.ssh`. A command Jev calls irreversible with less confidence is denied too when it also aims to wipe something (0.7). Reversible work always passes, so deleting lines, dropping a co-author trailer or uninstalling a plugin goes through. Plain reads like `ls`, `git status` or `grep` skip Jev entirely: plain code checks them, so they cost nothing and wait for nothing. |
|
|
62
62
|
| **Write gate** | Denies writes outside the repo and `allowPaths`, and writes that contain a real credential. The repo means every worktree of the one the session started in, and every worktree of a repo the agent has moved into. The path check is plain code, not Jev, and it follows symlinks the way the OS does. |
|
|
63
63
|
| **Exfil gate** | Denies a WebFetch or MCP call when Jev rates it 0.7 or more for sending your local data, files or credentials out. |
|
|
64
64
|
| **Policies** | Every gate also checks the call against your own rules in the `policies` setting. |
|
|
65
|
+
| **Your request** | Every gate also asks Jev whether your latest message asked for exactly this call. If it did (0.8), a gate's Jev deny lets the call through. So "delete the build folder" or "force push it" works, while the same command picked by the agent on its own is still denied. After a block, a plain "yes" works too: the gate shows Jev the call it just blocked next to your reply. Policy and path denies always stand. |
|
|
65
66
|
| **Injection screen** | When output from Bash, WebFetch, an MCP tool, or a Read outside the repo has instructions aimed at the model, it adds a note telling the model to treat that output as data. |
|
|
66
|
-
| **Done-check** | If the agent says it's done but nothing shows it ran a check, it blocks the stop once and tells the agent to verify. |
|
|
67
|
+
| **Done-check** | If the agent says it's done but nothing shows it ran a check, it blocks the stop once and tells the agent to verify. A turn that ran no tools is never blocked, since there was nothing to verify. |
|
|
67
68
|
| **Router** | Asks Jev once per request for a tier, an effort level and a risk score. Then it raises or lowers effort and picks the model for general-purpose subagents. With no key it falls back to Claude Code's built-in classifier, which reports no confidence, so then it only ever routes up. |
|
|
68
69
|
| **Auto-compact** | When the task changes after a finished step and the context is at least 40% full, it compacts the conversation. The summary keeps the new request in full. |
|
|
69
70
|
| **`ask_jev`** | A tool the model can use to ask Jev about repo files or text without loading them into its own context. |
|
|
@@ -193,14 +194,19 @@ Most of the time you won't notice it. Here's what it looks like when it does ste
|
|
|
193
194
|
|
|
194
195
|
### When a command gets blocked
|
|
195
196
|
|
|
196
|
-
Claude gets the denial as the tool's result,
|
|
197
|
+
Claude gets the denial as the tool's result, with an instruction not to work around it and a way out:
|
|
197
198
|
|
|
198
199
|
```
|
|
199
200
|
ohmyjev blocked this: irreversible (0.95): nothing would restore what this removes or overwrites.
|
|
200
|
-
|
|
201
|
-
|
|
201
|
+
Do not try to work around it with another command, another tool, a different path, or an encoding that does
|
|
202
|
+
the same thing. Tell the user what was blocked and why, and ask whether to run it anyway. If they say yes, run
|
|
203
|
+
exactly this again: their reply then lets it through.
|
|
202
204
|
```
|
|
203
205
|
|
|
206
|
+
So when the agent stops and asks, answer "yes". The next gate shows Jev the blocked call next to your reply, so a
|
|
207
|
+
yes confirms that call and nothing else. A yes with no block before it, "no", or a yes followed by a different
|
|
208
|
+
command still gets denied.
|
|
209
|
+
|
|
204
210
|
A write outside the repo gets a different message. It names the path and tells the model to ask you to add that
|
|
205
211
|
directory to `allowPaths`, because that block is a setting, not a hazard.
|
|
206
212
|
|
|
@@ -314,7 +320,8 @@ Run `/omj settings` to see every setting's current value (the key stays hidden).
|
|
|
314
320
|
| `logDecisions` | on | Shows each routing decision as a line in the transcript. |
|
|
315
321
|
| `statusLine` | on | Pins ohmyjev's status under the prompt. |
|
|
316
322
|
|
|
317
|
-
Each gate also has its own threshold setting.
|
|
323
|
+
Each gate also has its own threshold setting, and `requested` (0.8) sets how sure Jev must be that you asked for a
|
|
324
|
+
call. The defaults are the numbers in [What you get](#what-you-get).
|
|
318
325
|
|
|
319
326
|
## Logs
|
|
320
327
|
|
|
@@ -329,7 +336,7 @@ a fixed size:
|
|
|
329
336
|
|
|
330
337
|
| What asks | What it sends |
|
|
331
338
|
|---|---|
|
|
332
|
-
| Bash gate | The command (up to 16000 characters), the working directory
|
|
339
|
+
| Bash gate | The command (up to 16000 characters), the working directory, the tool call's description, your latest message (1500) and, right after a block, the blocked call (600). Plain reads send nothing |
|
|
333
340
|
| Write gate | The file path and the new content (up to 16000 characters). Code denies writes outside the repo and `allowPaths` before it sends anything |
|
|
334
341
|
| Exfil gate | The tool's name and its input (up to 4000 characters) |
|
|
335
342
|
| Injection screen | The tool's output (up to 6000 characters), from Bash, WebFetch, MCP tools and Reads outside the repo |
|
|
@@ -337,7 +344,8 @@ a fixed size:
|
|
|
337
344
|
| Router | The request (up to 1500 characters) |
|
|
338
345
|
| `ask_jev` | The model's question, any text it passes (up to 20000 characters) and the repo files it names (8000 each, 80000 in total). It never sends files outside the repo |
|
|
339
346
|
|
|
340
|
-
|
|
347
|
+
The write and exfil gates also send your latest message (1500), for the request check. When you've set `policies`,
|
|
348
|
+
every gate sends those too. Turning a feature off stops its calls.
|
|
341
349
|
|
|
342
350
|
With no key, nothing goes to Jev. The router's built-in classifier sends the request (up to 1500 characters) to
|
|
343
351
|
Claude's own small model, over the same connection Claude Code already uses. In pi it goes to the cheapest model you
|
|
@@ -379,6 +387,7 @@ git clone https://github.com/Novacon/ohmyjev && cd ohmyjev && bun install
|
|
|
379
387
|
claude --plugin-dir "$PWD" # run your working copy, and it writes the API types to .claude-plugin/types
|
|
380
388
|
omp -e ./adapters/omp.ts # or: pi -e ./adapters/pi.ts
|
|
381
389
|
bun run test && bun run typecheck && claude plugin validate .
|
|
390
|
+
bun tests/live.ts # 28 real commands against the live Jev, with the expected verdicts
|
|
382
391
|
```
|
|
383
392
|
|
|
384
393
|
The decision logic lives in `hooks/policy.ts` and is pure, so you can test it with plain tables. `hooks/ohmyjev.ts`
|
package/adapters/omp.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { readFile } from 'node:fs/promises'
|
|
5
5
|
import {
|
|
6
6
|
BASH_Q, DEFAULTS, EXFIL_Q, ROUTE_Q, SCREEN_Q, STOP_Q, SWITCHED_Q, WRITE_Q,
|
|
7
|
-
clip, decideRoute, denyText, gateBash, gateExfil, gateWrite, jevRouteLine, judgeStop, pathDenyText,
|
|
7
|
+
clip, decideRoute, denyText, gateBash, gateExfil, gateWrite, jevRouteLine, judgeStop, pathDenyText, callSummary, plainRead,
|
|
8
8
|
OMJ_HELP, OMJ_OFF, OMJ_ON, settingsRows, toggleArg,
|
|
9
9
|
keepInstructions, modelOf, readConfig, routeStep, screen, stepLine, withPolicies, withPolicyQ,
|
|
10
10
|
type Config, type Route,
|
|
@@ -254,6 +254,9 @@ export function createExtension(options: OmpExtensionOptions = {}): (pi: OmpApi)
|
|
|
254
254
|
let pushedBackAt = -1
|
|
255
255
|
let route: Route | undefined
|
|
256
256
|
let liveRequest = ''
|
|
257
|
+
let lastBlocked = '' // the call a gate denied this turn
|
|
258
|
+
let blockedBefore = '' // what was denied in the turn before this request: a "yes" confirms it
|
|
259
|
+
let userRequest = '' // the user's latest message: the gates ask whether it asked for the call
|
|
257
260
|
let compactPending = false
|
|
258
261
|
let classifiedPrompt: string | undefined
|
|
259
262
|
let askRegistered = false
|
|
@@ -398,6 +401,9 @@ export function createExtension(options: OmpExtensionOptions = {}): (pi: OmpApi)
|
|
|
398
401
|
pushedBackAt = -1
|
|
399
402
|
route = undefined
|
|
400
403
|
liveRequest = ''
|
|
404
|
+
userRequest = ''
|
|
405
|
+
lastBlocked = ''
|
|
406
|
+
blockedBefore = ''
|
|
401
407
|
compactPending = false
|
|
402
408
|
classifiedPrompt = undefined
|
|
403
409
|
status()
|
|
@@ -453,6 +459,9 @@ export function createExtension(options: OmpExtensionOptions = {}): (pi: OmpApi)
|
|
|
453
459
|
root = ''
|
|
454
460
|
route = undefined
|
|
455
461
|
liveRequest = ''
|
|
462
|
+
userRequest = ''
|
|
463
|
+
lastBlocked = ''
|
|
464
|
+
blockedBefore = ''
|
|
456
465
|
compactPending = false
|
|
457
466
|
classifiedPrompt = undefined
|
|
458
467
|
pushedBackAt = -1
|
|
@@ -466,6 +475,7 @@ export function createExtension(options: OmpExtensionOptions = {}): (pi: OmpApi)
|
|
|
466
475
|
const tool = event.toolName
|
|
467
476
|
const input = object(event.input)
|
|
468
477
|
if ((tool === 'bash' || tool === 'eval' || githubWrite(tool, input)) && c.bashGate) {
|
|
478
|
+
if (tool === 'bash' && plainRead(string(input, 'command'), c)) return // code says read-only, no Jev call
|
|
469
479
|
const command = tool === 'eval'
|
|
470
480
|
? string(input, 'code')
|
|
471
481
|
: tool === 'github'
|
|
@@ -482,11 +492,14 @@ export function createExtension(options: OmpExtensionOptions = {}): (pi: OmpApi)
|
|
|
482
492
|
description: clip(description, 300),
|
|
483
493
|
...(command.length > CLIP ? { truncated: true } : {}),
|
|
484
494
|
}
|
|
485
|
-
const d = await session.decide('tool_call', tool, withPolicies(state, c), withPolicyQ(BASH_Q, c))
|
|
495
|
+
const d = await session.decide('tool_call', tool, withPolicies(state, c, userRequest, blockedBefore), withPolicyQ(BASH_Q, c, userRequest))
|
|
486
496
|
if (!d) return
|
|
487
497
|
const judged = gateBash(d.answers, c)
|
|
488
498
|
session.record(d, judged.verdict, judged.reason)
|
|
489
|
-
if (judged.verdict === 'deny')
|
|
499
|
+
if (judged.verdict === 'deny') {
|
|
500
|
+
lastBlocked = callSummary(tool, input)
|
|
501
|
+
return { block: true, reason: denyText(judged.reason) }
|
|
502
|
+
}
|
|
490
503
|
return
|
|
491
504
|
}
|
|
492
505
|
if (WRITE_TOOLS[tool] && c.writeGate) {
|
|
@@ -505,25 +518,31 @@ export function createExtension(options: OmpExtensionOptions = {}): (pi: OmpApi)
|
|
|
505
518
|
const path = paths.join('; ')
|
|
506
519
|
const d = await session.decide(
|
|
507
520
|
'tool_call', tool,
|
|
508
|
-
withPolicies({ path, content: clip(writeContent(input), CLIP) }, c),
|
|
509
|
-
withPolicyQ(WRITE_Q, c),
|
|
521
|
+
withPolicies({ path, content: clip(writeContent(input), CLIP) }, c, userRequest, blockedBefore),
|
|
522
|
+
withPolicyQ(WRITE_Q, c, userRequest),
|
|
510
523
|
)
|
|
511
524
|
if (!d) return
|
|
512
525
|
const judged = gateWrite(d.answers, c)
|
|
513
526
|
session.record(d, judged.verdict, judged.reason)
|
|
514
|
-
if (judged.verdict === 'deny')
|
|
527
|
+
if (judged.verdict === 'deny') {
|
|
528
|
+
lastBlocked = callSummary(tool, input)
|
|
529
|
+
return { block: true, reason: denyText(judged.reason) }
|
|
530
|
+
}
|
|
515
531
|
return
|
|
516
532
|
}
|
|
517
533
|
if (c.exfilGate && exfilTool(tool, input)) {
|
|
518
534
|
const d = await session.decide(
|
|
519
535
|
'tool_call', tool,
|
|
520
|
-
withPolicies({ tool, input: clip(safeJson(input), 4000) }, c),
|
|
521
|
-
withPolicyQ(EXFIL_Q, c),
|
|
536
|
+
withPolicies({ tool, input: clip(safeJson(input), 4000) }, c, userRequest, blockedBefore),
|
|
537
|
+
withPolicyQ(EXFIL_Q, c, userRequest),
|
|
522
538
|
)
|
|
523
539
|
if (!d) return
|
|
524
540
|
const judged = gateExfil(d.answers, c)
|
|
525
541
|
session.record(d, judged.verdict, judged.reason)
|
|
526
|
-
if (judged.verdict === 'deny')
|
|
542
|
+
if (judged.verdict === 'deny') {
|
|
543
|
+
lastBlocked = callSummary(tool, input)
|
|
544
|
+
return { block: true, reason: denyText(judged.reason) }
|
|
545
|
+
}
|
|
527
546
|
}
|
|
528
547
|
} catch {
|
|
529
548
|
return
|
|
@@ -591,7 +610,7 @@ export function createExtension(options: OmpExtensionOptions = {}): (pi: OmpApi)
|
|
|
591
610
|
scheduleCompact(ctx)
|
|
592
611
|
return
|
|
593
612
|
}
|
|
594
|
-
const judged = judgeStop(d.answers, c)
|
|
613
|
+
const judged = judgeStop(d.answers, c, state.tools_this_turn.length > 0)
|
|
595
614
|
const block = c.doneCheck ? judged.block : null
|
|
596
615
|
if (block) pushedBackAt = requests.length
|
|
597
616
|
if (c.autoCompact && judged.wantsCompact) compactPending = true
|
|
@@ -609,6 +628,11 @@ export function createExtension(options: OmpExtensionOptions = {}): (pi: OmpApi)
|
|
|
609
628
|
if (ctx.agent.kind !== 'main' || !c.enabled) return
|
|
610
629
|
userEffort = userThinking(ctx)
|
|
611
630
|
const prompt = typeof rawEvent.prompt === 'string' ? rawEvent.prompt : ''
|
|
631
|
+
if (prompt.trim() && !/^\/\S+\s*$/.test(prompt.trim())) {
|
|
632
|
+
userRequest = prompt
|
|
633
|
+
blockedBefore = lastBlocked
|
|
634
|
+
lastBlocked = ''
|
|
635
|
+
}
|
|
612
636
|
if (!prompt.trim()) return
|
|
613
637
|
if (classifiedPrompt !== prompt) {
|
|
614
638
|
classifiedPrompt = prompt
|
package/adapters/pi.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises'
|
|
2
2
|
import {
|
|
3
3
|
BASH_Q, EXFIL_Q, ROUTE_Q, SCREEN_Q, STOP_Q, SWITCHED_Q, WRITE_Q,
|
|
4
|
-
TIER_LABELS, builtinRoute, clip, decideRoute, denyText, gateBash, gateExfil, gateWrite, jevRouteLine, judgeStop, pathDenyText,
|
|
4
|
+
TIER_LABELS, builtinRoute, clip, decideRoute, denyText, gateBash, gateExfil, gateWrite, jevRouteLine, judgeStop, pathDenyText, callSummary, plainRead,
|
|
5
5
|
OMJ_HELP, OMJ_OFF, OMJ_ON, settingsRows, toggleArg,
|
|
6
6
|
keepInstructions, readConfig, routeStep, screen, stepLine, withPolicies, withPolicyQ,
|
|
7
7
|
type Config, type Route,
|
|
@@ -257,6 +257,9 @@ export function createExtension(options: PiAdapterOptions = {}) {
|
|
|
257
257
|
let pushedBackAt = -1
|
|
258
258
|
let route: Route | undefined
|
|
259
259
|
let liveRequest = ''
|
|
260
|
+
let lastBlocked = '' // the call a gate denied this turn
|
|
261
|
+
let blockedBefore = '' // what was denied in the turn before this request: a "yes" confirms it
|
|
262
|
+
let userRequest = '' // the user's latest message: the gates ask whether it asked for the call
|
|
260
263
|
let compactPending = false
|
|
261
264
|
let userThinking: ThinkingLevel = 'medium'
|
|
262
265
|
let changingThinking = false
|
|
@@ -289,6 +292,9 @@ export function createExtension(options: PiAdapterOptions = {}) {
|
|
|
289
292
|
pushedBackAt = -1
|
|
290
293
|
route = undefined
|
|
291
294
|
liveRequest = ''
|
|
295
|
+
userRequest = ''
|
|
296
|
+
lastBlocked = ''
|
|
297
|
+
blockedBefore = ''
|
|
292
298
|
compactPending = false
|
|
293
299
|
}
|
|
294
300
|
const setThinking = (level: ThinkingLevel): void => {
|
|
@@ -403,6 +409,11 @@ export function createExtension(options: PiAdapterOptions = {}) {
|
|
|
403
409
|
|
|
404
410
|
pi.on('before_agent_start', guarded<BeforeAgentStartEvent, void>(async (event, ctx) => {
|
|
405
411
|
latestCtx = ctx
|
|
412
|
+
if (event.prompt.trim() && !/^\/\S+\s*$/.test(event.prompt.trim())) {
|
|
413
|
+
userRequest = event.prompt
|
|
414
|
+
blockedBefore = lastBlocked
|
|
415
|
+
lastBlocked = ''
|
|
416
|
+
}
|
|
406
417
|
if (!c.enabled) return
|
|
407
418
|
const text = event.prompt
|
|
408
419
|
if (!text.trim()) return
|
|
@@ -433,12 +444,16 @@ export function createExtension(options: PiAdapterOptions = {}) {
|
|
|
433
444
|
const input = event.input
|
|
434
445
|
if ((tool === 'bash' || tool === 'powershell') && c.bashGate) {
|
|
435
446
|
const command = arg(input, 'command')
|
|
436
|
-
|
|
437
|
-
const
|
|
447
|
+
if (plainRead(command, c)) return // ls, git status, grep: code says read-only, no Jev call
|
|
448
|
+
const state = withPolicies({ command: clip(command, CLIP), cwd: ctx.cwd, ...(command.length > CLIP ? { truncated: true } : {}) }, c, userRequest, blockedBefore)
|
|
449
|
+
const d = await x.decide('tool.call', tool, state, withPolicyQ(BASH_Q, c, userRequest))
|
|
438
450
|
if (!d) return
|
|
439
451
|
const judged = gateBash(d.answers, c)
|
|
440
452
|
x.record(d, judged.verdict, judged.reason)
|
|
441
|
-
if (judged.verdict === 'deny')
|
|
453
|
+
if (judged.verdict === 'deny') {
|
|
454
|
+
lastBlocked = callSummary(tool, input)
|
|
455
|
+
return { block: true, reason: denyText(judged.reason) }
|
|
456
|
+
}
|
|
442
457
|
return
|
|
443
458
|
}
|
|
444
459
|
if ((tool === 'write' || tool === 'edit') && c.writeGate) {
|
|
@@ -448,24 +463,30 @@ export function createExtension(options: PiAdapterOptions = {}) {
|
|
|
448
463
|
return { block: true, reason: pathDenyText(path) }
|
|
449
464
|
}
|
|
450
465
|
const content = tool === 'write' ? arg(input, 'content') : json(input.edits)
|
|
451
|
-
const d = await x.decide('tool.call', tool, withPolicies({ path, content: clip(content, CLIP) }, c), withPolicyQ(WRITE_Q, c))
|
|
466
|
+
const d = await x.decide('tool.call', tool, withPolicies({ path, content: clip(content, CLIP) }, c, userRequest, blockedBefore), withPolicyQ(WRITE_Q, c, userRequest))
|
|
452
467
|
if (!d) return
|
|
453
468
|
const judged = gateWrite(d.answers, c)
|
|
454
469
|
x.record(d, judged.verdict, judged.reason)
|
|
455
|
-
if (judged.verdict === 'deny')
|
|
470
|
+
if (judged.verdict === 'deny') {
|
|
471
|
+
lastBlocked = callSummary(tool, input)
|
|
472
|
+
return { block: true, reason: denyText(judged.reason) }
|
|
473
|
+
}
|
|
456
474
|
return
|
|
457
475
|
}
|
|
458
476
|
if (tool.startsWith('mcp__') && c.exfilGate) {
|
|
459
477
|
const d = await x.decide(
|
|
460
478
|
'tool.call',
|
|
461
479
|
tool,
|
|
462
|
-
withPolicies({ tool, input: clip(json(input), 4000) }, c),
|
|
463
|
-
withPolicyQ(EXFIL_Q, c),
|
|
480
|
+
withPolicies({ tool, input: clip(json(input), 4000) }, c, userRequest, blockedBefore),
|
|
481
|
+
withPolicyQ(EXFIL_Q, c, userRequest),
|
|
464
482
|
)
|
|
465
483
|
if (!d) return
|
|
466
484
|
const judged = gateExfil(d.answers, c)
|
|
467
485
|
x.record(d, judged.verdict, judged.reason)
|
|
468
|
-
if (judged.verdict === 'deny')
|
|
486
|
+
if (judged.verdict === 'deny') {
|
|
487
|
+
lastBlocked = callSummary(tool, input)
|
|
488
|
+
return { block: true, reason: denyText(judged.reason) }
|
|
489
|
+
}
|
|
469
490
|
}
|
|
470
491
|
}))
|
|
471
492
|
|
|
@@ -504,7 +525,7 @@ export function createExtension(options: PiAdapterOptions = {}) {
|
|
|
504
525
|
const questions = state.previous_requests.length ? { ...STOP_Q, ...SWITCHED_Q } : STOP_Q
|
|
505
526
|
const d = await x.decide('Stop', '', state, questions)
|
|
506
527
|
if (!d) return
|
|
507
|
-
const judged = judgeStop(d.answers, c)
|
|
528
|
+
const judged = judgeStop(d.answers, c, state.tools_this_turn.length > 0)
|
|
508
529
|
const block = c.doneCheck ? judged.block : null
|
|
509
530
|
if (block) pushedBackAt = count
|
|
510
531
|
if (c.autoCompact && judged.wantsCompact) compactPending = true
|
package/hooks/dashboard.ts
CHANGED
|
@@ -95,8 +95,8 @@ export function dashboardHtml(rows: LogEntry[], opts: { now: number; sid?: strin
|
|
|
95
95
|
@font-face { font-family: "Space Grotesk"; src: url("${FONTS}/SpaceGrotesk-Bold.woff2") format("woff2"); font-weight: 700; font-display: swap; }
|
|
96
96
|
@font-face { font-family: "JetBrains Mono"; src: url("${FONTS}/JetBrainsMono-Regular.woff2") format("woff2"); font-weight: 400; font-display: swap; }
|
|
97
97
|
:root {
|
|
98
|
-
--ground: #
|
|
99
|
-
--paper: #f4f8f5; --muted: #a5b5a9; --faint: #829487; --line: #
|
|
98
|
+
--ground: #0a0a0a; --surface: #131313; --panel: rgba(19, 19, 19, .9); --panel-hi: rgba(28, 28, 28, .94);
|
|
99
|
+
--paper: #f4f8f5; --muted: #a5b5a9; --faint: #829487; --line: #2e2e2e; --steel: #829487;
|
|
100
100
|
--brand: #00ee22; --brand-hi: #39ff53; --link: #79ff98; --lavender: #b69cff;
|
|
101
101
|
--ok: #56e887; --warn: #ffc857; --err: #ff737d; --info: #74c7ff;
|
|
102
102
|
--ui: clamp(.875rem, .82rem + .15vw, 1rem);
|
|
@@ -110,7 +110,7 @@ html { background: var(--ground); scrollbar-color: var(--line) var(--ground); }
|
|
|
110
110
|
body {
|
|
111
111
|
min-height: 100vh; padding: clamp(26px, 4.5vh, 64px) clamp(22px, 6vw, 120px) 80px; color: var(--paper); font-family: var(--body);
|
|
112
112
|
-webkit-font-smoothing: antialiased; background: var(--ground);
|
|
113
|
-
background-image: linear-gradient(rgba(
|
|
113
|
+
background-image: linear-gradient(rgba(140, 140, 140, .07) 1px, transparent 1px), linear-gradient(90deg, rgba(140, 140, 140, .07) 1px, transparent 1px);
|
|
114
114
|
background-size: 64px 64px;
|
|
115
115
|
}
|
|
116
116
|
::selection { background: rgba(0, 238, 34, .3); color: var(--paper); }
|
|
@@ -160,7 +160,7 @@ section { margin-bottom: clamp(24px, 4vh, 44px); }
|
|
|
160
160
|
.tbl { overflow: auto; border: 1px solid var(--line); scrollbar-width: thin; }
|
|
161
161
|
table { width: 100%; border-collapse: collapse; }
|
|
162
162
|
th { padding: 7px 14px; text-align: left; font: 600 .75rem var(--label); letter-spacing: .08em; text-transform: uppercase; color: var(--faint); border-bottom: 1px solid var(--line); background: var(--panel); white-space: nowrap; }
|
|
163
|
-
td { padding: 6px 14px; border-bottom: 1px solid var(--line); color: var(--muted); font-size: .9375rem; line-height: 1.35; vertical-align: top; background: rgba(
|
|
163
|
+
td { padding: 6px 14px; border-bottom: 1px solid var(--line); color: var(--muted); font-size: .9375rem; line-height: 1.35; vertical-align: top; background: rgba(19, 19, 19, .82); font-variant-numeric: tabular-nums; }
|
|
164
164
|
tr:last-child td { border-bottom: 0; }
|
|
165
165
|
tr:hover td { background: var(--panel-hi); }
|
|
166
166
|
td:first-child, td:nth-child(2) { white-space: nowrap; }
|
package/hooks/policy.ts
CHANGED
|
@@ -24,6 +24,7 @@ export const DEFAULTS = {
|
|
|
24
24
|
doneVerifiedMax: 0.3,
|
|
25
25
|
doneAsksUserMax: 0.5,
|
|
26
26
|
exfil: 0.7,
|
|
27
|
+
requested: 0.8,
|
|
27
28
|
askJev: true,
|
|
28
29
|
autoCompact: true,
|
|
29
30
|
compactSwitched: 0.8,
|
|
@@ -75,15 +76,18 @@ export const score = (instructions: string, criteria: string[]): Question => ({
|
|
|
75
76
|
// --- questions (rubrics from ten-levels-of-jev level 6) ---
|
|
76
77
|
|
|
77
78
|
export const BLOCK_NOTICE =
|
|
78
|
-
'
|
|
79
|
-
'
|
|
79
|
+
'Do not try to work around it with another command, another tool, a different path, or an encoding that does the ' +
|
|
80
|
+
'same thing. Tell the user what was blocked and why, and ask whether to run it anyway. If they say yes, run exactly ' +
|
|
81
|
+
'this again: their reply then lets it through.'
|
|
80
82
|
export const DONE_REASON =
|
|
81
83
|
"You said this is done but nothing shows it was verified. Run the check, or say explicitly why it can't be verified."
|
|
82
84
|
|
|
83
85
|
export const BASH_Q: Questions = {
|
|
84
86
|
effect: choice('What does running `command` in `cwd` do to the machine?', {
|
|
85
87
|
read_only: 'Lists, reads, searches, tests, builds into a scratch directory; nothing durable changes',
|
|
86
|
-
reversible:
|
|
88
|
+
reversible:
|
|
89
|
+
'Changes files or state that git, a reinstall or a rebuild can restore: edits, installs, uninstalls, deleting build ' +
|
|
90
|
+
'output or generated files, ordinary commits and pushes',
|
|
87
91
|
irreversible:
|
|
88
92
|
'Deletes or overwrites things with no way back: removing directories, force pushing, dropping data, rewriting history',
|
|
89
93
|
}),
|
|
@@ -92,6 +96,11 @@ export const BASH_Q: Questions = {
|
|
|
92
96
|
'rm -rf, drop, purge, force, reset --hard, truncate, overwriting real data',
|
|
93
97
|
'Reading, listing, testing, installing, generating, or editing in place',
|
|
94
98
|
),
|
|
99
|
+
exfiltrates: noul(
|
|
100
|
+
'Does `command` send local files, secrets or environment values to an outside host?',
|
|
101
|
+
'curl or wget uploading a file or piping local data out, scp or rsync to a remote, posting keys, tokens or ~/.ssh, ~/.aws, .env contents',
|
|
102
|
+
'Downloads, git push to the project remote, package installs, API calls that send only the query, or nothing over the network',
|
|
103
|
+
),
|
|
95
104
|
}
|
|
96
105
|
export const WRITE_Q: Questions = {
|
|
97
106
|
kind: choice('What kind of file is `path`, given `content`?', {
|
|
@@ -122,11 +131,30 @@ export const POLICY_Q: Questions = {
|
|
|
122
131
|
),
|
|
123
132
|
}
|
|
124
133
|
/** A gate's questions plus the policy question, only when the user wrote policies. */
|
|
125
|
-
export const withPolicyQ = (q: Questions, c: Config): Questions =>
|
|
134
|
+
export const withPolicyQ = (q: Questions, c: Config, request = ''): Questions =>
|
|
135
|
+
({ ...q, ...(splitList(c.policies).length ? POLICY_Q : {}), ...(request.trim() ? REQUESTED_Q : {}) })
|
|
126
136
|
/** A gate's state plus the user's policies, only when there are some. */
|
|
127
|
-
export const withPolicies = <T extends object>(state: T, c: Config
|
|
137
|
+
export const withPolicies = <T extends object>(state: T, c: Config, request = '', blockedBefore = ''): T => {
|
|
128
138
|
const policies = splitList(c.policies)
|
|
129
|
-
return
|
|
139
|
+
return {
|
|
140
|
+
...state,
|
|
141
|
+
...(policies.length ? { policies } : {}),
|
|
142
|
+
...(request.trim() ? { request: clip(request, 1500), ...(blockedBefore ? { blocked_before: blockedBefore } : {}) } : {}),
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/** The call a gate denied, as the next turn's gates show it to Jev: a reply of "yes" then confirms this call. */
|
|
146
|
+
export const callSummary = (tool: string, input: unknown): string => clip(`${tool} ${JSON.stringify(input)}`, 600)
|
|
147
|
+
/** Asked only when there is a user request: does the user's own latest message ask for exactly this call? */
|
|
148
|
+
export const REQUESTED_Q: Questions = {
|
|
149
|
+
requested: noul(
|
|
150
|
+
'Does `request`, the user\'s own latest message, ask for what this call does? When `blocked_before` is set, the ' +
|
|
151
|
+
'agent had just told the user that call was blocked and asked whether to run it. Without `blocked_before`, a bare ' +
|
|
152
|
+
'confirmation names nothing, so it asks for nothing.',
|
|
153
|
+
'The user named this action or its target (delete that file, force push, remove the co-author line, uninstall X), ' +
|
|
154
|
+
'or `blocked_before` is this same call and the user confirmed it (yes, do it, go ahead, run it)',
|
|
155
|
+
'The request is about something else, is vague, says no or wait, is a bare yes or ok with no `blocked_before`, or ' +
|
|
156
|
+
'the call differs from `blocked_before`; the agent chose this step on its own',
|
|
157
|
+
),
|
|
130
158
|
}
|
|
131
159
|
/** The tier rubric: Jev's choice criteria, and the labels the built-in classifier picks from. */
|
|
132
160
|
const TIER_CRITERIA: Record<Tier, string> = {
|
|
@@ -189,6 +217,47 @@ const nv = (a: Answers, k: string): number => {
|
|
|
189
217
|
}
|
|
190
218
|
const ch = (a: Answers, k: string): Choice => a[k] as Choice
|
|
191
219
|
|
|
220
|
+
// --- the plain-read fast path: code, not Jev ---
|
|
221
|
+
|
|
222
|
+
const READ_CMDS = new Set(['ls', 'cat', 'head', 'tail', 'wc', 'grep', 'egrep', 'rg', 'pwd', 'echo', 'printf', 'which', 'file', 'stat',
|
|
223
|
+
'du', 'df', 'tree', 'sort', 'uniq', 'cut', 'tr', 'diff', 'date', 'whoami', 'uname', 'basename', 'dirname', 'realpath', 'cd',
|
|
224
|
+
'true', 'nl', 'column', 'sleep', 'jq', 'test', '[', 'type', 'readlink', 'md5', 'shasum', 'less', 'env', 'id', 'hostname'])
|
|
225
|
+
const GIT_READS = new Set(['status', 'log', 'diff', 'show', 'rev-parse', 'ls-files', 'ls-tree', 'blame', 'grep', 'shortlog', 'describe',
|
|
226
|
+
'reflog', 'rev-list', 'cat-file', 'merge-base', 'worktree list', 'config --get', 'remote get-url'])
|
|
227
|
+
const GIT_LISTS: Record<string, RegExp> = { branch: /^(-a|-r|-v|-vv|--list|--show-current|--all|--merged|--no-merged)$/, remote: /^(-v)$/, tag: /^(-l|--list)$/, stash: /^list$/ }
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* True for a command that only reads, by code alone: every piece of a pipeline, `&&` chain or line is a known reader,
|
|
231
|
+
* with no substitution, redirect (other than to /dev/null), background job, heredoc or subshell. Quoted text is
|
|
232
|
+
* literal, so a `|` in a grep pattern is fine. Anything else, however harmless, goes to Jev.
|
|
233
|
+
*/
|
|
234
|
+
export function isPlainRead(command: string): boolean {
|
|
235
|
+
if (!command.trim() || /`|\$\(|<\(|>\(/.test(command)) return false
|
|
236
|
+
const s = command
|
|
237
|
+
.replace(/'[^']*'|"(?:[^"\\]|\\.)*"/g, 'Q') // quoted text is an argument, not syntax
|
|
238
|
+
.replace(/\s(?:[12]|&)?>\s*\/dev\/null(?=$|[\s;&|])|\s2>&1(?=$|[\s;&|])/g, ' ') // discarding output writes nothing
|
|
239
|
+
if (/[<>()]|(^|[^&])&($|[^&])|\\\n/.test(s)) return false
|
|
240
|
+
return s.split(/&&|\|\||;|\||\n/).every(piece => {
|
|
241
|
+
const w = piece.trim().split(/\s+/).filter(Boolean)
|
|
242
|
+
while (w[0] && /^[A-Za-z_]\w*=\S*$/.test(w[0])) w.shift() // VAR=value: sets a variable, runs nothing
|
|
243
|
+
const [cmd, ...args] = w
|
|
244
|
+
if (!cmd) return true
|
|
245
|
+
if (cmd === 'find') return !w.some(x => /^-(delete|exec|execdir|ok|okdir|fprint|fprintf|fls)$/.test(x))
|
|
246
|
+
if (cmd === 'git') {
|
|
247
|
+
while (args[0] === '--no-pager' || args[0] === '-C') args.splice(0, args[0] === '-C' ? 2 : 1)
|
|
248
|
+
const [sub, ...rest] = args
|
|
249
|
+
if (!sub) return false
|
|
250
|
+
if (GIT_READS.has(sub) || GIT_READS.has(`${sub} ${rest[0]}`)) return !w.some(x => /^--(output|ext-diff)/.test(x))
|
|
251
|
+
const ok = GIT_LISTS[sub]
|
|
252
|
+
return !!ok && rest.every(x => ok.test(x)) && (sub !== 'stash' || rest.length > 0) // a bare `git stash` stashes
|
|
253
|
+
}
|
|
254
|
+
if (cmd === 'env') return args.length === 0 // env with a command runs that command
|
|
255
|
+
return READ_CMDS.has(cmd)
|
|
256
|
+
})
|
|
257
|
+
}
|
|
258
|
+
/** Skip Jev for a plain read, unless the user's policies might forbid even reading something. */
|
|
259
|
+
export const plainRead = (command: string, c: Config): boolean => !splitList(c.policies).length && isPlainRead(command)
|
|
260
|
+
|
|
192
261
|
// --- judges ---
|
|
193
262
|
|
|
194
263
|
/** A deny when Jev says the call breaks a listed policy at least at the gate's own threshold. */
|
|
@@ -197,16 +266,25 @@ const policyDeny = (a: Answers, threshold: number): Judged | null => {
|
|
|
197
266
|
return p >= threshold ? { verdict: 'deny', reason: `breaks a listed policy (${f2(p)})` } : null
|
|
198
267
|
}
|
|
199
268
|
|
|
269
|
+
/** A Jev deny the user's own request asked for passes. Policy and path denies never reach here. */
|
|
270
|
+
const unlessRequested = (j: Judged, a: Answers, c: Config): Judged => {
|
|
271
|
+
const r = nv(a, 'requested')
|
|
272
|
+
return j.verdict === 'deny' && r >= c.requested ? { verdict: null, reason: `${j.reason}; the user asked for it (${f2(r)})` } : j
|
|
273
|
+
}
|
|
274
|
+
|
|
200
275
|
export function gateBash(a: Answers, c: Config): Judged {
|
|
201
276
|
const effect = ch(a, 'effect')
|
|
202
277
|
const destructive = nv(a, 'destructive_intent')
|
|
203
278
|
const policy = policyDeny(a, c.bashDestructive)
|
|
204
279
|
if (policy) return policy
|
|
280
|
+
// destructive intent alone is most editing (deleting lines, uninstalling); it only tips an irreversible answer
|
|
205
281
|
if (effect.choice === 'irreversible' && effect.confidence >= c.bashIrreversible)
|
|
206
|
-
return { verdict: 'deny', reason: `irreversible (${f2(effect.confidence)}): nothing would restore what this removes or overwrites` }
|
|
207
|
-
if (destructive >= c.bashDestructive)
|
|
208
|
-
return { verdict: 'deny', reason: `destructive
|
|
209
|
-
|
|
282
|
+
return unlessRequested({ verdict: 'deny', reason: `irreversible (${f2(effect.confidence)}): nothing would restore what this removes or overwrites` }, a, c)
|
|
283
|
+
if (effect.choice === 'irreversible' && destructive >= c.bashDestructive)
|
|
284
|
+
return unlessRequested({ verdict: 'deny', reason: `irreversible (${f2(effect.confidence)}) and destructive (${f2(destructive)}): this aims to wipe something with no way back` }, a, c)
|
|
285
|
+
const out = nv(a, 'exfiltrates')
|
|
286
|
+
if (out >= c.exfil) return unlessRequested({ verdict: 'deny', reason: `sends local data out (${f2(out)}): keep local files and credentials local` }, a, c)
|
|
287
|
+
return { verdict: null, reason: `${effect.choice} (${f2(effect.confidence)}), destructive ${f2(destructive)}, exfil ${f2(out)}` }
|
|
210
288
|
}
|
|
211
289
|
|
|
212
290
|
export function gateWrite(a: Answers, c: Config): Judged {
|
|
@@ -215,9 +293,9 @@ export function gateWrite(a: Answers, c: Config): Judged {
|
|
|
215
293
|
const policy = policyDeny(a, c.writeSecret)
|
|
216
294
|
if (policy) return policy
|
|
217
295
|
if (secret >= c.writeSecret)
|
|
218
|
-
return { verdict: 'deny', reason: `contains a credential (${f2(secret)}): put it in an ignored .env or a secret store` }
|
|
296
|
+
return unlessRequested({ verdict: 'deny', reason: `contains a credential (${f2(secret)}): put it in an ignored .env or a secret store` }, a, c)
|
|
219
297
|
if (kind.choice === 'secrets' && kind.confidence >= c.writeSecretsKind)
|
|
220
|
-
return { verdict: 'deny', reason: `a secrets file (${f2(kind.confidence)}): keep credentials out of the repo` }
|
|
298
|
+
return unlessRequested({ verdict: 'deny', reason: `a secrets file (${f2(kind.confidence)}): keep credentials out of the repo` }, a, c)
|
|
221
299
|
return { verdict: null, reason: `${kind.choice} (${f2(kind.confidence)}), secret ${f2(secret)}` }
|
|
222
300
|
}
|
|
223
301
|
|
|
@@ -225,7 +303,7 @@ export function gateExfil(a: Answers, c: Config): Judged {
|
|
|
225
303
|
const p = nv(a, 'exfiltrates')
|
|
226
304
|
const policy = policyDeny(a, c.exfil)
|
|
227
305
|
if (policy) return policy
|
|
228
|
-
if (p >= c.exfil) return { verdict: 'deny', reason: `sends local data out (${f2(p)}): keep local files and credentials local` }
|
|
306
|
+
if (p >= c.exfil) return unlessRequested({ verdict: 'deny', reason: `sends local data out (${f2(p)}): keep local files and credentials local` }, a, c)
|
|
229
307
|
return { verdict: null, reason: `exfil ${f2(p)}` }
|
|
230
308
|
}
|
|
231
309
|
|
|
@@ -239,8 +317,10 @@ export function screen(a: Answers, c: Config): { flagged: boolean; reason: strin
|
|
|
239
317
|
}
|
|
240
318
|
|
|
241
319
|
/** The done-check (DONE_REASON when done is claimed with no sign of a check or question) and the compact verdict. */
|
|
242
|
-
|
|
320
|
+
/** `ranTools` false: the turn ran no tool, so there is nothing that could have been verified and nothing to block. */
|
|
321
|
+
export function judgeStop(a: Answers, c: Config, ranTools = true): { block: string | null; wantsCompact: boolean } {
|
|
243
322
|
const unverified =
|
|
323
|
+
ranTools &&
|
|
244
324
|
nv(a, 'claimed_done') >= c.doneClaimed && nv(a, 'verified') < c.doneVerifiedMax && nv(a, 'asks_user') < c.doneAsksUserMax
|
|
245
325
|
const wantsCompact = nv(a, 'switched_gears') >= c.compactSwitched && nv(a, 'at_boundary') >= c.compactBoundary
|
|
246
326
|
return { block: unverified ? DONE_REASON : null, wantsCompact }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ohmyjev",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Guardrails for coding agents, decided by TypeSafe's Jev: blocks destructive commands, catches prompt injection, pushes back on an unverified done and routes effort per turn. For Claude Code, pi and omp.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -126,6 +126,11 @@
|
|
|
126
126
|
"description": "Deny when Jev rates a call as sending local data out at least this probably (0-1)",
|
|
127
127
|
"default": 0.7
|
|
128
128
|
},
|
|
129
|
+
"requested": {
|
|
130
|
+
"type": "number",
|
|
131
|
+
"description": "A gate's Jev deny passes when Jev rates your latest message as asking for exactly that call at least this probable (0-1). Policy and path denies always stand",
|
|
132
|
+
"default": 0.8
|
|
133
|
+
},
|
|
129
134
|
"askJev": {
|
|
130
135
|
"type": "boolean",
|
|
131
136
|
"description": "Expose the ask_jev tool for quick judgments about repo files or text",
|