SLOPSHOPPER

jev-auto-mode

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…

newguardcommandtoaststatusprompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · jev-auto-mode
› fix the failing auth test and add an audit log call ╭────────────────────────────────────────────╮ │ jev-auto-mode │ ● jev-auto-mode: [jev-auto-mode] could not reload the policy, keeping t│ jev-auto-mode: policy reload failed; the │ ⏺ Read(src/auth.ts) │ previous policy stays in force │ ⎿ Read 6 lines ╰────────────────────────────────────────────╯ ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /jev-auto-mode ⎿ jev-auto-mode: jev-auto-mode: enforce · default passthrough · 0 rules · ask via mod · judge built-in classifier · no policy f ⎿ jev-auto-mode: decisions: 0 allowed · 0 asked · 0 denied · 9 left to the engine ⎿ jev-auto-mode: /jev-auto-mode log · reload · init (your file) · init project ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ jev-auto-mode: auto · 0✓ 0? 0✗
README

jev-auto-mode

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).

How a decision is made

For every tool call, in this order:

  1. Self-protection. Claude may not change the policy files in force (yours, including a custom configFile path, and the project's) or the mod's own files. The check errs toward refusing:
  2. Any tool other than a reader whose input names one of them in a path is refused. That covers Write/Edit and MCP file tools, but not a file's content that merely mentions the name.
  3. Any shell part naming them that isn't a plain read (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.

  1. Rules. Every rule that matches is collected, and deny beats ask beats allow, whatever order they were written in.
  2. Compound shell commands are split (&&, ||, ;, |, &, newlines, $( … ), backticks, bash -c '…').
  3. Each part is matched as written, and also as the shell will run it: quotes and escapes dropped (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 /).
  4. A deny or ask rule hits when any part matches. An allow rule only applies when it covers every part, so an allow on ls* does not let ls && rm -rf ~ through.
  5. A part whose program is only known at run time ($TOOL …, eval, source) can't be read by any rule, so it gets at least opaqueShell (ask by default).
  6. Default. When nothing matched, default decides: allow, ask, deny, passthrough (the engine's normal permission flow), or jev (the judge, below).
  7. The verdict.
  8. deny: the call is refused, and the model reads the rule's reason.
  9. allow: the call runs without the engine's prompt. A deny from your settings (permissions.deny) or from plan mode still stands.
  10. 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.

The judge (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:

HazardQuestion (short)Default decision
destructivedeletes, overwrites or irreversibly changes data, history or infrastructure?ask
exfiltrationsends secrets, credentials, private code or personal data off the machine?deny
security_weakeningdisables checks, widens permissions, touches credentials/CI secrets, runs untrusted code?ask
out_of_scopegoes 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:

BackendEndpointProbability
typesafePOST api.typesafe.ai/v1/systemone, jev-latestnoul, calibrated
gatewayPOST ai-gateway.vercel.sh/v4/ai/evaluation-model, typesafe-ai/jevprobability
built-inthe 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.

The policy file

Two files are merged:

FileWho writes itWhat it may do
~/.claude/jev-auto-mode.json (or the configFile option)youeverything
<project>/.claude/jev-auto-mode.jsonthe repositorytighten 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
  }
}

Rule matchers

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.

MatcherMatchesNotes
tooltool nameBash, Write, mcp__github__*, Agent, Skill, *
mcpServerthe server of an mcp__server__toolgithub
basheach part of a Bash command, as a glob* matches anything, spaces included: git push*
bashRegexeach part of a Bash command, as a regex
pathfile_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
domainthe host of a url*.example.com also matches example.com
skillthe Skill tool's skill, a typed /skill, a preloaded skill
commanda slash command you type, without the slash
agentthe Agent tool's subagent_type
inputRegexthe action's whole input as JSONthe catch-all

A rule with no matcher is rejected, since it would match everything. If that's what you want, write "tool": "*".

Commands

  • /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

Options

  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".

Install

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.

Limits

  • It governs Claude, not you: a command you run yourself in a terminal is not seen.
  • Shell matching is static. It reads what can be read from the command line (quoting, $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.
  • Self-protection covers the policy files and the mod's directory. Someone who can edit your ~/.claude/settings.json can still turn the mod off; keep a permissions.deny on that file too if Claude should never touch it.
  • The judge sees your latest prompt, not the whole conversation.
  • Built-in classifier judgements carry no probability, so the thresholds do not apply to them.

Privacy

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.

Tests

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

Source 3 files
hooks/jev-auto-mode.ts 429 lines
1/**
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}
429
hooks/judge.ts 211 lines
1/**
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}
211
hooks/rules.ts 800 lines
1/**
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