ohmyjev 0.4.0 → 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 +9 -7
- package/adapters/omp.ts +28 -8
- package/adapters/pi.ts +26 -8
- package/hooks/policy.ts +69 -9
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -58,11 +58,11 @@ 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. A command Jev calls irreversible with less confidence is denied too when it also aims to wipe something (0.7).
|
|
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. Policy and path denies always stand. |
|
|
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. |
|
|
66
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. |
|
|
67
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. |
|
|
68
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. |
|
|
@@ -199,12 +199,13 @@ Claude gets the denial as the tool's result, with an instruction not to work aro
|
|
|
199
199
|
```
|
|
200
200
|
ohmyjev blocked this: irreversible (0.95): nothing would restore what this removes or overwrites.
|
|
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
|
|
203
|
-
|
|
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.
|
|
204
204
|
```
|
|
205
205
|
|
|
206
|
-
So when the agent stops and
|
|
207
|
-
A
|
|
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.
|
|
208
209
|
|
|
209
210
|
A write outside the repo gets a different message. It names the path and tells the model to ask you to add that
|
|
210
211
|
directory to `allowPaths`, because that block is a setting, not a hazard.
|
|
@@ -335,7 +336,7 @@ a fixed size:
|
|
|
335
336
|
|
|
336
337
|
| What asks | What it sends |
|
|
337
338
|
|---|---|
|
|
338
|
-
| Bash gate | The command (up to 16000 characters), the working directory, the tool call's description
|
|
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 |
|
|
339
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 |
|
|
340
341
|
| Exfil gate | The tool's name and its input (up to 4000 characters) |
|
|
341
342
|
| Injection screen | The tool's output (up to 6000 characters), from Bash, WebFetch, MCP tools and Reads outside the repo |
|
|
@@ -386,6 +387,7 @@ git clone https://github.com/Novacon/ohmyjev && cd ohmyjev && bun install
|
|
|
386
387
|
claude --plugin-dir "$PWD" # run your working copy, and it writes the API types to .claude-plugin/types
|
|
387
388
|
omp -e ./adapters/omp.ts # or: pi -e ./adapters/pi.ts
|
|
388
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
|
|
389
391
|
```
|
|
390
392
|
|
|
391
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,8 @@ 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
|
|
257
259
|
let userRequest = '' // the user's latest message: the gates ask whether it asked for the call
|
|
258
260
|
let compactPending = false
|
|
259
261
|
let classifiedPrompt: string | undefined
|
|
@@ -400,6 +402,8 @@ export function createExtension(options: OmpExtensionOptions = {}): (pi: OmpApi)
|
|
|
400
402
|
route = undefined
|
|
401
403
|
liveRequest = ''
|
|
402
404
|
userRequest = ''
|
|
405
|
+
lastBlocked = ''
|
|
406
|
+
blockedBefore = ''
|
|
403
407
|
compactPending = false
|
|
404
408
|
classifiedPrompt = undefined
|
|
405
409
|
status()
|
|
@@ -456,6 +460,8 @@ export function createExtension(options: OmpExtensionOptions = {}): (pi: OmpApi)
|
|
|
456
460
|
route = undefined
|
|
457
461
|
liveRequest = ''
|
|
458
462
|
userRequest = ''
|
|
463
|
+
lastBlocked = ''
|
|
464
|
+
blockedBefore = ''
|
|
459
465
|
compactPending = false
|
|
460
466
|
classifiedPrompt = undefined
|
|
461
467
|
pushedBackAt = -1
|
|
@@ -469,6 +475,7 @@ export function createExtension(options: OmpExtensionOptions = {}): (pi: OmpApi)
|
|
|
469
475
|
const tool = event.toolName
|
|
470
476
|
const input = object(event.input)
|
|
471
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
|
|
472
479
|
const command = tool === 'eval'
|
|
473
480
|
? string(input, 'code')
|
|
474
481
|
: tool === 'github'
|
|
@@ -485,11 +492,14 @@ export function createExtension(options: OmpExtensionOptions = {}): (pi: OmpApi)
|
|
|
485
492
|
description: clip(description, 300),
|
|
486
493
|
...(command.length > CLIP ? { truncated: true } : {}),
|
|
487
494
|
}
|
|
488
|
-
const d = await session.decide('tool_call', tool, withPolicies(state, c, userRequest), withPolicyQ(BASH_Q, c, userRequest))
|
|
495
|
+
const d = await session.decide('tool_call', tool, withPolicies(state, c, userRequest, blockedBefore), withPolicyQ(BASH_Q, c, userRequest))
|
|
489
496
|
if (!d) return
|
|
490
497
|
const judged = gateBash(d.answers, c)
|
|
491
498
|
session.record(d, judged.verdict, judged.reason)
|
|
492
|
-
if (judged.verdict === 'deny')
|
|
499
|
+
if (judged.verdict === 'deny') {
|
|
500
|
+
lastBlocked = callSummary(tool, input)
|
|
501
|
+
return { block: true, reason: denyText(judged.reason) }
|
|
502
|
+
}
|
|
493
503
|
return
|
|
494
504
|
}
|
|
495
505
|
if (WRITE_TOOLS[tool] && c.writeGate) {
|
|
@@ -508,25 +518,31 @@ export function createExtension(options: OmpExtensionOptions = {}): (pi: OmpApi)
|
|
|
508
518
|
const path = paths.join('; ')
|
|
509
519
|
const d = await session.decide(
|
|
510
520
|
'tool_call', tool,
|
|
511
|
-
withPolicies({ path, content: clip(writeContent(input), CLIP) }, c, userRequest),
|
|
521
|
+
withPolicies({ path, content: clip(writeContent(input), CLIP) }, c, userRequest, blockedBefore),
|
|
512
522
|
withPolicyQ(WRITE_Q, c, userRequest),
|
|
513
523
|
)
|
|
514
524
|
if (!d) return
|
|
515
525
|
const judged = gateWrite(d.answers, c)
|
|
516
526
|
session.record(d, judged.verdict, judged.reason)
|
|
517
|
-
if (judged.verdict === 'deny')
|
|
527
|
+
if (judged.verdict === 'deny') {
|
|
528
|
+
lastBlocked = callSummary(tool, input)
|
|
529
|
+
return { block: true, reason: denyText(judged.reason) }
|
|
530
|
+
}
|
|
518
531
|
return
|
|
519
532
|
}
|
|
520
533
|
if (c.exfilGate && exfilTool(tool, input)) {
|
|
521
534
|
const d = await session.decide(
|
|
522
535
|
'tool_call', tool,
|
|
523
|
-
withPolicies({ tool, input: clip(safeJson(input), 4000) }, c, userRequest),
|
|
536
|
+
withPolicies({ tool, input: clip(safeJson(input), 4000) }, c, userRequest, blockedBefore),
|
|
524
537
|
withPolicyQ(EXFIL_Q, c, userRequest),
|
|
525
538
|
)
|
|
526
539
|
if (!d) return
|
|
527
540
|
const judged = gateExfil(d.answers, c)
|
|
528
541
|
session.record(d, judged.verdict, judged.reason)
|
|
529
|
-
if (judged.verdict === 'deny')
|
|
542
|
+
if (judged.verdict === 'deny') {
|
|
543
|
+
lastBlocked = callSummary(tool, input)
|
|
544
|
+
return { block: true, reason: denyText(judged.reason) }
|
|
545
|
+
}
|
|
530
546
|
}
|
|
531
547
|
} catch {
|
|
532
548
|
return
|
|
@@ -612,7 +628,11 @@ export function createExtension(options: OmpExtensionOptions = {}): (pi: OmpApi)
|
|
|
612
628
|
if (ctx.agent.kind !== 'main' || !c.enabled) return
|
|
613
629
|
userEffort = userThinking(ctx)
|
|
614
630
|
const prompt = typeof rawEvent.prompt === 'string' ? rawEvent.prompt : ''
|
|
615
|
-
if (prompt.trim() && !/^\/\S+\s*$/.test(prompt.trim()))
|
|
631
|
+
if (prompt.trim() && !/^\/\S+\s*$/.test(prompt.trim())) {
|
|
632
|
+
userRequest = prompt
|
|
633
|
+
blockedBefore = lastBlocked
|
|
634
|
+
lastBlocked = ''
|
|
635
|
+
}
|
|
616
636
|
if (!prompt.trim()) return
|
|
617
637
|
if (classifiedPrompt !== prompt) {
|
|
618
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,8 @@ 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
|
|
260
262
|
let userRequest = '' // the user's latest message: the gates ask whether it asked for the call
|
|
261
263
|
let compactPending = false
|
|
262
264
|
let userThinking: ThinkingLevel = 'medium'
|
|
@@ -291,6 +293,8 @@ export function createExtension(options: PiAdapterOptions = {}) {
|
|
|
291
293
|
route = undefined
|
|
292
294
|
liveRequest = ''
|
|
293
295
|
userRequest = ''
|
|
296
|
+
lastBlocked = ''
|
|
297
|
+
blockedBefore = ''
|
|
294
298
|
compactPending = false
|
|
295
299
|
}
|
|
296
300
|
const setThinking = (level: ThinkingLevel): void => {
|
|
@@ -405,7 +409,11 @@ export function createExtension(options: PiAdapterOptions = {}) {
|
|
|
405
409
|
|
|
406
410
|
pi.on('before_agent_start', guarded<BeforeAgentStartEvent, void>(async (event, ctx) => {
|
|
407
411
|
latestCtx = ctx
|
|
408
|
-
if (event.prompt.trim() && !/^\/\S+\s*$/.test(event.prompt.trim()))
|
|
412
|
+
if (event.prompt.trim() && !/^\/\S+\s*$/.test(event.prompt.trim())) {
|
|
413
|
+
userRequest = event.prompt
|
|
414
|
+
blockedBefore = lastBlocked
|
|
415
|
+
lastBlocked = ''
|
|
416
|
+
}
|
|
409
417
|
if (!c.enabled) return
|
|
410
418
|
const text = event.prompt
|
|
411
419
|
if (!text.trim()) return
|
|
@@ -436,12 +444,16 @@ export function createExtension(options: PiAdapterOptions = {}) {
|
|
|
436
444
|
const input = event.input
|
|
437
445
|
if ((tool === 'bash' || tool === 'powershell') && c.bashGate) {
|
|
438
446
|
const command = arg(input, 'command')
|
|
439
|
-
|
|
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)
|
|
440
449
|
const d = await x.decide('tool.call', tool, state, withPolicyQ(BASH_Q, c, userRequest))
|
|
441
450
|
if (!d) return
|
|
442
451
|
const judged = gateBash(d.answers, c)
|
|
443
452
|
x.record(d, judged.verdict, judged.reason)
|
|
444
|
-
if (judged.verdict === 'deny')
|
|
453
|
+
if (judged.verdict === 'deny') {
|
|
454
|
+
lastBlocked = callSummary(tool, input)
|
|
455
|
+
return { block: true, reason: denyText(judged.reason) }
|
|
456
|
+
}
|
|
445
457
|
return
|
|
446
458
|
}
|
|
447
459
|
if ((tool === 'write' || tool === 'edit') && c.writeGate) {
|
|
@@ -451,24 +463,30 @@ export function createExtension(options: PiAdapterOptions = {}) {
|
|
|
451
463
|
return { block: true, reason: pathDenyText(path) }
|
|
452
464
|
}
|
|
453
465
|
const content = tool === 'write' ? arg(input, 'content') : json(input.edits)
|
|
454
|
-
const d = await x.decide('tool.call', tool, withPolicies({ path, content: clip(content, CLIP) }, c, userRequest), withPolicyQ(WRITE_Q, c, userRequest))
|
|
466
|
+
const d = await x.decide('tool.call', tool, withPolicies({ path, content: clip(content, CLIP) }, c, userRequest, blockedBefore), withPolicyQ(WRITE_Q, c, userRequest))
|
|
455
467
|
if (!d) return
|
|
456
468
|
const judged = gateWrite(d.answers, c)
|
|
457
469
|
x.record(d, judged.verdict, judged.reason)
|
|
458
|
-
if (judged.verdict === 'deny')
|
|
470
|
+
if (judged.verdict === 'deny') {
|
|
471
|
+
lastBlocked = callSummary(tool, input)
|
|
472
|
+
return { block: true, reason: denyText(judged.reason) }
|
|
473
|
+
}
|
|
459
474
|
return
|
|
460
475
|
}
|
|
461
476
|
if (tool.startsWith('mcp__') && c.exfilGate) {
|
|
462
477
|
const d = await x.decide(
|
|
463
478
|
'tool.call',
|
|
464
479
|
tool,
|
|
465
|
-
withPolicies({ tool, input: clip(json(input), 4000) }, c, userRequest),
|
|
480
|
+
withPolicies({ tool, input: clip(json(input), 4000) }, c, userRequest, blockedBefore),
|
|
466
481
|
withPolicyQ(EXFIL_Q, c, userRequest),
|
|
467
482
|
)
|
|
468
483
|
if (!d) return
|
|
469
484
|
const judged = gateExfil(d.answers, c)
|
|
470
485
|
x.record(d, judged.verdict, judged.reason)
|
|
471
|
-
if (judged.verdict === 'deny')
|
|
486
|
+
if (judged.verdict === 'deny') {
|
|
487
|
+
lastBlocked = callSummary(tool, input)
|
|
488
|
+
return { block: true, reason: denyText(judged.reason) }
|
|
489
|
+
}
|
|
472
490
|
}
|
|
473
491
|
}))
|
|
474
492
|
|
package/hooks/policy.ts
CHANGED
|
@@ -77,15 +77,17 @@ export const score = (instructions: string, criteria: string[]): Question => ({
|
|
|
77
77
|
|
|
78
78
|
export const BLOCK_NOTICE =
|
|
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
|
|
81
|
-
'
|
|
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.'
|
|
82
82
|
export const DONE_REASON =
|
|
83
83
|
"You said this is done but nothing shows it was verified. Run the check, or say explicitly why it can't be verified."
|
|
84
84
|
|
|
85
85
|
export const BASH_Q: Questions = {
|
|
86
86
|
effect: choice('What does running `command` in `cwd` do to the machine?', {
|
|
87
87
|
read_only: 'Lists, reads, searches, tests, builds into a scratch directory; nothing durable changes',
|
|
88
|
-
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',
|
|
89
91
|
irreversible:
|
|
90
92
|
'Deletes or overwrites things with no way back: removing directories, force pushing, dropping data, rewriting history',
|
|
91
93
|
}),
|
|
@@ -94,6 +96,11 @@ export const BASH_Q: Questions = {
|
|
|
94
96
|
'rm -rf, drop, purge, force, reset --hard, truncate, overwriting real data',
|
|
95
97
|
'Reading, listing, testing, installing, generating, or editing in place',
|
|
96
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
|
+
),
|
|
97
104
|
}
|
|
98
105
|
export const WRITE_Q: Questions = {
|
|
99
106
|
kind: choice('What kind of file is `path`, given `content`?', {
|
|
@@ -127,16 +134,26 @@ export const POLICY_Q: Questions = {
|
|
|
127
134
|
export const withPolicyQ = (q: Questions, c: Config, request = ''): Questions =>
|
|
128
135
|
({ ...q, ...(splitList(c.policies).length ? POLICY_Q : {}), ...(request.trim() ? REQUESTED_Q : {}) })
|
|
129
136
|
/** A gate's state plus the user's policies, only when there are some. */
|
|
130
|
-
export const withPolicies = <T extends object>(state: T, c: Config, request = ''): T => {
|
|
137
|
+
export const withPolicies = <T extends object>(state: T, c: Config, request = '', blockedBefore = ''): T => {
|
|
131
138
|
const policies = splitList(c.policies)
|
|
132
|
-
return {
|
|
139
|
+
return {
|
|
140
|
+
...state,
|
|
141
|
+
...(policies.length ? { policies } : {}),
|
|
142
|
+
...(request.trim() ? { request: clip(request, 1500), ...(blockedBefore ? { blocked_before: blockedBefore } : {}) } : {}),
|
|
143
|
+
}
|
|
133
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)
|
|
134
147
|
/** Asked only when there is a user request: does the user's own latest message ask for exactly this call? */
|
|
135
148
|
export const REQUESTED_Q: Questions = {
|
|
136
149
|
requested: noul(
|
|
137
|
-
'Does `request`, the user\'s own latest message,
|
|
138
|
-
|
|
139
|
-
|
|
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',
|
|
140
157
|
),
|
|
141
158
|
}
|
|
142
159
|
/** The tier rubric: Jev's choice criteria, and the labels the built-in classifier picks from. */
|
|
@@ -200,6 +217,47 @@ const nv = (a: Answers, k: string): number => {
|
|
|
200
217
|
}
|
|
201
218
|
const ch = (a: Answers, k: string): Choice => a[k] as Choice
|
|
202
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
|
+
|
|
203
261
|
// --- judges ---
|
|
204
262
|
|
|
205
263
|
/** A deny when Jev says the call breaks a listed policy at least at the gate's own threshold. */
|
|
@@ -224,7 +282,9 @@ export function gateBash(a: Answers, c: Config): Judged {
|
|
|
224
282
|
return unlessRequested({ verdict: 'deny', reason: `irreversible (${f2(effect.confidence)}): nothing would restore what this removes or overwrites` }, a, c)
|
|
225
283
|
if (effect.choice === 'irreversible' && destructive >= c.bashDestructive)
|
|
226
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)
|
|
227
|
-
|
|
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)}` }
|
|
228
288
|
}
|
|
229
289
|
|
|
230
290
|
export function gateWrite(a: Answers, c: Config): Judged {
|
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",
|