auto-mode 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -38,7 +38,7 @@ Create the classifier configuration:
38
38
 
39
39
  Save it as `~/.config/auto-mode/config.json` and export your TypeSafe API key, or set the entry's
40
40
  `apiKeyCommand` to a command that prints it. [Configuration](./docs/guides/configuration.md) covers
41
- the registries, credentials, and migrating an older file.
41
+ the registries and credentials.
42
42
 
43
43
  ## Standing permissions
44
44
 
@@ -77,7 +77,6 @@ fit.
77
77
  | `auto-mode run --local-only` | Skip the model tier |
78
78
  | `auto-mode run --jev-only` | Require Jev with a 5-second API timeout |
79
79
  | `auto-mode print-prompt` | Print the selected provider's base policy |
80
- | `auto-mode config migrate` | Rewrite an older config file in place |
81
80
 
82
81
  The mod evaluates only ask decisions and preserves existing allow and deny decisions. It runs the
83
82
  CLI as a bounded child process with `--jev-only`. The `spark`, `claude`, `glm`, and `messages`
package/dist/cli.js CHANGED
@@ -1,67 +1,174 @@
1
1
  #!/usr/bin/env node
2
- import { d as resolveConfigPath, f as normalizeConfigFile, l as loadConfig, n as parseActionRequest, r as classifyAction, s as loadPolicy, t as renderVerdict } from "./render-verdict-CAg3AKWH.js";
3
- import { appendFile, constants, copyFile, mkdir, readFile, rename, writeFile } from "node:fs/promises";
2
+ import { g as string, h as strictObject, l as DEFAULT_DENIAL_BUDGET, m as number, n as parseActionRequest, r as classifyAction, s as loadPolicy, t as renderVerdict, u as loadConfig } from "./render-verdict-CK9sRJpa.js";
3
+ import { appendFile, mkdir, readFile, rename, writeFile } from "node:fs/promises";
4
4
  import { homedir } from "node:os";
5
5
  import { dirname, join } from "node:path";
6
6
  import { createHash, randomUUID } from "node:crypto";
7
7
  import { text } from "node:stream/consumers";
8
8
  import { parseArgs } from "node:util";
9
- //#region src/config/build-jev-only-config.ts
10
- function buildJevOnlyConfig(config) {
11
- if (config.provider.protocol !== "system-one") return null;
9
+ //#region src/budget/build-retry-key.ts
10
+ const DESCRIPTIVE_FIELDS = { Bash: [
11
+ "description",
12
+ "timeout",
13
+ "run_in_background"
14
+ ] };
15
+ function buildRetryKey(request) {
16
+ const ignored = Object.hasOwn(DESCRIPTIVE_FIELDS, request.toolName) ? DESCRIPTIVE_FIELDS[request.toolName] ?? [] : [];
17
+ const input = Object.fromEntries(Object.entries(request.toolInput).filter(([key]) => !ignored.includes(key)));
18
+ const canonical = JSON.stringify(sortKeys({
19
+ cwd: request.cwd,
20
+ toolName: request.toolName,
21
+ toolInput: input,
22
+ lastDirectUserMessage: request.decisionContext?.lastDirectUserMessage?.text ?? null
23
+ }));
24
+ return createHash("sha256").update(canonical).digest("hex");
25
+ }
26
+ function sortKeys(value) {
27
+ if (Array.isArray(value)) return value.map((item) => sortKeys(item));
28
+ if (typeof value === "object" && value !== null) return Object.fromEntries(Object.entries(value).toSorted(([a], [b]) => a < b ? -1 : 1).map(([key, item]) => [key, sortKeys(item)]));
29
+ return value;
30
+ }
31
+ //#endregion
32
+ //#region src/budget/find-retry-deny.ts
33
+ function findRetryDeny(state, retryKey) {
34
+ if (state.lastDenied === null || state.lastDenied.retryKey !== retryKey) return null;
12
35
  return {
13
- ...config,
14
- provider: {
15
- ...config.provider,
16
- timeoutMs: Math.min(config.provider.timeoutMs, 5e3)
17
- }
36
+ kind: "deny",
37
+ rule: state.lastDenied.rule,
38
+ reason: state.lastDenied.reason
18
39
  };
19
40
  }
20
41
  //#endregion
21
- //#region src/config/write-migrated-config.ts
22
- async function writeMigratedConfig(path) {
42
+ //#region src/budget/types.ts
43
+ const EMPTY_DENIAL_STATE = {
44
+ consecutive: 0,
45
+ session: 0,
46
+ lastDenied: null
47
+ };
48
+ //#endregion
49
+ //#region src/budget/load-denial-state.ts
50
+ const count = number().int().nonnegative();
51
+ const stateSchema = strictObject({
52
+ consecutive: count,
53
+ session: count,
54
+ lastDenied: strictObject({
55
+ retryKey: string().min(1),
56
+ rule: string(),
57
+ reason: string()
58
+ }).nullable()
59
+ });
60
+ async function loadDenialState(path) {
23
61
  let raw;
24
62
  try {
25
63
  raw = await readFile(path, "utf8");
26
- } catch (error) {
27
- if (error instanceof Error && "code" in error && error.code === "ENOENT") return `${path} does not exist; nothing to migrate`;
28
- throw new Error(`${path} is unreadable`, { cause: error });
29
- }
30
- let json;
31
- try {
32
- json = JSON.parse(raw);
33
64
  } catch {
34
- throw new Error(`${path} is not valid JSON`);
65
+ return EMPTY_DENIAL_STATE;
35
66
  }
36
- const normalized = normalizeConfigFile(json, path);
37
- if (!normalized.isLegacy) return `${path} already uses the current shape`;
38
- const backup = `${path}.bak`;
39
- const staged = `${path}.migrating`;
40
67
  try {
41
- await copyFile(path, backup, constants.COPYFILE_EXCL);
42
- } catch (error) {
43
- if (error instanceof Error && "code" in error && error.code === "EEXIST") throw new Error(`${backup} already exists; move it aside and run the migration again`, { cause: error });
44
- throw error;
68
+ const parsed = stateSchema.safeParse(JSON.parse(raw));
69
+ return parsed.success ? parsed.data : EMPTY_DENIAL_STATE;
70
+ } catch {
71
+ return EMPTY_DENIAL_STATE;
45
72
  }
46
- await writeFile(staged, `${JSON.stringify(normalized.file, null, 2)}\n`, { mode: 384 });
73
+ }
74
+ //#endregion
75
+ //#region src/budget/plan-denial-budget.ts
76
+ function planDenialBudget(state, verdict, retryKey, budget) {
77
+ if (verdict === null) return {
78
+ verdict: null,
79
+ state,
80
+ escalation: false
81
+ };
82
+ if (verdict.kind === "allow") return {
83
+ verdict,
84
+ state: {
85
+ ...state,
86
+ consecutive: 0,
87
+ lastDenied: null
88
+ },
89
+ escalation: false
90
+ };
91
+ if (state.consecutive >= budget.consecutive || state.session >= budget.perSession) return {
92
+ verdict: null,
93
+ state: EMPTY_DENIAL_STATE,
94
+ escalation: true
95
+ };
96
+ const next = {
97
+ consecutive: state.consecutive + 1,
98
+ session: state.session + 1,
99
+ lastDenied: {
100
+ retryKey,
101
+ rule: verdict.rule,
102
+ reason: verdict.reason
103
+ }
104
+ };
105
+ const left = Math.min(budget.consecutive - next.consecutive, budget.perSession - next.session);
106
+ return {
107
+ verdict: {
108
+ ...verdict,
109
+ reason: `${verdict.reason} ${formatDenialsLeft(left)}`
110
+ },
111
+ state: next,
112
+ escalation: false
113
+ };
114
+ }
115
+ function formatDenialsLeft(left) {
116
+ if (left > 0) return `Denials left before auto-mode asks the user: ${left}.`;
117
+ return "This is the last denial before auto-mode asks the user. Stop now, without finishing the rest of the task, and tell the user what consent you need to continue.";
118
+ }
119
+ //#endregion
120
+ //#region src/state/resolve-state-dir.ts
121
+ function resolveStateDir() {
122
+ const stateHome = process.env["XDG_STATE_HOME"];
123
+ const base = stateHome === void 0 || stateHome === "" ? join(homedir(), ".local", "state") : stateHome;
124
+ return join(base, "auto-mode");
125
+ }
126
+ //#endregion
127
+ //#region src/budget/resolve-denial-state-path.ts
128
+ function resolveDenialStatePath(request) {
129
+ const agentID = request.decisionContext?.agentID ?? "";
130
+ const key = createHash("sha256").update(`${request.sessionID}\0${agentID}`).digest("hex").slice(0, 32);
131
+ return join(resolveStateDir(), "denials", `${key}.json`);
132
+ }
133
+ //#endregion
134
+ //#region src/budget/write-denial-state.ts
135
+ async function writeDenialState(path, state) {
136
+ const staged = `${path}.${randomUUID()}.tmp`;
137
+ await mkdir(dirname(path), {
138
+ recursive: true,
139
+ mode: 448
140
+ });
141
+ await writeFile(staged, `${JSON.stringify(state)}\n`, { mode: 384 });
47
142
  await rename(staged, path);
48
- return `rewrote ${path} in the current shape; the old file is at ${backup}`;
143
+ }
144
+ //#endregion
145
+ //#region src/config/build-jev-only-config.ts
146
+ function buildJevOnlyConfig(config) {
147
+ if (config.provider.protocol !== "system-one") return null;
148
+ return {
149
+ ...config,
150
+ provider: {
151
+ ...config.provider,
152
+ timeoutMs: Math.min(config.provider.timeoutMs, 5e3)
153
+ }
154
+ };
49
155
  }
50
156
  //#endregion
51
157
  //#region src/diagnostics/write-action-diagnostic.ts
52
158
  async function writeActionDiagnostic(payload, entry) {
53
- const stateHome = process.env["XDG_STATE_HOME"];
54
- const stateDir = stateHome === void 0 || stateHome === "" ? join(homedir(), ".local", "state") : stateHome;
55
- const path = process.env["AUTO_MODE_DIAGNOSTICS_PATH"] ?? join(stateDir, "auto-mode", "actions.jsonl");
159
+ const path = process.env["AUTO_MODE_DIAGNOSTICS_PATH"] ?? join(resolveStateDir(), "actions.jsonl");
56
160
  if (path === "") return;
57
161
  const record = {
58
- schemaVersion: 2,
162
+ schemaVersion: 3,
59
163
  time: (/* @__PURE__ */ new Date()).toISOString(),
60
164
  invocationID: entry.invocationID,
61
165
  sessionHash: toHash(payload.sessionID),
62
166
  actionHash: payload.toolUseID === void 0 ? null : toHash(payload.toolUseID),
63
167
  status: entry.status,
64
168
  verdict: entry.verdict ?? null,
169
+ decidingStage: entry.decidingStage ?? null,
170
+ denials: entry.denials ?? null,
171
+ escalation: entry.escalation ?? false,
65
172
  diagnostics: entry.diagnostics ?? null
66
173
  };
67
174
  try {
@@ -84,7 +191,6 @@ const USAGE = `auto-mode — a permission classifier for the auto-mode Claude Co
84
191
  Usage:
85
192
  auto-mode run Read an action request on stdin, write a verdict on stdout
86
193
  auto-mode print-prompt Print the system prompt the classifier receives
87
- auto-mode config migrate Rewrite the config file from the old keys into the current shape
88
194
 
89
195
  Options:
90
196
  --classifier <path> Use this framework file instead of the shipped one
@@ -151,24 +257,50 @@ async function run(explain, localOnly, jevOnly, deadline) {
151
257
  process.on("SIGINT", stopEvaluation);
152
258
  }
153
259
  try {
154
- const outcome = await classifyAction(request, config, {
260
+ const statePath = resolveDenialStatePath(request);
261
+ const retryKey = buildRetryKey(request);
262
+ const before = await loadDenialState(statePath);
263
+ const retry = findRetryDeny(before, retryKey);
264
+ const outcome = retry === null ? await classifyAction(request, config, {
155
265
  localOnly,
156
266
  deadlineAt,
157
267
  signal: controller.signal
158
- });
159
- if (outcome.verdict !== null) writeVerdict(outcome.verdict);
268
+ }) : {
269
+ verdict: retry,
270
+ decidingStage: "retry",
271
+ note: "retry of the action just denied; denied again",
272
+ status: "deny"
273
+ };
274
+ const latest = retry === null ? await loadDenialState(statePath) : before;
275
+ const plan = planDenialBudget(latest, outcome.verdict, retryKey, config.denialBudget ?? DEFAULT_DENIAL_BUDGET);
276
+ if (plan.state !== latest) await tryWriteDenialState(statePath, plan.state);
277
+ if (plan.verdict !== null) writeVerdict(plan.verdict);
160
278
  await writeActionDiagnostic(request, {
161
279
  invocationID,
162
280
  status: outcome.status,
163
- verdict: outcome.verdict?.kind ?? "defer",
281
+ verdict: plan.verdict?.kind ?? "defer",
282
+ decidingStage: plan.escalation ? "budget" : outcome.decidingStage,
283
+ denials: {
284
+ consecutive: plan.state.consecutive,
285
+ session: plan.state.session
286
+ },
287
+ escalation: plan.escalation,
164
288
  ...outcome.diagnostics === void 0 ? {} : { diagnostics: outcome.diagnostics }
165
289
  });
166
- return printNote(explain || outcome.unavailable === true, outcome.note);
290
+ const note = plan.escalation ? "denial budget exhausted; the user decides this action" : outcome.note;
291
+ return printNote(explain || outcome.unavailable === true, note);
167
292
  } finally {
168
293
  process.off("SIGTERM", stopEvaluation);
169
294
  process.off("SIGINT", stopEvaluation);
170
295
  }
171
296
  }
297
+ async function tryWriteDenialState(path, state) {
298
+ try {
299
+ await writeDenialState(path, state);
300
+ } catch {
301
+ process.stderr.write("auto-mode: denial counts unavailable\n");
302
+ }
303
+ }
172
304
  function writeVerdict(verdict) {
173
305
  process.stdout.write(renderVerdict(verdict));
174
306
  }
@@ -218,21 +350,9 @@ async function main(argv) {
218
350
  process.stdout.write(`${prompt}\n`);
219
351
  return 0;
220
352
  }
221
- if (command === "config" && args.positionals[1] === "migrate") return runConfigMigration();
222
353
  process.stderr.write(`auto-mode: unknown command '${command}'\n\n${USAGE}`);
223
354
  return 2;
224
355
  }
225
- async function runConfigMigration() {
226
- const path = resolveConfigPath();
227
- try {
228
- const result = await writeMigratedConfig(path);
229
- process.stdout.write(`${result}\n`);
230
- return 0;
231
- } catch (error) {
232
- process.stderr.write(`auto-mode: ${error instanceof Error ? error.message : "config migration failed"}\n`);
233
- return 1;
234
- }
235
- }
236
356
  process.exitCode = await main(process.argv.slice(2));
237
357
  //#endregion
238
358
  export {};
package/dist/index.d.ts CHANGED
@@ -1,3 +1,9 @@
1
+ //#region src/budget/types.d.ts
2
+ interface DenialBudget {
3
+ readonly consecutive: number;
4
+ readonly perSession: number;
5
+ }
6
+ //#endregion
1
7
  //#region src/config/types.d.ts
2
8
  interface EvaluationOptions {
3
9
  readonly deadlineAt?: number | undefined;
@@ -33,6 +39,7 @@ interface Config {
33
39
  readonly onFailure: 'defer' | 'deny';
34
40
  readonly claudeSettingsPath?: string | null | undefined;
35
41
  readonly minConfidence?: number | undefined;
42
+ readonly denialBudget?: DenialBudget;
36
43
  readonly warnings?: readonly string[];
37
44
  }
38
45
  export declare const DEFAULT_CONFIG: Config;
@@ -108,8 +115,10 @@ interface DecisionDiagnostics {
108
115
  }
109
116
  //#endregion
110
117
  //#region src/classify-action.d.ts
118
+ type DecidingStage = 'local' | 'jev' | 'messages' | 'retry';
111
119
  interface ActionOutcome {
112
120
  readonly verdict: Verdict | null;
121
+ readonly decidingStage: DecidingStage;
113
122
  readonly note: string;
114
123
  readonly status: 'allow' | 'skipped' | 'failure' | DecisionDiagnostics['status'];
115
124
  readonly unavailable?: boolean;
package/dist/index.js CHANGED
@@ -1,2 +1,2 @@
1
- import { a as classifyWithModel, c as DEFAULT_CONFIG, d as resolveConfigPath, i as classifyLocally, l as loadConfig, n as parseActionRequest, o as parseModelVerdict, p as PRESETS, r as classifyAction, s as loadPolicy, t as renderVerdict, u as resolveApiKey } from "./render-verdict-CAg3AKWH.js";
1
+ import { a as classifyWithModel, c as DEFAULT_CONFIG, d as resolveApiKey, f as resolveConfigPath, i as classifyLocally, n as parseActionRequest, o as parseModelVerdict, p as PRESETS, r as classifyAction, s as loadPolicy, t as renderVerdict, u as loadConfig } from "./render-verdict-CK9sRJpa.js";
2
2
  export { DEFAULT_CONFIG, PRESETS, classifyAction, classifyLocally, classifyWithModel, loadConfig, loadPolicy, parseActionRequest, parseModelVerdict, renderVerdict, resolveApiKey, resolveConfigPath };
@@ -5615,126 +5615,6 @@ function findPreset(id) {
5615
5615
  return Object.hasOwn(PRESETS, id) ? PRESETS[id] : void 0;
5616
5616
  }
5617
5617
  //#endregion
5618
- //#region src/config/normalize-config-file.ts
5619
- function normalizeConfigFile(json, path) {
5620
- if (!isRecord(json)) return {
5621
- file: json,
5622
- isLegacy: false,
5623
- warnings: []
5624
- };
5625
- const legacyKeys = Object.keys(json).filter((key) => LEGACY_KEYS.has(key));
5626
- if (legacyKeys.length === 0) return {
5627
- file: json,
5628
- isLegacy: false,
5629
- warnings: []
5630
- };
5631
- const currentKeys = Object.keys(json).filter((key) => !LEGACY_KEYS.has(key));
5632
- if (currentKeys.length > 0) throw new Error(`${path} mixes the old keys (${legacyKeys.join(", ")}) with the new ones (${currentKeys.join(", ")}); run \`auto-mode config migrate\` on a copy without the new keys`);
5633
- const parsed = legacyConfigSchema.safeParse(json);
5634
- if (!parsed.success) throw new Error(`${path} is not a valid config: ${prettifyError(parsed.error)}`);
5635
- const legacy = parsed.data;
5636
- const warnings = [`${path} uses the old config keys (${legacyKeys.join(", ")}); run \`auto-mode config migrate\` to rewrite it`, ...legacy.transcriptEntries === void 0 ? [] : [`${path} sets transcriptEntries, which has no effect; migration drops it`]];
5637
- return {
5638
- file: buildFileFromLegacy(legacy, path),
5639
- isLegacy: true,
5640
- warnings
5641
- };
5642
- }
5643
- function isRecord(value) {
5644
- return typeof value === "object" && value !== null && !Array.isArray(value);
5645
- }
5646
- const LEGACY_KEYS = /* @__PURE__ */ new Set([
5647
- "preset",
5648
- "provider",
5649
- "classifierPath",
5650
- "rulesPath",
5651
- "transcriptEntries",
5652
- "onFailure",
5653
- "claudeSettingsPath",
5654
- "minConfidence"
5655
- ]);
5656
- const text$1 = string().min(1);
5657
- const positive$1 = number().positive();
5658
- const legacyConfigSchema = strictObject({
5659
- preset: text$1.optional(),
5660
- provider: strictObject({
5661
- protocol: _enum(["messages", "system-one"]).optional(),
5662
- baseURL: text$1.optional(),
5663
- model: text$1.optional(),
5664
- apiKeyEnv: text$1.optional(),
5665
- apiKeyCommand: text$1.optional(),
5666
- reasoning: boolean().optional(),
5667
- maxTokens: positive$1.optional(),
5668
- timeoutMs: positive$1.optional()
5669
- }).optional(),
5670
- classifierPath: text$1.optional(),
5671
- rulesPath: text$1.optional(),
5672
- transcriptEntries: number().nonnegative().optional(),
5673
- onFailure: _enum(["defer", "deny"]).optional(),
5674
- claudeSettingsPath: text$1.nullable().optional(),
5675
- minConfidence: number().min(.5).max(1).optional()
5676
- });
5677
- function buildFileFromLegacy(legacy, path) {
5678
- const hasClassifier = legacy.preset !== void 0 || legacy.provider !== void 0;
5679
- const decision = {
5680
- ...hasClassifier ? { classifier: getLegacyClassifierID(legacy) } : {},
5681
- ...legacy.minConfidence === void 0 ? {} : { minConfidence: legacy.minConfidence },
5682
- ...legacy.onFailure === void 0 ? {} : { onFailure: legacy.onFailure }
5683
- };
5684
- const policy = {
5685
- ...legacy.rulesPath === void 0 ? {} : { rulesPath: legacy.rulesPath },
5686
- ...legacy.classifierPath === void 0 ? {} : { frameworkPath: legacy.classifierPath },
5687
- ...legacy.claudeSettingsPath === void 0 ? {} : { claudeSettingsPath: legacy.claudeSettingsPath }
5688
- };
5689
- return {
5690
- ...hasClassifier ? { classifiers: { [getLegacyClassifierID(legacy)]: buildClassifierEntry(getLegacyClassifierID(legacy), buildLegacyProvider(legacy, path)) } } : {},
5691
- ...Object.keys(decision).length === 0 ? {} : { decision },
5692
- ...Object.keys(policy).length === 0 ? {} : { policy }
5693
- };
5694
- }
5695
- function getLegacyClassifierID(legacy) {
5696
- if (legacy.preset !== void 0) return legacy.preset;
5697
- return legacy.provider !== void 0 && legacy.provider.protocol !== "system-one" ? "spark" : "jev";
5698
- }
5699
- function buildLegacyProvider(legacy, path) {
5700
- const preset = findPreset(getLegacyClassifierID(legacy));
5701
- if (preset === void 0) throw new Error(`${path} names an unknown preset '${legacy.preset}'; known presets are ${Object.keys(PRESETS).join(", ")}`);
5702
- const override = legacy.provider ?? {};
5703
- return {
5704
- protocol: override.protocol ?? preset.protocol ?? "messages",
5705
- baseURL: override.baseURL ?? preset.baseURL,
5706
- model: override.model ?? preset.model,
5707
- apiKeyEnv: override.apiKeyEnv ?? preset.apiKeyEnv,
5708
- apiKeyCommand: override.apiKeyCommand ?? preset.apiKeyCommand,
5709
- reasoning: override.reasoning ?? preset.reasoning,
5710
- maxTokens: override.maxTokens ?? preset.maxTokens,
5711
- timeoutMs: override.timeoutMs ?? preset.timeoutMs
5712
- };
5713
- }
5714
- const PROVIDER_FIELDS = [
5715
- "baseURL",
5716
- "model",
5717
- "apiKeyEnv",
5718
- "apiKeyCommand",
5719
- "reasoning",
5720
- "maxTokens",
5721
- "timeoutMs"
5722
- ];
5723
- function buildClassifierEntry(id, provider) {
5724
- const kind = pickEntryKind(id, provider);
5725
- const base = kind === "messages" ? MESSAGES_DEFAULTS : findPreset(kind) ?? {};
5726
- const fields = Object.fromEntries(PROVIDER_FIELDS.filter((field) => provider[field] !== void 0 && provider[field] !== base[field]).map((field) => [field, provider[field]]));
5727
- return {
5728
- ...kind === id ? {} : { kind },
5729
- ...fields
5730
- };
5731
- }
5732
- function pickEntryKind(id, provider) {
5733
- const preset = findPreset(id);
5734
- if (preset !== void 0 && (preset.protocol ?? "messages") === provider.protocol) return id;
5735
- return provider.protocol === "system-one" ? "jev" : "messages";
5736
- }
5737
- //#endregion
5738
5618
  //#region src/config/read-api-key-from-command.ts
5739
5619
  function readApiKeyFromCommand(command, options = {}) {
5740
5620
  const remainingMs = options.deadlineAt === void 0 ? 5e3 : options.deadlineAt - Date.now();
@@ -5797,10 +5677,15 @@ const SCOPE_SOURCE_KINDS = [
5797
5677
  ];
5798
5678
  const DEFAULT_PROVIDER = findPreset("jev");
5799
5679
  if (DEFAULT_PROVIDER === void 0) throw new Error("the jev preset is missing");
5680
+ const DEFAULT_DENIAL_BUDGET = {
5681
+ consecutive: 3,
5682
+ perSession: 20
5683
+ };
5800
5684
  const DEFAULT_CONFIG = {
5801
5685
  provider: DEFAULT_PROVIDER,
5802
5686
  onFailure: "defer",
5803
- minConfidence: .8
5687
+ minConfidence: .8,
5688
+ denialBudget: DEFAULT_DENIAL_BUDGET
5804
5689
  };
5805
5690
  function resolveConfigPath() {
5806
5691
  const xdg = process.env["XDG_CONFIG_HOME"];
@@ -5821,11 +5706,14 @@ async function loadConfig(path = resolveConfigPath()) {
5821
5706
  } catch {
5822
5707
  throw new Error(`${path} is not valid JSON`);
5823
5708
  }
5824
- const normalized = normalizeConfigFile(json, path);
5825
- return buildConfig(normalized.file, path, normalized.warnings);
5709
+ return buildConfig(json, path);
5826
5710
  }
5827
5711
  const text = string().min(1);
5828
5712
  const positive = number().positive();
5713
+ const denialBudgetSchema = strictObject({
5714
+ consecutive: number().int().positive().optional(),
5715
+ perSession: number().int().positive().optional()
5716
+ });
5829
5717
  const configFileSchema = strictObject({
5830
5718
  classifiers: record(text, unknown()).optional(),
5831
5719
  scopeSources: record(text, unknown()).optional(),
@@ -5833,7 +5721,8 @@ const configFileSchema = strictObject({
5833
5721
  classifier: text.optional(),
5834
5722
  judge: text.nullable().optional(),
5835
5723
  minConfidence: number().min(.5).max(1).optional(),
5836
- onFailure: _enum(["defer", "deny"]).optional()
5724
+ onFailure: _enum(["defer", "deny"]).optional(),
5725
+ denialBudget: denialBudgetSchema.optional()
5837
5726
  }).optional(),
5838
5727
  policy: strictObject({
5839
5728
  rulesPath: text.nullable().optional(),
@@ -5841,11 +5730,11 @@ const configFileSchema = strictObject({
5841
5730
  claudeSettingsPath: text.nullable().optional()
5842
5731
  }).optional()
5843
5732
  });
5844
- function buildConfig(json, path, legacyWarnings) {
5733
+ function buildConfig(json, path) {
5845
5734
  const parsed = configFileSchema.safeParse(json);
5846
5735
  if (!parsed.success) throw new Error(`${path} is not a valid config: ${prettifyError(parsed.error)}`);
5847
5736
  const file = parsed.data;
5848
- const warnings = [...legacyWarnings];
5737
+ const warnings = [];
5849
5738
  const classifiers = buildRegistry(file.classifiers ?? {}, (id, entry) => parseClassifierEntry(id, entry));
5850
5739
  const scopeSources = buildRegistry(file.scopeSources ?? {}, (id, entry) => parseScopeSourceEntry(id, entry));
5851
5740
  warnings.push(...classifiers.dropped.map((line) => `${path}: classifiers.${line}`), ...scopeSources.dropped.map((line) => `${path}: scopeSources.${line}`));
@@ -5861,6 +5750,10 @@ function buildConfig(json, path, legacyWarnings) {
5861
5750
  onFailure: decision.onFailure ?? DEFAULT_CONFIG.onFailure,
5862
5751
  claudeSettingsPath: policy.claudeSettingsPath === null ? null : expandHomePath(policy.claudeSettingsPath),
5863
5752
  minConfidence: decision.minConfidence ?? DEFAULT_CONFIG.minConfidence,
5753
+ denialBudget: {
5754
+ consecutive: decision.denialBudget?.consecutive ?? DEFAULT_DENIAL_BUDGET.consecutive,
5755
+ perSession: decision.denialBudget?.perSession ?? DEFAULT_DENIAL_BUDGET.perSession
5756
+ },
5864
5757
  warnings
5865
5758
  };
5866
5759
  }
@@ -7204,6 +7097,7 @@ async function classifyAction(request, config, options = {}) {
7204
7097
  const guidance = config.onFailure === "deny" ? await tryReadDenialGuidance() : null;
7205
7098
  return {
7206
7099
  verdict: config.onFailure === "deny" ? buildGuidedDeny("Classifier Unavailable", "Claude settings unreadable.", guidance) : null,
7100
+ decidingStage: "jev",
7207
7101
  note: "Claude settings unreadable; classifier unavailable",
7208
7102
  status: "failure",
7209
7103
  unavailable: true
@@ -7212,17 +7106,20 @@ async function classifyAction(request, config, options = {}) {
7212
7106
  const local = configured !== null && (configured.hard_deny.length > 0 || configured.soft_deny.length > 0) ? { kind: "escalate" } : classifyLocally(request);
7213
7107
  if (local.kind === "allow") return {
7214
7108
  verdict: { kind: "allow" },
7109
+ decidingStage: "local",
7215
7110
  note: `allowed by ${local.exception} (local)`,
7216
7111
  status: "allow"
7217
7112
  };
7218
7113
  if (options.localOnly === true) return {
7219
7114
  verdict: null,
7115
+ decidingStage: "local",
7220
7116
  note: `${request.toolName} needs the model tier, which this run skipped`,
7221
7117
  status: "skipped"
7222
7118
  };
7223
7119
  const [outcome, guidance] = await Promise.all([classifyWithModel(request, config, options), tryReadDenialGuidance()]);
7224
7120
  return {
7225
7121
  ...outcome,
7122
+ decidingStage: config.provider.protocol === "system-one" ? "jev" : "messages",
7226
7123
  verdict: outcome.verdict?.kind === "deny" ? buildGuidedDeny(outcome.verdict.rule, outcome.verdict.reason, guidance) : outcome.verdict,
7227
7124
  status: outcome.diagnostics?.status ?? outcome.verdict?.kind ?? "failure"
7228
7125
  };
@@ -7300,4 +7197,4 @@ function renderVerdict(verdict) {
7300
7197
  })).exhaustive();
7301
7198
  }
7302
7199
  //#endregion
7303
- export { classifyWithModel as a, DEFAULT_CONFIG as c, resolveConfigPath as d, normalizeConfigFile as f, classifyLocally as i, loadConfig as l, parseActionRequest as n, parseModelVerdict as o, PRESETS as p, classifyAction as r, loadPolicy as s, renderVerdict as t, resolveApiKey as u };
7200
+ export { classifyWithModel as a, DEFAULT_CONFIG as c, resolveApiKey as d, resolveConfigPath as f, string as g, strictObject as h, classifyLocally as i, DEFAULT_DENIAL_BUDGET as l, number as m, parseActionRequest as n, parseModelVerdict as o, PRESETS as p, classifyAction as r, loadPolicy as s, renderVerdict as t, loadConfig as u };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auto-mode",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "A permission classifier library and Claude Code mod, so agents that are not Claude can run unattended",
5
5
  "keywords": [
6
6
  "agent",