SLOPSHOPPER

jev-guard

Asks Jev whether a prompt leaves a choice open and whether each edit stays within what the user approved

newguardpromptprocessnetwork
v0.1.0no licenseupdated 2026-10-12klefix/claude-code-mods/jev-guard
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · jev-guard
› fix the failing auth test and add an audit log call ● jev-guard: jev-guard: jev-guard blocked this prompt because Jev could not be asked: no AI Gateway key in ~/.config/jev-guard/key; run jev-guard/set-key.sh ● jev-guard: jev-guard: Edit denied: jev-guard blocked this call because Jev could not be asked: no AI Gateway key in ~/.config/jev-guard/key; run jev-guard/set-key.sh ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Edit(/work/app/src/auth.ts) ⎿ Denied by jev-guard: jev-guard blocked this call because Jev could not be asked: no AI Gateway key in ~/.c ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ● jev-guard: jev-guard: Write denied: jev-guard blocked this call because Jev could not be asked: no AI Gateway key in ~/.config/jev-guard/key; run jev-guard/set-key.sh ● jev-guard: jev-guard: Write denied: jev-guard blocked this call because Jev could not be asked: no AI Gateway key in ~/.config/jev-guard/key; run jev-guard/set-key.sh ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-code-mods

A few Claude Code mods. A mod is a plugin with a TypeScript or JavaScript hooks module that Claude Code runs on events such as tool calls, prompts and rendering. The mods need Claude Code v2.1.287 or later.

The mods

ModWhat it does
git-statusRuns git status --porcelain at session start, after each prompt, after file-changing tool calls and when the working directory changes. It keeps the changed, staged and unstaged file counts in session state.
tool-callsCounts how often each tool and each skill was used in the session. /clear resets the counts.
sidebarA pane that shows the git-status counts above the tool-calls counts. It opens at session start in terminals at least 144 columns wide. /sidebar opens it at any width. It needs git-status and tool-calls to be loaded.
jev-guardAsks Jev, a classifier model on the Vercel AI Gateway, two kinds of questions. See below.

jev-guard

On each prompt you type, jev-guard asks Jev whether the request leaves a choice open, such as which fix or what scope. If it does, and the prompt is not a question, a dialog asks you to clarify, send the prompt as written, or cancel it.

Before each Edit, Write, MultiEdit, NotebookEdit and Bash call, jev-guard asks Jev whether the call stays within what you approved and whether it removes behavior you did not ask to remove. It denies the call if the in-scope rating is below 0.5 or the removes-behavior rating is 0.7 or more. Claude then sees the reason and is told to ask you. A Bash command that Jev rates as read-only is allowed without those checks. The thresholds are at the top of jev-guard/hooks/judge.ts.

Things to know before you enable jev-guard:

  • It sends your prompt, recent assistant text and the tool call input to https://ai-gateway.vercel.sh. For a Write call it also sends the current content of the file being overwritten.
  • It fails closed. If Jev cannot be reached or takes longer than 5 seconds, it drops the prompt or denies the tool call. Each block is reported in the transcript.
  • It writes the last request it sent to jev-guard/.debug/last-request.json. That folder is git-ignored.

It needs a Vercel AI Gateway API key. Run jev-guard/set-key.sh, which saves the key to ~/.config/jev-guard/key with permissions 600.

Setup

Clone the repo:

git clone https://github.com/klefix/claude-code-mods.git ~/claude-code-mods

To load mods in every session, add their directories to CLAUDE_CODE_PLUGIN_DIRS in ~/.claude/settings.json. The value is a list of absolute paths separated by :. Use the path you cloned to:

{
  "env": {
    "CLAUDE_CODE_PLUGIN_DIRS": "/Users/you/claude-code-mods/git-status:/Users/you/claude-code-mods/tool-calls:/Users/you/claude-code-mods/sidebar"
  }
}

Add /Users/you/claude-code-mods/jev-guard to the list if you want jev-guard, after you have set the key.

To try one mod for a single session, run claude --plugin-dir ./sidebar from the repo. Claude Code reloads the mod when you save a file.

To check that a mod is loaded, open /plugin and look at the Installed tab.

Development

npm install
npm run lint        # Biome lint and format check
npm run lint:fix    # apply the fixes
claude plugin validate ./jev-guard
claude plugin test jev-guard

Claude Code generates .claude-plugin/types/ and tsconfig.json in each mod directory when it loads the mod. Both are git-ignored.

Source 2 files
hooks/register.ts 144 lines
1import type { EngineInterface, On } from 'claude-code'
2import {
3  type Answers,
4  lastExchange,
5  PROMPT_QUESTIONS,
6  probability,
7  QUESTION_MIN,
8  TOOL_QUESTIONS,
9  toolCallState,
10  toolVerdict,
11  VAGUE_MIN,
12  withStepText,
13} from './judge.js'
14
15const GUARDED = ['Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'Bash']
16const USER_ORIGINS = ['composer', 'bridge', 'sdk', 'slack-ping']
17const JEV_TIMEOUT_MS = 5000
18const PROCEED = 'Proceed as written'
19const CANCEL = 'Cancel prompt'
20
21let stepText = ''
22
23function report($: EngineInterface, text: string) {
24  $.ui.log(`jev-guard: ${text}`)
25}
26
27async function readKey($: EngineInterface) {
28  const { exitCode, stdout } = await $.process.run(['sh', '-c', 'cat "$HOME/.config/jev-guard/key"'])
29  const key = stdout.trim()
30  if (exitCode !== 0 || !key) {
31    throw new Error('no AI Gateway key in ~/.config/jev-guard/key; run jev-guard/set-key.sh')
32  }
33  return key
34}
35
36async function askJev($: EngineInterface, state: object, questions: object): Promise<Answers> {
37  const apiKey = await readKey($)
38  await $.fs.write(`${$.plugin.root}/.debug/last-request.json`, JSON.stringify({ state, questions }, null, 2))
39  const request = $.http.fetch('https://ai-gateway.vercel.sh/v1/evaluate', {
40    method: 'POST',
41    headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
42    body: JSON.stringify({ model: 'typesafe-ai/jev', state, questions }),
43  })
44  const timeout = $.clock.sleep(JEV_TIMEOUT_MS).then(() => {
45    throw new Error(`Jev did not answer within ${JEV_TIMEOUT_MS} ms`)
46  })
47  const res = await Promise.race([request, timeout])
48  if (!res.ok) {
49    throw new Error(`Jev HTTP ${res.status}: ${res.text.slice(0, 200)}`)
50  }
51  const { answers }: { answers: Answers } = JSON.parse(res.text)
52  $.ui.log(`jev-guard ${JSON.stringify(answers)}`, { to: 'debug' })
53  return answers
54}
55
56export function register(on: On) {
57  on('prompt.submit', async ($, e, next) => {
58    if (!USER_ORIGINS.includes(e.origin.kind)) {
59      return next(e)
60    }
61    const { assistantProposal } = lastExchange(await $.session.messages())
62    const answers = await askJev($, { userRequest: e.text, assistantProposal }, PROMPT_QUESTIONS)
63    const vague = probability(answers, 'vague')
64    if (vague < VAGUE_MIN || probability(answers, 'question') >= QUESTION_MIN) {
65      return next(e)
66    }
67    let answer: string
68    try {
69      answer = await $.ui.ask(
70        `Jev: this request may leave a choice open (${vague.toFixed(2)}). Type the choice under Other, or pick:`,
71        {
72          options: [PROCEED, CANCEL],
73          header: 'jev-guard',
74        },
75      )
76    } catch (err) {
77      const reason = `jev-guard blocked this prompt (vague ${vague.toFixed(2)}): the clarification dialog failed: ${err instanceof Error ? err.message : String(err)}`
78      report($, reason)
79      return { drop: reason }
80    }
81    if (answer === CANCEL) {
82      report($, `prompt cancelled at the clarification dialog (vague ${vague.toFixed(2)})`)
83      return { drop: 'Cancelled by jev-guard clarification' }
84    }
85    if (answer === PROCEED) {
86      return next(e)
87    }
88    return next({ ...e, text: `${e.text}\n\nClarification: ${answer}` })
89  }).catch(($, e, next) => {
90    const error = next.error.message ?? next.error.kind
91    if (next.called) {
92      report($, `prompt hook failed after passing the prompt on: ${error}`)
93      return next(e)
94    }
95    const reason = `jev-guard blocked this prompt because Jev could not be asked: ${error}`
96    report($, reason)
97    return { drop: reason }
98  })
99
100  on('turn.step', async function* (_$, e, next) {
101    if (e.agentId) {
102      return yield* next(e)
103    }
104    stepText = ''
105    const stream = next(e)
106    let item = await stream.next()
107    while (!item.done) {
108      if (item.value.kind === 'text') {
109        stepText += item.value.text
110      }
111      yield item.value
112      item = await stream.next()
113    }
114    return item.value
115  })
116
117  on('tool.call', async ($, e, next) => {
118    if (!GUARDED.includes(e.tool)) {
119      return next(e)
120    }
121    const exchange = withStepText(lastExchange(await $.session.messages()), e.agentId ? '' : stepText)
122    let currentFile: string | undefined
123    if (e.tool === 'Write') {
124      currentFile = await $.fs.read(e.file_path).catch(() => '')
125    }
126    const answers = await askJev($, toolCallState(exchange, e, currentFile), TOOL_QUESTIONS)
127    const reason = toolVerdict(e.tool, answers)
128    if (reason) {
129      report($, `${e.tool} denied: ${reason}`)
130      return { deny: reason }
131    }
132    return next(e)
133  }).catch(($, e, next) => {
134    const error = next.error.message ?? next.error.kind
135    if (next.called) {
136      report($, `${e.tool} hook failed after passing the call on: ${error}`)
137      return next(e)
138    }
139    const reason = `jev-guard blocked this call because Jev could not be asked: ${error}`
140    report($, `${e.tool} denied: ${reason}`)
141    return { deny: reason }
142  })
143}
144
hooks/judge.ts 113 lines
1import type { SessionMessage } from 'claude-code'
2
3export const VAGUE_MIN = 0.6
4export const QUESTION_MIN = 0.5
5export const IN_SCOPE_MIN = 0.5
6export const REMOVES_MAX = 0.7
7const MAX_FIELD = 8000
8
9export type Exchange = { userRequest: string; assistantProposal: string; assistantThisTurn: string }
10export type Answers = Record<string, { probability?: number } | undefined>
11type ToolCallState = Exchange & { toolCall: Record<string, unknown>; currentFileContent?: string }
12
13export const PROMPT_QUESTIONS = {
14  vague: {
15    type: 'boolean',
16    instructions:
17      'Does the user request leave a choice open that the user has not made, such as which fix, which option, or what scope?',
18  },
19  question: {
20    type: 'boolean',
21    instructions:
22      'Is the user asking a question, such as for options or an explanation, rather than asking for a change to be made?',
23  },
24}
25
26export const TOOL_QUESTIONS = {
27  writes: {
28    type: 'boolean',
29    instructions: 'Does this tool call change files, git state or external systems?',
30  },
31  inScope: {
32    type: 'boolean',
33    instructions:
34      'Does this tool call do only what the user approved in userRequest, read together with assistantProposal? Choices the user did not make count as not approved.',
35  },
36  removes: {
37    type: 'boolean',
38    instructions: 'Does this tool call remove or disable existing behavior that the user did not ask to remove?',
39  },
40}
41
42function clip<T>(text: T): T | string {
43  if (typeof text !== 'string') {
44    return text
45  }
46  return text.length > MAX_FIELD ? `${text.slice(0, MAX_FIELD)}\n[truncated]` : text
47}
48
49// Tool results arrive as user messages with empty text
50export function lastExchange(messages: SessionMessage[]): Exchange {
51  const i = messages.findLastIndex((m) => m.role === 'user' && m.text.trim() !== '')
52  if (i === -1) {
53    return { userRequest: '', assistantProposal: '', assistantThisTurn: '' }
54  }
55  const before = messages.slice(0, i).findLast((m) => m.role === 'assistant' && m.text.trim() !== '')
56  const after = messages
57    .slice(i + 1)
58    .filter((m) => m.role === 'assistant')
59    .map((m) => m.text)
60  return {
61    userRequest: clip(messages[i]?.text ?? ''),
62    assistantProposal: clip(before?.text ?? ''),
63    assistantThisTurn: clip(after.join('\n').trim()),
64  }
65}
66
67// The response that holds a tool call is not in $.session.messages() yet
68export function withStepText(exchange: Exchange, stepText: string): Exchange {
69  const text = stepText.trim()
70  if (!text || exchange.assistantThisTurn.includes(text)) {
71    return exchange
72  }
73  return { ...exchange, assistantThisTurn: clip([exchange.assistantThisTurn, text].filter(Boolean).join('\n')) }
74}
75
76export function toolCallState(exchange: Exchange, e: { tool: string } & Record<string, unknown>, currentFile?: string) {
77  const { tool, tool_use_id, agentId, ...input } = e
78  const clipped = Object.fromEntries(Object.entries(input).map(([k, v]) => [k, clip(v)]))
79  const state: ToolCallState = { ...exchange, toolCall: { tool, ...clipped } }
80  if (currentFile !== undefined) {
81    state.currentFileContent = clip(currentFile)
82  }
83  return state
84}
85
86export function probability(answers: Answers, name: string) {
87  const p = answers?.[name]?.probability
88  if (typeof p !== 'number') {
89    throw new Error(`Jev answer missing: ${name}`)
90  }
91  return p
92}
93
94export function toolVerdict(tool: string, answers: Answers) {
95  const writes = probability(answers, 'writes')
96  if (tool === 'Bash' && writes < 0.5) {
97    return null
98  }
99  const inScope = probability(answers, 'inScope')
100  const removes = probability(answers, 'removes')
101  const fails: string[] = []
102  if (inScope < IN_SCOPE_MIN) {
103    fails.push(`in-scope ${inScope.toFixed(2)} (needs ${IN_SCOPE_MIN} or more)`)
104  }
105  if (removes >= REMOVES_MAX) {
106    fails.push(`removes-behavior ${removes.toFixed(2)} (needs below ${REMOVES_MAX})`)
107  }
108  if (fails.length === 0) {
109    return null
110  }
111  return `jev-guard blocked this call. Jev rated ${fails.join(' and ')}. Ask the user before trying again.`
112}
113