Judge pending permission requests with Jev before the permission dialog

<h1>auto-mode</h1> <a href="https://www.npmjs.com/package/auto-mode"><img src="https://img.shields.io/npm/v/auto-mode" alt="npm version"></a> <a href="./LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT license"></a>
auto-mode judges the tool calls that Claude Code would prompt for. It is a core library and a Claude Code mod. The mod returns allow, deny, or no verdict before the permission dialog appears. Local rules settle read-only actions and build cleanup; Jev evaluates the remaining actions against a written policy.
The Jev request includes the policy, your configured environment and permissions, the complete proposed action, and the last direct user message. It excludes the rest of the session. Configure standing permissions upfront; earlier conversational grants and restrictions are unavailable to the classifier.
npm i -g auto-mode
claude --plugin-dir "$(npm root -g)/auto-mode/mods/auto-mode"
The package ships the mod in mods/auto-mode. The mod runs the auto-mode executable on PATH; its command option takes another executable. Claude permission mod covers the mod, its time limits, and a trial session.
Create the classifier configuration:
{
"classifiers": { "jev": { "apiKeyEnv": "TYPESAFE_API_KEY" } },
"decision": { "classifier": "jev" }
}
Save it as ~/.config/auto-mode/config.json and export your TypeSafe API key, or set the entry's apiKeyCommand to a command that prints it. Configuration covers the registries and credentials.
Jev imports explicit entries from autoMode.environment, autoMode.allow, autoMode.soft_deny, and autoMode.hard_deny in your user Claude settings. It imports no credential values or shell permission patterns. $defaults refers to auto-mode's shipped policy; it does not expand Claude's built-in classifier rules.
Configured allows clear soft blocks. Hard blocks take priority over all allows and conversational consent. False-positive clarification can clear Policy Tampering and Audit Tampering. The last direct user message can supply the exact action and target that a soft block needs; replies such as “go ahead” cannot supply an unseen proposal.
Read the policy before you enable the mod. Writing a policy covers the precedence and the limits of an action-only assessment.
Jev evaluates each named rule with a typed choice. auto-mode returns a denial for a confident block, approval when every rule confidently allows the action, and a denial for uncertain decisions. Every denial names its rule, the harm, and what clears it, and the agent continues on another path. The confidence threshold is configurable and needs evaluation against your actions.
A missing key, failed API call, malformed response, or oversized request follows decision.onFailure. The default defer writes no verdict and keeps the prompt; deny fails closed. Failures write a diagnostic to stderr. auto-mode never truncates a Jev action to make it fit.
| Command | Effect |
|---|---|
auto-mode run | Read a mod request and write a verdict |
auto-mode run --explain | Write decision details to stderr |
auto-mode run --local-only | Skip the model tier |
auto-mode run --jev-only | Require Jev with a 5-second API timeout |
auto-mode print-prompt | Print the selected provider's base policy |
The mod evaluates only ask decisions and preserves existing allow and deny decisions. It runs the CLI as a bounded child process with --jev-only. The spark, claude, glm, and messages classifier kinds use the Messages API and serve library callers.
import { classifyAction, loadConfig, parseActionRequest } from 'auto-mode';
const request = parseActionRequest(body);
if (request !== null) {
const outcome = await classifyAction(request, await loadConfig());
}
classifyAction runs both tiers and returns the verdict, a note, and diagnostics. A request carries the session identity, the action, and the task context; parseActionRequest builds one from the mod's JSON.
MIT.
hooks/register.ts 230 lines1import { buildPromptContext } from './build-prompt-context.ts';
2import { parseDecision } from './parse-decision.ts';
3import type { ModOn, ModOptions, PromptContext } from './types.ts';
4
5// Commands that can create a worktree, a branch, or a PR. Any other call skips
6// the record subprocess, so ordinary calls pay nothing for the session scope.
7const SCOPE_COMMAND =
8 /\bgit\s[^\n]*?\b(?:worktree\s+add|checkout|switch|branch)\b|\bgh\s+pr\s+create\b/u;
9
10// The record may look up a created PR's head branch with gh, which the CLI
11// bounds at 5 seconds.
12const RECORD_TIMEOUT_MS = 8000;
13
14export function register(on: ModOn, options: ModOptions): void {
15 let sessionID: string | null = null;
16
17 const activeCalls = new Map<string, string | null>();
18 const delegatedTasks = new Map<string, string>();
19
20 let prompts: PromptContext = buildPromptContext(null, { source: 'reload' });
21
22 on('classic.SessionStart', (_api, e, next) => {
23 if (e.agent_id === undefined) {
24 const isCompact = e.source === 'compact' && sessionID === e.session_id;
25
26 sessionID = e.session_id;
27
28 const previousPrompts = isCompact ? prompts : null;
29
30 prompts = buildPromptContext(previousPrompts, { source: e.source ?? 'unknown' });
31
32 if (!isCompact) {
33 delegatedTasks.clear();
34 }
35 }
36
37 return next(e);
38 });
39
40 on('classic.UserPromptSubmit', (_api, e, next) => {
41 if (e.agent_id === undefined) {
42 sessionID = e.session_id;
43 }
44
45 return next(e);
46 });
47
48 on('prompt.submit', (_api, e, next) => {
49 prompts = buildPromptContext(prompts, e);
50
51 return next(e);
52 });
53
54 on('agent.spawn', async (_api, e, next) => {
55 const result = await next(e);
56
57 if (result.agentId !== undefined && !delegatedTasks.has(result.agentId)) {
58 delegatedTasks.set(result.agentId, e.prompt);
59 }
60
61 return result;
62 });
63
64 on('tool.call', async ($, e, next) => {
65 if (e.tool_use_id === undefined) {
66 return next(e);
67 }
68
69 activeCalls.set(e.tool_use_id, e.agentId ?? null);
70
71 const command =
72 e.tool === 'Bash' && typeof e.command === 'string' && SCOPE_COMMAND.test(e.command)
73 ? e.command
74 : null;
75
76 const startedAt = Date.now();
77 const cwd = command === null || sessionID === null ? null : await $.session.cwd();
78 let result;
79
80 try {
81 result = await next(e);
82 } finally {
83 activeCalls.delete(e.tool_use_id);
84 }
85
86 if (command !== null && cwd !== null && sessionID !== null && result.deny === undefined) {
87 const executable = typeof options.command === 'string' ? options.command : 'auto-mode';
88 const request = { sessionID, cwd, startedAt, command, resultText: result.text ?? '' };
89
90 try {
91 await $.process.run([executable, 'record'], {
92 timeoutMs: RECORD_TIMEOUT_MS,
93 stdin: JSON.stringify(request),
94 });
95 } catch {
96 $.ui.log('auto-mode: session scope not recorded; subprocess failure', { to: 'debug' });
97 }
98 }
99
100 return result;
101 });
102
103 on('tool.check', async ($, e, next) => {
104 const decided = await next(e);
105
106 const actionID =
107 e.tool_use_id !== undefined && /^call_[\da-f]{24,32}$/u.test(e.tool_use_id)
108 ? e.tool_use_id
109 : 'unavailable';
110
111 const logPrefix = `auto-mode action ${actionID}:`;
112
113 if (decided.decision !== 'ask') {
114 return decided;
115 }
116
117 if (next.signal.aborted || sessionID === null) {
118 const status = next.signal.aborted ? 'cancelled' : 'missing session context';
119
120 $.ui.log(`${logPrefix} evaluation skipped; ${status}`, { to: 'debug' });
121
122 return decided;
123 }
124
125 const timeoutMs = Math.min(8000, next.budget.remainingMs - 250);
126
127 if (timeoutMs < 500) {
128 $.ui.log(`${logPrefix} evaluation skipped; insufficient budget`, { to: 'debug' });
129
130 return decided;
131 }
132
133 try {
134 const cwd = await $.session.cwd();
135
136 const agentID = e.tool_use_id === undefined ? null : activeCalls.get(e.tool_use_id);
137
138 if (e.tool_use_id !== undefined && agentID === undefined) {
139 $.ui.log(`${logPrefix} evaluation skipped; untracked tool call`, { to: 'debug' });
140
141 return decided;
142 }
143
144 const isChild = agentID !== null && agentID !== undefined;
145 const delegatedText = isChild ? delegatedTasks.get(agentID) : undefined;
146
147 const delegatedTask =
148 delegatedText === undefined ? null : { text: delegatedText, origin: 'agent.spawn' };
149
150 const omittedTaskContext = [
151 ...(prompts.originalUserTask === null
152 ? [{ field: 'originalUserTask', reason: 'unavailable' }]
153 : []),
154 ...(isChild && delegatedTask === null
155 ? [{ field: 'delegatedTask', reason: 'unavailable' }]
156 : []),
157 ];
158
159 const command = typeof options.command === 'string' ? options.command : 'auto-mode';
160 const childTimeoutMs = Math.min(timeoutMs, next.budget.remainingMs - 250);
161
162 if (childTimeoutMs < 500) {
163 $.ui.log(`${logPrefix} evaluation skipped; insufficient subprocess budget`, {
164 to: 'debug',
165 });
166
167 return decided;
168 }
169
170 const deadlineAt = Date.now() + childTimeoutMs - 500;
171
172 $.ui.log(`${logPrefix} evaluator invoked`, { to: 'debug' });
173
174 const request = {
175 sessionID,
176 ...(e.tool_use_id === undefined ? {} : { toolUseID: e.tool_use_id }),
177 cwd,
178 toolName: e.tool,
179 toolInput: e.input,
180 context: {
181 agentID: agentID ?? null,
182 originalUserTask: prompts.originalUserTask,
183 delegatedTask,
184 lastDirectUserMessage: isChild ? null : prompts.lastDirectUserMessage,
185 omittedTaskContext,
186 },
187 };
188
189 const result = await $.process.run(
190 [command, 'run', '--jev-only', '--evaluation-deadline', String(deadlineAt)],
191 { timeoutMs: childTimeoutMs, stdin: JSON.stringify(request) },
192 );
193
194 if (result.exitCode !== 0 || result.isStdoutTruncated || next.signal.aborted) {
195 let status = 'nonzero exit';
196
197 if (next.signal.aborted) {
198 status = 'cancelled';
199 } else if (result.isStdoutTruncated) {
200 status = 'truncated verdict';
201 }
202
203 $.ui.log(`${logPrefix} manual approval retained; ${status}`, { to: 'debug' });
204
205 return decided;
206 }
207
208 const verdict = parseDecision(result.stdout);
209
210 const message =
211 verdict === null
212 ? 'manual approval retained; no usable verdict; inspect action diagnostics'
213 : `evaluator verdict ${verdict.decision}`;
214
215 $.ui.log(`${logPrefix} ${message}`, { to: 'debug' });
216
217 return verdict ?? decided;
218 } catch (error) {
219 const status =
220 error instanceof Error && /aborted: still running after \d+ms$/u.test(error.message)
221 ? 'subprocess timeout'
222 : 'subprocess failure';
223
224 $.ui.log(`${logPrefix} manual approval retained; ${status}`, { to: 'debug' });
225
226 return decided;
227 }
228 });
229}
230hooks/build-prompt-context.ts 36 lines1import type { PromptContext, UserTask } from './types.ts';
2
3export function buildPromptContext(
4 previous: PromptContext | null,
5 input:
6 | { readonly source: string }
7 | { readonly text: string; readonly origin: { readonly kind: string } },
8): PromptContext {
9 if ('source' in input) {
10 if (input.source === 'compact' && previous !== null) {
11 return previous;
12 }
13
14 return {
15 originalUserTask: null,
16 lastDirectUserMessage: null,
17 canCaptureOriginal: input.source === 'startup' || input.source === 'clear',
18 };
19 }
20
21 const origin = input.origin.kind;
22
23 const lastDirectUserMessage: UserTask | null =
24 origin === 'composer' || origin === 'bridge' || origin === 'sdk'
25 ? { text: input.text, origin }
26 : (previous?.lastDirectUserMessage ?? null);
27
28 return {
29 originalUserTask:
30 previous?.originalUserTask ??
31 (previous?.canCaptureOriginal === true ? lastDirectUserMessage : null),
32 lastDirectUserMessage,
33 canCaptureOriginal: previous?.canCaptureOriginal ?? false,
34 };
35}
36hooks/parse-decision.ts 40 lines1interface Decision {
2 readonly decision: 'allow' | 'deny';
3 readonly reason?: string;
4}
5
6// The CLI ships with this mod, so any other shape is a mismatched or broken CLI,
7// and only an exact verdict may replace the prompt.
8export function parseDecision(stdout: string): Decision | null {
9 let body: unknown;
10
11 try {
12 body = JSON.parse(stdout);
13 } catch {
14 return null;
15 }
16
17 if (typeof body !== 'object' || body === null || Array.isArray(body)) {
18 return null;
19 }
20
21 const keys = Object.keys(body).toSorted().join(',');
22
23 if (keys === 'decision' && 'decision' in body && body.decision === 'allow') {
24 return { decision: 'allow' };
25 }
26
27 if (
28 keys === 'decision,reason' &&
29 'decision' in body &&
30 body.decision === 'deny' &&
31 'reason' in body &&
32 typeof body.reason === 'string' &&
33 body.reason.trim() !== ''
34 ) {
35 return { decision: 'deny', reason: body.reason };
36 }
37
38 return null;
39}
40hooks/types.ts 151 lines1export interface PermissionDecision {
2 readonly decision: 'allow' | 'ask' | 'deny';
3 readonly reason?: string;
4 readonly rule?: string;
5 readonly hook?: string;
6}
7
8export interface SessionContext {
9 readonly cwd: string;
10 readonly session_id: string;
11 readonly transcript_path: string;
12 readonly agent_id?: string;
13}
14
15export interface ProcessInput {
16 readonly argv: readonly string[];
17 readonly init?: { readonly stdin?: string; readonly timeoutMs?: number };
18}
19
20export interface ProcessResult {
21 readonly exitCode: number;
22 readonly stdout: string;
23 readonly stderr: string;
24 readonly isStdoutTruncated: boolean;
25 readonly isStderrTruncated: boolean;
26}
27
28interface CheckInput {
29 readonly tool: string;
30 readonly input: unknown;
31 readonly tool_use_id?: string;
32}
33
34interface CallInput {
35 readonly tool: string;
36 readonly tool_use_id?: string;
37 readonly agentId?: string;
38 readonly command?: unknown;
39}
40
41export interface CallResult {
42 readonly result?: unknown;
43 readonly deny?: string;
44 readonly text?: string;
45 readonly isError?: true;
46}
47
48export interface UserTask {
49 readonly text: string;
50 readonly origin: 'composer' | 'bridge' | 'sdk';
51}
52
53export interface PromptContext {
54 readonly originalUserTask: UserTask | null;
55 readonly lastDirectUserMessage: UserTask | null;
56 readonly canCaptureOriginal: boolean;
57}
58
59interface PromptInput {
60 readonly text: string;
61 readonly origin: { readonly kind: string };
62}
63
64interface SpawnInput {
65 readonly prompt: string;
66 readonly cwd?: string;
67 readonly parentAgentId?: string;
68}
69
70interface ModEvents {
71 readonly 'classic.SessionStart': {
72 readonly input: SessionContext & { readonly source?: string };
73 readonly result: object;
74 };
75 readonly 'classic.UserPromptSubmit': { readonly input: SessionContext; readonly result: object };
76 readonly 'prompt.submit': { readonly input: PromptInput; readonly result: object };
77 readonly 'agent.spawn': {
78 readonly input: SpawnInput;
79 readonly result: { readonly agentId?: string; readonly model?: string; readonly deny?: string };
80 };
81 readonly 'tool.call': { readonly input: CallInput; readonly result: CallResult };
82 readonly 'tool.check': { readonly input: CheckInput; readonly result: PermissionDecision };
83 readonly 'process.run': {
84 readonly input: ProcessInput;
85 readonly result: { readonly value: ProcessResult };
86 };
87 readonly 'session.cwd': { readonly input: object; readonly result: { readonly value: string } };
88 readonly 'ui.log': {
89 readonly input: { readonly text: string; readonly to?: 'debug' | 'transcript' };
90 readonly result: object;
91 };
92}
93
94interface Next<E extends keyof ModEvents> {
95 (input: ModEvents[E]['input']): Promise<ModEvents[E]['result']>;
96 readonly signal: Pick<AbortSignal, 'aborted'>;
97 readonly budget: { readonly remainingMs: number };
98}
99
100interface LogOptions {
101 readonly to: 'debug';
102}
103
104// The call a mod makes differs from the event a hook receives: the host adds the
105// parent agent to the event, and the call takes the spawn options.
106interface AgentSpawnArgs {
107 readonly prompt: string;
108 readonly description?: string;
109 readonly subagentType?: string;
110 readonly model?: string;
111 readonly name?: string;
112 readonly cwd?: string;
113}
114
115export interface ModAPI {
116 readonly ui: { readonly log: (text: string, options?: LogOptions) => void };
117 readonly session: { readonly cwd: () => Promise<string> };
118 readonly process: {
119 readonly run: (argv: readonly string[], init?: ProcessInput['init']) => Promise<ProcessResult>;
120 };
121 readonly prompt: { readonly submit: (input: PromptInput) => Promise<object> };
122 readonly agent: {
123 readonly spawn: (input: AgentSpawnArgs) => Promise<ModEvents['agent.spawn']['result']>;
124 };
125 readonly tool: {
126 readonly check: (input: CheckInput) => Promise<PermissionDecision>;
127 readonly call: (input: CallInput) => Promise<CallResult>;
128 };
129 readonly classic: {
130 readonly SessionStart: (
131 input: Partial<SessionContext> & { readonly source: string },
132 ) => Promise<object>;
133 readonly UserPromptSubmit: (
134 input: Partial<SessionContext> & { readonly prompt: string },
135 ) => Promise<object>;
136 };
137}
138
139export type ModOn = <E extends keyof ModEvents>(
140 event: E,
141 hook: (
142 api: ModAPI,
143 input: ModEvents[E]['input'],
144 next: Next<E>,
145 ) => ModEvents[E]['result'] | Promise<ModEvents[E]['result']>,
146) => void;
147
148export interface ModOptions {
149 readonly command?: unknown;
150}
151