claude-autorouter 0.4.0 → 0.5.1

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.
@@ -1,7 +1,8 @@
1
1
  import { readConfig, parseSessionLogDir, parseStopHookBlockCap } from './config.mjs';
2
- import { CONFIG_KEYS, SECRET_CONFIG_KEYS, loadUserConfig, saveUserConfig } from './user-config.mjs';
2
+ import { CONFIG_KEYS, SECRET_CONFIG_KEYS, SECRET_STORES, keychainRemovals, loadUserConfig, saveUserConfig } from './user-config.mjs';
3
3
  import { modelCapabilities } from './model-catalog.mjs';
4
4
  import { askSecret } from './onboarding.mjs';
5
+ import { applyPolicy } from './policy.mjs';
5
6
 
6
7
  const secretKey = key => SECRET_CONFIG_KEYS.includes(key);
7
8
  const providerFor = key => key.startsWith('AUTOROUTER_OLLAMA_') ? 'ollama'
@@ -30,16 +31,21 @@ function effectiveValues(config, env) {
30
31
  /** Redacted effective configuration, independent of Claude launch arguments. */
31
32
  export function configReport(loaded, env, { checkAll = false } = {}) {
32
33
  const config = readConfig(loaded.env);
33
- const values = effectiveValues(config, loaded.env);
34
+ const values = { ...effectiveValues(config, loaded.env), AUTOROUTER_SECRET_STORE: loaded.secretStore ?? 'file' };
34
35
  const settings = {};
35
36
  for (const key of CONFIG_KEYS) {
36
- const source = env[key] !== undefined ? 'environment' : Object.hasOwn(loaded.values, key) ? 'file' : 'default';
37
+ const saved = loaded.keychainSecrets?.includes(key) ? 'keychain' : 'file';
38
+ // The saved store setting governs saved secrets; the environment cannot redirect it.
39
+ const source = loaded.policyLocked?.includes(key) ? 'policy'
40
+ : env[key] !== undefined && key !== 'AUTOROUTER_SECRET_STORE' ? 'environment'
41
+ : Object.hasOwn(loaded.values, key) ? saved : 'default';
37
42
  const provider = providerFor(key);
38
43
  const active = (!provider || provider === config.evaluator)
39
44
  && (key !== 'ANTHROPIC_API_KEY' || config.authMode === 'api-key');
40
45
  const secret = secretKey(key);
41
46
  settings[key] = { source, active,
42
47
  ...(source === 'environment' && Object.hasOwn(loaded.values, key) ? { overrides_file: true } : {}),
48
+ ...(source === 'environment' && loaded.unavailableSecrets?.includes(key) ? { keychain_unavailable: true } : {}),
43
49
  ...(secret ? { secret: true, present: typeof loaded.env[key] === 'string' && Boolean(loaded.env[key].trim()) }
44
50
  : { value: active ? values[key] ?? null : null }),
45
51
  };
@@ -53,7 +59,8 @@ export function configReport(loaded, env, { checkAll = false } = {}) {
53
59
  try { readConfig(loaded.env, { validateAll: true }); }
54
60
  catch (failure) { valid = false; error = failure.message; }
55
61
  }
56
- return { schema_version: 1, config_path: loaded.path, config_exists: loaded.exists, valid,
62
+ return { schema_version: 1, config_path: loaded.path, config_exists: loaded.exists,
63
+ ...(loaded.policyPath ? { policy_path: loaded.policyPath } : {}), valid,
57
64
  checked: checkAll ? 'all_evaluators' : 'active_evaluator', settings, warnings,
58
65
  ...(error ? { error } : {}),
59
66
  };
@@ -84,19 +91,21 @@ function normalizedValue(key, value) {
84
91
  }
85
92
  if (key === 'AUTOROUTER_SESSION_LOG_DIR') return parseSessionLogDir(value) ?? '';
86
93
  if (key === 'CLAUDE_CODE_STOP_HOOK_BLOCK_CAP') return String(parseStopHookBlockCap(value));
94
+ if (key === 'AUTOROUTER_SECRET_STORE' && !SECRET_STORES.includes(value)) throw new Error('AUTOROUTER_SECRET_STORE must be file or keychain.');
87
95
  if (/[\r\n\0\u001b]/.test(value)) throw new Error(`${key} must be a single-line setting.`);
88
96
  return value;
89
97
  }
90
98
 
91
99
  export async function configCommand(args, {
92
- env = process.env, write = console.log, input = process.stdin, promptSecret = askSecret,
100
+ env = process.env, write = console.log, input = process.stdin, promptSecret = askSecret, keychain, policy,
93
101
  } = {}) {
102
+ const store = { ...(keychain ? { keychain } : {}), ...(policy ? { policy } : {}) };
94
103
  const [operation, ...rest] = args;
95
104
  if (operation === 'show') {
96
105
  if (rest.some(arg => !['--json', '--check-all'].includes(arg))) throw new Error('Usage: claude-autorouter config show [--json] [--check-all]');
97
106
  let loaded, report;
98
107
  try {
99
- loaded = loadUserConfig(env, { allowMissing: true });
108
+ loaded = loadUserConfig(env, { allowMissing: true, ...store });
100
109
  report = configReport(loaded, env, { checkAll: rest.includes('--check-all') });
101
110
  }
102
111
  catch (error) {
@@ -124,7 +133,7 @@ export async function configCommand(args, {
124
133
  throw new Error('Secret values are not accepted as command arguments. Use --stdin or the hidden prompt.');
125
134
  }
126
135
  if (operation === 'set' && !secretKey(key) && (value === undefined || value === '--stdin')) throw new Error('Nonsecret settings require a value argument.');
127
- const loaded = loadUserConfig(env, { allowMissing: true });
136
+ const loaded = loadUserConfig(env, { allowMissing: true, enforcePolicy: false, ...store });
128
137
  const next = { ...loaded.values };
129
138
  if (operation === 'unset') delete next[key];
130
139
  else next[key] = normalizedValue(key, secretKey(key)
@@ -134,8 +143,20 @@ export async function configCommand(args, {
134
143
  // mask it. An explicitly edited inactive provider is checked too.
135
144
  const provider = operation === 'set' ? providerFor(key) : undefined;
136
145
  readConfig({ ...next, ...(provider ? { AUTOROUTER_EVALUATOR: provider } : {}) });
137
- saveUserConfig(next, { env, overwrite: loaded.exists, expectedRevision: loaded.revision });
138
- write(`${operation === 'unset' ? 'Removed saved' : 'Saved'} ${key}. Other saved settings are unchanged.`);
146
+ if (loaded.policy) applyPolicy(next, loaded.policy);
147
+ const nextStore = next.AUTOROUTER_SECRET_STORE ?? 'file';
148
+ // Changing the store moves saved secrets; unsetting a secret deletes its item.
149
+ const removeSecrets = key === 'AUTOROUTER_SECRET_STORE' ? keychainRemovals(loaded, nextStore)
150
+ : operation === 'unset' && secretKey(key) && loaded.secretStore === 'keychain' ? [key] : [];
151
+ saveUserConfig(next, { env, overwrite: loaded.exists, expectedRevision: loaded.revision, removeSecrets, ...store });
152
+ if (key === 'AUTOROUTER_SECRET_STORE') {
153
+ const moved = SECRET_CONFIG_KEYS.filter(name => Object.hasOwn(next, name)).length;
154
+ const where = nextStore === 'keychain' ? 'macOS Keychain' : 'configuration file';
155
+ write(nextStore === loaded.secretStore || !moved ? `Saved ${key}. Saved secrets are stored in the ${where}.`
156
+ : `Saved ${key}. Moved ${moved} saved secret${moved === 1 ? '' : 's'} to the ${where}.`);
157
+ return true;
158
+ }
159
+ write(`${operation === 'unset' ? 'Removed saved' : 'Saved'} ${key}${secretKey(key) && nextStore === 'keychain' ? ' in the macOS Keychain' : ''}. Other saved settings are unchanged.`);
139
160
  if (env[key] !== undefined) write(`The current environment still overrides ${key}; unset that environment variable to use the saved/default value.`);
140
161
  return true;
141
162
  }
package/src/config.mjs CHANGED
@@ -58,12 +58,12 @@ function modelName(value, name) {
58
58
  * @returns {import('./contracts.mjs').RouterConfig}
59
59
  */
60
60
  export function readConfig(env = process.env, { validateAll = false } = {}) {
61
- const sessionLogMode = env.AUTOROUTER_SESSION_LOG_MODE === undefined ? 'prompts' : env.AUTOROUTER_SESSION_LOG_MODE;
61
+ const sessionLogMode = env.AUTOROUTER_SESSION_LOG_MODE === undefined ? 'metadata' : env.AUTOROUTER_SESSION_LOG_MODE;
62
62
  if (sessionLogMode !== 'metadata' && sessionLogMode !== 'prompts') throw new Error('AUTOROUTER_SESSION_LOG_MODE must be metadata or prompts');
63
63
  for (const key of ['AUTOROUTER_STATUSLINE', 'AUTOROUTER_DEBUG']) {
64
64
  if (env[key] !== undefined && !['0', '1'].includes(env[key])) throw new Error(`${key} must be 0 or 1`);
65
65
  }
66
- const evaluator = env.AUTOROUTER_EVALUATOR ?? 'jev';
66
+ const evaluator = env.AUTOROUTER_EVALUATOR ?? 'ollama';
67
67
  if (evaluator !== 'jev' && evaluator !== 'ollama') throw new Error('AUTOROUTER_EVALUATOR must be jev or ollama');
68
68
  // A stale inactive backend must not stop the selected evaluator. Config
69
69
  // inspection can request validation of both providers explicitly.
@@ -0,0 +1,58 @@
1
+ import { spawnSync } from 'node:child_process';
2
+
3
+ // macOS Keychain access through the system `security` tool. Secret values are
4
+ // passed on stdin to its interactive mode, never as process arguments, and are
5
+ // read back from stdout. Tool output can describe items, so failures report
6
+ // only a generic category.
7
+ const SECURITY = '/usr/bin/security';
8
+ const SERVICE = 'claude-autorouter';
9
+ const NOT_FOUND = 44;
10
+ const PRINTABLE = /^[\x20-\x7e]+$/;
11
+
12
+ function keychainError(message) {
13
+ const error = new Error(message);
14
+ error.code = 'AUTOROUTER_CONFIG_ERROR';
15
+ return error;
16
+ }
17
+
18
+ // Quote for the `security -i` command parser, which accepts backslash escapes
19
+ // inside double quotes. Values are validated as printable single-line ASCII.
20
+ const quote = value => `"${value.replace(/[\\"]/g, '\\$&')}"`;
21
+
22
+ export function createKeychain({ run = spawnSync, platform = process.platform } = {}) {
23
+ const available = platform === 'darwin';
24
+ const call = (args, input) => {
25
+ if (!available) throw keychainError('The macOS Keychain secret store is available only on macOS.');
26
+ const result = run(SECURITY, args, {
27
+ input, encoding: 'utf8', timeout: 15000, maxBuffer: 64 * 1024, stdio: ['pipe', 'pipe', 'pipe'],
28
+ });
29
+ if (result.error) throw keychainError('Could not run the macOS Keychain tool.');
30
+ return result;
31
+ };
32
+ const read = account => {
33
+ const result = call(['find-generic-password', '-s', SERVICE, '-a', account, '-w']);
34
+ if (result.status === NOT_FOUND) return undefined;
35
+ if (result.status !== 0) {
36
+ throw keychainError('Could not read an AutoRouter secret from the macOS Keychain. Unlock the login keychain, or set the key in the environment.');
37
+ }
38
+ return String(result.stdout).replace(/\n$/, '');
39
+ };
40
+ return {
41
+ available,
42
+ read,
43
+ write(account, value, label) {
44
+ if (typeof value !== 'string' || !PRINTABLE.test(value)) {
45
+ throw keychainError('Keychain secrets must be printable single-line ASCII.');
46
+ }
47
+ call(['-i'], `add-generic-password -U -s ${quote(SERVICE)} -a ${quote(account)} -l ${quote(label)} -w ${quote(value)}\n`);
48
+ // Interactive mode exits successfully even when a command fails.
49
+ if (read(account) !== value) throw keychainError('Could not save an AutoRouter secret to the macOS Keychain.');
50
+ },
51
+ remove(account) {
52
+ const result = call(['delete-generic-password', '-s', SERVICE, '-a', account]);
53
+ if (result.status !== 0 && result.status !== NOT_FOUND) {
54
+ throw keychainError('Could not remove an AutoRouter secret from the macOS Keychain.');
55
+ }
56
+ },
57
+ };
58
+ }
@@ -23,6 +23,9 @@ export function validateOllamaEndpoint(value) {
23
23
  || endpoint.username || endpoint.password || endpoint.search || endpoint.hash || endpoint.pathname !== '/') {
24
24
  throw new Error('Ollama must use a loopback base URL without a path, credentials, a query, or a fragment.');
25
25
  }
26
+ // Connect to the loopback address itself. Where `localhost` resolves comes
27
+ // from /etc/hosts and the resolver, which are not part of this check.
28
+ if (endpoint.hostname === 'localhost') endpoint.hostname = '127.0.0.1';
26
29
  return endpoint.origin;
27
30
  }
28
31
 
@@ -3,8 +3,9 @@ import { createInterface } from 'node:readline';
3
3
  import { Writable } from 'node:stream';
4
4
  import { promisify } from 'node:util';
5
5
  import { CLIENT_PROFILES, readConfig, requireKeys, parseStopHookBlockCap, parseSessionLogDir } from './config.mjs';
6
+ import { applyPolicy } from './policy.mjs';
6
7
  import { buildClaudeEnv, conflictingProviders, LOCAL_AUTH_HEADER } from './auth.mjs';
7
- import { CONFIG_KEYS, SECRET_CONFIG_KEYS, loadUserConfig, saveUserConfig } from './user-config.mjs';
8
+ import { CONFIG_KEYS, SECRET_CONFIG_KEYS, SECRET_STORES, keychainRemovals, loadUserConfig, saveUserConfig } from './user-config.mjs';
8
9
  import { DEFAULT_OLLAMA_MODEL, validateOllamaModel } from './ollama-models.mjs';
9
10
  import { inspectOllama, setupOllama } from './ollama-setup.mjs';
10
11
 
@@ -37,10 +38,12 @@ export async function askSecret(label, { input = process.stdin, output = process
37
38
  }
38
39
 
39
40
  export async function setup(args, {
40
- env = process.env, write = console.log, prompt = askSecret, fetchImpl = fetch, signal,
41
+ env = process.env, write = console.log, prompt = askSecret, fetchImpl = fetch, signal, keychain,
42
+ platform = process.platform, policy,
41
43
  } = {}) {
42
44
  if (signal?.aborted) throw new Error('Setup cancelled');
43
- const loaded = loadUserConfig(env, { allowMissing: true });
45
+ const store = { ...(keychain ? { keychain } : {}), ...(policy ? { policy } : {}) };
46
+ const loaded = loadUserConfig(env, { allowMissing: true, enforcePolicy: false, ...store });
44
47
  const replace = args.includes('--replace');
45
48
  const mergeExisting = loaded.exists && !replace;
46
49
  // Updating one preference must not turn unrelated runtime overrides into
@@ -50,12 +53,13 @@ export async function setup(args, {
50
53
  let explicitEvaluator = false, explicitAuthMode = false;
51
54
  let authMode = effectiveEnv.AUTOROUTER_AUTH_MODE ?? 'subscription';
52
55
  let clientProfile = effectiveEnv.AUTOROUTER_CLIENT_PROFILE ?? 'compatible';
53
- let evaluator = effectiveEnv.AUTOROUTER_EVALUATOR ?? 'jev';
56
+ let evaluator = effectiveEnv.AUTOROUTER_EVALUATOR ?? 'ollama';
54
57
  let model;
55
58
  let ollamaTimeoutMs;
56
59
  let stopHookBlockCap = effectiveEnv.CLAUDE_CODE_STOP_HOOK_BLOCK_CAP;
57
60
  let sessionLogDir = effectiveEnv.AUTOROUTER_SESSION_LOG_DIR;
58
61
  let sessionLogMode = effectiveEnv.AUTOROUTER_SESSION_LOG_MODE;
62
+ let secretStore;
59
63
  let pull = false;
60
64
  let overwrite = replace;
61
65
  for (let i = 0; i < args.length; i++) {
@@ -82,10 +86,14 @@ export async function setup(args, {
82
86
  sessionLogMode = args[++i];
83
87
  if (!['metadata', 'prompts'].includes(sessionLogMode)) throw new Error('--session-log-mode must be metadata or prompts');
84
88
  }
89
+ else if (args[i] === '--secret-store') {
90
+ secretStore = args[++i];
91
+ if (!SECRET_STORES.includes(secretStore)) throw new Error('--secret-store must be file or keychain');
92
+ }
85
93
  else if (args[i] === '--pull') pull = true;
86
94
  else if (args[i] === '--force') overwrite = true;
87
95
  else if (args[i] === '--replace') continue;
88
- else throw new Error('Usage: claude-autorouter setup [--auth-mode subscription|api-key] [--client-profile compatible|native|auto] [--evaluator jev|ollama] [--ollama-model TAG] [--ollama-timeout-ms N] [--stop-hook-block-cap N] [--session-log-dir DIR] [--session-log-mode metadata|prompts] [--pull] [--force|--replace]');
96
+ else throw new Error('Usage: claude-autorouter setup [--auth-mode subscription|api-key] [--client-profile compatible|native|auto] [--evaluator jev|ollama] [--ollama-model TAG] [--ollama-timeout-ms N] [--stop-hook-block-cap N] [--session-log-dir DIR] [--session-log-mode metadata|prompts] [--secret-store file|keychain] [--pull] [--force|--replace]');
89
97
  }
90
98
  if (!['subscription', 'api-key'].includes(authMode)) throw new Error('--auth-mode must be subscription or api-key');
91
99
  if (!CLIENT_PROFILES.includes(clientProfile)) throw new Error('--client-profile must be compatible, native or auto');
@@ -108,6 +116,11 @@ export async function setup(args, {
108
116
  if (stopHookBlockCap !== undefined) values.CLAUDE_CODE_STOP_HOOK_BLOCK_CAP = String(stopHookBlockCap);
109
117
  if (sessionLogDir !== undefined) values.AUTOROUTER_SESSION_LOG_DIR = sessionLogDir;
110
118
  if (sessionLogMode !== undefined) values.AUTOROUTER_SESSION_LOG_MODE = sessionLogMode;
119
+ if (secretStore !== undefined) values.AUTOROUTER_SECRET_STORE = secretStore;
120
+ // New and rebuilt configurations keep keys in the macOS Keychain. An existing
121
+ // configuration keeps its store until the user moves it deliberately.
122
+ const defaultedStore = values.AUTOROUTER_SECRET_STORE === undefined && platform === 'darwin' && (!loaded.exists || replace);
123
+ if (defaultedStore) values.AUTOROUTER_SECRET_STORE = 'keychain';
111
124
  if (evaluator === 'ollama') {
112
125
  values.AUTOROUTER_OLLAMA_MODEL = validateOllamaModel(model
113
126
  ?? (explicitEvaluator ? env.AUTOROUTER_OLLAMA_MODEL : undefined) ?? effectiveEnv.AUTOROUTER_OLLAMA_MODEL ?? DEFAULT_OLLAMA_MODEL);
@@ -119,6 +132,10 @@ export async function setup(args, {
119
132
  const keys = [...(evaluator === 'jev' ? ['TYPESAFE_API_KEY'] : []), ...(authMode === 'api-key' ? ['ANTHROPIC_API_KEY'] : [])];
120
133
  // Reject invalid settings before inviting secret input or making local calls.
121
134
  readConfig(values);
135
+ if (loaded.policy) applyPolicy(values, loaded.policy);
136
+ if (values.AUTOROUTER_SECRET_STORE === 'keychain' && platform !== 'darwin') {
137
+ throw new Error('--secret-store keychain is available only on macOS');
138
+ }
122
139
  for (const key of keys) {
123
140
  if (signal?.aborted) throw new Error('Setup cancelled');
124
141
  const explicitlySelected = !mergeExisting || (key === 'TYPESAFE_API_KEY' ? explicitEvaluator : explicitAuthMode);
@@ -140,17 +157,43 @@ export async function setup(args, {
140
157
  const cancel = () => controller.abort();
141
158
  if (!signal) for (const name of ['SIGINT', 'SIGTERM']) process.once(name, cancel);
142
159
  try { await setupOllama(config, { pull, warm: true, write, fetchImpl, signal: signal ?? controller.signal }); }
160
+ catch (error) {
161
+ // Local evaluation is the default, not a choice the user made, so a
162
+ // missing or stopped Ollama should say how to pick another evaluator.
163
+ if (!explicitEvaluator && !mergeExisting && !signal?.aborted) {
164
+ error.message = `${error.message} Local Ollama is the default evaluator; add --pull to download its model, or use TypeSafe Jev instead: claude-autorouter setup --evaluator jev`;
165
+ }
166
+ throw error;
167
+ }
143
168
  finally { if (!signal) for (const name of ['SIGINT', 'SIGTERM']) process.removeListener(name, cancel); }
144
169
  }
145
170
  if (signal?.aborted) throw new Error('Setup cancelled');
146
- saveUserConfig(values, { env, overwrite, expectedRevision: loaded.revision });
171
+ let savedStore = values.AUTOROUTER_SECRET_STORE ?? 'file';
172
+ try {
173
+ saveUserConfig(values, { env, overwrite, expectedRevision: loaded.revision,
174
+ removeSecrets: keychainRemovals(loaded, savedStore, { replace, values }), ...store });
175
+ } catch (error) {
176
+ // The default must not make setup impossible where the Keychain cannot be
177
+ // used (locked, headless). An explicit request still fails.
178
+ if (!defaultedStore || !keys.length || error?.code !== 'AUTOROUTER_CONFIG_ERROR' || !/Keychain/.test(error.message)) throw error;
179
+ write('The macOS Keychain is unavailable; saving keys in the private configuration file instead. Move them later with: claude-autorouter config set AUTOROUTER_SECRET_STORE keychain');
180
+ delete values.AUTOROUTER_SECRET_STORE;
181
+ savedStore = 'file';
182
+ saveUserConfig(values, { env, overwrite, expectedRevision: loaded.revision,
183
+ removeSecrets: keychainRemovals(loaded, savedStore, { replace, values }), ...store });
184
+ }
147
185
  write(`Saved ${authMode} configuration to ${path}`);
148
- write(`${keys.length ? 'Keys and settings are' : 'Settings are'} stored locally in this file with owner-only permissions. Environment variables take precedence.`);
186
+ if (keys.length && savedStore === 'keychain') {
187
+ write('Keys are stored in the macOS Keychain; settings are stored in this file with owner-only permissions. Environment variables take precedence.');
188
+ } else {
189
+ write(`${keys.length ? 'Keys and settings are' : 'Settings are'} stored locally in this file with owner-only permissions. Environment variables take precedence.`);
190
+ if (keys.length && platform === 'darwin' && savedStore === 'file' && !defaultedStore) write('Keys are plaintext in this file. Move them into the macOS Keychain: claude-autorouter config set AUTOROUTER_SECRET_STORE keychain');
191
+ }
149
192
  write('Next: claude-autorouter doctor, then claude-autorouter claude from your project.');
150
193
  }
151
194
 
152
195
  export async function doctor({ env = process.env, write = console.log, run = execute, fetchImpl = fetch, signal,
153
- evaluateLocal = false, json = false, diagnosticRunner } = {}) {
196
+ evaluateLocal = false, json = false, diagnosticRunner, keychain, platform = process.platform } = {}) {
154
197
  if (evaluateLocal) {
155
198
  try {
156
199
  const config = readConfig(loadUserConfig(env).env);
@@ -180,9 +223,14 @@ export async function doctor({ env = process.env, write = console.log, run = exe
180
223
  let config;
181
224
  let effectiveEnv = env;
182
225
  try {
183
- const loaded = loadUserConfig(env);
226
+ const loaded = loadUserConfig(env, keychain ? { keychain } : {});
184
227
  effectiveEnv = loaded.env;
185
228
  write(`Config: ${loaded.path}${loaded.exists ? '' : ' (absent; using environment)'}`);
229
+ if (loaded.policyPath) write(`Organization policy: ${loaded.policyPath} (locks: ${loaded.policyLocked.map(key => key.replace('AUTOROUTER_', '')).join(', ') || 'none'}).`);
230
+ if (loaded.secretStore === 'keychain') write(`Saved secrets: macOS Keychain (${loaded.keychainSecrets.length} found).`);
231
+ else if (platform === 'darwin' && SECRET_CONFIG_KEYS.some(key => Object.hasOwn(loaded.values, key))) {
232
+ write('WARN Saved keys are plaintext in the configuration file. Move them into the macOS Keychain: claude-autorouter config set AUTOROUTER_SECRET_STORE keychain');
233
+ }
186
234
  config = readConfig(effectiveEnv);
187
235
  requireKeys(config);
188
236
  report(true, `Configuration and required keys present (${config.authMode})`);
package/src/policy.mjs ADDED
@@ -0,0 +1,116 @@
1
+ import { lstatSync, readFileSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+
4
+ // Optional organization policy. It lives at a fixed system path that no
5
+ // environment variable can redirect, must be owned by root and not writable by
6
+ // anyone else, and is applied after the saved file and the environment, so
7
+ // neither can override it. This guards against configuration drift and
8
+ // environment-driven changes (direnv, devcontainers, CI variables). It does not
9
+ // stop someone who can run modified code; pair it with device management.
10
+ const ALLOWLISTS = Object.freeze({
11
+ allowed_evaluators: { key: 'AUTOROUTER_EVALUATOR', fallback: 'ollama', values: ['jev', 'ollama'] },
12
+ allowed_auth_modes: { key: 'AUTOROUTER_AUTH_MODE', fallback: 'api-key', values: ['api-key', 'subscription'] },
13
+ });
14
+ const LOCKS = Object.freeze({
15
+ session_log_mode: { key: 'AUTOROUTER_SESSION_LOG_MODE', values: ['metadata', 'prompts'] },
16
+ upstream_url: { key: 'AUTOROUTER_UPSTREAM_URL' },
17
+ jev_url: { key: 'AUTOROUTER_JEV_URL' },
18
+ });
19
+ export const POLICY_KEYS = Object.freeze([...Object.keys(ALLOWLISTS), ...Object.keys(LOCKS)]);
20
+
21
+ function policyError(message) {
22
+ const error = new Error(message);
23
+ error.code = 'AUTOROUTER_CONFIG_ERROR';
24
+ return error;
25
+ }
26
+
27
+ export function defaultPolicyPath(platform = process.platform) {
28
+ return platform === 'darwin'
29
+ ? '/Library/Application Support/claude-autorouter/policy.json'
30
+ : '/etc/claude-autorouter/policy.json';
31
+ }
32
+
33
+ function checkTrusted(path, trustedUid) {
34
+ for (const [target, kind] of [[path, 'file'], [dirname(path), 'directory']]) {
35
+ const stat = lstatSync(target);
36
+ const typeOk = kind === 'file' ? stat.isFile() : stat.isDirectory();
37
+ if (stat.isSymbolicLink() || !typeOk || stat.uid !== trustedUid || (stat.mode & 0o022) !== 0) {
38
+ throw policyError(`The AutoRouter organization policy ${kind} must be a regular ${kind} owned by root and not writable by group or others. Refusing to start.`);
39
+ }
40
+ }
41
+ }
42
+
43
+ function validate(parsed) {
44
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw policyError('The AutoRouter organization policy must be a JSON object.');
45
+ const policy = {};
46
+ for (const name of Object.keys(parsed)) {
47
+ if (!POLICY_KEYS.includes(name)) throw policyError('The AutoRouter organization policy contains an unsupported key.');
48
+ }
49
+ for (const [name, { values }] of Object.entries(ALLOWLISTS)) {
50
+ if (parsed[name] === undefined) continue;
51
+ const list = parsed[name];
52
+ if (!Array.isArray(list) || !list.length || list.some(value => !values.includes(value))) {
53
+ throw policyError(`Policy ${name} must be a nonempty list of: ${values.join(', ')}.`);
54
+ }
55
+ policy[name] = [...new Set(list)];
56
+ }
57
+ for (const [name, { values }] of Object.entries(LOCKS)) {
58
+ if (parsed[name] === undefined) continue;
59
+ const value = parsed[name];
60
+ const valid = values ? values.includes(value)
61
+ : typeof value === 'string' && /^https?:\/\/[^\s]+$/.test(value) && !/[\u0000-\u001f\u007f]/.test(value);
62
+ if (!valid) throw policyError(`Policy ${name} has an invalid value.`);
63
+ policy[name] = value;
64
+ }
65
+ return policy;
66
+ }
67
+
68
+ /**
69
+ * Returns undefined when no policy file exists. A file that exists but cannot
70
+ * be trusted or parsed is an error: policy fails closed.
71
+ */
72
+ export function loadPolicy({ path = defaultPolicyPath(), trustedUid = 0 } = {}) {
73
+ let content;
74
+ try {
75
+ lstatSync(path);
76
+ } catch (error) {
77
+ if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return undefined;
78
+ throw policyError(`Could not read the AutoRouter organization policy (${error.code ?? 'error'}). Refusing to start.`);
79
+ }
80
+ try {
81
+ checkTrusted(path, trustedUid);
82
+ content = readFileSync(path, 'utf8');
83
+ } catch (error) {
84
+ if (error?.code === 'AUTOROUTER_CONFIG_ERROR') throw error;
85
+ throw policyError('Could not read the AutoRouter organization policy. Refusing to start.');
86
+ }
87
+ let parsed;
88
+ try { parsed = JSON.parse(content); }
89
+ catch { throw policyError('The AutoRouter organization policy must contain valid JSON.'); }
90
+ return { path, values: validate(parsed) };
91
+ }
92
+
93
+ /**
94
+ * Enforce allowlists (an unlisted choice is an error) and apply locks (the
95
+ * policy value replaces whatever the file or environment supplied). Commands
96
+ * that repair a configuration pass allowlists: false and check new values
97
+ * themselves, so a disallowed saved setting can still be corrected.
98
+ */
99
+ export function applyPolicy(env, policy, { allowlists = true } = {}) {
100
+ const result = { ...env };
101
+ const locked = [];
102
+ for (const [name, { key, fallback }] of Object.entries(ALLOWLISTS)) {
103
+ const allowed = policy[name];
104
+ if (!allowed || !allowlists) continue;
105
+ const value = result[key] ?? fallback;
106
+ if (!allowed.includes(value)) {
107
+ throw policyError(`${key} is not permitted by the AutoRouter organization policy. Allowed: ${allowed.join(', ')}.`);
108
+ }
109
+ }
110
+ for (const [name, { key }] of Object.entries(LOCKS)) {
111
+ if (policy[name] === undefined) continue;
112
+ result[key] = policy[name];
113
+ locked.push(key);
114
+ }
115
+ return { env: result, locked };
116
+ }
@@ -1,3 +1,5 @@
1
+ import { redactSensitive } from './redaction.mjs';
2
+
1
3
  // Claude Code prepends these as separate text blocks. Ignore only complete
2
4
  // wrapper blocks; a user's text that mentions a tag or mixes it with a task
3
5
  // must remain part of the classifier's input.
@@ -51,6 +53,16 @@ function excerpt(text, length) {
51
53
 
52
54
  const textCost = text => JSON.stringify(text).length - 2;
53
55
 
56
+ // Redact before excerpting so a value cannot be split into an unrecognizable
57
+ // fragment. Very long text keeps only end windows wider than any retained
58
+ // excerpt; the margin also keeps a value cut at a window edge out of the result.
59
+ const REDACTION_MARGIN = 4096;
60
+ function classifierText(text, limit) {
61
+ const span = limit + REDACTION_MARGIN;
62
+ if (text.length > 2 * span) text = `${text.slice(0, span)}\n[... omitted ...]\n${text.slice(-span)}`;
63
+ return redactSensitive(text);
64
+ }
65
+
54
66
  // Budget serialized characters, including JSON escaping, rather than just
55
67
  // raw text length. The two ends keep both an initial instruction and a final
56
68
  // question visible when one long text block must be shortened.
@@ -145,6 +157,9 @@ export function promptExcerpt(body, maxChars = 500) {
145
157
  const blocks = typeof content === 'string' ? [{ type: 'text', text: content }]
146
158
  : Array.isArray(content) ? content : [];
147
159
  const characters = [];
160
+ // Collect past the retained length: redaction must see a value that
161
+ // straddles the final boundary, and may shorten the text before the cut.
162
+ const window = maxChars + REDACTION_MARGIN;
148
163
  let nonText = false;
149
164
  for (const block of blocks) {
150
165
  if (block?.type !== 'text' || typeof block.text !== 'string') {
@@ -157,12 +172,12 @@ export function promptExcerpt(body, maxChars = 500) {
157
172
  if (!/\S/.test(value) || isReminderBlock(value)) continue;
158
173
  if (characters.length) characters.push('\n');
159
174
  for (const character of value) {
160
- if (characters.length >= maxChars) break;
175
+ if (characters.length >= window) break;
161
176
  characters.push(character);
162
177
  }
163
- if (characters.length >= maxChars) break;
178
+ if (characters.length >= window) break;
164
179
  }
165
- if (characters.length) return characters.join('').toWellFormed();
180
+ if (characters.length) return [...redactSensitive(characters.join('').toWellFormed())].slice(0, maxChars).join('').toWellFormed();
166
181
  // An image/document-only human turn is a new task with no safe excerpt;
167
182
  // do not incorrectly label it with the preceding human task's text.
168
183
  if (nonText) return '';
@@ -196,9 +211,9 @@ export function buildState(body, limit = 12000) {
196
211
  const remaining = () => Math.max(0, limit - JSON.stringify(state).length);
197
212
  // Reserve more than half of the budget for the actual latest human task
198
213
  // before considering reminders, original instructions, or tool results.
199
- state.current_task = fitText(currentTask, Math.min(remaining(), Math.floor(limit * 0.55)));
200
- state.original_task = fitText(firstTask, Math.min(2000, Math.floor(remaining() * 0.3)));
201
- state.system = fitText(contentText(body.system, true), Math.min(1000, Math.floor(remaining() * 0.3)));
214
+ state.current_task = fitText(classifierText(currentTask, limit), Math.min(remaining(), Math.floor(limit * 0.55)));
215
+ state.original_task = fitText(classifierText(firstTask, limit), Math.min(2000, Math.floor(remaining() * 0.3)));
216
+ state.system = fitText(classifierText(contentText(body.system, true), limit), Math.min(1000, Math.floor(remaining() * 0.3)));
202
217
 
203
218
  for (let index = messages.length - 1; index >= 0 && state.recent_messages.length < 8; index--) {
204
219
  // current_task already contains this message; leave room for actual
@@ -210,7 +225,7 @@ export function buildState(body, limit = 12000) {
210
225
  const overhead = JSON.stringify(entry).length + (state.recent_messages.length ? 1 : 0);
211
226
  const budget = Math.min(3000, remaining() - overhead);
212
227
  if (budget < 1) break;
213
- entry.content = fitText(text, budget);
228
+ entry.content = fitText(classifierText(text, limit), budget);
214
229
  state.recent_messages.unshift(entry);
215
230
  }
216
231
  return state;
@@ -0,0 +1,97 @@
1
+ // @ts-check
2
+ // Pattern-based redaction for evaluator excerpts. Classification needs the
3
+ // shape of a task, not credentials or personal identifiers, so recognizable
4
+ // values are replaced before any excerpt leaves the inference path. This is a
5
+ // best-effort filter: unrecognized formats can remain, and code that merely
6
+ // resembles an assignment can be over-redacted. Every quantifier is bounded or
7
+ // excludes its delimiter, so scanning stays linear in the input length.
8
+
9
+ const marker = kind => `[REDACTED:${kind}]`;
10
+
11
+ function ibanValid(value) {
12
+ const compact = value.replace(/ /g, '');
13
+ if (compact.length < 15 || compact.length > 34) return false;
14
+ const rearranged = compact.slice(4) + compact.slice(0, 4);
15
+ let remainder = 0;
16
+ for (const character of rearranged) {
17
+ const digits = /[0-9]/.test(character) ? character : String(character.charCodeAt(0) - 55);
18
+ for (const digit of digits) remainder = (remainder * 10 + Number(digit)) % 97;
19
+ }
20
+ return remainder === 1;
21
+ }
22
+
23
+ function luhnValid(value) {
24
+ const digits = value.replace(/[ -]/g, '');
25
+ if (digits.length < 13 || digits.length > 19) return false;
26
+ let sum = 0;
27
+ for (let i = 0; i < digits.length; i++) {
28
+ let digit = Number(digits[digits.length - 1 - i]);
29
+ if (i % 2 === 1) { digit *= 2; if (digit > 9) digit -= 9; }
30
+ sum += digit;
31
+ }
32
+ return sum % 10 === 0;
33
+ }
34
+
35
+ const base64 = code => (code >= 48 && code <= 57) || (code >= 65 && code <= 90) || (code >= 97 && code <= 122)
36
+ || code === 43 || code === 47 || code === 61;
37
+
38
+ // Key material whose BEGIN line was cut off before the excerpt: walk back from
39
+ // each END line over whole base64 lines. A regex line repetition would
40
+ // backtrack quadratically on a long base64 run that is not followed by END.
41
+ function redactKeyTails(text) {
42
+ const end = /-----END [A-Z0-9 ]{0,40}PRIVATE KEY(?: BLOCK)?-----/g;
43
+ let output = '', copied = 0;
44
+ for (let match; (match = end.exec(text));) {
45
+ let start = match.index;
46
+ while (start > copied && text[start - 1] === '\n') {
47
+ let lineEnd = start - 1;
48
+ if (lineEnd > copied && text[lineEnd - 1] === '\r') lineEnd--;
49
+ let lineStart = lineEnd;
50
+ while (lineStart > copied && base64(text.charCodeAt(lineStart - 1))) lineStart--;
51
+ if (lineEnd - lineStart < 16 || (lineStart > copied && text[lineStart - 1] !== '\n')) break;
52
+ start = lineStart;
53
+ }
54
+ if (start === match.index) continue;
55
+ output += text.slice(copied, start) + marker('private_key');
56
+ copied = end.lastIndex;
57
+ }
58
+ return copied ? output + text.slice(copied) : text;
59
+ }
60
+
61
+ /** @type {ReadonlyArray<readonly [RegExp, string | ((...match: string[]) => string)] | ((text: string) => string)>} */
62
+ const RULES = Object.freeze([
63
+ // A block cut off by excerpting is still redacted through the end of text.
64
+ [/-----BEGIN [A-Z0-9 ]{0,40}PRIVATE KEY(?: BLOCK)?-----[\s\S]*?(?:-----END [A-Z0-9 ]{0,40}PRIVATE KEY(?: BLOCK)?-----|$)/g, marker('private_key')],
65
+ redactKeyTails,
66
+ [/\b([a-z][a-z0-9+.-]{1,20}:\/\/)[^\s:@/]{1,256}:[^\s@/]{1,256}@/gi, (_, scheme) => `${scheme}${marker('credentials')}@`],
67
+ [/\bsk-[A-Za-z0-9_-]{20,}/g, marker('secret')],
68
+ [/\b[rs]k_(?:live|test)_[A-Za-z0-9]{16,}/g, marker('secret')],
69
+ [/\b(?:AKIA|ASIA|ABIA|ACCA)[A-Z0-9]{16}\b/g, marker('secret')],
70
+ [/\b(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{22,})/g, marker('secret')],
71
+ [/\bglpat-[A-Za-z0-9_-]{20,}/g, marker('secret')],
72
+ [/\bxox[abposr]-[A-Za-z0-9-]{10,}/g, marker('secret')],
73
+ [/https:\/\/hooks\.slack\.com\/services\/[A-Za-z0-9/]{8,}/g, marker('secret')],
74
+ [/\bAIza[0-9A-Za-z_-]{35}/g, marker('secret')],
75
+ [/\bnpm_[A-Za-z0-9]{36}/g, marker('secret')],
76
+ [/\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g, marker('secret')],
77
+ [/\b((?:proxy-)?authorization["']?[ \t]{0,8}[:=][ \t]{0,8}["']?)(?:(Bearer|Basic|Token|Digest)[ \t]+)?[^\s"',;]{4,}/gi,
78
+ (_, prefix, scheme) => `${prefix}${scheme ? `${scheme} ` : ''}${marker('secret')}`],
79
+ [/\b(Bearer[ \t]+)[A-Za-z0-9._~+/-]{16,}=*/g, (_, prefix) => `${prefix}${marker('secret')}`],
80
+ // Keep the setting name: it tells the classifier what kind of work this is.
81
+ [/\b([A-Za-z0-9_.-]{0,40}(?:passw(?:or)?d|pwd|secret|token|api[_-]?key|access[_-]?key|private[_-]?key|credentials?)[A-Za-z0-9_.-]{0,40})(["']?[ \t]{0,8}[:=][ \t]{0,8})(["']?)[^\s"',;]{4,}/gi,
82
+ (_, name, separator, quote) => `${name}${separator}${quote}${marker('secret')}`],
83
+ [/\b[A-Za-z0-9._%+-]{1,64}@[A-Za-z0-9.-]{1,253}\.[A-Za-z]{2,24}\b/g, marker('email')],
84
+ [/\b[A-Z]{2}[0-9]{2}(?: ?[A-Z0-9]{4}){2,7}(?: ?[A-Z0-9]{1,4})?\b/g, match => ibanValid(match) ? marker('iban') : match],
85
+ // Major card networks only, so millisecond timestamps and IDs survive.
86
+ [/\b(?:4|5[1-5]|2[2-7]|3[47]|6)(?:[0-9][ -]?){11,17}[0-9]\b/g, match => luhnValid(match) ? marker('card') : match],
87
+ ]);
88
+
89
+ /** @param {string} text */
90
+ export function redactSensitive(text) {
91
+ let result = text;
92
+ for (const rule of RULES) {
93
+ // @ts-ignore String.replace accepts either replacement form.
94
+ result = typeof rule === 'function' ? rule(result) : result.replace(rule[0], rule[1]);
95
+ }
96
+ return result;
97
+ }
package/src/router.mjs CHANGED
@@ -191,7 +191,7 @@ export class Router {
191
191
  // Include live configuration/rubric facts so configuration changes cannot
192
192
  // reuse cached decisions from another evaluator, account or confidence rule.
193
193
  const c = this.config;
194
- const evaluator = c.evaluator ?? 'jev';
194
+ const evaluator = c.evaluator ?? 'ollama';
195
195
  const settings = evaluator === 'ollama'
196
196
  ? { evaluator, ollamaEndpoint: c.ollamaEndpoint, ollamaModel: c.ollamaModel, ollamaTimeoutMs: c.ollamaTimeoutMs,
197
197
  ollamaStateChars: c.ollamaStateChars, ollamaKeepAlive: c.ollamaKeepAlive }
package/src/server.mjs CHANGED
@@ -47,8 +47,10 @@ function readBody(req, limit) {
47
47
 
48
48
  function jsonError(res, status, message) {
49
49
  if (res.headersSent || res.destroyed) { res.destroy(); return; }
50
- res.writeHead(status, { 'content-type': 'application/json' });
50
+ // An oversized body is no longer read; close rather than keep the socket.
51
+ res.writeHead(status, { 'content-type': 'application/json', ...(status === 413 ? { connection: 'close' } : {}) });
51
52
  res.end(JSON.stringify({ type: 'error', error: { type: 'api_error', message } }));
53
+ if (status === 413) res.once('finish', () => res.socket?.destroy());
52
54
  }
53
55
 
54
56
  function upstreamHeaders(incoming, config) {
@@ -231,7 +231,8 @@ export async function sessionsCommand(args, { env = process.env, write = console
231
231
  || (operation === 'list' ? positional.length !== 0 : positional.length !== 1)) {
232
232
  throw new Error('Usage: claude-autorouter sessions list [--json] | sessions show ID [--json]');
233
233
  }
234
- const loaded = loadUserConfig(env, { allowMissing: true });
234
+ // History needs only the log directory; never prompt for keychain access.
235
+ const loaded = loadUserConfig(env, { allowMissing: true, readSecrets: false, enforcePolicy: false });
235
236
  const directory = parseSessionLogDir(loaded.env.AUTOROUTER_SESSION_LOG_DIR);
236
237
  if (!directory) {
237
238
  const report = { schema_version: 1, type: 'session_history', logging_enabled: false, sessions: [],