claude-autorouter 0.5.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 CHANGED
@@ -3,7 +3,7 @@ AUTOROUTER_AUTH_MODE=subscription
3
3
  AUTOROUTER_CLIENT_PROFILE=compatible
4
4
  # Local Ollama is the default evaluator (experimental; see settings below).
5
5
  # Set jev to use TypeSafe's hosted evaluator, which needs TYPESAFE_API_KEY.
6
- AUTOROUTER_EVALUATOR=jev
6
+ AUTOROUTER_EVALUATOR=ollama
7
7
  # The launcher enables the router status line for this session. Set 0 to keep your own.
8
8
  AUTOROUTER_STATUSLINE=1
9
9
  # Claude's Auto permission mode needs a supported Sonnet or Opus client.
@@ -14,10 +14,12 @@ AUTOROUTER_STATUSLINE=1
14
14
  # Optional metadata logs on stderr. Redirect stderr to a file when using the UI.
15
15
  # AUTOROUTER_DEBUG=1
16
16
  # Optional persistent JSONL decisions/outcomes, one file per session per launch.
17
- # Includes up to 500 characters of user prompt text; keep the directory local.
17
+ # Defaults to metadata only. Set AUTOROUTER_SESSION_LOG_MODE=prompts to include
18
+ # up to 500 characters of user prompt text; keep the directory local.
18
19
  # Unset or empty disables logging. This does not print prompts in the terminal.
19
20
  # AUTOROUTER_SESSION_LOG_DIR=/absolute/path/to/autorouter-sessions
20
- # Omit all prompt excerpt fields (does not enable logging by itself).
21
+ # metadata (default) omits prompt excerpt fields; prompts includes them.
22
+ # Setting a mode does not enable logging by itself.
21
23
  # AUTOROUTER_SESSION_LOG_MODE=metadata
22
24
  # Optional: allow two tool-free Stop-hook continuations, then end the turn on
23
25
  # the third block. Applies to /goal and all Stop/SubagentStop hooks.
package/README.md CHANGED
@@ -4,7 +4,7 @@ An independent local model-routing gateway for Claude Code. AutoRouter is not af
4
4
 
5
5
  Use Haiku, Sonnet and Opus in one Claude Code session. AutoRouter evaluates each coding request, checks model compatibility and context capacity, and forwards it through a local gateway. Native Ollama `/v1/systemone` models are the default, experimental local evaluator, so task excerpts stay on your machine; [TypeSafe Jev](https://typesafe.ai/blog/introducing-system-one-models-and-jev) is an optional hosted evaluator (`setup --evaluator jev`). Claude owns authentication, tool permissions and safety review.
6
6
 
7
- Requires Node.js 22+, macOS or Linux (including WSL), an installed `claude` command, and a Claude subscription login or Anthropic API key. The default evaluator also needs a [TypeSafe API key](https://console.typesafe.ai). The installed CLI has no runtime dependencies.
7
+ Requires Node.js 22+, macOS or Linux (including WSL), an installed `claude` command, and a Claude subscription login or Anthropic API key. The optional Jev evaluator needs a [TypeSafe API key](https://console.typesafe.ai). The installed CLI has no runtime dependencies.
8
8
 
9
9
  Version 0.4.0 adds `config`, `sessions` and `doctor --evaluate-local`, durable task continuity, and clearer model outcomes. Upgrade from 0.3.x to use these commands. The [contributor guide](CONTRIBUTING.md) explains local verification, and the [release guide](docs/releasing.md) covers the changes and verified publication.
10
10
 
package/SECURITY.md CHANGED
@@ -16,8 +16,10 @@ Relevant reports include credential exposure, unauthorized access to the local g
16
16
 
17
17
  ## Handling diagnostic data
18
18
 
19
+ Organizations can enforce the evaluator, authentication mode, upstream and log mode with a root-owned policy file that environment variables and saved settings cannot override. See [organization policy](docs/reference.md#organization-policy).
20
+
19
21
  AutoRouter's default evaluator is local Ollama, which keeps classification on loopback. The optional hosted Jev evaluator (`--evaluator jev`) receives bounded task/history excerpts, which may contain private code or tool results. Recognizable credentials and personal identifiers are redacted from those excerpts first; this pattern-based filter reduces, but does not eliminate, disclosure. Anthropic still receives the full inference request. See [data flow and authentication](docs/reference.md#data-flow-and-authentication).
20
22
 
21
23
  New macOS setups keep saved keys in the login Keychain. Existing and non-macOS configurations keep plaintext keys in the private configuration file until you run `claude-autorouter config set AUTOROUTER_SECRET_STORE keychain` (macOS only). See [credential storage](docs/reference.md#credential-storage).
22
24
 
23
- Session logging is optional. Enabling only a log directory uses the default `prompts` mode, which includes bounded human-task excerpts with recognizable credentials and personal identifiers redacted (pattern-based, so not exhaustive). Select `AUTOROUTER_SESSION_LOG_MODE=metadata` before enabling a directory to omit those excerpts. Inspect even metadata-only output before sharing it; identifiers, paths or environment details can still be sensitive. Saved logs have no automatic deletion policy. See [history and privacy](docs/reference.md#session-decision-logs).
25
+ Session logging is optional. Enabling a log directory uses the default `metadata` mode, which omits prompt excerpts. Opting into `AUTOROUTER_SESSION_LOG_MODE=prompts` includes bounded human-task excerpts with recognizable credentials and personal identifiers redacted (pattern-based, so not exhaustive). Inspect even metadata-only output before sharing it; identifiers, paths or environment details can still be sensitive. Saved logs have no automatic deletion policy. See [history and privacy](docs/reference.md#session-decision-logs).
@@ -7,6 +7,7 @@ import { createRouterServer, listen } from '../src/server.mjs';
7
7
  import { buildClaudeEnv, clientProfileForLaunch, conflictingProviders } from '../src/auth.mjs';
8
8
  import { dirname } from 'node:path';
9
9
  import { createStatusState } from '../src/status-state.mjs';
10
+ import { removeStaleStatusDirectories } from '../src/status-cleanup.mjs';
10
11
  import { addStatusLineSettings } from '../src/status-settings.mjs';
11
12
  import { createSessionLog } from '../src/session-log.mjs';
12
13
  import { loadUserConfig } from '../src/user-config.mjs';
@@ -100,6 +101,7 @@ if (['--version', '-v', 'version'].includes(command)) {
100
101
  const statusEnabled = command === 'claude' && runtimeEnv.AUTOROUTER_STATUSLINE !== '0';
101
102
  let claudeArgs = args;
102
103
  if (statusEnabled) {
104
+ await removeStaleStatusDirectories();
103
105
  status = createStatusState({ baselineModel: config.models.opus });
104
106
  await status.ready;
105
107
  if (status.path) {
package/docs/reference.md CHANGED
@@ -70,7 +70,7 @@ For an environment-only subscription launch, set `AUTOROUTER_AUTH_MODE=subscript
70
70
  | `AUTOROUTER_STATUSLINE` | enabled | `0` retains your existing status line |
71
71
  | `AUTOROUTER_DEBUG` | off | `1` enables launcher metadata logs on stderr |
72
72
  | `AUTOROUTER_SESSION_LOG_DIR` | off | Write per-session JSONL decisions and outcomes into this directory; unset or empty disables it |
73
- | `AUTOROUTER_SESSION_LOG_MODE` | `prompts` | `metadata` omits prompt excerpts; setting a mode alone does not enable logging |
73
+ | `AUTOROUTER_SESSION_LOG_MODE` | `metadata` | `metadata` omits prompt excerpts; `prompts` includes bounded human-task excerpts; setting a mode alone does not enable logging |
74
74
  | `CLAUDE_CODE_STOP_HOOK_BLOCK_CAP` | unset; Claude currently uses `8` | Optional cap on consecutive Stop/SubagentStop continuations without tool use; `0` disables the cap |
75
75
  | `ENABLE_TOOL_SEARCH` | `true` in launcher when unset | Load MCP tool definitions on demand; explicit values are preserved |
76
76
  | `AUTOROUTER_HAIKU_MODEL` | `claude-haiku-4-5-20251001` | Routine tier |
@@ -105,6 +105,24 @@ claude-autorouter config set TYPESAFE_API_KEY
105
105
 
106
106
  Normal startup validates the selected evaluator; stale settings for the inactive evaluator do not prevent it from starting. `show --check-all` explicitly checks both. Blank numeric settings fail with their setting name; zero retains its documented meaning. `claude-autorouter help COMMAND` gives focused command help. `claude-autorouter claude --help` and `--version` call Claude directly without router setup or credentials.
107
107
 
108
+ ### Organization policy
109
+
110
+ An administrator can restrict what users may configure with a policy file at a fixed system path: `/Library/Application Support/claude-autorouter/policy.json` on macOS and `/etc/claude-autorouter/policy.json` on Linux. No environment variable changes this path. The file and its directory must be regular, owned by root, and not writable by group or others; otherwise AutoRouter refuses to start. An invalid or unreadable file also stops it, so a broken policy never silently turns off.
111
+
112
+ ```json
113
+ {
114
+ "allowed_evaluators": ["ollama"],
115
+ "allowed_auth_modes": ["subscription"],
116
+ "session_log_mode": "metadata",
117
+ "upstream_url": "https://api.anthropic.com",
118
+ "jev_url": "https://api.typesafe.ai/v1/systemone"
119
+ }
120
+ ```
121
+
122
+ All keys are optional. `allowed_evaluators` and `allowed_auth_modes` reject any other choice, including an unset default, with an error that names the setting. `session_log_mode`, `upstream_url` and `jev_url` replace whatever the saved file or environment supplies; `config show` reports them with source `policy`. `doctor` prints the policy path and the locked settings. `setup` and `config set` refuse to save a disallowed value, but still let a user correct a setting that the policy now forbids.
123
+
124
+ The policy guards against configuration drift and environment-driven changes such as direnv, devcontainer or CI variables. It does not stop someone who can run modified code, or run Claude Code without AutoRouter. Deploy it with device management, and use it with an allowlist of approved package versions.
125
+
108
126
  ## Ollama evaluator
109
127
 
110
128
  The local configuration documented here requires AutoRouter 0.3.2 or newer and remains experimental. It uses Ollama's native `/v1/systemone` decision endpoint for every model, replacing the chat backend from 0.2.0. Jev is the optional hosted evaluator, using TypeSafe's `/v1/systemone` endpoint and a TypeSafe API key. Selecting Ollama never silently switches back to Jev. Haiku, Sonnet, or Opus still completes the task through Anthropic.
@@ -145,7 +163,7 @@ claude-autorouter setup --evaluator ollama --ollama-model tev1:4b-q4_K_M --pull
145
163
 
146
164
  For Nimble, the explicit Q4_K_M tag avoids `nimble:latest`, which currently selects an approximately 9.5 GB Q8 model. For Tev1, `tev1:latest` and `tev1:4b` select approximately 4.5 GB Q8 weights; the explicit `tev1:4b-q4_K_M` tag selects the smaller 4B download. Download size is not resident memory: runtime and context allocations add to it, and other applications need memory too. Downloaded models have their own licenses and are not bundled in this package. In historical tests before 0.3.2 on a 16 GiB M4, Tev1 0.8B matched 18/24 held-out labels at 450 ms median latency within 1,500 ms; 4B matched 22/24 at 3.15 seconds with a separate 10-second deadline. See the [local measurements](ollama-evaluation.md) before choosing a latency deadline.
147
165
 
148
- The endpoint must be loopback (`127.0.0.1`, `localhost`, or `::1`), without a path, credentials, query, or fragment. Cloud model tags and metadata identifying a remote model are rejected before sending task text. Claude and Jev credentials are never attached to Ollama requests.
166
+ The endpoint must be loopback (`127.0.0.1`, `localhost`, or `::1`); `localhost` is converted to `127.0.0.1` so the connection does not depend on name resolution, without a path, credentials, query, or fragment. Cloud model tags and metadata identifying a remote model are rejected before sending task text. Claude and Jev credentials are never attached to Ollama requests.
149
167
 
150
168
  ### Classification and fallback
151
169
 
@@ -210,7 +228,7 @@ In API-key mode, the upstream key stays in the proxy and Claude receives a tempo
210
228
 
211
229
  The proxy processes authenticated requests in memory, including their authorization headers. Preserving Claude's login flow does not by itself establish that every deployment is permitted. The [provider-policy note](subscription-integration.md#provider-guidance-and-unresolved-scope) records the current documentation and the unresolved scope of model-rewriting subscription forwarding. Jev requires its own TypeSafe credentials and billing, separate from Anthropic authentication.
212
230
 
213
- Routine diagnostic logs contain route, model, timing, usage, and error-category metadata, not prompts, raw responses, or credentials. Opt-in session history is separate and includes task excerpts in its default `prompts` mode. Status snapshots contain routing metadata and token counts in a private temporary directory and are deleted on normal launcher exit. Classification, turn, and token-count caches are held in memory. Claude Code and the external providers have their own storage and logging behavior.
231
+ Routine diagnostic logs contain route, model, timing, usage, and error-category metadata, not prompts, raw responses, or credentials. Opt-in session history is separate and includes task excerpts only in `prompts` mode; the default is `metadata` (changed from `prompts` in 0.5.1; set `AUTOROUTER_SESSION_LOG_MODE=prompts` to keep excerpts). Status snapshots contain routing metadata and token counts in a private temporary directory and are deleted on normal launcher exit. After a hard kill, the next launch removes directories whose process is gone (only your own, owner-only `autorouter-status-*` directories in the temporary directory). Classification, turn, and token-count caches are held in memory. Claude Code and the external providers have their own storage and logging behavior.
214
232
 
215
233
  ## Routing policy
216
234
 
@@ -321,7 +339,7 @@ claude-autorouter sessions show autorouter-session-EXAMPLE --json
321
339
 
322
340
  Use the exact `id` printed by `sessions list`. Commands need no evaluator credentials and do not contact providers. Human summaries distinguish selected models, observed serving models, confirmed completions, failures, cancellations, and pending/unconfirmed requests. They report routing latency, fallback and override counts, and API-equivalent savings coverage. A selected model or an HTTP 200 alone does not prove successful inference. Old schema-1 decision logs remain readable and explicitly lack outcome evidence.
323
341
 
324
- `prompts` mode preserves the existing excerpt behavior when a log directory is enabled. Main requests retain at most 500 Unicode characters of the human task; recognized tool and goal continuations retain the originating task. Auxiliary, subagent, compaction, workflow, and attachment-only requests have empty excerpts. Metadata mode omits the excerpt fields entirely. Neither mode logs authentication headers, provider replies, full transcripts, or tool payloads. Text entered directly in a prompt can appear in an enabled prompt excerpt.
342
+ `prompts` mode (opt-in) records excerpts when a log directory is enabled. Main requests retain at most 500 Unicode characters of the human task; recognized tool and goal continuations retain the originating task. Auxiliary, subagent, compaction, workflow, and attachment-only requests have empty excerpts. Metadata mode omits the excerpt fields entirely. Neither mode logs authentication headers, provider replies, full transcripts, or tool payloads. Text entered directly in a prompt can appear in an enabled prompt excerpt.
325
343
 
326
344
  The settings also work with `serve` and every client profile. `setup --session-log-dir DIR --session-log-mode metadata --force` updates an existing configuration. Setup resolves relative directories at setup time; environment-only paths resolve from the launch directory. `AUTOROUTER_SESSION_LOG_DIR=''` disables a saved directory for one launch. Setting only the mode never enables logging. `doctor` reports preferences without creating files.
327
345
 
package/docs/releasing.md CHANGED
@@ -20,6 +20,8 @@ Version `0.4.0` completes the routing, configuration, history and performance im
20
20
 
21
21
  Version `0.5.0` makes local Ollama the default evaluator and TypeSafe Jev an explicit option (`setup --evaluator jev`), so evaluator excerpts stay on the machine unless the user opts in. **Breaking for environment-only launches** that relied on the implicit Jev default; configurations created by `setup` record their evaluator and are unchanged. Evaluator excerpts and opt-in session-log prompt excerpts are now redacted for recognizable credentials and personal identifiers (pattern-based, not exhaustive). On macOS, new `setup` runs keep saved keys in the login Keychain by default; existing plaintext configurations are not moved implicitly, and `doctor` prints the `config set AUTOROUTER_SECRET_STORE keychain` command to move them. See [credential storage](reference.md#credential-storage) and the [data flow](reference.md#data-flow-and-authentication).
22
22
 
23
+ Version `0.5.1` hardens defaults and adds an optional organization policy. **Behavior change:** enabling `AUTOROUTER_SESSION_LOG_DIR` now records metadata only; set `AUTOROUTER_SESSION_LOG_MODE=prompts` to keep prompt excerpts. Saved configurations that already record a mode are unchanged. A root-owned policy file can restrict the evaluator and authentication mode and lock the log mode and service URLs; see [organization policy](reference.md#organization-policy). The next launch removes status directories left by a hard-killed launcher, an Ollama `localhost` endpoint now connects to `127.0.0.1`, an oversized request body closes its connection after the 413 response, and `.env.example` selects the local evaluator to match the default.
24
+
23
25
  The GitHub repository became public on October 6, 2026, after preparation PR #1 merged. That launch created no release tag and published no new npm version. npm publication remains a separate release operation. Its tarball includes runtime source, README, configuration example, license, and shipped documentation; model weights, user configuration, credentials, transcripts, session logs, local artifacts, and test fixtures are excluded. Review each release archive, especially when the package allowlist changes. The public Git repository also exposes history, development scripts and tests; the npm archive allowlist does not govern that material.
24
26
 
25
27
  ## What runs automatically
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-autorouter",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "description": "AutoRouter: a local model-routing gateway for Claude Code with Jev and Ollama System One evaluators",
@@ -2,6 +2,7 @@ import { readConfig, parseSessionLogDir, parseStopHookBlockCap } from './config.
2
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'
@@ -35,7 +36,8 @@ export function configReport(loaded, env, { checkAll = false } = {}) {
35
36
  for (const key of CONFIG_KEYS) {
36
37
  const saved = loaded.keychainSecrets?.includes(key) ? 'keychain' : 'file';
37
38
  // The saved store setting governs saved secrets; the environment cannot redirect it.
38
- const source = env[key] !== undefined && key !== 'AUTOROUTER_SECRET_STORE' ? 'environment'
39
+ const source = loaded.policyLocked?.includes(key) ? 'policy'
40
+ : env[key] !== undefined && key !== 'AUTOROUTER_SECRET_STORE' ? 'environment'
39
41
  : Object.hasOwn(loaded.values, key) ? saved : 'default';
40
42
  const provider = providerFor(key);
41
43
  const active = (!provider || provider === config.evaluator)
@@ -57,7 +59,8 @@ export function configReport(loaded, env, { checkAll = false } = {}) {
57
59
  try { readConfig(loaded.env, { validateAll: true }); }
58
60
  catch (failure) { valid = false; error = failure.message; }
59
61
  }
60
- 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,
61
64
  checked: checkAll ? 'all_evaluators' : 'active_evaluator', settings, warnings,
62
65
  ...(error ? { error } : {}),
63
66
  };
@@ -94,9 +97,9 @@ function normalizedValue(key, value) {
94
97
  }
95
98
 
96
99
  export async function configCommand(args, {
97
- env = process.env, write = console.log, input = process.stdin, promptSecret = askSecret, keychain,
100
+ env = process.env, write = console.log, input = process.stdin, promptSecret = askSecret, keychain, policy,
98
101
  } = {}) {
99
- const store = keychain ? { keychain } : {};
102
+ const store = { ...(keychain ? { keychain } : {}), ...(policy ? { policy } : {}) };
100
103
  const [operation, ...rest] = args;
101
104
  if (operation === 'show') {
102
105
  if (rest.some(arg => !['--json', '--check-all'].includes(arg))) throw new Error('Usage: claude-autorouter config show [--json] [--check-all]');
@@ -130,7 +133,7 @@ export async function configCommand(args, {
130
133
  throw new Error('Secret values are not accepted as command arguments. Use --stdin or the hidden prompt.');
131
134
  }
132
135
  if (operation === 'set' && !secretKey(key) && (value === undefined || value === '--stdin')) throw new Error('Nonsecret settings require a value argument.');
133
- const loaded = loadUserConfig(env, { allowMissing: true, ...store });
136
+ const loaded = loadUserConfig(env, { allowMissing: true, enforcePolicy: false, ...store });
134
137
  const next = { ...loaded.values };
135
138
  if (operation === 'unset') delete next[key];
136
139
  else next[key] = normalizedValue(key, secretKey(key)
@@ -140,6 +143,7 @@ export async function configCommand(args, {
140
143
  // mask it. An explicitly edited inactive provider is checked too.
141
144
  const provider = operation === 'set' ? providerFor(key) : undefined;
142
145
  readConfig({ ...next, ...(provider ? { AUTOROUTER_EVALUATOR: provider } : {}) });
146
+ if (loaded.policy) applyPolicy(next, loaded.policy);
143
147
  const nextStore = next.AUTOROUTER_SECRET_STORE ?? 'file';
144
148
  // Changing the store moves saved secrets; unsetting a secret deletes its item.
145
149
  const removeSecrets = key === 'AUTOROUTER_SECRET_STORE' ? keychainRemovals(loaded, nextStore)
package/src/config.mjs CHANGED
@@ -58,7 +58,7 @@ 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`);
@@ -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,6 +3,7 @@ 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
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';
@@ -38,11 +39,11 @@ export async function askSecret(label, { input = process.stdin, output = process
38
39
 
39
40
  export async function setup(args, {
40
41
  env = process.env, write = console.log, prompt = askSecret, fetchImpl = fetch, signal, keychain,
41
- platform = process.platform,
42
+ platform = process.platform, policy,
42
43
  } = {}) {
43
44
  if (signal?.aborted) throw new Error('Setup cancelled');
44
- const store = keychain ? { keychain } : {};
45
- const loaded = loadUserConfig(env, { allowMissing: true, ...store });
45
+ const store = { ...(keychain ? { keychain } : {}), ...(policy ? { policy } : {}) };
46
+ const loaded = loadUserConfig(env, { allowMissing: true, enforcePolicy: false, ...store });
46
47
  const replace = args.includes('--replace');
47
48
  const mergeExisting = loaded.exists && !replace;
48
49
  // Updating one preference must not turn unrelated runtime overrides into
@@ -131,6 +132,7 @@ export async function setup(args, {
131
132
  const keys = [...(evaluator === 'jev' ? ['TYPESAFE_API_KEY'] : []), ...(authMode === 'api-key' ? ['ANTHROPIC_API_KEY'] : [])];
132
133
  // Reject invalid settings before inviting secret input or making local calls.
133
134
  readConfig(values);
135
+ if (loaded.policy) applyPolicy(values, loaded.policy);
134
136
  if (values.AUTOROUTER_SECRET_STORE === 'keychain' && platform !== 'darwin') {
135
137
  throw new Error('--secret-store keychain is available only on macOS');
136
138
  }
@@ -224,6 +226,7 @@ export async function doctor({ env = process.env, write = console.log, run = exe
224
226
  const loaded = loadUserConfig(env, keychain ? { keychain } : {});
225
227
  effectiveEnv = loaded.env;
226
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'}).`);
227
230
  if (loaded.secretStore === 'keychain') write(`Saved secrets: macOS Keychain (${loaded.keychainSecrets.length} found).`);
228
231
  else if (platform === 'darwin' && SECRET_CONFIG_KEYS.some(key => Object.hasOwn(loaded.values, key))) {
229
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');
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/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) {
@@ -232,7 +232,7 @@ export async function sessionsCommand(args, { env = process.env, write = console
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 });
235
+ const loaded = loadUserConfig(env, { allowMissing: true, readSecrets: false, enforcePolicy: false });
236
236
  const directory = parseSessionLogDir(loaded.env.AUTOROUTER_SESSION_LOG_DIR);
237
237
  if (!directory) {
238
238
  const report = { schema_version: 1, type: 'session_history', logging_enabled: false, sessions: [],
@@ -0,0 +1,65 @@
1
+ import * as fileSystem from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+
5
+ // A launcher that is killed hard (SIGKILL, power loss) cannot delete its
6
+ // private status directory, which also holds the rewritten Claude settings.
7
+ // The next launch removes directories whose owning process is gone. Only
8
+ // directories this user created, with owner-only permissions, are considered.
9
+ const PREFIX = 'autorouter-status-';
10
+ const MAX_ENTRIES = 200;
11
+ const SNAPSHOT_LIMIT = 1024 * 1024;
12
+ // Without a readable snapshot, wait before treating a directory as abandoned so
13
+ // a launch that is still starting is never removed.
14
+ const UNREADABLE_GRACE_MS = 10 * 60 * 1000;
15
+ // A reused process ID can look alive. The heartbeat is written every few
16
+ // seconds, so a snapshot this old belongs to an earlier process even if a
17
+ // sleeping machine suspended the real one for a while.
18
+ const HEARTBEAT_STALE_MS = 24 * 60 * 60 * 1000;
19
+
20
+ const defaultAlive = pid => {
21
+ try { process.kill(pid, 0); return true; } catch (error) { return error.code === 'EPERM'; }
22
+ };
23
+
24
+ async function readSnapshot(io, path) {
25
+ let handle;
26
+ try {
27
+ handle = await io.open(path, 'r');
28
+ const stat = await handle.stat();
29
+ if (!stat.isFile() || stat.size > SNAPSHOT_LIMIT) return undefined;
30
+ return JSON.parse(await handle.readFile('utf8'));
31
+ } catch { return undefined; }
32
+ finally { try { await handle?.close(); } catch {} }
33
+ }
34
+
35
+ /**
36
+ * Best effort and never throws. Returns the number of directories removed.
37
+ * @param {{directory?:string,io?:typeof fileSystem,alive?:(pid:number)=>boolean,now?:number,uid?:number}} [options]
38
+ */
39
+ export async function removeStaleStatusDirectories({
40
+ directory = tmpdir(), io = fileSystem, alive = defaultAlive, now = Date.now(), uid = process.getuid?.(),
41
+ } = {}) {
42
+ let removed = 0;
43
+ try {
44
+ if (uid === undefined) return 0;
45
+ const names = (await io.readdir(directory)).filter(name => name.startsWith(PREFIX)).slice(0, MAX_ENTRIES);
46
+ for (const name of names) {
47
+ try {
48
+ const path = join(directory, name);
49
+ const stat = await io.lstat(path);
50
+ if (!stat.isDirectory() || stat.isSymbolicLink() || stat.uid !== uid || (stat.mode & 0o077) !== 0) continue;
51
+ const snapshot = await readSnapshot(io, join(path, 'state.json'));
52
+ const pid = snapshot?.pid;
53
+ let stale;
54
+ if (Number.isSafeInteger(pid) && pid > 0) {
55
+ const heartbeat = Number.isFinite(snapshot.heartbeat_at) ? snapshot.heartbeat_at : 0;
56
+ stale = !alive(pid) || now - heartbeat > HEARTBEAT_STALE_MS;
57
+ } else stale = now - stat.mtimeMs > UNREADABLE_GRACE_MS;
58
+ if (!stale) continue;
59
+ await io.rm(path, { recursive: true, force: true });
60
+ removed++;
61
+ } catch {}
62
+ }
63
+ } catch {}
64
+ return removed;
65
+ }
@@ -6,6 +6,7 @@ import { createHash, randomBytes } from 'node:crypto';
6
6
  import { homedir } from 'node:os';
7
7
  import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
8
8
  import { createKeychain } from './keychain.mjs';
9
+ import { applyPolicy, loadPolicy } from './policy.mjs';
9
10
 
10
11
  export const CONFIG_KEYS = Object.freeze([
11
12
  'AUTOROUTER_AUTH_MODE', 'AUTOROUTER_CLIENT_PROFILE', 'AUTOROUTER_SECRET_STORE',
@@ -87,7 +88,7 @@ export function getConfigPath(env = process.env) {
87
88
  // The saved store setting decides where saved secrets live; the environment
88
89
  // only overrides values. With the keychain store, `values` includes secrets
89
90
  // read from the keychain so callers can update settings without losing them.
90
- export function loadUserConfig(env = process.env, { allowMissing = false, readSecrets = true, keychain = defaultKeychain } = {}) {
91
+ function readUserConfig(env, { allowMissing = false, readSecrets = true, keychain = defaultKeychain } = {}) {
91
92
  const path = getConfigPath(env);
92
93
  let content;
93
94
  try {
@@ -126,6 +127,18 @@ export function loadUserConfig(env = process.env, { allowMissing = false, readSe
126
127
  secretStore, keychainSecrets, unavailableSecrets };
127
128
  }
128
129
 
130
+ // An organization policy, when present, is applied last. Its allowlists reject
131
+ // a disallowed choice and its locks replace file and environment values, so
132
+ // every consumer of the effective environment sees the enforced settings.
133
+ // `policy` options exist for tests; production always uses the system path.
134
+ export function loadUserConfig(env = process.env, { policy: policyOptions, enforcePolicy = true, ...options } = {}) {
135
+ const policy = loadPolicy(policyOptions);
136
+ const loaded = readUserConfig(env, options);
137
+ if (!policy) return loaded;
138
+ const applied = applyPolicy(loaded.env, policy.values, { allowlists: enforcePolicy });
139
+ return { ...loaded, env: applied.env, policy: policy.values, policyPath: policy.path, policyLocked: applied.locked };
140
+ }
141
+
129
142
  // Keychain items to delete when saving `nextStore`, given what was loaded.
130
143
  // Moving between stores needs every saved secret, so refuse while one could
131
144
  // not be read; deleting it unseen would lose it.