claude-autorouter 0.5.0 → 0.5.2

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) {
@@ -94,7 +94,7 @@ For a classifier-only rubric evaluation:
94
94
  npm run eval
95
95
  ```
96
96
 
97
- The bundled evaluation makes 19 classifier calls and no Claude generations. Jev is the default and incurs TypeSafe usage; set `AUTOROUTER_EVALUATOR=ollama` to evaluate an installed local model. It reports agreement with the starting rubric, fallback count, and p50/p95 routing latency. Edit `test/fixtures/routing.json` to represent the tasks you want to measure. Rubric agreement alone does not establish answer quality or net savings; compare completed tasks against fixed-model baselines.
97
+ The bundled evaluation makes 19 classifier calls and no Claude generations. The evaluation uses the configured evaluator, which defaults to a local Ollama model and needs an installed one; set `AUTOROUTER_EVALUATOR=jev` to evaluate with Jev, which incurs TypeSafe usage and sends redacted excerpts to TypeSafe. It reports agreement with the starting rubric, fallback count, and p50/p95 routing latency. Edit `test/fixtures/routing.json` to represent the tasks you want to measure. Rubric agreement alone does not establish answer quality or net savings; compare completed tasks against fixed-model baselines.
98
98
 
99
99
  For local evaluator measurements, use Ollama 0.35+ and a model compatible with `/v1/systemone`. Distinguish cold model loading from warmed classification, and record the model tag, hardware, Ollama version, context size, prompt length, and resident memory. The launcher primes the classifier with a synthetic task before opening the UI, with a separate deadline of up to 60 seconds. Runtime and benchmark share the 3,000-character/3,000-UTF-8-byte state limit, so include non-ASCII cases and excerpts that fill the budget. Also measure the first request after keep-alive expiration: its reload can hit the normal deadline even when warm requests pass. Repeat on realistic prompt distributions instead of selecting a model from a single easy request. Disk download size is not resident RAM. Keep model downloads opt-in and respect each model's license.
100
100
 
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
 
@@ -202,7 +220,7 @@ Claude Code → authenticated local gateway → Jev or local Ollama classificati
202
220
 
203
221
  AutoRouter launches the user's installed official Claude Code binary without patching it and uses Claude Code's [gateway integration](https://code.claude.com/docs/en/llm-gateway-protocol), so it sees inference requests and tool continuations. It does not rely on a user-prompt hook. Each user uses their own provider credentials; AutoRouter does not provide a Claude sign-in service or a shared provider account.
204
222
 
205
- The selected evaluator receives a bounded state containing the latest human request and excerpts of the original task and recent messages: up to 12,000 serialized characters sent to TypeSafe for Jev, or 3,000 UTF-8 bytes sent to the local Ollama service. Jev also receives system-text excerpts. The local path excludes Claude's top-level executor system instructions. These excerpts can include private source code and tool results. Before excerpting, recognizable sensitive values are replaced (the same filter applies to opt-in session-log prompt excerpts, including when old logs are read back) with markers such as `[REDACTED:secret]`: private keys, common provider token formats (Anthropic, OpenAI-style `sk-`, AWS, GitHub, GitLab, Slack, Google, Stripe, npm), JWTs, authorization headers, URL credentials, values assigned to password/secret/token/key-like names, email addresses, and checksum-valid IBANs and payment card numbers. Setting names stay visible. Redaction is pattern-based: unrecognized formats can remain, code resembling an assignment can be over-redacted, and it does not make arbitrary private source code safe to share. Images, document payloads, and signed thinking are omitted. Full tool schemas and full conversation history are not sent to either classifier. Anthropic receives the complete request, including its tools and attachments. Large or multimodal requests may also go to Anthropic's token-count endpoint before inference, including when classification is local.
223
+ The selected evaluator receives a bounded state containing the latest human request and excerpts of the original task and recent messages: up to 12,000 serialized characters sent to TypeSafe for Jev, or 3,000 UTF-8 bytes sent to the local Ollama service. Jev also receives system-text excerpts. The local path excludes Claude's top-level executor system instructions. These excerpts can include private source code and tool results. Before excerpting, recognizable sensitive values are replaced (the same filter applies to opt-in session-log prompt excerpts, including when old logs are read back) with markers such as `[REDACTED:secret]`: private keys, common provider token formats (Anthropic, OpenAI-style `sk-`, AWS, GitHub, GitLab, Slack, Google, Stripe, npm, PyPI, SendGrid, Shopify, Hugging Face, DigitalOcean, Linear, Notion, Databricks, Atlassian, Mailgun, Twilio, Telegram, Azure storage keys), JWTs, authorization and cookie headers, URL credentials (including an empty user or a password containing `@`), values assigned to password/secret/token/key-like names (including quoted values with spaces and upper-case `*_KEY` names), credentials on command lines (`curl -u`, `--password`, `--api-key`, `sshpass -p`, `mysql -p`), email addresses, international phone numbers with a leading `+`, checksum-valid Italian tax codes, US social security numbers, and checksum-valid IBANs and payment card numbers. Names, postal addresses, other national ID formats, customer identifiers and source code are not detected. Setting names stay visible. Redaction is pattern-based: unrecognized formats can remain, code resembling an assignment can be over-redacted, and it does not make arbitrary private source code safe to share. Images, document payloads, and signed thinking are omitted. Full tool schemas and full conversation history are not sent to either classifier. Anthropic receives the complete request, including its tools and attachments. Large or multimodal requests may also go to Anthropic's token-count endpoint before inference, including when classification is local.
206
224
 
207
225
  In subscription mode, Claude Code owns login and OAuth refresh. AutoRouter forwards the current request's authorization and beta headers to Anthropic. It does not read keychain or saved login files, persist subscription tokens, or send them to Jev. A separate temporary `X-Autorouter-Token` authenticates the local connection and is stripped upstream. Subscription forwarding is restricted to `https://api.anthropic.com`. See [subscriptions and gateways](https://code.claude.com/docs/en/llm-gateway#subscriptions-and-gateways).
208
226
 
@@ -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,10 @@ 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
+
25
+ Version `0.5.2` widens secret redaction and closes a Keychain command-injection path. Redaction now also covers URL credentials with an empty user or a password containing `@`, passwords containing `;` or spaces in quotes, `*_KEY`, `*_PASS` and `*_AUTH` names, cookies, command-line credentials (`curl -u`, `--password`, `--api-key`, `sshpass -p`, `mysql -p`), about fifteen more provider token formats, international phone numbers, checksum-validated Italian tax codes and US social security numbers. **Behavior change:** evaluator excerpts and prompt-mode logs contain more `[REDACTED:...]` markers than before, and recognizable personal identifiers now appear as `[REDACTED:phone]` or `[REDACTED:national_id]`. A configuration path containing control or line-separator characters is now rejected, and Keychain item names are validated before they reach the `security` tool. Contributor documentation no longer names Jev as the default evaluator. See the [data flow](reference.md#data-flow-and-authentication).
26
+
23
27
  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
28
 
25
29
  ## 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.2",
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`);
package/src/keychain.mjs CHANGED
@@ -16,8 +16,15 @@ function keychainError(message) {
16
16
  }
17
17
 
18
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, '\\$&')}"`;
19
+ // inside double quotes and reads one command per line. The secret is validated
20
+ // as printable single-line ASCII; the account and label (which can carry a
21
+ // user-chosen path) must contain no control or line-separator characters, since
22
+ // a newline would start another command.
23
+ const UNSAFE = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/;
24
+ const quote = value => {
25
+ if (typeof value !== 'string' || UNSAFE.test(value)) throw keychainError('Keychain item names must not contain control characters.');
26
+ return `"${value.replace(/[\\"]/g, '\\$&')}"`;
27
+ };
21
28
 
22
29
  export function createKeychain({ run = spawnSync, platform = process.platform } = {}) {
23
30
  const available = platform === 'darwin';
@@ -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/redaction.mjs CHANGED
@@ -5,6 +5,10 @@
5
5
  // best-effort filter: unrecognized formats can remain, and code that merely
6
6
  // resembles an assignment can be over-redacted. Every quantifier is bounded or
7
7
  // excludes its delimiter, so scanning stays linear in the input length.
8
+ //
9
+ // This is deliberately not a dependency. Scanner libraries such as secretlint
10
+ // detect provider token formats only; they miss the assignment, URL and command
11
+ // forms below, and would add dozens of packages to a CLI that ships none.
8
12
 
9
13
  const marker = kind => `[REDACTED:${kind}]`;
10
14
 
@@ -32,6 +36,25 @@ function luhnValid(value) {
32
36
  return sum % 10 === 0;
33
37
  }
34
38
 
39
+ const CF_ODD = [1, 0, 5, 7, 9, 13, 15, 17, 19, 21, 2, 4, 18, 20, 11, 3, 6, 8, 12, 14, 16, 10, 22, 25, 24, 23];
40
+ function codiceFiscaleValid(value) {
41
+ const text = value.toUpperCase();
42
+ if (text.length !== 16) return false;
43
+ let sum = 0;
44
+ for (let i = 0; i < 15; i++) {
45
+ const code = text.charCodeAt(i);
46
+ const index = code >= 65 ? code - 65 : code - 48;
47
+ if (index < 0 || index > 25) return false;
48
+ sum += i % 2 === 0 ? CF_ODD[index] : index;
49
+ }
50
+ return text.charCodeAt(15) === 65 + (sum % 26);
51
+ }
52
+
53
+ function phoneValid(value) {
54
+ const digits = value.replace(/[^0-9]/g, '').length;
55
+ return digits >= 8 && digits <= 15;
56
+ }
57
+
35
58
  const base64 = code => (code >= 48 && code <= 57) || (code >= 65 && code <= 90) || (code >= 97 && code <= 122)
36
59
  || code === 43 || code === 47 || code === 61;
37
60
 
@@ -58,32 +81,81 @@ function redactKeyTails(text) {
58
81
  return copied ? output + text.slice(copied) : text;
59
82
  }
60
83
 
84
+ // Setting names that hold a secret. "pass", "auth" and "key" are too common in
85
+ // ordinary names (tests_pass, AUTH_MODE, primary_key), so they are matched only
86
+ // as upper-case environment names or as the whole word, further below.
87
+ const SECRET_NAME = '(?:passw(?:or)?d|pwd|passphrase|secret|token|api[_-]?key|access[_-]?key|private[_-]?key|signing[_-]?key|encryption[_-]?key|credentials?)';
88
+ // Characters that may follow an unquoted value. A comma or semicolon ends it
89
+ // only before whitespace, so a password such as a;b is not cut after one letter.
90
+ const VALUE = '(?:[^\\s"\'&,;]|[,;](?=[^\\s"\'&]))';
91
+ const SEPARATOR = '(["\']?[ \\t]{0,8}[:=][ \\t]{0,8})';
92
+ const FLAG_NAME = '(?:password|passwd|pwd|passphrase|pass|token|secret|api-?key|access-?key|private-?key|client-?secret|auth-?token|auth)';
93
+
61
94
  /** @type {ReadonlyArray<readonly [RegExp, string | ((...match: string[]) => string)] | ((text: string) => string)>} */
62
95
  const RULES = Object.freeze([
63
96
  // A block cut off by excerpting is still redacted through the end of text.
64
97
  [/-----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
98
  redactKeyTails,
66
- [/\b([a-z][a-z0-9+.-]{1,20}:\/\/)[^\s:@/]{1,256}:[^\s@/]{1,256}@/gi, (_, scheme) => `${scheme}${marker('credentials')}@`],
99
+ // The userinfo may have an empty user (redis://:pw@host) and a password that
100
+ // contains "@": the match runs to the last "@" before the host.
101
+ [/\b([a-z][a-z0-9+.-]{1,20}:\/\/)[^\s:@/]{0,256}:[^\s/]{1,256}@/gi, (_, scheme) => `${scheme}${marker('credentials')}@`],
102
+ // Provider token formats with distinctive prefixes.
67
103
  [/\bsk-[A-Za-z0-9_-]{20,}/g, marker('secret')],
68
104
  [/\b[rs]k_(?:live|test)_[A-Za-z0-9]{16,}/g, marker('secret')],
105
+ [/\bwhsec_[A-Za-z0-9]{16,}/g, marker('secret')],
69
106
  [/\b(?:AKIA|ASIA|ABIA|ACCA)[A-Z0-9]{16}\b/g, marker('secret')],
70
107
  [/\b(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{22,})/g, marker('secret')],
71
108
  [/\bglpat-[A-Za-z0-9_-]{20,}/g, marker('secret')],
72
109
  [/\bxox[abposr]-[A-Za-z0-9-]{10,}/g, marker('secret')],
73
110
  [/https:\/\/hooks\.slack\.com\/services\/[A-Za-z0-9/]{8,}/g, marker('secret')],
74
111
  [/\bAIza[0-9A-Za-z_-]{35}/g, marker('secret')],
112
+ [/\bya29\.[A-Za-z0-9_-]{20,}/g, marker('secret')],
75
113
  [/\bnpm_[A-Za-z0-9]{36}/g, marker('secret')],
114
+ [/\bpypi-[A-Za-z0-9_-]{50,}/g, marker('secret')],
115
+ [/\bSG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}/g, marker('secret')],
116
+ [/\bshp(?:at|ca|pa|ss)_[a-f0-9]{32}\b/g, marker('secret')],
117
+ [/\bhf_[A-Za-z0-9]{30,}/g, marker('secret')],
118
+ [/\bdo[por]_v1_[a-f0-9]{64}\b/g, marker('secret')],
119
+ [/\blin_api_[A-Za-z0-9]{30,}/g, marker('secret')],
120
+ [/\bntn_[A-Za-z0-9]{30,}/g, marker('secret')],
121
+ [/\bdapi[a-f0-9]{32}\b/g, marker('secret')],
122
+ [/\bATATT3[A-Za-z0-9_=-]{20,}/g, marker('secret')],
123
+ [/\bkey-[0-9a-f]{32}\b/g, marker('secret')],
124
+ [/\bSK[0-9a-f]{32}\b/g, marker('secret')],
125
+ [/\b[0-9]{8,10}:[A-Za-z0-9_-]{35}\b/g, marker('secret')],
76
126
  [/\beyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g, marker('secret')],
127
+ [/\b((?:AccountKey|SharedAccessKey|SharedAccessSignature)=)[^;\s"']{8,}/g, (_, prefix) => `${prefix}${marker('secret')}`],
77
128
  [/\b((?:proxy-)?authorization["']?[ \t]{0,8}[:=][ \t]{0,8}["']?)(?:(Bearer|Basic|Token|Digest)[ \t]+)?[^\s"',;]{4,}/gi,
78
129
  (_, prefix, scheme) => `${prefix}${scheme ? `${scheme} ` : ''}${marker('secret')}`],
130
+ [/\b((?:set-)?cookie["']?[ \t]{0,8}[:=][ \t]{0,8}["']?)[^\r\n"']{8,}/gi, (_, prefix) => `${prefix}${marker('secret')}`],
79
131
  [/\b(Bearer[ \t]+)[A-Za-z0-9._~+/-]{16,}=*/g, (_, prefix) => `${prefix}${marker('secret')}`],
132
+ // Command lines: curl -u user:pass, --password value, sshpass -p, mysql -pvalue.
133
+ [/((?:^|\s)(?:-u|--user|--proxy-user)(?:[ \t]+|=)["']?)[^\s:"']{0,64}:[^\s"']+/g, (_, prefix) => `${prefix}${marker('credentials')}`],
134
+ [new RegExp(`((?:^|\\s)--?[a-z0-9-]{0,31}?${FLAG_NAME})([ \\t]+|=)(?!-)(["']?)[^\\s"']{4,}`, 'gi'),
135
+ (_, flag, separator, quote) => `${flag}${separator}${quote}${marker('secret')}`],
136
+ [/\b(sshpass[ \t]+-p[ \t]*["']?)[^\s"']+/g, (_, prefix) => `${prefix}${marker('secret')}`],
137
+ [/\b((?:mysql|mysqldump|mysqladmin|mariadb)\b[^\n]{0,200}?[ \t]-p)\S{3,}/g, (_, prefix) => `${prefix}${marker('secret')}`],
80
138
  // 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,
139
+ // A quoted value may contain spaces and delimiters.
140
+ [new RegExp(`\\b([A-Za-z0-9_.-]{0,40}${SECRET_NAME}[A-Za-z0-9_.-]{0,40})${SEPARATOR}(["'])(?:(?!\\3)[^\\r\\n]){4,512}\\3`, 'gi'),
141
+ (_, name, separator, quote) => `${name}${separator}${quote}${marker('secret')}${quote}`],
142
+ [new RegExp(`\\b([A-Za-z0-9_.-]{0,40}${SECRET_NAME}[A-Za-z0-9_.-]{0,40})${SEPARATOR}(["']?)${VALUE}{4,}`, 'gi'),
143
+ (_, name, separator, quote) => `${name}${separator}${quote}${marker('secret')}`],
144
+ // Upper-case environment names ending in _KEY, _PASS or _AUTH (STRIPE_KEY,
145
+ // DB_PASS), and the bare lowercase words pass and auth. Booleans stay readable.
146
+ [new RegExp(`\\b([A-Z][A-Z0-9_]{0,40}[_-]KEY|(?:[A-Z][A-Z0-9_]{0,40}[_-])?(?:PASS|AUTH))${SEPARATOR}(["']?)${VALUE}{4,}`, 'g'),
82
147
  (_, name, separator, quote) => `${name}${separator}${quote}${marker('secret')}`],
148
+ [new RegExp(`(?<![A-Za-z0-9_-])((?:pass|auth)["']?[ \\t]{0,8}[:=][ \\t]{0,8})(["']?)(?!(?:true|false|null|none|undefined)\\b)${VALUE}{4,}`, 'gi'),
149
+ (_, prefix, quote) => `${prefix}${quote}${marker('secret')}`],
83
150
  [/\b[A-Za-z0-9._%+-]{1,64}@[A-Za-z0-9.-]{1,253}\.[A-Za-z]{2,24}\b/g, marker('email')],
84
151
  [/\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
152
  // Major card networks only, so millisecond timestamps and IDs survive.
86
153
  [/\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],
154
+ // Italian tax code (checksum-validated) and US social security numbers.
155
+ [/\b[A-Z]{6}[0-9LMNPQRSTUV]{2}[A-EHLMPRST][0-9LMNPQRSTUV]{2}[A-Z][0-9LMNPQRSTUV]{3}[A-Z]\b/g, match => codiceFiscaleValid(match) ? marker('national_id') : match],
156
+ [/\b(?!000|666|9[0-9]{2})[0-9]{3}-(?!00)[0-9]{2}-(?!0000)[0-9]{4}\b/g, marker('national_id')],
157
+ // International phone numbers need a leading plus sign and 8 to 15 digits.
158
+ [/(?<![\w+])\+[0-9][0-9 .()-]{6,20}[0-9]/g, match => phoneValid(match) ? marker('phone') : match],
87
159
  ]);
88
160
 
89
161
  /** @param {string} text */
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',
@@ -70,24 +71,34 @@ function validate(values) {
70
71
  return validated;
71
72
  }
72
73
 
74
+ // The path is printed, stored in Keychain item labels and sent to the macOS
75
+ // `security -i` command parser, which reads one command per line. Control
76
+ // characters (including line and paragraph separators) can therefore split a
77
+ // command or inject terminal escapes, and no real configuration path needs them.
78
+ const UNSAFE_PATH = /[\u0000-\u001f\u007f-\u009f\u2028\u2029\u202a-\u202e\u2066-\u2069]/;
79
+
73
80
  export function getConfigPath(env = process.env) {
81
+ let path;
74
82
  if (env.AUTOROUTER_CONFIG !== undefined) {
75
83
  if (typeof env.AUTOROUTER_CONFIG !== 'string' || !env.AUTOROUTER_CONFIG.trim()) {
76
84
  throw configError('AUTOROUTER_CONFIG must be a non-empty path.');
77
85
  }
78
- return resolve(env.AUTOROUTER_CONFIG);
79
- }
80
- const xdg = env.XDG_CONFIG_HOME;
81
- if (xdg !== undefined && (typeof xdg !== 'string' || (xdg && !isAbsolute(xdg)))) {
82
- throw configError('XDG_CONFIG_HOME must be an absolute path when set.');
86
+ path = resolve(env.AUTOROUTER_CONFIG);
87
+ } else {
88
+ const xdg = env.XDG_CONFIG_HOME;
89
+ if (xdg !== undefined && (typeof xdg !== 'string' || (xdg && !isAbsolute(xdg)))) {
90
+ throw configError('XDG_CONFIG_HOME must be an absolute path when set.');
91
+ }
92
+ path = join(xdg || join(homedir(), '.config'), 'claude-autorouter', 'config.json');
83
93
  }
84
- return join(xdg || join(homedir(), '.config'), 'claude-autorouter', 'config.json');
94
+ if (UNSAFE_PATH.test(path)) throw configError('The AutoRouter configuration path must not contain control characters.');
95
+ return path;
85
96
  }
86
97
 
87
98
  // The saved store setting decides where saved secrets live; the environment
88
99
  // only overrides values. With the keychain store, `values` includes secrets
89
100
  // 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 } = {}) {
101
+ function readUserConfig(env, { allowMissing = false, readSecrets = true, keychain = defaultKeychain } = {}) {
91
102
  const path = getConfigPath(env);
92
103
  let content;
93
104
  try {
@@ -126,6 +137,18 @@ export function loadUserConfig(env = process.env, { allowMissing = false, readSe
126
137
  secretStore, keychainSecrets, unavailableSecrets };
127
138
  }
128
139
 
140
+ // An organization policy, when present, is applied last. Its allowlists reject
141
+ // a disallowed choice and its locks replace file and environment values, so
142
+ // every consumer of the effective environment sees the enforced settings.
143
+ // `policy` options exist for tests; production always uses the system path.
144
+ export function loadUserConfig(env = process.env, { policy: policyOptions, enforcePolicy = true, ...options } = {}) {
145
+ const policy = loadPolicy(policyOptions);
146
+ const loaded = readUserConfig(env, options);
147
+ if (!policy) return loaded;
148
+ const applied = applyPolicy(loaded.env, policy.values, { allowlists: enforcePolicy });
149
+ return { ...loaded, env: applied.env, policy: policy.values, policyPath: policy.path, policyLocked: applied.locked };
150
+ }
151
+
129
152
  // Keychain items to delete when saving `nextStore`, given what was loaded.
130
153
  // Moving between stores needs every saved secret, so refuse while one could
131
154
  // not be read; deleting it unseen would lose it.