SLOPSHOPPER

auto-mode

Judge pending permission requests with Jev before the permission dialog

newguardpromptprocessagents
v0.1.0MITupdated 2026-10-08zgeoff/auto-mode/mods/auto-mode
A shopper browsing a rack in a slop shop
README

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

Install

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.

Standing permissions

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.

Decisions and failures

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.

Commands

CommandEffect
auto-mode runRead a mod request and write a verdict
auto-mode run --explainWrite decision details to stderr
auto-mode run --local-onlySkip the model tier
auto-mode run --jev-onlyRequire Jev with a 5-second API timeout
auto-mode print-promptPrint 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.

Library

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.

Documentation

Licence

MIT.

Source 4 files
hooks/register.ts 230 lines
1import { 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}
230
hooks/build-prompt-context.ts 36 lines
1import 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}
36
hooks/parse-decision.ts 40 lines
1interface 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}
40
hooks/types.ts 151 lines
1export 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