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.
- package/.env.example +7 -4
- package/CODE_OF_CONDUCT.md +9 -0
- package/CONTRIBUTING.md +21 -1
- package/README.md +13 -9
- package/SECURITY.md +25 -0
- package/SUPPORT.md +18 -0
- package/bin/autorouter.mjs +2 -0
- package/docs/reference.md +43 -9
- package/docs/releasing.md +7 -3
- package/docs/subscription-integration.md +27 -0
- package/package.json +10 -2
- package/src/cli-help.mjs +10 -7
- package/src/config-command.mjs +30 -9
- package/src/config.mjs +2 -2
- package/src/keychain.mjs +58 -0
- package/src/ollama-models.mjs +3 -0
- package/src/onboarding.mjs +57 -9
- package/src/policy.mjs +116 -0
- package/src/prompt-state.mjs +22 -7
- package/src/redaction.mjs +97 -0
- package/src/router.mjs +1 -1
- package/src/server.mjs +3 -1
- package/src/session-history.mjs +2 -1
- package/src/status-cleanup.mjs +65 -0
- package/src/telemetry-event.mjs +4 -0
- package/src/user-config.mjs +80 -6
package/src/config-command.mjs
CHANGED
|
@@ -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
|
|
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,
|
|
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
|
-
|
|
138
|
-
|
|
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 ? '
|
|
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 ?? '
|
|
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.
|
package/src/keychain.mjs
ADDED
|
@@ -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
|
+
}
|
package/src/ollama-models.mjs
CHANGED
|
@@ -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
|
|
package/src/onboarding.mjs
CHANGED
|
@@ -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
|
|
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 ?? '
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/prompt-state.mjs
CHANGED
|
@@ -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 >=
|
|
175
|
+
if (characters.length >= window) break;
|
|
161
176
|
characters.push(character);
|
|
162
177
|
}
|
|
163
|
-
if (characters.length >=
|
|
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 ?? '
|
|
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
|
-
|
|
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) {
|
package/src/session-history.mjs
CHANGED
|
@@ -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
|
-
|
|
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: [],
|