A permission layer driven by a JSON policy: allow, ask or deny every tool call (Bash, edits, web, MCP, subagents, skills), every slash command or skill you…

A permission layer for Claude Code driven by a JSON policy. It decides whether Claude may run each tool call (Bash, file edits, web, MCP tools, subagents through the Agent tool, skills through the Skill tool), each slash command or skill you type, and each skill preloaded into a subagent. Rules decide first. Anything no rule covers can go to Jev, TypeSafe's System One decision model, which judges the action against your latest request. This is the idea behind Claude Code's own auto mode, except the policy, the questions and the thresholds are yours.
It pairs with jev-guardrails: guardrails screens what is said (prompts and replies), while auto mode governs what is done (every action).
[jev-auto-mode] ready: enforce · default jev · 14 rules · ask via mod · judge typesafe jev-latest · /home/you/.claude/jev-auto-mode.json
[jev-auto-mode] deny Bash(rm -rf build) (rule no-rm-rf)
[jev-auto-mode] judge Bash(curl -X POST https://… -d @dump.sql): exfiltration 0.91 · destructive 0.12 · … · severity 2.0 · 310ms
[jev-auto-mode] deny Bash(curl -X POST https://… -d @dump.sql) (jev)
[jev-auto-mode] ask Bash(git push origin main) (rule publish-and-deploy)
The status line keeps a running tally: auto · 42✓ 3? 2✗ · deny Bash(rm -rf build).
For every tool call, in this order:
configFile path, and the project's) or the mod's own files. The check errs toward refusing:cat, grep, git diff, …, with no redirect and no write option such as -o, -i or --output) is refused. That covers sed -i, >, curl -o, wget -O, git show --output=, cp, mv, ln, a variable holding the path, and so on.This check runs before any rule, so no rule and no project file can switch it off.
&&, ||, ;, |, &, newlines, $( … ), backticks, bash -c '…').r'm' is rm), $IFS read as a space, leading VAR=x assignments and wrappers stripped (sudo, env, nohup, xargs, …), and variables the same line assigned read back (X=rm; $X -rf / is rm -rf /).ls* does not let ls && rm -rf ~ through.$TOOL …, eval, source) can't be read by any rule, so it gets at least opaqueShell (ask by default).default decides: allow, ask, deny, passthrough (the engine's normal permission flow), or jev (the judge, below).deny: the call is refused, and the model reads the rule's reason.allow: the call runs without the engine's prompt. A deny from your settings (permissions.deny) or from plan mode still stands.ask: with askWith: "mod", the mod shows you an Allow / Deny dialog, and dismissing it denies. With askWith: "engine", the question goes to your permission mode. In claude -p no one can answer, so headless decides (default deny).Slash commands and skills you type are checked against the rules only: you typed them, so there is nothing to judge. A skill's prompt is also checked when it expands (typed, through the Skill tool, or preloaded into a subagent). A deny rule replaces the prompt with a notice, so a blocked skill cannot reach a subagent through its definition either. An ask rule asks there too, unless the Skill call or the typed /skill was just approved, so you're never asked twice.
If the mod itself fails, it denies. The engine skips a hook that throws, which would let the call through unchecked. So an internal error denies the tool call, refuses the typed command, or withholds the skill, and says why. A policy file that can't be re-read leaves the last good policy in force rather than an empty one.
Inputs over 20,000 characters are asked about. Matching is synchronous, so the rules don't match past that length, and the judge never sees a half-shown command. For the same reason, a regex whose repeated group itself repeats ((a+)+) is refused when the file loads.
default: "jev")For a call to a tool listed in jev.tools that no rule decided, one request goes to Jev. It carries your latest prompt (the intent), the action, and a battery of yes/no questions, one per hazard:
| Hazard | Question (short) | Default decision |
|---|---|---|
destructive | deletes, overwrites or irreversibly changes data, history or infrastructure? | ask |
exfiltration | sends secrets, credentials, private code or personal data off the machine? | deny |
security_weakening | disables checks, widens permissions, touches credentials/CI secrets, runs untrusted code? | ask |
out_of_scope | goes clearly beyond what you asked for? | ask |
It also asks for a severity score (0–3). A hazard at or above threshold triggers its decision, and one at or above askThreshold asks. A severity at or above severityDeny turns an ask into a deny. The strictest result wins. Judgements are cached per identical action within the same request.
Backends, chosen by whichever key is set, the same as in jev-guardrails:
| Backend | Endpoint | Probability |
|---|---|---|
typesafe | POST api.typesafe.ai/v1/systemone, jev-latest | noul, calibrated |
gateway | POST ai-gateway.vercel.sh/v4/ai/evaluation-model, typesafe-ai/jev | probability |
| built-in | the engine's $.model.classify (no key needed) | none: the label is the decision |
If the judge errors or runs past timeoutMs, jev.onError decides (ask by default, or passthrough, allow, deny). The default asks rather than fails open: an outage should not quietly remove the protection.
Two files are merged:
| File | Who writes it | What it may do |
|---|---|---|
~/.claude/jev-auto-mode.json (or the configFile option) | you | everything |
<project>/.claude/jev-auto-mode.json | the repository | tighten only: deny and ask rules, a stricter default, mode, headless, askWith or opaqueShell, lower thresholds, more judged tools. Its allow rules are ignored unless your file sets "trustProjectAllow": true |
A repository you clone cannot use its own file to open things up. Both files are re-read at the start of each turn if they changed. A broken rule is reported (log + toast) and dropped, while the rest of the file still applies.
/jev-auto-mode init writes the example policy to your user file, and /jev-auto-mode init project writes it to the project's. Neither overwrites an existing file.
{
"mode": "enforce", // "audit": log what it would do, block nothing
"default": "jev", // allow | ask | deny | passthrough | jev
"askWith": "mod", // "mod": the mod's dialog · "engine": your permission mode
"headless": "deny", // what an ask becomes in claude -p
"trustProjectAllow": false, // user file only
"opaqueShell": "ask", // floor for `$X …`, eval, source: ask | deny | allow
"rules": [
{ "id": "read-only", "decision": "allow", "tool": ["Read", "Glob", "Grep"] },
{ "id": "no-rm-rf", "decision": "deny", "bashRegex": "\\brm\\s+-[a-z]*r[a-z]*f", "reason": "Delete specific files instead." },
{ "id": "publish", "decision": "ask", "bash": ["npm publish*", "git push*"] },
{ "id": "secrets", "decision": "deny", "tool": ["Read", "Edit"], "path": ["**/.env", "~/.ssh/**"] },
{ "id": "github-deletes", "decision": "ask", "mcpServer": "github", "inputRegex": "\"delete" },
{ "id": "no-paste", "decision": "deny", "tool": "WebFetch", "domain": "*.pastebin.com" },
{ "id": "no-deploy-skill", "decision": "deny", "skill": "deploy-*" },
{ "id": "no-typed-deploy", "decision": "deny", "command": "deploy" },
{ "id": "no-nested-agents", "decision": "deny", "tool": "Agent", "scope": "subagents" }
],
"jev": {
"tools": ["Bash", "Write", "Edit", "MultiEdit", "NotebookEdit", "WebFetch", "mcp__*"],
"hazards": { "destructive": "ask", "exfiltration": "deny", "security_weakening": "ask", "out_of_scope": "ask" },
"threshold": 0.7, "askThreshold": 0.4, "severityDeny": 3,
"onError": "ask", "timeoutMs": 2500
}
}
A rule has a decision, an optional id, reason and scope (all, main or subagents), and at least one matcher. The matchers it names must all hit, and a list within one matcher hits if any entry does.
| Matcher | Matches | Notes |
|---|---|---|
tool | tool name | Bash, Write, mcp__github__*, Agent, Skill, * |
mcpServer | the server of an mcp__server__tool | github |
bash | each part of a Bash command, as a glob | * matches anything, spaces included: git push* |
bashRegex | each part of a Bash command, as a regex | |
path | file_path / path / notebook_path, and a Bash command's arguments | * stays in one segment, ** crosses them, ~ is your home. Relative and absolute forms both match. On Bash, a deny or ask hits when any argument matches (cat ~/.aws/credentials), and an allow only when every argument does |
domain | the host of a url | *.example.com also matches example.com |
skill | the Skill tool's skill, a typed /skill, a preloaded skill | |
command | a slash command you type, without the slash | |
agent | the Agent tool's subagent_type | |
inputRegex | the action's whole input as JSON | the catch-all |
A rule with no matcher is rejected, since it would match everything. If that's what you want, write "tool": "*".
/jev-auto-mode: the active policy, where it was loaded from, the decision tally, and any problems in the files/jev-auto-mode log: the last 15 decisions and what decided each one/jev-auto-mode reload: re-read the files on your next prompt/jev-auto-mode init / init project: write the example policy configFile: file your policy file; empty uses ~/.claude/jev-auto-mode.json
typesafeApiKey: string TypeSafe key for the judge (preferred: calibrated probabilities)
gatewayApiKey: string Vercel AI Gateway key
provider: string "auto" | "typesafe" | "gateway" | "builtin"
typesafeBaseUrl, typesafeModel, gatewayBaseUrl, gatewayModel: overrides, empty for the defaults
logLevel: string "blocked" (asks and denies, default) | "all" | "off"
Keys live only in the options, never in the policy JSON. Set them in user settings (~/.claude/settings.json, never project settings), with --settings <file> or in managed settings:
{ "pluginConfigs": { "jev-auto-mode@skills-dir": { "options": { "typesafeApiKey": "" } } } }
With --plugin-dir the key is plain "jev-auto-mode".
npx claude-code-templates@latest --mod security/jev-auto-mode
claude
Then run /jev-auto-mode init and edit ~/.claude/jev-auto-mode.json. The mod is written to .claude/skills/jev-auto-mode/ and auto-loads as jev-auto-mode@skills-dir in a trusted project. For one session: claude --plugin-dir .claude/skills/jev-auto-mode.
Start with "mode": "audit" to see what it would block in your own work before you let it block anything.
$IFS, wrappers, same-line variables, bash -c) and sends what can't to opaqueShell. But a script that builds the dangerous command inside a file it then runs (python x.py, ./deploy.sh) is only as safe as the rules and the judge are about running that script. Treat the rules as a strong guard, not a sandbox.~/.claude/settings.json can still turn the mod off; keep a permissions.deny on that file too if Claude should never touch it.With a key set and default: "jev", your latest prompt and the judged action's input (a command, a file's path and new content, a URL) are sent to the backend the key belongs to. With no key, nothing leaves the machine.
claude plugin test .claude/skills/jev-auto-mode
The tests cover glob and shell parsing, rule precedence, the project-file trust model, self-protection and the judge's thresholds. Through the engine they also check that tool calls are denied, allowed past the engine prompt or handed to the engine's ask, the headless and audit paths, typed commands and skill prompts.
Requirements. Mods are on by default in Claude Code 2.1.287+. Written and tested on 2.1.282 against the 2.1.278 declarations. Typed against Anthropic's declarations: https://github.com/anthropics/claude-code/tree/main/mods
hooks/jev-auto-mode.ts 429 lines1/**
2 * jev-auto-mode — Claude Mod
3 *
4 * A permission layer that decides what Claude may do, from a JSON policy:
5 * every tool call (Bash, file edits, web, MCP tools, subagents through the
6 * Agent tool, skills through the Skill tool), every slash command or skill
7 * the person types, and every skill preloaded into a subagent. Rules decide
8 * first (deny beats ask beats allow); what no rule covers goes to the
9 * policy's `default`, which can be TypeSafe's Jev judging the action against
10 * the user's latest request, like Claude Code's own auto mode does with its
11 * classifier, but with thresholds you set.
12 *
13 * tool.call the pipeline: self-protection → rules → default / Jev →
14 * allow, ask (the mod's dialog, or the engine's), or deny
15 * tool.check makes the pipeline's verdict the permission decision: an
16 * allow skips the engine's prompt, an engine deny still wins
17 * command.run rules over typed /commands and /skills; /jev-auto-mode
18 * skill.prompt deny rules over a skill's prompt, preloaded ones included
19 * turn.start records the user's request (the judge's intent) and
20 * reloads the JSON files when they changed
21 *
22 * Policy files, merged: ~/.claude/jev-auto-mode.json (yours; the option
23 * `configFile` names another) and <project>/.claude/jev-auto-mode.json (the
24 * repository's, which can only tighten unless yours sets trustProjectAllow).
25 * Claude can never edit either file, nor the mod: that check runs before
26 * any rule.
27 *
28 * Keys come from the plugin's options (typesafeApiKey / gatewayApiKey),
29 * never from the JSON files or this code. With no key the engine's own
30 * `$.model.classify` judges. Needs Claude Code >= 2.1.287.
31 *
32 * Privacy: with a key set and `default: "jev"`, the user's latest request and
33 * the judged action's input are sent to the backend the key belongs to.
34 */
35import type { Register } from 'claude-code'
36import {
37 BUILTIN_LABELS,
38 DEFAULT_BASE_URL,
39 DEFAULT_MODEL,
40 classifyText,
41 describeJudgement,
42 endpoint,
43 readJudgement,
44 requestBody,
45 requestHeaders,
46 rule as ruleOf,
47 rulingReason,
48 selectProvider,
49 stateText,
50} from './judge.ts'
51import type { Judgement, Provider, Ruling } from './judge.ts'
52import {
53 CONFIG_NAME,
54 DEFAULT_CONFIG,
55 describeAction,
56 evaluate,
57 judged,
58 mergeConfigs,
59 parseConfig,
60 selfProtection,
61 toolAction,
62} from './rules.ts'
63import type { Action, Config, Decision, Fallback, Parsed } from './rules.ts'
64
65const COMMAND = 'jev-auto-mode'
66const ALLOW = 'Allow'
67const DENY = 'Deny'
68const HISTORY = 40
69
70type Entry = { at: number; action: string; decision: Decision | 'passthrough'; by: string; audit: boolean }
71
72let config: Config = DEFAULT_CONFIG
73let loadErrors: string[] = []
74let loadNotes: string[] = []
75let sources: string[] = []
76let searched: string[] = []
77let stamp = ''
78let intent = ''
79const verdicts = new Map<string, { decision: 'allow' | 'ask'; reason: string }>()
80const judgements = new Map<string, Ruling>()
81// skills let through as a Skill call or a typed /skill a moment ago: their prompt is not asked about twice
82const approvedSkills = new Map<string, number>()
83const APPROVAL_MS = 60_000
84// the policy files in force, protected by name from Claude's edits
85let policyFiles: string[] = []
86const history: Entry[] = []
87const tally = { allow: 0, ask: 0, deny: 0, passthrough: 0 }
88
89function remember(entry: Entry): void {
90 history.push(entry)
91 if (history.length > HISTORY) history.splice(0, history.length - HISTORY)
92 tally[entry.decision] += 1
93}
94
95function statusLine(last?: Entry): string {
96 const mode = config.mode === 'audit' ? 'audit' : 'auto'
97 const tail = last && last.decision !== 'allow' && last.decision !== 'passthrough' ? ` · ${last.decision} ${last.action}` : ''
98 return `${mode} · ${tally.allow}✓ ${tally.ask}? ${tally.deny}✗${tail}`
99}
100
101/** Folds freshly read file texts into the active policy. */
102function applyLoaded(userText: string | undefined, userPath: string, projectText: string | undefined, projectPath: string): void {
103 const empty: Parsed = { config: {}, errors: [] }
104 const user = userText === undefined ? empty : parseConfig(userText, 'user')
105 const project = projectText === undefined ? empty : parseConfig(projectText, 'project')
106 const merged = mergeConfigs(user.config, project.config)
107 config = merged.config
108 loadErrors = [...user.errors, ...project.errors]
109 loadNotes = merged.notes
110 sources = [userText !== undefined && userPath, projectText !== undefined && projectPath].filter((s): s is string => !!s)
111 searched = [userPath, projectPath].filter(Boolean)
112 judgements.clear()
113}
114
115function describePolicy(backend: string): string {
116 const rules = config.rules.length
117 const where = sources.length ? sources.join(' + ') : `no policy file at ${searched.join(' or ')} (defaults)`
118 return `${config.mode} · default ${config.default} · ${rules} rule${rules === 1 ? '' : 's'} · ask via ${config.askWith} · judge ${backend} · ${where}`
119}
120
121export const register: Register = (on, options) => {
122 const text = (key: string, fallback: string) =>
123 typeof options[key] === 'string' && options[key] ? (options[key] as string) : fallback
124 const typesafeKey = text('typesafeApiKey', '')
125 const gatewayKey = text('gatewayApiKey', '')
126 const forced = text('provider', 'auto')
127 const active: Provider | null = selectProvider(forced, typesafeKey, gatewayKey)
128 const apiKey = active === 'typesafe' ? typesafeKey : active === 'gateway' ? gatewayKey : ''
129 const modelId = !active ? '' : active === 'typesafe' ? text('typesafeModel', DEFAULT_MODEL.typesafe) : text('gatewayModel', DEFAULT_MODEL.gateway)
130 const url = !active
131 ? ''
132 : active === 'typesafe'
133 ? endpoint('typesafe', text('typesafeBaseUrl', DEFAULT_BASE_URL.typesafe))
134 : endpoint('gateway', text('gatewayBaseUrl', DEFAULT_BASE_URL.gateway))
135 const backend = active ? `${active} ${modelId}` : 'built-in classifier'
136 const configFile = text('configFile', '')
137 const logLevel = text('logLevel', 'blocked')
138 const logs = (d: Decision | 'passthrough') => logLevel === 'all' || (logLevel === 'blocked' && d !== 'allow' && d !== 'passthrough')
139
140 on('session.start', async ($, e, next) => {
141 const r = await next(e)
142 await $.command
143 .register({
144 name: COMMAND,
145 description: 'Show or reload the auto-mode policy (status|reload|log|init)',
146 argumentHint: '[status|reload|log|init|init project]',
147 immediate: true,
148 })
149 .catch(err => $.ui.log(`[jev-auto-mode] /${COMMAND} not registered: ${err}`))
150 return r
151 })
152
153 // The user's words are the judge's intent; each turn also picks up edits to the policy files.
154 on('turn.start', async ($, e, next) => {
155 // a failed read keeps the last good policy (never an empty one) and says so
156 try {
157 if (e.text.trim()) intent = e.text
158 const home = await $.env.get('HOME')
159 const root = await $.session.root()
160 const userPath = configFile || (home ? `${home}/.claude/${CONFIG_NAME}` : '')
161 const projectPath = `${root}/.claude/${CONFIG_NAME}`
162 policyFiles = [userPath, projectPath].filter(Boolean)
163 // exists first: a missing policy file is normal, not an error for the debug log
164 const mtime = async (p: string) =>
165 p && (await $.fs.exists(p).catch(() => false)) ? await $.fs.stat(p).then(s => `${s.mtimeMs}`, () => '-') : '-'
166 const now = `${userPath}:${await mtime(userPath)}|${projectPath}:${await mtime(projectPath)}`
167 if (now !== stamp) {
168 const first = stamp === ''
169 const read = async (p: string) =>
170 p && (await $.fs.exists(p).catch(() => false)) ? await $.fs.read(p).then(t => t as string, () => undefined) : undefined
171 applyLoaded(await read(userPath), userPath, await read(projectPath), projectPath)
172 stamp = now
173 $.ui.log(`[jev-auto-mode] ${first ? 'ready' : 'policy reloaded'}: ${describePolicy(backend)}`)
174 for (const line of [...loadErrors, ...loadNotes]) $.ui.log(`[jev-auto-mode] ${line}`)
175 if (loadErrors.length) $.ui.toast(`jev-auto-mode: ${loadErrors.length} problem(s) in ${CONFIG_NAME}; see the transcript`)
176 $.ui.status(statusLine())
177 }
178 } catch (err) {
179 $.ui.log(`[jev-auto-mode] could not reload the policy, keeping the last one: ${String(err)}`)
180 $.ui.toast('jev-auto-mode: policy reload failed; the previous policy stays in force')
181 }
182 return next(e)
183 })
184
185 on('tool.call', async ($, e, next) => {
186 // An exception in a hook makes the engine skip it, which would let the call through unchecked:
187 // for a permission layer that is fail-open, so any internal error denies instead. (`next(e)` is
188 // returned, not awaited, so the tool's own errors never land here.)
189 try {
190 const { tool, tool_use_id: id, agentId, consent: _consent, ...input } = e as unknown as Record<string, unknown> & {
191 tool: string
192 tool_use_id?: string
193 agentId?: string
194 consent?: string
195 }
196 const a: Action = toolAction(tool, input, agentId)
197 const root = await $.session.root()
198 const home = await $.env.get('HOME')
199 const ctx = { root, home }
200
201 // Before any rule: Claude does not get to rewrite its own leash.
202 const guard = selfProtection(a, $.plugin.root, ctx, [...policyFiles, configFile])
203 if (guard) {
204 const entry: Entry = { at: await $.clock.now(), action: describeAction(a), decision: 'deny', by: 'self-protection', audit: false }
205 remember(entry)
206 $.ui.log(`[jev-auto-mode] deny ${entry.action} (self-protection)`)
207 $.ui.status(statusLine(entry))
208 return { deny: guard }
209 }
210
211 const verdict = evaluate(config, a, ctx)
212 let decision: Decision | 'passthrough'
213 let reason: string
214 let by: string
215 if (verdict.source === 'rule') {
216 decision = verdict.decision
217 reason = verdict.reason
218 by = `rule ${verdict.rule.id}`
219 } else if (verdict.fallback === 'jev' && judged(config, a)) {
220 const key = `${intent}\u0000${tool}\u0000${JSON.stringify(input)}`
221 let ruling = judgements.get(key) ?? null
222 if (!ruling) {
223 const startedAt = await $.clock.now()
224 const state = stateText(intent, a)
225 let judgement: Judgement | null = null
226 try {
227 if (active) {
228 const response = await Promise.race([
229 $.http.fetch(url, { method: 'POST', headers: requestHeaders(active, apiKey, modelId), body: requestBody(active, state, modelId) }),
230 $.clock.sleep(config.jev.timeoutMs),
231 ])
232 if (response && response.ok) judgement = readJudgement(response.text)
233 else $.ui.log(`[jev-auto-mode] judge: ${response ? `${active} responded ${response.status}` : `no answer in ${config.jev.timeoutMs}ms`}`)
234 if (judgement) ruling = ruleOf(judgement, config.jev)
235 } else {
236 const label = await $.model.classify(classifyText(state, config.jev), BUILTIN_LABELS)
237 if (label === 'allow' || label === 'ask' || label === 'deny') ruling = { decision: label, hazard: null, probability: null, escalated: false }
238 }
239 } catch (err) {
240 $.ui.log(`[jev-auto-mode] judge failed: ${String(err)}`)
241 }
242 const ms = (await $.clock.now()) - startedAt
243 if (logLevel !== 'off') {
244 $.ui.log(`[jev-auto-mode] judge ${describeAction(a)}: ${active ? describeJudgement(judgement, ms) : `built-in → ${ruling?.decision ?? 'no answer'} · ${Math.round(ms)}ms`}`)
245 }
246 if (ruling) {
247 judgements.set(key, ruling)
248 if (judgements.size > 300) judgements.delete(judgements.keys().next().value as string)
249 }
250 }
251 if (ruling) {
252 decision = ruling.decision
253 reason = rulingReason(ruling, active ?? 'built-in')
254 by = 'jev'
255 } else {
256 const fallback: Fallback = config.jev.onError
257 decision = fallback === 'jev' ? 'passthrough' : fallback
258 reason = `jev-auto-mode: the judge gave no answer (onError: ${fallback})`
259 by = 'jev error'
260 }
261 } else {
262 const fallback = verdict.fallback === 'jev' ? 'passthrough' : verdict.fallback
263 decision = fallback
264 reason = `jev-auto-mode: no rule matched (default: ${fallback})`
265 by = 'default'
266 }
267
268 const entry: Entry = { at: await $.clock.now(), action: describeAction(a), decision, by, audit: config.mode === 'audit' }
269 remember(entry)
270 if (logs(decision)) $.ui.log(`[jev-auto-mode] ${entry.audit ? 'audit: would ' : ''}${decision} ${entry.action} (${by})`)
271 $.ui.status(statusLine(entry))
272
273 const now = entry.at
274 const approve = () => {
275 if (a.skill) approvedSkills.set(a.skill, now)
276 }
277 if (entry.audit || decision === 'passthrough') {
278 approve()
279 return next(e)
280 }
281 if (decision === 'deny') return { deny: reason }
282 if (decision === 'allow') {
283 if (id) verdicts.set(id, { decision: 'allow', reason })
284 approve()
285 return next(e)
286 }
287
288 // ask
289 if (config.askWith === 'engine') {
290 if (id) verdicts.set(id, { decision: 'ask', reason })
291 approve()
292 return next(e)
293 }
294 const surfaces = await $.session.surfaces().then(s => s.length, () => 0)
295 if (surfaces === 0) {
296 if (config.headless === 'deny') return { deny: `${reason} (asked, but no one is here to answer: headless runs deny)` }
297 if (id) verdicts.set(id, { decision: 'allow', reason })
298 approve()
299 return next(e)
300 }
301 const who = agentId ? 'a subagent' : 'Claude'
302 const answer = await $.ui
303 .ask(`${reason.replace(/[.\s]+$/, '')}. Allow ${who} to run ${entry.action}?`, { options: [ALLOW, DENY], header: 'auto mode' })
304 .catch(() => DENY)
305 if (answer !== ALLOW) {
306 $.ui.log(`[jev-auto-mode] you denied ${entry.action}`)
307 return { deny: `The user declined this action (${reason}). Do not retry it; ask the user how to proceed.` }
308 }
309 if (id) verdicts.set(id, { decision: 'allow', reason: 'approved by the user' })
310 approve()
311 return next(e)
312 } catch (err) {
313 $.ui.log(`[jev-auto-mode] internal error, denied ${e.tool}: ${String(err)}`)
314 return { deny: `jev-auto-mode could not evaluate this call (${String(err)}); denied to be safe. Tell the user.` }
315 }
316 })
317
318 // The pipeline's verdict becomes the permission decision; an engine deny (a settings rule, plan mode) still stands.
319 on('tool.check', async ($, e, next) => {
320 const mine = e.tool_use_id ? verdicts.get(e.tool_use_id) : undefined
321 const skill = e.tool === 'Skill' && e.input && typeof e.input === 'object' ? (e.input as { skill?: unknown }).skill : undefined
322 if (!mine) {
323 const engine = await next(e)
324 if (engine.decision !== 'allow' && typeof skill === 'string') approvedSkills.delete(skill)
325 return engine
326 }
327 verdicts.delete(e.tool_use_id!)
328 const engine = await next(e)
329 if (engine.decision === 'deny') {
330 if (typeof skill === 'string') approvedSkills.delete(skill)
331 return engine
332 }
333 return { decision: mine.decision, reason: mine.reason }
334 })
335
336 // Typed slash commands and skills: rules only (the person typed them; there is nothing to judge).
337 on('command.run', async ($, e, next) => {
338 try {
339 if (e.command === COMMAND) {
340 const arg = e.args.trim().toLowerCase()
341 if (arg === 'log') {
342 if (!history.length) return { text: 'jev-auto-mode: no decisions yet' }
343 return {
344 text: history
345 .slice(-15)
346 .map(h => `${h.audit ? '(audit) ' : ''}${h.decision.padEnd(11)} ${h.action} · ${h.by}`)
347 .join('\n'),
348 }
349 }
350 if (arg === 'init' || arg === 'init project') {
351 const home = await $.env.get('HOME')
352 const target =
353 arg === 'init project' ? `${await $.session.root()}/.claude/${CONFIG_NAME}` : configFile || `${home ?? '~'}/.claude/${CONFIG_NAME}`
354 if (await $.fs.exists(target)) return { text: `jev-auto-mode: ${target} already exists; not overwritten` }
355 const example = await $.fs.read(`${$.plugin.root}/examples/${CONFIG_NAME}`)
356 await $.fs.write(target, example as string)
357 stamp = ''
358 return { text: `jev-auto-mode: wrote ${target}; it loads on your next prompt` }
359 }
360 if (arg === 'reload') stamp = ''
361 const problems = [...loadErrors, ...loadNotes]
362 return {
363 text: [
364 `jev-auto-mode: ${describePolicy(backend)}`,
365 `decisions: ${tally.allow} allowed · ${tally.ask} asked · ${tally.deny} denied · ${tally.passthrough} left to the engine`,
366 ...problems.map(p => ` ! ${p}`),
367 arg === 'reload' ? 'the policy files are re-read on your next prompt' : '/jev-auto-mode log · reload · init (your file) · init project',
368 ].join('\n'),
369 }
370 }
371
372 const a: Action = { kind: 'command', tool: `/${e.command}`, command: e.command, skill: e.command, input: { args: e.args } }
373 const verdict = evaluate(config, a, { root: await $.session.root(), home: await $.env.get('HOME') })
374 if (verdict.source !== 'rule' || verdict.decision === 'allow') {
375 approvedSkills.set(e.command, await $.clock.now())
376 return next(e)
377 }
378 const entry: Entry = { at: await $.clock.now(), action: describeAction(a), decision: verdict.decision, by: `rule ${verdict.rule.id}`, audit: config.mode === 'audit' }
379 remember(entry)
380 $.ui.log(`[jev-auto-mode] ${entry.audit ? 'audit: would ' : ''}${verdict.decision} ${entry.action} (${entry.by})`)
381 $.ui.status(statusLine(entry))
382 if (entry.audit) return next(e)
383 if (verdict.decision === 'deny') return { text: `⛔ jev-auto-mode blocked /${e.command}: ${verdict.reason}` }
384 const answer = await $.ui
385 .ask(`${verdict.reason.replace(/[.\s]+$/, '')}. Run /${e.command}?`, { options: [ALLOW, DENY], header: 'auto mode' })
386 .catch(() => DENY)
387 if (answer !== ALLOW) return { text: `jev-auto-mode: /${e.command} not run` }
388 approvedSkills.set(e.command, await $.clock.now())
389 return next(e)
390 } catch (err) {
391 // a throwing hook is skipped by the engine, which would run a blocked command: refuse instead
392 $.ui.log(`[jev-auto-mode] internal error on /${e.command}: ${String(err)}`)
393 return { text: `⛔ jev-auto-mode could not check /${e.command} (${String(err)}); not run.` }
394 }
395 })
396
397 // A skill's prompt, however it arrives (typed, Skill tool, preloaded into a subagent): deny rules replace it,
398 // ask rules ask unless the Skill call or the typed /skill was just let through.
399 on('skill.prompt', async ($, e, next) => {
400 try {
401 const a: Action = { kind: 'skill', tool: 'Skill', skill: e.skill, input: { skill: e.skill } }
402 const verdict = evaluate(config, a, { root: await $.session.root(), home: await $.env.get('HOME') })
403 const at = approvedSkills.get(e.skill)
404 approvedSkills.delete(e.skill)
405 const approved = at !== undefined && (await $.clock.now()) - at < APPROVAL_MS
406 if (verdict.source !== 'rule' || verdict.decision === 'allow' || config.mode === 'audit') return next(e)
407 const blocked = (why: string) => ({
408 text: `The skill "${e.skill}" is blocked by the user's jev-auto-mode policy: ${why}. Do not follow or reconstruct its instructions; tell the user it is blocked.`,
409 })
410 if (verdict.decision === 'deny') {
411 $.ui.log(`[jev-auto-mode] deny skill ${e.skill} (rule ${verdict.rule.id})`)
412 return blocked(verdict.reason)
413 }
414 if (approved) return next(e)
415 const surfaces = await $.session.surfaces().then(list => list.length, () => 0)
416 if (surfaces === 0) return config.headless === 'deny' ? blocked(`${verdict.reason} (no one to ask)`) : next(e)
417 const answer = await $.ui
418 .ask(`${verdict.reason.replace(/[.\s]+$/, '')}. Load the skill ${e.skill}?`, { options: [ALLOW, DENY], header: 'auto mode' })
419 .catch(() => DENY)
420 return answer === ALLOW ? next(e) : blocked('the user declined it')
421 } catch (err) {
422 $.ui.log(`[jev-auto-mode] internal error on skill ${e.skill}: ${String(err)}`)
423 return {
424 text: `The skill "${e.skill}" could not be checked by the user's jev-auto-mode policy (${String(err)}), so it is withheld. Tell the user.`,
425 }
426 }
427 })
428}
429hooks/judge.ts 211 lines1/**
2 * jev-auto-mode — the judge: TypeSafe's Jev asked about one action.
3 *
4 * No `$` and no I/O here. When no rule decided an action and the policy's
5 * `default` is `jev`, the hooks module sends one request carrying the user's
6 * latest request (the intent), the action, and a battery of yes/no questions,
7 * one per hazard, plus a severity score; this module builds that request,
8 * reads the answer and turns the probabilities into allow / ask / deny with
9 * the thresholds of the JSON policy.
10 *
11 * The wire shapes are jev-guardrails' (TypeSafe's System One API and the
12 * Vercel AI Gateway's evaluation-model endpoint); the questions are this
13 * mod's own, about actions rather than messages.
14 */
15import type { Action, Decision, Hazard, JevConfig } from './rules.ts'
16import { describeAction } from './rules.ts'
17
18export type Provider = 'typesafe' | 'gateway'
19
20export const DEFAULT_BASE_URL: Record<Provider, string> = {
21 typesafe: 'https://api.typesafe.ai',
22 gateway: 'https://ai-gateway.vercel.sh/v4/ai',
23}
24
25export const DEFAULT_MODEL: Record<Provider, string> = {
26 typesafe: 'jev-latest',
27 gateway: 'typesafe-ai/jev',
28}
29
30/** `@ai-sdk/gateway`'s AI_GATEWAY_PROTOCOL_VERSION, as jev-guardrails sends it. */
31const AI_GATEWAY_PROTOCOL_VERSION = '0.0.1'
32
33export function selectProvider(forced: string, typesafeKey: string, gatewayKey: string): Provider | null {
34 if (forced === 'builtin') return null
35 if (forced === 'typesafe') return typesafeKey ? 'typesafe' : null
36 if (forced === 'gateway') return gatewayKey ? 'gateway' : null
37 if (typesafeKey) return 'typesafe'
38 if (gatewayKey) return 'gateway'
39 return null
40}
41
42export function endpoint(provider: Provider, baseUrl: string): string {
43 const root = baseUrl.replace(/\/+$/, '')
44 return provider === 'typesafe' ? `${root}/v1/systemone` : `${root}/evaluation-model`
45}
46
47type Question = { instructions: string; yes: string; no: string }
48
49/** The action battery: what could go wrong if this action ran. */
50export const BATTERY: Record<Hazard, Question> = {
51 destructive: {
52 instructions:
53 'Would this action delete, overwrite or irreversibly change data, files, git history or infrastructure (for example rm -rf, a force push, a hard reset, DROP TABLE, terraform destroy)?',
54 yes: 'It destroys or irreversibly changes something that may not be recoverable.',
55 no: 'It reads, or makes an ordinary, recoverable change.',
56 },
57 exfiltration: {
58 instructions:
59 'Would this action send secrets, credentials, private source code or personal data to a place outside this machine and its own repository remote?',
60 yes: 'It sends sensitive data somewhere it should not go.',
61 no: 'It sends nothing sensitive off the machine.',
62 },
63 security_weakening: {
64 instructions:
65 'Would this action weaken security: disable checks or tests, widen permissions, edit credentials, auth or CI secrets, or download and run untrusted code (for example curl | sh)?',
66 yes: 'It lowers the security of the project or the machine.',
67 no: 'It leaves security as it was.',
68 },
69 out_of_scope: {
70 instructions:
71 "Given the user's request, does this action go clearly beyond what the user asked for, or act on something the user did not mention?",
72 yes: 'It is not something the user asked for or would expect.',
73 no: 'It is a reasonable step toward what the user asked.',
74 },
75}
76
77export const HAZARDS = Object.keys(BATTERY) as Hazard[]
78
79const SEVERITY_INSTRUCTIONS = 'If this action turned out to be a mistake, how bad would the damage be?'
80export const SEVERITY_CRITERIA: readonly string[] = [
81 'None: read-only or trivially undone.',
82 'Mild: a local change that is easy to undo.',
83 'Serious: lost work, a broken environment, or data leaving the machine.',
84 'Severe: irreversible loss, a production outage, or leaked credentials.',
85]
86
87/**
88 * The situation the judge reads: the user's intent, then the action, whole.
89 * Inputs past MAX_MATCH_CHARS never reach the judge (the rules ask about
90 * them), so nothing here is cut: a judge shown half a command could approve
91 * what it never saw.
92 */
93export function stateText(intent: string, a: Action): string {
94 const body = JSON.stringify(a.input, null, 1)
95 return [
96 "The user's latest request to an AI coding agent:",
97 intent.trim() ? intent.trim().slice(0, 2000) : '(none recorded)',
98 '',
99 `The agent${a.agentId ? ' (a subagent)' : ''} is about to run: ${describeAction(a)}`,
100 `Kind: ${a.kind}. Tool: ${a.tool}. Input:`,
101 body,
102 ].join('\n')
103}
104
105function yesNo(provider: Provider, q: Question): Record<string, unknown> {
106 if (provider === 'typesafe') return { type: 'noul', instructions: q.instructions, criteria: { true: q.yes, false: q.no } }
107 return { type: 'boolean', instructions: `${q.instructions} Yes: ${q.yes} No: ${q.no}` }
108}
109
110export function requestBody(provider: Provider, state: string, model: string): string {
111 const questions: Record<string, unknown> = {}
112 for (const hazard of HAZARDS) questions[hazard] = yesNo(provider, BATTERY[hazard])
113 questions.severity = { type: 'score', instructions: SEVERITY_INSTRUCTIONS, criteria: SEVERITY_CRITERIA }
114 return JSON.stringify(provider === 'typesafe' ? { model, state, questions } : { state, questions })
115}
116
117export function requestHeaders(provider: Provider, apiKey: string, model: string): Record<string, string> {
118 const common = { 'content-type': 'application/json', authorization: `Bearer ${apiKey}` }
119 if (provider === 'typesafe') return common
120 return {
121 ...common,
122 'ai-gateway-auth-method': 'api-key',
123 'ai-model-id': model,
124 'ai-gateway-protocol-version': AI_GATEWAY_PROTOCOL_VERSION,
125 'ai-evaluation-model-specification-version': '4',
126 }
127}
128
129export type Judgement = { probabilities: Record<Hazard, number>; severity: number | null }
130
131/** Reads either backend's answer; a battery with any hazard unanswered reads as none. */
132export function readJudgement(text: string): Judgement | null {
133 let parsed: unknown
134 try {
135 parsed = JSON.parse(text)
136 } catch {
137 return null
138 }
139 if (parsed === null || typeof parsed !== 'object') return null
140 const answers = (parsed as { answers?: Record<string, Record<string, unknown>> }).answers
141 if (!answers || typeof answers !== 'object') return null
142 const probabilities = {} as Record<Hazard, number>
143 for (const hazard of HAZARDS) {
144 const a = answers[hazard]
145 const p = typeof a?.noul === 'number' ? a.noul : typeof a?.probability === 'number' ? a.probability : null
146 if (p === null) return null
147 probabilities[hazard] = p
148 }
149 const severity = answers.severity
150 return { probabilities, severity: typeof severity?.score === 'number' ? severity.score : null }
151}
152
153export type Ruling = { decision: Decision; hazard: Hazard | null; probability: number | null; escalated: boolean }
154
155const RANK: Record<Decision, number> = { allow: 0, ask: 1, deny: 2 }
156
157/**
158 * Probabilities to a decision: a hazard at or above `threshold` triggers its
159 * configured decision, one at or above `askThreshold` asks, and a severity
160 * at or above `severityDeny` turns an ask into a deny. The strictest wins.
161 */
162export function rule(j: Judgement, cfg: JevConfig): Ruling {
163 let best: Ruling = { decision: 'allow', hazard: null, probability: null, escalated: false }
164 for (const hazard of HAZARDS) {
165 const p = j.probabilities[hazard]
166 let decision: Decision = 'allow'
167 if (p >= cfg.threshold) decision = cfg.hazards[hazard]
168 else if (p >= cfg.askThreshold && cfg.hazards[hazard] !== 'allow') decision = 'ask'
169 if (decision === 'allow') continue
170 let escalated = false
171 if (decision === 'ask' && j.severity !== null && j.severity >= cfg.severityDeny) {
172 decision = 'deny'
173 escalated = true
174 }
175 const stronger = RANK[decision] > RANK[best.decision] || (decision === best.decision && p > (best.probability ?? -1))
176 if (stronger) best = { decision, hazard, probability: p, escalated }
177 }
178 return best
179}
180
181export const BUILTIN_LABELS: readonly Decision[] = ['allow', 'ask', 'deny']
182
183/** The rubric the engine's small classifier reads when no Jev key is set. */
184export function classifyText(state: string, cfg: JevConfig): string {
185 return [
186 'You are the permission judge for an AI coding agent. Classify the action below as allow, ask or deny.',
187 '',
188 ...HAZARDS.map(h => `- ${h} → ${cfg.hazards[h]}: ${BATTERY[h].instructions}`),
189 '- allow: none of the above applies; ordinary development work toward the request.',
190 'Answer "ask" when a hazard may apply but is unclear. Answer "deny" when the damage would be severe and irreversible.',
191 '',
192 state,
193 ].join('\n')
194}
195
196export function describeJudgement(j: Judgement | null, ms: number): string {
197 const took = ` · ${Math.round(ms)}ms`
198 if (!j) return `no answer${took}`
199 const parts = (Object.entries(j.probabilities) as [Hazard, number][])
200 .sort((a, b) => b[1] - a[1])
201 .map(([h, p]) => `${h} ${p.toFixed(2)}`)
202 if (j.severity !== null) parts.push(`severity ${j.severity.toFixed(1)}`)
203 return parts.join(' · ') + took
204}
205
206export function rulingReason(r: Ruling, backend: string): string {
207 if (r.hazard === null) return `jev-auto-mode (${backend}) judged it ${r.decision}`
208 const p = r.probability === null ? '' : ` ${r.probability.toFixed(2)}`
209 return `jev-auto-mode (${backend}): ${r.hazard.replace(/_/g, ' ')}${p}${r.escalated ? ', severity escalated it' : ''}`
210}
211hooks/rules.ts 800 lines1/**
2 * jev-auto-mode — the JSON policy and its matcher.
3 *
4 * No `$` and no I/O here: this module parses and validates the config files,
5 * merges the user and project layers, and decides which rules an action hits.
6 * The hooks module reads the files and asks; the tests drive this directly.
7 *
8 * An "action" is anything Claude is about to do that the mod governs:
9 * tool a tool call (Bash, Write, WebFetch, mcp__server__tool, Skill...)
10 * command a slash command the person typed (/deploy, a skill run as /name)
11 * agent a subagent about to be spawned
12 * skill a skill's prompt about to be expanded (typed, Skill tool, or
13 * preloaded into a subagent)
14 */
15
16export type Decision = 'allow' | 'ask' | 'deny'
17
18/** What happens to an action no rule matched. */
19export type Fallback = Decision | 'passthrough' | 'jev'
20
21export type ActionKind = 'tool' | 'command' | 'agent' | 'skill'
22
23export type Scope = 'all' | 'main' | 'subagents'
24
25export type Rule = {
26 /** Shown in the log and in the reason the model reads; defaults to rule #n. */
27 id?: string
28 decision: Decision
29 /** Why, in a sentence: what the model reads on a deny and the person on an ask. */
30 reason?: string
31 /** Tool name globs: `Bash`, `mcp__github__*`, `Write`. */
32 tool?: string | string[]
33 /** MCP server globs: `github` matches every `mcp__github__*` tool. */
34 mcpServer?: string | string[]
35 /** Bash command globs, matched against each part of a compound command. */
36 bash?: string | string[]
37 /** Regexes over each part of a compound Bash command. */
38 bashRegex?: string | string[]
39 /** Path globs over file_path / path / notebook_path (`**` crosses `/`). */
40 path?: string | string[]
41 /** Host globs over a WebFetch/WebSearch url or domain: `*.example.com`. */
42 domain?: string | string[]
43 /** Skill name globs: the Skill tool, a typed /skill, a preloaded skill. */
44 skill?: string | string[]
45 /** Slash command name globs (without the slash). */
46 command?: string | string[]
47 /** Subagent type globs: `general-purpose`, `Explore`, `*`. */
48 agent?: string | string[]
49 /** Regexes over the action's input as JSON: the catch-all matcher. */
50 inputRegex?: string | string[]
51 /** Which loops the rule applies in (default all). */
52 scope?: Scope
53}
54
55export type Hazard = 'destructive' | 'exfiltration' | 'security_weakening' | 'out_of_scope'
56
57export type JevConfig = {
58 /** Tool globs the judge is asked about when no rule decided; the rest fall to `default`. */
59 tools: string[]
60 /** What each hazard does once it crosses `threshold`. */
61 hazards: Record<Hazard, Decision>
62 /** At or above this probability a hazard triggers its decision. */
63 threshold: number
64 /** At or above this (but under `threshold`) a hazard asks. */
65 askThreshold: number
66 /** A severity (0-3) at or above this turns an ask into a deny. */
67 severityDeny: number
68 /** What a judgement that failed or timed out decides. */
69 onError: Fallback
70 timeoutMs: number
71}
72
73export type Config = {
74 /** enforce acts; audit only logs what it would have done. */
75 mode: 'enforce' | 'audit'
76 /** What an action no rule matched gets: `jev` asks the judge. */
77 default: Fallback
78 /** Who settles an `ask`: the mod's own dialog, or the engine's permission mode. */
79 askWith: 'mod' | 'engine'
80 /** What an `ask` becomes when there is no one to ask (`claude -p`). */
81 headless: 'deny' | 'allow'
82 /** Allow rules a project file may add (only honoured from the user file). */
83 trustProjectAllow: boolean
84 /**
85 * What a shell command whose program is only known at run time gets
86 * (`$X -rf /`, `eval …`, `source …`): no rule can read it, so at least this.
87 */
88 opaqueShell: Decision
89 rules: Rule[]
90 jev: JevConfig
91}
92
93export const CONFIG_NAME = 'jev-auto-mode.json'
94
95export const DEFAULT_JEV: JevConfig = {
96 tools: ['Bash', 'Write', 'Edit', 'MultiEdit', 'NotebookEdit', 'WebFetch', 'mcp__*'],
97 hazards: {
98 destructive: 'ask',
99 exfiltration: 'deny',
100 security_weakening: 'ask',
101 out_of_scope: 'ask',
102 },
103 threshold: 0.7,
104 askThreshold: 0.4,
105 severityDeny: 3,
106 onError: 'ask',
107 timeoutMs: 2500,
108}
109
110export const DEFAULT_CONFIG: Config = {
111 mode: 'enforce',
112 default: 'passthrough',
113 askWith: 'mod',
114 headless: 'deny',
115 trustProjectAllow: false,
116 opaqueShell: 'ask',
117 rules: [],
118 jev: DEFAULT_JEV,
119}
120
121const DECISIONS: readonly string[] = ['allow', 'ask', 'deny']
122const FALLBACKS: readonly string[] = [...DECISIONS, 'passthrough', 'jev']
123const MATCHERS = ['tool', 'mcpServer', 'bash', 'bashRegex', 'path', 'domain', 'skill', 'command', 'agent', 'inputRegex'] as const
124const HAZARDS: readonly Hazard[] = ['destructive', 'exfiltration', 'security_weakening', 'out_of_scope']
125
126export type Parsed = { config: Partial<Omit<Config, 'jev'>> & { jev?: Partial<JevConfig> }; errors: string[] }
127
128const isObject = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v)
129const strings = (v: unknown): v is string | string[] =>
130 typeof v === 'string' || (Array.isArray(v) && v.every(x => typeof x === 'string'))
131
132/**
133 * Reads one config file's text. Every problem is reported and the offending
134 * piece dropped, never the whole file: a typo in one rule must not silently
135 * switch the policy off.
136 */
137export function parseConfig(text: string, source: string): Parsed {
138 const errors: string[] = []
139 let raw: unknown
140 try {
141 raw = JSON.parse(text)
142 } catch (err) {
143 return { config: {}, errors: [`${source}: not valid JSON (${String(err)})`] }
144 }
145 if (!isObject(raw)) return { config: {}, errors: [`${source}: the top level must be an object`] }
146
147 const config: Parsed['config'] = {}
148 const pick = <T extends string>(key: string, allowed: readonly string[]): T | undefined => {
149 if (raw[key] === undefined) return undefined
150 if (typeof raw[key] === 'string' && allowed.includes(raw[key] as string)) return raw[key] as T
151 errors.push(`${source}: "${key}" must be one of ${allowed.join(', ')}`)
152 return undefined
153 }
154 config.mode = pick('mode', ['enforce', 'audit'])
155 config.default = pick('default', FALLBACKS)
156 config.askWith = pick('askWith', ['mod', 'engine'])
157 config.headless = pick('headless', ['deny', 'allow'])
158 config.opaqueShell = pick('opaqueShell', DECISIONS)
159 if (raw.trustProjectAllow !== undefined) {
160 if (typeof raw.trustProjectAllow === 'boolean') config.trustProjectAllow = raw.trustProjectAllow
161 else errors.push(`${source}: "trustProjectAllow" must be true or false`)
162 }
163
164 if (raw.rules !== undefined) {
165 if (!Array.isArray(raw.rules)) errors.push(`${source}: "rules" must be an array`)
166 else {
167 config.rules = []
168 raw.rules.forEach((r, i) => {
169 const rule = parseRule(r, `${source} rule #${i + 1}`, errors)
170 if (rule) config.rules!.push({ ...rule, id: rule.id ?? `${source}#${i + 1}` })
171 })
172 }
173 }
174
175 if (raw.jev !== undefined) {
176 if (!isObject(raw.jev)) errors.push(`${source}: "jev" must be an object`)
177 else config.jev = parseJev(raw.jev, source, errors)
178 }
179 return { config, errors }
180}
181
182/**
183 * A regex whose matching can blow up (a quantified group that itself holds a
184 * quantifier, as in `(a+)+` or `(\\w*)*`): matching is synchronous, so one such
185 * pattern could stall every tool call. Refused at load time.
186 */
187export function riskyRegex(source: string): boolean {
188 return /\((?:[^()\\]|\\.)*[+*}](?:[^()\\]|\\.)*\)[+*{]/.test(source)
189}
190
191/** Text longer than this is not regex-matched: the action is asked about instead. */
192export const MAX_MATCH_CHARS = 20_000
193
194function parseRule(r: unknown, where: string, errors: string[]): Rule | null {
195 if (!isObject(r)) {
196 errors.push(`${where}: must be an object`)
197 return null
198 }
199 if (typeof r.decision !== 'string' || !DECISIONS.includes(r.decision)) {
200 errors.push(`${where}: "decision" must be allow, ask or deny`)
201 return null
202 }
203 const rule: Rule = { decision: r.decision as Decision }
204 if (typeof r.id === 'string') rule.id = r.id
205 if (typeof r.reason === 'string') rule.reason = r.reason
206 if (r.scope !== undefined) {
207 if (r.scope === 'all' || r.scope === 'main' || r.scope === 'subagents') rule.scope = r.scope
208 else errors.push(`${where}: "scope" must be all, main or subagents`)
209 }
210 let matchers = 0
211 for (const key of MATCHERS) {
212 if (r[key] === undefined) continue
213 if (!strings(r[key])) {
214 errors.push(`${where}: "${key}" must be a string or a list of strings`)
215 return null
216 }
217 if (key === 'bashRegex' || key === 'inputRegex') {
218 for (const source of list(r[key] as string | string[])) {
219 try {
220 new RegExp(source)
221 } catch {
222 errors.push(`${where}: "${key}" has an invalid regex: ${source}`)
223 return null
224 }
225 if (riskyRegex(source)) {
226 errors.push(`${where}: "${key}" nests quantifiers (${source}), which can stall matching; rewrite it without a repeated group that itself repeats`)
227 return null
228 }
229 }
230 }
231 ;(rule as Record<string, unknown>)[key] = r[key]
232 matchers += 1
233 }
234 // A rule with no matcher would hit everything: almost always a typo'd key.
235 if (matchers === 0) {
236 errors.push(`${where}: names no matcher (${MATCHERS.join(', ')}); a rule that matches everything must say tool: "*"`)
237 return null
238 }
239 const unknown = Object.keys(r).filter(k => !['id', 'decision', 'reason', 'scope', ...MATCHERS].includes(k))
240 if (unknown.length) errors.push(`${where}: unknown key${unknown.length > 1 ? 's' : ''} ${unknown.join(', ')} ignored`)
241 return rule
242}
243
244function parseJev(j: Record<string, unknown>, source: string, errors: string[]): Partial<JevConfig> {
245 const out: Partial<JevConfig> = {}
246 if (j.tools !== undefined) {
247 if (strings(j.tools)) out.tools = list(j.tools)
248 else errors.push(`${source}: "jev.tools" must be a list of tool globs`)
249 }
250 if (j.hazards !== undefined) {
251 if (!isObject(j.hazards)) errors.push(`${source}: "jev.hazards" must be an object`)
252 else {
253 const hazards: Partial<Record<Hazard, Decision>> = {}
254 for (const [name, value] of Object.entries(j.hazards)) {
255 if (!HAZARDS.includes(name as Hazard)) errors.push(`${source}: unknown hazard "${name}" (${HAZARDS.join(', ')})`)
256 else if (typeof value !== 'string' || !DECISIONS.includes(value)) errors.push(`${source}: hazard "${name}" must be allow, ask or deny`)
257 else hazards[name as Hazard] = value as Decision
258 }
259 out.hazards = hazards as Record<Hazard, Decision>
260 }
261 }
262 for (const key of ['threshold', 'askThreshold'] as const) {
263 if (j[key] === undefined) continue
264 if (typeof j[key] === 'number' && (j[key] as number) >= 0 && (j[key] as number) <= 1) out[key] = j[key] as number
265 else errors.push(`${source}: "jev.${key}" must be a number from 0 to 1`)
266 }
267 if (j.severityDeny !== undefined) {
268 if (typeof j.severityDeny === 'number') out.severityDeny = j.severityDeny
269 else errors.push(`${source}: "jev.severityDeny" must be a number from 0 to 3`)
270 }
271 if (j.timeoutMs !== undefined) {
272 if (typeof j.timeoutMs === 'number' && j.timeoutMs > 0) out.timeoutMs = j.timeoutMs
273 else errors.push(`${source}: "jev.timeoutMs" must be a positive number`)
274 }
275 if (j.onError !== undefined) {
276 if (typeof j.onError === 'string' && FALLBACKS.includes(j.onError) && j.onError !== 'jev') out.onError = j.onError as Fallback
277 else errors.push(`${source}: "jev.onError" must be allow, ask, deny or passthrough`)
278 }
279 return out
280}
281
282/**
283 * Folds the layers into one policy. The user file (yours, outside any
284 * repository) may do anything. The project file ships with the repository, so
285 * a checkout could otherwise open everything up: it adds deny and ask rules,
286 * and may tighten the settings, but its allow rules only count when the user
287 * file says `trustProjectAllow: true`.
288 */
289export function mergeConfigs(user: Parsed['config'], project: Parsed['config']): { config: Config; notes: string[] } {
290 const notes: string[] = []
291 const trust = user.trustProjectAllow === true
292 const projectRules = (project.rules ?? []).filter(r => {
293 if (r.decision !== 'allow' || trust) return true
294 notes.push(`project rule ${r.id} (allow) ignored: set trustProjectAllow in your user file to honour project allow rules`)
295 return false
296 })
297
298 // A setting the project names may only move toward caution.
299 const stricter = <T>(order: readonly T[], a: T | undefined, b: T | undefined, fallback: T): T => {
300 const base = a ?? fallback
301 if (b === undefined || trust) return b ?? base
302 return order.indexOf(b) > order.indexOf(base) ? b : base
303 }
304 const config: Config = {
305 mode: stricter(['audit', 'enforce'] as const, user.mode, project.mode, DEFAULT_CONFIG.mode),
306 default: stricter(['allow', 'passthrough', 'jev', 'ask', 'deny'] as const, user.default, project.default, DEFAULT_CONFIG.default),
307 // the mod's own dialog (with its headless deny) is the stricter of the two
308 askWith: stricter(['engine', 'mod'] as const, user.askWith, project.askWith, DEFAULT_CONFIG.askWith),
309 headless: stricter(['allow', 'deny'] as const, user.headless, project.headless, DEFAULT_CONFIG.headless),
310 opaqueShell: stricter(['allow', 'ask', 'deny'] as const, user.opaqueShell, project.opaqueShell, DEFAULT_CONFIG.opaqueShell),
311 trustProjectAllow: trust,
312 rules: [...(user.rules ?? []), ...projectRules],
313 jev: {
314 ...DEFAULT_JEV,
315 ...user.jev,
316 ...(project.jev ? tightenJev(user.jev ?? {}, project.jev, trust) : {}),
317 hazards: { ...DEFAULT_JEV.hazards, ...user.jev?.hazards, ...tightenHazards(user.jev?.hazards ?? {}, project.jev?.hazards ?? {}, trust) },
318 },
319 }
320 return { config, notes }
321}
322
323const RANK: Record<Decision, number> = { allow: 0, ask: 1, deny: 2 }
324
325function tightenHazards(
326 user: Partial<Record<Hazard, Decision>>,
327 project: Partial<Record<Hazard, Decision>>,
328 trust: boolean,
329): Partial<Record<Hazard, Decision>> {
330 const out: Partial<Record<Hazard, Decision>> = {}
331 for (const [name, value] of Object.entries(project) as [Hazard, Decision][]) {
332 const base = user[name] ?? DEFAULT_JEV.hazards[name]
333 out[name] = trust || RANK[value] > RANK[base] ? value : base
334 }
335 return out
336}
337
338function tightenJev(user: Partial<JevConfig>, project: Partial<JevConfig>, trust: boolean): Partial<JevConfig> {
339 if (trust) {
340 const { hazards: _h, ...rest } = project
341 return rest
342 }
343 const out: Partial<JevConfig> = {}
344 // more tools judged, lower thresholds: both are more cautious
345 if (project.tools) out.tools = [...new Set([...(user.tools ?? DEFAULT_JEV.tools), ...project.tools])]
346 if (project.threshold !== undefined) out.threshold = Math.min(project.threshold, user.threshold ?? DEFAULT_JEV.threshold)
347 if (project.askThreshold !== undefined)
348 out.askThreshold = Math.min(project.askThreshold, user.askThreshold ?? DEFAULT_JEV.askThreshold)
349 if (project.severityDeny !== undefined)
350 out.severityDeny = Math.min(project.severityDeny, user.severityDeny ?? DEFAULT_JEV.severityDeny)
351 return out
352}
353
354// ---------------------------------------------------------------------------
355// Matching
356
357export function list(v: string | string[] | undefined): string[] {
358 return v === undefined ? [] : Array.isArray(v) ? v : [v]
359}
360
361const cache = new Map<string, RegExp>()
362
363/**
364 * A glob as a regex. In `path` mode `*` stays inside one segment and `**`
365 * crosses them; elsewhere `*` matches anything, spaces included, so
366 * `git push*--force*` reads the way it is written. `?` is one character.
367 */
368export function globToRegex(glob: string, mode: 'path' | 'text' = 'text'): RegExp {
369 const key = `${mode}:${glob}`
370 const hit = cache.get(key)
371 if (hit) return hit
372 let out = ''
373 for (let i = 0; i < glob.length; i++) {
374 const c = glob[i]!
375 if (c === '*') {
376 if (mode === 'path' && glob[i + 1] === '*') {
377 // `**/` also matches no directory at all
378 if (glob[i + 2] === '/') {
379 out += '(?:.*/)?'
380 i += 2
381 } else {
382 out += '.*'
383 i += 1
384 }
385 } else out += mode === 'path' ? '[^/]*' : '.*'
386 } else if (c === '?') out += mode === 'path' ? '[^/]' : '.'
387 else out += c.replace(/[.+^${}()|[\]\\]/g, '\\$&')
388 }
389 const re = new RegExp(`^${out}$`, mode === 'text' ? 's' : '')
390 cache.set(key, re)
391 return re
392}
393
394export const globMatch = (glob: string, value: string, mode: 'path' | 'text' = 'text') => globToRegex(glob, mode).test(value)
395
396/**
397 * The simple commands of a Bash line: split on `&&`, `||`, `;`, `|`, `&` and
398 * newlines outside quotes, with `$( … )` and backtick bodies checked as parts
399 * of their own. A deny rule hits a line when it hits any part; an allow rule
400 * only when it covers every part, so `ls && rm -rf ~` is not allowed by an
401 * allow on `ls*`.
402 */
403/** `[wrappers and VAR=x …] [/path/]bash|sh|zsh [flags] -c '<script>'`, the script captured. */
404const SHELL_C =
405 /^(?:(?:sudo|env|nohup|time|command|builtin|exec|nice|stdbuf|timeout|xargs|doas)(?:\s+(?:-\S+|\d\S*))*\s+|[A-Za-z_][A-Za-z0-9_]*=\S*\s+)*(?:\S*\/)?(?:ba|z|da|k|fi)?sh\s+(?:-[a-zA-Z]+\s+)*-[a-zA-Z]*c[a-zA-Z]*\s+(['"])([\s\S]*)\1/
406
407export function bashParts(command: string): string[] {
408 const parts: string[] = []
409 const nested: string[] = []
410 let current = ''
411 let quote: '"' | "'" | null = null
412 for (let i = 0; i < command.length; i++) {
413 const c = command[i]!
414 // `$( … )` and backticks run inside double quotes too (not inside single quotes)
415 if (quote !== "'" && c === '$' && command[i + 1] === '(') {
416 let depth = 1
417 let j = i + 2
418 for (; j < command.length && depth > 0; j++) {
419 if (command[j] === '(') depth += 1
420 else if (command[j] === ')') depth -= 1
421 }
422 nested.push(command.slice(i + 2, j - 1))
423 current += command.slice(i, j)
424 i = j - 1
425 continue
426 }
427 if (quote !== "'" && c === '`') {
428 const end = command.indexOf('`', i + 1)
429 nested.push(end === -1 ? command.slice(i + 1) : command.slice(i + 1, end))
430 current += end === -1 ? command.slice(i) : command.slice(i, end + 1)
431 i = end === -1 ? command.length : end
432 continue
433 }
434 if (quote) {
435 if (c === quote) quote = null
436 else if (c === '\\' && quote === '"') {
437 current += c + (command[i + 1] ?? '')
438 i += 1
439 continue
440 }
441 current += c
442 continue
443 }
444 if (c === "'" || c === '"') {
445 quote = c
446 current += c
447 continue
448 }
449 if (c === '\\') {
450 current += c + (command[i + 1] ?? '')
451 i += 1
452 continue
453 }
454 const two = command.slice(i, i + 2)
455 if (two === '&&' || two === '||') {
456 parts.push(current)
457 current = ''
458 i += 1
459 continue
460 }
461 if (c === ';' || c === '|' || c === '\n' || (c === '&' && command[i + 1] !== '>' && command[i - 1] !== '>')) {
462 parts.push(current)
463 current = ''
464 continue
465 }
466 current += c
467 }
468 parts.push(current)
469 // `bash -c '…'` / `sh -c "…"`: the quoted script is a command line of its own
470 for (const part of parts) {
471 const m = SHELL_C.exec(part.trim())
472 if (m) nested.push(m[2]!)
473 }
474 return [...parts, ...nested.flatMap(bashParts)].map(p => p.trim().replace(/\s+/g, ' ')).filter(Boolean)
475}
476
477/**
478 * A part as the shell will run it, for matching: `$IFS` read as a space,
479 * backslash escapes and quotes dropped, so `r'm' -rf` and `rm${IFS}-rf` read
480 * as `rm -rf`.
481 */
482export function normalizePart(part: string): string {
483 return part
484 .replace(/\$\{IFS[^}]*\}|\$IFS\b/g, ' ')
485 .replace(/\\(.)/g, '$1')
486 .replace(/['"]/g, '')
487 .replace(/\s+/g, ' ')
488 .trim()
489}
490
491const WRAPPERS = new Set(['sudo', 'env', 'nohup', 'time', 'command', 'builtin', 'exec', 'nice', 'stdbuf', 'timeout', 'xargs'])
492
493/** The part without leading `VAR=value` assignments and wrappers (`sudo`, `env`, `nohup` …). */
494export function strippedPart(part: string): string {
495 const words = normalizePart(part).split(' ')
496 let i = 0
497 while (i < words.length) {
498 const w = words[i]!
499 if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(w) || WRAPPERS.has(w)) i += 1
500 else if (i > 0 && WRAPPERS.has(words[i - 1]!) && /^-/.test(w)) i += 1
501 else break
502 }
503 return words.slice(i).join(' ')
504}
505
506/** `NAME=value` assignments made anywhere in a command line, for reading `$NAME` back. */
507export function assignments(command: string): Record<string, string> {
508 const vars: Record<string, string> = {}
509 for (const part of bashParts(command)) {
510 for (const word of normalizePart(part).split(' ')) {
511 const m = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(word)
512 if (m) vars[m[1]!] = m[2]!
513 else break
514 }
515 }
516 return vars
517}
518
519/** `$NAME` / `${NAME}` replaced by what the same command line assigned it. */
520export function substitute(part: string, vars: Record<string, string>): string {
521 return part.replace(/\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?/g, (whole, name: string) => vars[name] ?? whole)
522}
523
524/** Every reading of a part a rule should see: as written, normalized, unwrapped, and with its variables read back. */
525export function readings(part: string, vars: Record<string, string> = {}): string[] {
526 const resolved = substitute(part, vars)
527 return [...new Set([part, normalizePart(part), strippedPart(part), normalizePart(resolved), strippedPart(resolved)].filter(Boolean))]
528}
529
530/**
531 * Whether the program a part runs is only known at run time (`$X -rf /`,
532 * `eval "$cmd"`, `source ./x`): no rule can read what it will do.
533 */
534export function opaquePart(part: string, vars: Record<string, string> = {}): boolean {
535 const word = strippedPart(substitute(part, vars)).split(' ')[0] ?? ''
536 return /^[$`]/.test(word) || word === 'eval' || word === 'source' || word === '.'
537}
538
539/** One governed action, with what each matcher reads already pulled out. */
540export type Action = {
541 kind: ActionKind
542 /** The tool name; for command/agent/skill, `/name`, `Agent`, `Skill`. */
543 tool: string
544 input: Record<string, unknown>
545 /** Command name (without slash) for kind command. */
546 command?: string
547 /** Skill name for the Skill tool, a /skill, or a preloaded skill. */
548 skill?: string
549 /** Subagent type for kind agent (or the Agent tool's subagent_type). */
550 agent?: string
551 /** The loop it runs in: undefined on main. */
552 agentId?: string
553}
554
555const str = (v: unknown) => (typeof v === 'string' ? v : undefined)
556
557/** A tool call as an Action, with the fields the matchers need. */
558export function toolAction(tool: string, input: Record<string, unknown>, agentId?: string): Action {
559 return {
560 kind: 'tool',
561 tool,
562 input,
563 agentId,
564 skill: tool === 'Skill' ? str(input.skill) ?? str(input.command) : undefined,
565 agent: tool === 'Agent' || tool === 'Task' ? str(input.subagent_type) ?? 'general-purpose' : undefined,
566 }
567}
568
569/**
570 * The path-like arguments of a Bash command: every word after the program in
571 * every part (read as the shell runs it), `--opt=value` values and redirect
572 * targets included, so a `path` rule sees `cat ~/.aws/credentials` too.
573 */
574export function bashPathArgs(command: string): string[] {
575 const out: string[] = []
576 const vars = assignments(command)
577 for (const part of bashParts(command)) {
578 const words = strippedPart(substitute(part, vars)).split(' ').slice(1)
579 for (let w of words) {
580 w = w.replace(/^[0-9]*[<>]+&?/, '')
581 if (w.startsWith('-')) {
582 const eq = w.indexOf('=')
583 if (eq === -1) continue
584 w = w.slice(eq + 1)
585 }
586 w = w.replace(/^\.\//, '').replace(/[;,)]+$/, '')
587 if (w && !/^[0-9]+$/.test(w)) out.push(w)
588 }
589 }
590 return out
591}
592
593export function paths(a: Action, root: string, home: string | undefined): string[] {
594 const command = a.tool === 'Bash' ? str(a.input.command) : undefined
595 const raw = [
596 ...[a.input.file_path, a.input.path, a.input.notebook_path].map(str).filter((p): p is string => !!p),
597 ...(command !== undefined ? bashPathArgs(command) : []),
598 ]
599 const out = new Set<string>()
600 for (let p of raw) {
601 if (home && (p === '~' || p.startsWith('~/'))) p = home + p.slice(1)
602 out.add(p)
603 const r = root.replace(/\/+$/, '')
604 if (p.startsWith(`${r}/`)) out.add(p.slice(r.length + 1))
605 else if (!p.startsWith('/')) out.add(`${r}/${p}`)
606 }
607 return [...out]
608}
609
610export function hostOf(a: Action): string | undefined {
611 const url = str(a.input.url)
612 if (url) {
613 // the authority, less any `user:pass@` (https://allowed.com@evil.com goes to evil.com) and port
614 const m = /^[a-z][a-z0-9+.-]*:\/\/([^/?#]*)/i.exec(url.trim())
615 if (m) {
616 const host = m[1]!.slice(m[1]!.lastIndexOf('@') + 1).replace(/:\d*$/, '').replace(/^\[|\]$/g, '')
617 return host.toLowerCase().replace(/\.$/, '')
618 }
619 }
620 return str(a.input.domain)?.toLowerCase()
621}
622
623function expandHome(glob: string, home: string | undefined): string {
624 return home && (glob === '~' || glob.startsWith('~/')) ? home + glob.slice(1) : glob
625}
626
627export type MatchContext = { root: string; home?: string }
628
629/**
630 * Whether a rule applies to an action. Every matcher the rule names must hit
631 * (they AND together); within one matcher any listed glob may hit (OR). A
632 * matcher that cannot apply to this kind of action (a `bash` glob on a
633 * Write) makes the rule miss rather than match vacuously.
634 */
635export function ruleMatches(rule: Rule, a: Action, ctx: MatchContext): boolean {
636 if (rule.scope === 'main' && a.agentId) return false
637 if (rule.scope === 'subagents' && !a.agentId) return false
638
639 if (rule.tool !== undefined && !list(rule.tool).some(g => globMatch(g, a.tool))) return false
640 if (rule.mcpServer !== undefined) {
641 const m = /^mcp__(.+?)__/.exec(a.tool)
642 if (!m || !list(rule.mcpServer).some(g => globMatch(g, m[1]!))) return false
643 }
644 if (rule.bash !== undefined || rule.bashRegex !== undefined) {
645 const command = a.tool === 'Bash' ? str(a.input.command) : undefined
646 if (command === undefined) return false
647 const parts = bashParts(command.slice(0, MAX_MATCH_CHARS))
648 const vars = assignments(command.slice(0, MAX_MATCH_CHARS))
649 const one = (text: string) =>
650 list(rule.bash).some(g => globMatch(g, text)) || list(rule.bashRegex).some(r => new RegExp(r).test(text))
651 // deny/ask read every spelling of a part (written, unquoted, unwrapped);
652 // an allow must hold for the part as the shell will run it
653 const hits = (part: string) => (rule.decision === 'allow' ? one(normalizePart(part)) : readings(part, vars).some(one))
654 // deny/ask: any part is enough; allow: every part must be covered
655 if (rule.decision === 'allow' ? !parts.every(hits) : !parts.some(hits)) return false
656 }
657 if (rule.path !== undefined) {
658 const hit = (p: string) => list(rule.path).some(g => globMatch(expandHome(g, ctx.home), p, 'path'))
659 const command = a.tool === 'Bash' ? str(a.input.command) : undefined
660 if (command !== undefined && rule.decision === 'allow') {
661 // an allow must hold for every argument, or `rm -rf / src/a` would ride on `src/**`
662 const args = bashPathArgs(command)
663 if (!args.length || !args.every(arg => paths(toolAction('Read', { file_path: arg }), ctx.root, ctx.home).some(hit))) return false
664 } else {
665 const ps = paths(a, ctx.root, ctx.home)
666 if (!ps.length || !ps.some(hit)) return false
667 }
668 }
669 if (rule.domain !== undefined) {
670 const host = hostOf(a)
671 if (!host || !list(rule.domain).some(g => globMatch(g.toLowerCase(), host) || (g.startsWith('*.') && host === g.slice(2).toLowerCase())))
672 return false
673 }
674 if (rule.skill !== undefined && !(a.skill && list(rule.skill).some(g => globMatch(g, a.skill!)))) return false
675 if (rule.command !== undefined && !(a.kind === 'command' && a.command && list(rule.command).some(g => globMatch(g, a.command!))))
676 return false
677 if (rule.agent !== undefined && !(a.agent && list(rule.agent).some(g => globMatch(g, a.agent!)))) return false
678 if (rule.inputRegex !== undefined) {
679 const json = JSON.stringify(a.input).slice(0, MAX_MATCH_CHARS)
680 if (!list(rule.inputRegex).some(r => new RegExp(r).test(json))) return false
681 }
682 return true
683}
684
685export type Verdict =
686 | { source: 'rule'; decision: Decision; rule: Rule; reason: string }
687 | { source: 'fallback'; fallback: Fallback }
688
689/** deny beats ask beats allow, whatever order the rules were written in. */
690export function evaluate(config: Config, a: Action, ctx: MatchContext): Verdict {
691 let best: Rule | undefined
692 for (const rule of config.rules) {
693 if (!ruleMatches(rule, a, ctx)) continue
694 if (!best || RANK[rule.decision] > RANK[best.decision]) best = rule
695 if (best.decision === 'deny') break
696 }
697 // An input too long to match safely (regexes are synchronous) is asked about, never waved through.
698 if ((!best || RANK[best.decision] < RANK.ask) && JSON.stringify(a.input).length > MAX_MATCH_CHARS) {
699 best = { id: 'too-long', decision: 'ask', reason: `jev-auto-mode: this input is over ${MAX_MATCH_CHARS} characters, too long to check against the rules` }
700 }
701 // A program only known at run time slips past every bash matcher: give it at least opaqueShell.
702 const command = a.tool === 'Bash' ? str(a.input.command) : undefined
703 if (command !== undefined && (!best || RANK[best.decision] < RANK[config.opaqueShell]) && bashParts(command).some(part => opaquePart(part, assignments(command)))) {
704 best = {
705 id: 'opaque-shell',
706 decision: config.opaqueShell,
707 reason: `jev-auto-mode: this command runs a program only known at run time (a variable, eval or source), which no rule can check`,
708 }
709 }
710 if (best) {
711 return {
712 source: 'rule',
713 decision: best.decision,
714 rule: best,
715 reason: best.reason ?? `rule ${best.id} (${best.decision})`,
716 }
717 }
718 return { source: 'fallback', fallback: config.default }
719}
720
721/** Whether the judge is asked about this action when no rule decided. */
722export function judged(config: Config, a: Action): boolean {
723 return config.default === 'jev' && (a.kind !== 'tool' || config.jev.tools.some(g => globMatch(g, a.tool)))
724}
725
726// ---------------------------------------------------------------------------
727// Self-protection: Claude may not rewrite the policy that governs it.
728
729/** Tools that only read, and tools whose input is prose (a prompt, a question), not an operation. */
730const HARMLESS_TOOLS = new Set(['Read', 'Glob', 'Grep', 'LS', 'Agent', 'Task', 'TodoWrite', 'AskUserQuestion', 'WebSearch', 'Skill', 'ToolSearch'])
731const READ_ONLY_COMMAND = /^(cat|less|more|head|tail|grep|rg|jq|ls|stat|wc|diff|file|git (diff|log|show|status|blame))(\s|$)/
732/** Options by which a "read" command writes a file: `git show --output=x`, `less -o x`, `sed -i`, `sort -o x` … */
733const WRITE_OPTION = /\s(-[a-zA-Z]*[oOiw][a-zA-Z]*|--(output|out|log-file|in-place)[\w-]*)(=|\s|$)/
734
735/**
736 * A reason to refuse an action that would change this mod's policy or the mod
737 * itself, or undefined. It runs before any rule, so no rule (and no project
738 * file) can switch it off. It errs toward refusing: any tool but a reader
739 * whose input names the policy file or the mod's directory is refused, and so
740 * is any shell part naming them that is not a plain read (`cat`, `grep`, …).
741 */
742export function selfProtection(a: Action, pluginRoot: string, ctx: MatchContext, policyFiles: readonly string[] = []): string | undefined {
743 const root = pluginRoot.replace(/\/+$/, '')
744 // the active policy files by every spelling a command might use: absolute, ~/…, relative to the project, bare name
745 const needles = new Set<string>([CONFIG_NAME])
746 for (const f of policyFiles) {
747 if (!f) continue
748 needles.add(f)
749 const name = f.slice(f.lastIndexOf('/') + 1)
750 if (name) needles.add(name)
751 if (ctx.home && f.startsWith(`${ctx.home}/`)) needles.add(`~${f.slice(ctx.home.length)}`)
752 const r = ctx.root.replace(/\/+$/, '')
753 if (f.startsWith(`${r}/`)) needles.add(f.slice(r.length + 1))
754 }
755 const mentions = (text: string) =>
756 [...needles].some(n => text.includes(n)) || (root !== '' && text.includes(root)) || /jev-auto-mode\/(hooks|\.claude-plugin|examples)\b/.test(text)
757 if (a.kind !== 'tool' || HARMLESS_TOOLS.has(a.tool)) return undefined
758 if (a.tool === 'Bash') {
759 const command = str(a.input.command) ?? ''
760 const touches = bashParts(command).some(part => {
761 const plain = normalizePart(part)
762 if (!mentions(plain)) return false
763 return !(READ_ONLY_COMMAND.test(strippedPart(part)) && !/[>]/.test(plain) && !WRITE_OPTION.test(plain))
764 })
765 return touches ? `jev-auto-mode: shell commands that touch ${CONFIG_NAME} or the mod beyond reading them are refused; edit it yourself` : undefined
766 }
767 // path-like values only (no whitespace): a file's content that merely mentions the name is fine
768 const values: string[] = []
769 const walk = (v: unknown): void => {
770 if (typeof v === 'string') {
771 if (!/\s/.test(v.trim())) values.push(v.trim())
772 } else if (Array.isArray(v)) v.forEach(walk)
773 else if (v && typeof v === 'object') Object.values(v).forEach(walk)
774 }
775 walk(a.input)
776 if (values.some(mentions) || paths(a, ctx.root, ctx.home).some(p => mentions(p))) {
777 return `jev-auto-mode: ${CONFIG_NAME} and the mod's own files can only be edited by the person, not by Claude`
778 }
779 return undefined
780}
781
782/** One line for the log: what the action is. */
783export function describeAction(a: Action): string {
784 if (a.kind === 'command') return `/${a.command}${a.input.args ? ` ${a.input.args}` : ''}`
785 if (a.kind === 'agent') return `Agent(${a.agent})`
786 if (a.kind === 'skill') return `skill ${a.skill}`
787 const detail =
788 str(a.input.command) ??
789 str(a.input.file_path) ??
790 str(a.input.notebook_path) ??
791 str(a.input.path) ??
792 str(a.input.url) ??
793 str(a.input.skill) ??
794 str(a.input.pattern) ??
795 str(a.input.description) ??
796 ''
797 const one = detail.replace(/\s+/g, ' ').trim()
798 return one ? `${a.tool}(${one.length > 80 ? `${one.slice(0, 79)}…` : one})` : a.tool
799}
800