unbound-cli 1.6.5 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unbound-cli",
3
- "version": "1.6.5",
3
+ "version": "1.7.0",
4
4
  "description": "CLI tool for Unbound - AI Gateway management",
5
5
  "main": "src/index.js",
6
6
  "bin": {
package/src/api.js CHANGED
@@ -141,6 +141,25 @@ function getRaw(url) {
141
141
  });
142
142
  }
143
143
 
144
+ // WEB-4949: Probe whether a key is tenant-valid by hitting the same endpoint
145
+ // `unbound login` uses. 200 -> valid, 401/403 -> invalid, anything else
146
+ // (timeout, DNS, 5xx) -> unknown. Callers in status/doctor fail open on
147
+ // 'unknown' so an offline laptop doesn't false-tamper. Short default timeout
148
+ // (3s) keeps `unbound status` snappy even when N keys are validated in
149
+ // parallel against a slow link.
150
+ async function validateApiKey(apiKey, { baseUrl, timeoutMs = 3000 } = {}) {
151
+ if (!apiKey) return 'invalid';
152
+ try {
153
+ await request('GET', '/api/v1/users/privileges/', { apiKey, baseUrl, timeoutMs });
154
+ return 'valid';
155
+ } catch (err) {
156
+ if (err instanceof ApiError && (err.statusCode === 401 || err.statusCode === 403)) {
157
+ return 'invalid';
158
+ }
159
+ return 'unknown';
160
+ }
161
+ }
162
+
144
163
  module.exports = {
145
164
  ApiError,
146
165
  get: (path, opts) => request('GET', path, opts),
@@ -148,4 +167,5 @@ module.exports = {
148
167
  put: (path, opts) => request('PUT', path, opts),
149
168
  del: (path, opts) => request('DELETE', path, opts),
150
169
  getRaw,
170
+ validateApiKey,
151
171
  };
@@ -3,7 +3,7 @@ const config = require('../config');
3
3
  const api = require('../api');
4
4
  const output = require('../output');
5
5
  const { getDeviceSerial } = require('../device-serial');
6
- const { detectTools } = require('../toolHealth');
6
+ const { detectTools, validateToolKeys, countValidationSkipped } = require('../toolHealth');
7
7
  const { hasRootPrivileges } = require('./setup');
8
8
 
9
9
  // Validate the stored API key against the backend. Returns one of:
@@ -61,12 +61,16 @@ Examples:
61
61
  .option('--json', 'Output raw JSON')
62
62
  .action(async (opts) => {
63
63
  try {
64
- const apiKey = config.getApiKey();
65
64
  const gatewayUrl = config.getGatewayUrl();
65
+ const baseUrl = config.getBaseUrl();
66
66
 
67
67
  const spin = output.spinner('Running diagnostics...');
68
68
  const key = await checkApiKey();
69
- const tools = detectTools({ gatewayUrl, apiKey });
69
+ const tools = detectTools({ gatewayUrl });
70
+ // WEB-4949: per-tool API keys are validated against the gateway in
71
+ // parallel (fail-open on network errors).
72
+ await validateToolKeys(tools, (k) => api.validateApiKey(k, { baseUrl }));
73
+ const keySkippedCount = countValidationSkipped(tools);
70
74
  spin.stop();
71
75
 
72
76
  const tampered = tools.filter((t) => t.status === 'tampered');
@@ -81,9 +85,13 @@ Examples:
81
85
  mode: t.mode,
82
86
  status: t.status,
83
87
  conflict: !!t.conflict,
84
- checks: t.checks.map((c) => ({ name: c.name, ok: c.ok, kind: c.kind, detail: c.detail, warn: !!c.warn })),
88
+ checks: t.checks.map((c) => ({
89
+ name: c.name, ok: c.ok, kind: c.kind, detail: c.detail,
90
+ warn: !!c.warn, validation_skipped: !!c.validationSkipped,
91
+ })),
85
92
  })),
86
93
  healthy: !unhealthy,
94
+ key_validations_skipped: keySkippedCount,
87
95
  });
88
96
  if (unhealthy) process.exitCode = 1;
89
97
  return;
@@ -116,6 +124,11 @@ Examples:
116
124
  } else s = C.dim('○ Not set up');
117
125
  console.log(` ${labelOf(t).padEnd(W)} ${s}`);
118
126
  }
127
+ // WEB-4949: surface fail-open so CI/operators don't read an offline
128
+ // run as "everything verified". Mirrors the unverified-key-state line.
129
+ if (keySkippedCount > 0) {
130
+ console.log(` ${C.dim(`Note: ${keySkippedCount} per-tool API key validation${keySkippedCount === 1 ? '' : 's'} skipped (gateway unreachable).`)}`);
131
+ }
119
132
  console.log('');
120
133
 
121
134
  // Fix routing. User-level tools are fixable by anyone (`unbound doctor
@@ -2,7 +2,7 @@ const config = require('../config');
2
2
  const api = require('../api');
3
3
  const output = require('../output');
4
4
  const { getDeviceSerial } = require('../device-serial');
5
- const { detectTools } = require('../toolHealth');
5
+ const { detectTools, validateToolKeys, countValidationSkipped } = require('../toolHealth');
6
6
 
7
7
  function roleFromPrivileges(p) {
8
8
  if (!p) return null;
@@ -76,8 +76,17 @@ Examples:
76
76
  pairs.push(['API status', connectivity]);
77
77
 
78
78
  // Locally detected AI tools wired through Unbound, with their mode.
79
- const connected = detectTools({ gatewayUrl: config.getGatewayUrl(), apiKey: config.getApiKey() })
80
- .filter((t) => t.status !== 'not-installed');
79
+ // WEB-4949: per-tool API keys are validated against the gateway in
80
+ // parallel (fail-open on network errors) see toolHealth.validateToolKeys.
81
+ // The spinner is essential — N keys × 3s timeout on a slow link would
82
+ // otherwise look like the command had frozen.
83
+ const baseUrl = config.getBaseUrl();
84
+ const detected = detectTools({ gatewayUrl: config.getGatewayUrl() });
85
+ const keySpin = output.spinner('Validating tool API keys...');
86
+ await validateToolKeys(detected, (k) => api.validateApiKey(k, { baseUrl }));
87
+ keySpin.stop();
88
+ const connected = detected.filter((t) => t.status !== 'not-installed');
89
+ const skippedCount = countValidationSkipped(connected);
81
90
 
82
91
  if (opts.json) {
83
92
  const cfg = loggedIn ? config.readConfig() : {};
@@ -92,6 +101,7 @@ Examples:
92
101
  gateway_url: config.getGatewayUrl(),
93
102
  api_status: connectivity,
94
103
  connected_tools: connected.map((t) => ({ tool: t.key, label: t.label, mode: t.mode, status: t.status })),
104
+ key_validations_skipped: skippedCount,
95
105
  });
96
106
  return;
97
107
  }
@@ -113,6 +123,11 @@ Examples:
113
123
  console.log(` ${mark} ${t.label}${mode}${note}`);
114
124
  }
115
125
  }
126
+ // WEB-4949: tell the operator when fail-open masked a real check.
127
+ // Without this, an offline run looks identical to a fully-validated one.
128
+ if (skippedCount > 0) {
129
+ console.log(` ${C.dim(`Note: ${skippedCount} API key validation${skippedCount === 1 ? '' : 's'} skipped (gateway unreachable).`)}`);
130
+ }
116
131
  console.log('');
117
132
  } catch (err) {
118
133
  output.error(err.message);
package/src/toolHealth.js CHANGED
@@ -126,6 +126,26 @@ function scriptCheck(label, file, kind = 'structural') {
126
126
  return { name: label, ok, kind, detail: ok ? file : `not found (${file})`, summary: `${label.toLowerCase()} missing` };
127
127
  }
128
128
 
129
+ // Set-but-non-empty check + value capture, intended for per-tool API key envs.
130
+ // WEB-4949: API keys are not equality-checked against the stored
131
+ // `config.api_key` — per-tool keys can legitimately differ for the same tenant
132
+ // (different keys for Cursor vs Codex, dashboard-rotated key, etc.). The
133
+ // `keyCheck`/`envName`/`value` tags let `validateToolKeys` revisit each
134
+ // captured value and flip the check not-ok when the gateway rejects it.
135
+ function envKeyCheck(label, name, kind = 'aux') {
136
+ const base = label.replace(/ env$/, '');
137
+ const r = readEnvVar(name);
138
+ if (!r.found || !r.value) {
139
+ return { name: label, ok: false, kind, detail: `${name} is not set`, summary: `${base} not set` };
140
+ }
141
+ return {
142
+ name: label, ok: true, kind,
143
+ detail: `${name} set (${r.source})`,
144
+ summary: `${base} set`,
145
+ keyCheck: true, envName: name, value: r.value,
146
+ };
147
+ }
148
+
129
149
  function envCheck(label, name, expected, kind = 'aux') {
130
150
  const base = label.replace(/ env$/, '');
131
151
  const r = readEnvVar(name);
@@ -217,8 +237,11 @@ function refsUnbound(obj) {
217
237
  }
218
238
 
219
239
  // One descriptor per (tool, mode). `family` groups the two-mode tools so the
220
- // collapsed view shows a single line per product.
221
- function buildVariants(gatewayUrl, apiKey, binaryPath) {
240
+ // collapsed view shows a single line per product. `apiKey` is no longer
241
+ // equality-checked (WEB-4949) per-tool keys can legitimately differ from
242
+ // the stored login key. Use `validateToolKeys` post-detection to verify each
243
+ // captured key against the gateway.
244
+ function buildVariants(gatewayUrl, binaryPath) {
222
245
  const gw = (gatewayUrl || GATEWAY_DEFAULT).replace(/\/+$/, ''); // setup rstrips too
223
246
  return [
224
247
  {
@@ -226,7 +249,7 @@ function buildVariants(gatewayUrl, apiKey, binaryPath) {
226
249
  checks: () => [
227
250
  configCheck('Config', '~/.cursor/hooks.json', { json: true, test: refsUnbound }),
228
251
  scriptCheck('Hook script', '~/.cursor/hooks/unbound.py'),
229
- envCheck('API key env', 'UNBOUND_CURSOR_API_KEY', apiKey),
252
+ envKeyCheck('API key env', 'UNBOUND_CURSOR_API_KEY'),
230
253
  ],
231
254
  },
232
255
  {
@@ -234,7 +257,7 @@ function buildVariants(gatewayUrl, apiKey, binaryPath) {
234
257
  checks: () => [
235
258
  configCheck('Config', '~/.claude/settings.json', { json: true, test: refsUnbound }),
236
259
  scriptCheck('Hook script', '~/.claude/hooks/unbound.py'),
237
- envCheck('API key env', 'UNBOUND_CLAUDE_API_KEY', apiKey),
260
+ envKeyCheck('API key env', 'UNBOUND_CLAUDE_API_KEY'),
238
261
  ],
239
262
  },
240
263
  {
@@ -242,7 +265,9 @@ function buildVariants(gatewayUrl, apiKey, binaryPath) {
242
265
  checks: () => [
243
266
  configCheck('Config', '~/.claude/settings.json', { json: true, test: (j) => typeof j.apiKeyHelper === 'string' && j.apiKeyHelper.includes('anthropic_key.sh') }),
244
267
  scriptCheck('Key helper', '~/.claude/anthropic_key.sh'),
245
- envCheck('API key env', 'UNBOUND_API_KEY', apiKey),
268
+ envKeyCheck('API key env', 'UNBOUND_API_KEY'),
269
+ // Gateway URL is a real equality check — the hook must point at the
270
+ // configured backend; tenants don't generate different gateway URLs.
246
271
  envCheck('Gateway URL env', 'ANTHROPIC_BASE_URL', gw),
247
272
  ],
248
273
  },
@@ -252,14 +277,14 @@ function buildVariants(gatewayUrl, apiKey, binaryPath) {
252
277
  configCheck('Config', '~/.codex/hooks.json', { json: true, test: refsUnbound }),
253
278
  configCheck('Hooks feature flag', '~/.codex/config.toml', { json: false, test: (t) => /codex_hooks\s*=\s*true/.test(t) }, { short: 'codex hooks not enabled' }),
254
279
  scriptCheck('Hook script', '~/.codex/hooks/unbound.py'),
255
- envCheck('API key env', 'UNBOUND_CODEX_API_KEY', apiKey),
280
+ envKeyCheck('API key env', 'UNBOUND_CODEX_API_KEY'),
256
281
  ],
257
282
  },
258
283
  {
259
284
  key: 'codex-gateway', label: 'Codex (gateway)', family: 'codex', mode: 'gateway',
260
285
  checks: () => [
261
286
  configCheck('Config', '~/.codex/config.toml', { json: false, test: (t) => /openai_base_url\s*=/.test(t) }),
262
- envCheck('API key env', 'OPENAI_API_KEY', apiKey),
287
+ envKeyCheck('API key env', 'OPENAI_API_KEY'),
263
288
  ],
264
289
  },
265
290
  {
@@ -276,7 +301,7 @@ function buildVariants(gatewayUrl, apiKey, binaryPath) {
276
301
  const checks = [configCheck('Config', '~/.copilot/hooks/unbound.json', { json: true, test: refsUnbound })];
277
302
  if (hasBinary) checks.push(scriptCheck('Hook binary', binaryPath || BINARY_PATH));
278
303
  else checks.push(scriptCheck('Hook script', '~/.copilot/hooks/unbound.py'));
279
- checks.push(envCheck('API key env', 'UNBOUND_COPILOT_API_KEY', apiKey));
304
+ checks.push(envKeyCheck('API key env', 'UNBOUND_COPILOT_API_KEY'));
280
305
  return checks;
281
306
  },
282
307
  },
@@ -305,8 +330,8 @@ function detectVariant(variant) {
305
330
  // org-managed scenarios can be exercised without writing under /Library or /etc.
306
331
  // `_binaryPath` (test-only) overrides the system hook-binary path so binary-mode
307
332
  // scenarios can be exercised without writing under /opt.
308
- function detectTools({ gatewayUrl, apiKey, _mdmDirs, _binaryPath } = {}) {
309
- const variants = buildVariants(gatewayUrl, apiKey, _binaryPath).map(detectVariant);
333
+ function detectTools({ gatewayUrl, _mdmDirs, _binaryPath } = {}) {
334
+ const variants = buildVariants(gatewayUrl, _binaryPath).map(detectVariant);
310
335
  const families = [];
311
336
  const seen = new Set();
312
337
  for (const v of variants) {
@@ -337,4 +362,80 @@ function detectTools({ gatewayUrl, apiKey, _mdmDirs, _binaryPath } = {}) {
337
362
  return families;
338
363
  }
339
364
 
340
- module.exports = { detectTools, statusOf, readEnvVar, GATEWAY_DEFAULT };
365
+ // WEB-4949: Validate each tool's captured env-var key against the gateway and
366
+ // mutate the corresponding `keyCheck: true` entries in place. Fail-open: an
367
+ // 'unknown' verdict (network error, timeout, 5xx) leaves the check healthy
368
+ // but tags it `validationSkipped: true` so status/doctor can surface
369
+ // "gateway unreachable — N key(s) not verified" instead of silently lying.
370
+ // Unique keys are fanned out in parallel so N tools share one round-trip's
371
+ // worth of latency. `validateKey` is
372
+ // `(key) => Promise<'valid'|'invalid'|'unknown'>` — injected from
373
+ // status/doctor (which wrap `api.validateApiKey`) so tests can stub it. A
374
+ // validator that throws is coerced to 'unknown' so a regression in the api
375
+ // client can never wipe out the local-state view callers depend on.
376
+ async function validateToolKeys(tools, validateKey) {
377
+ if (!tools?.length) return tools;
378
+ const unique = new Set();
379
+ for (const t of tools) {
380
+ for (const c of t.checks || []) {
381
+ if (c.keyCheck && c.value) unique.add(c.value);
382
+ }
383
+ }
384
+ if (!unique.size) return tools;
385
+ const entries = await Promise.all(
386
+ [...unique].map(async (k) => {
387
+ try {
388
+ return [k, await validateKey(k)];
389
+ } catch {
390
+ return [k, 'unknown'];
391
+ }
392
+ })
393
+ );
394
+ const validity = new Map(entries);
395
+ for (const t of tools) {
396
+ if (!t.checks?.length) continue;
397
+ let okFlipped = false;
398
+ for (const c of t.checks) {
399
+ if (!c.keyCheck) continue;
400
+ const verdict = validity.get(c.value);
401
+ if (verdict === 'invalid') {
402
+ c.ok = false;
403
+ c.warn = true;
404
+ const base = c.name.replace(/ env$/, '');
405
+ c.summary = `${base} invalid`;
406
+ c.detail = `${c.envName} set but not valid for this tenant`;
407
+ okFlipped = true;
408
+ } else if (verdict === 'unknown') {
409
+ // Stay healthy (fail-open), but record the skip so the caller can
410
+ // surface it. Without this tag the operator can't tell an offline
411
+ // run from a fully-validated one — that's the WEB-4922 lesson.
412
+ // Does NOT flip `ok`, so the `statusOf` re-derivation stays guarded.
413
+ c.validationSkipped = true;
414
+ c.detail = `${c.detail}; validation skipped — gateway unreachable`;
415
+ }
416
+ }
417
+ // ONLY re-derive when a check's `ok` actually flipped. Without this guard,
418
+ // `statusOf` overwrites `managed-by-mdm` (the sentinel `detectTools` sets
419
+ // for MDM-managed tools) back to a plain 'healthy', erasing the MDM badge
420
+ // from both human + JSON output. MDM checks have no `keyCheck` entries so
421
+ // this guard naturally excludes them — no MDM-specific carve-out needed.
422
+ if (okFlipped) t.status = statusOf(t.checks);
423
+ }
424
+ return tools;
425
+ }
426
+
427
+ // WEB-4949: Convenience accessor for status/doctor. Returns the count of
428
+ // `keyCheck` entries flagged `validationSkipped: true` across all tools.
429
+ // Used to render a one-line "N key(s) not verified" note when the validator
430
+ // failed open. Returns 0 when validation ran cleanly or no key checks exist.
431
+ function countValidationSkipped(tools) {
432
+ let n = 0;
433
+ for (const t of tools || []) {
434
+ for (const c of t.checks || []) {
435
+ if (c.keyCheck && c.validationSkipped) n += 1;
436
+ }
437
+ }
438
+ return n;
439
+ }
440
+
441
+ module.exports = { detectTools, statusOf, readEnvVar, validateToolKeys, countValidationSkipped, GATEWAY_DEFAULT };
@@ -0,0 +1,107 @@
1
+ const { test } = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const http = require('http');
4
+ const api = require('../src/api');
5
+
6
+ // WEB-4949: validateApiKey wraps the /api/v1/users/privileges/ probe with the
7
+ // fail-open semantics status/doctor need. Stand up a real local HTTP server
8
+ // per case so we exercise the actual transport (timeout, status mapping, no
9
+ // throw on 4xx) instead of mocking the api client.
10
+ function withServer(handler, fn) {
11
+ return new Promise((resolve, reject) => {
12
+ const server = http.createServer(handler);
13
+ server.listen(0, '127.0.0.1', async () => {
14
+ const { address, port } = server.address();
15
+ const baseUrl = `http://${address}:${port}`;
16
+ try {
17
+ await fn(baseUrl);
18
+ resolve();
19
+ } catch (err) {
20
+ reject(err);
21
+ } finally {
22
+ server.close();
23
+ }
24
+ });
25
+ server.on('error', reject);
26
+ });
27
+ }
28
+
29
+ test('validateApiKey: 200 → valid', async () => {
30
+ await withServer(
31
+ (_req, res) => { res.writeHead(200, { 'content-type': 'application/json' }); res.end('{}'); },
32
+ async (baseUrl) => {
33
+ assert.equal(await api.validateApiKey('k', { baseUrl }), 'valid');
34
+ }
35
+ );
36
+ });
37
+
38
+ test('validateApiKey: 401 → invalid', async () => {
39
+ await withServer(
40
+ (_req, res) => { res.writeHead(401, { 'content-type': 'application/json' }); res.end('{"error":"bad key"}'); },
41
+ async (baseUrl) => {
42
+ assert.equal(await api.validateApiKey('k', { baseUrl }), 'invalid');
43
+ }
44
+ );
45
+ });
46
+
47
+ test('validateApiKey: 403 → invalid', async () => {
48
+ await withServer(
49
+ (_req, res) => { res.writeHead(403); res.end(''); },
50
+ async (baseUrl) => {
51
+ assert.equal(await api.validateApiKey('k', { baseUrl }), 'invalid');
52
+ }
53
+ );
54
+ });
55
+
56
+ test('validateApiKey: 500 → unknown (fail-open)', async () => {
57
+ await withServer(
58
+ (_req, res) => { res.writeHead(500); res.end(''); },
59
+ async (baseUrl) => {
60
+ assert.equal(await api.validateApiKey('k', { baseUrl }), 'unknown');
61
+ }
62
+ );
63
+ });
64
+
65
+ test('validateApiKey: timeout → unknown (fail-open)', async () => {
66
+ // Handler never responds; short timeout forces the unknown branch.
67
+ await withServer(
68
+ (_req, _res) => { /* hang */ },
69
+ async (baseUrl) => {
70
+ assert.equal(await api.validateApiKey('k', { baseUrl, timeoutMs: 100 }), 'unknown');
71
+ }
72
+ );
73
+ });
74
+
75
+ test('validateApiKey: connection refused → unknown (fail-open)', async () => {
76
+ // Pick a localhost port nothing is listening on. 1 is reliably refused.
77
+ assert.equal(
78
+ await api.validateApiKey('k', { baseUrl: 'http://127.0.0.1:1', timeoutMs: 500 }),
79
+ 'unknown'
80
+ );
81
+ });
82
+
83
+ test('validateApiKey: missing key → invalid without a network call', async () => {
84
+ // baseUrl points at a dead port; if we made a network call the test would
85
+ // hang or take ages. A short timeout would also work but is unnecessary
86
+ // because the function should short-circuit before any IO.
87
+ assert.equal(await api.validateApiKey('', { baseUrl: 'http://127.0.0.1:1' }), 'invalid');
88
+ assert.equal(await api.validateApiKey(null, { baseUrl: 'http://127.0.0.1:1' }), 'invalid');
89
+ assert.equal(await api.validateApiKey(undefined, { baseUrl: 'http://127.0.0.1:1' }), 'invalid');
90
+ });
91
+
92
+ test('validateApiKey: sends the key as bearer + hits /api/v1/users/privileges/', async () => {
93
+ let observedAuth = null;
94
+ let observedPath = null;
95
+ await withServer(
96
+ (req, res) => {
97
+ observedAuth = req.headers['authorization'];
98
+ observedPath = req.url;
99
+ res.writeHead(200, { 'content-type': 'application/json' }); res.end('{}');
100
+ },
101
+ async (baseUrl) => {
102
+ await api.validateApiKey('sk-abc123', { baseUrl });
103
+ assert.equal(observedAuth, 'Bearer sk-abc123');
104
+ assert.ok(observedPath.startsWith('/api/v1/users/privileges/'), observedPath);
105
+ }
106
+ );
107
+ });
@@ -79,15 +79,19 @@ test('detectTools: cursor config + script present but env key blank → tampered
79
79
  });
80
80
  });
81
81
 
82
- test('detectTools: cursor env key differs from the configured key → tampered, not healthy', () => {
82
+ // WEB-4949: per-tool API keys are no longer equality-checked against the
83
+ // stored config key — different valid keys for the same tenant are legal.
84
+ // detectTools just verifies presence; validateToolKeys (separately tested
85
+ // below) handles tenant-validity.
86
+ test('detectTools: cursor env key differs from the configured key → still healthy (no equality check)', () => {
83
87
  withHome((tmp, th) => {
84
88
  const script = path.join(tmp, '.cursor', 'hooks', 'unbound.py');
85
89
  writeFile(path.join(tmp, '.cursor', 'hooks.json'), JSON.stringify({ hooks: { PreToolUse: [{ command: script }] } }));
86
90
  writeFile(script, '# unbound');
87
- process.env.UNBOUND_CURSOR_API_KEY = 'stale-key';
91
+ process.env.UNBOUND_CURSOR_API_KEY = 'different-but-presumed-valid';
88
92
  try {
89
- const cursor = th.detectTools({ apiKey: 'current-key' }).find((t) => t.key === 'cursor');
90
- assert.equal(cursor.status, 'tampered');
93
+ const cursor = th.detectTools().find((t) => t.key === 'cursor');
94
+ assert.equal(cursor.status, 'healthy');
91
95
  } finally {
92
96
  delete process.env.UNBOUND_CURSOR_API_KEY;
93
97
  }
@@ -302,20 +306,242 @@ test('detectTools: codex binary regression (wrapper at ~/.codex/hooks/unbound.py
302
306
  });
303
307
  });
304
308
 
305
- test('detectTools: envCheck rc-file fallback (process.env stale, shell rc holds expected value) → healthy', () => {
309
+ // envCheck's rc-file fallback applies to the gateway URL check
310
+ // (ANTHROPIC_BASE_URL is still equality-checked since tenants share one
311
+ // gateway URL). API keys are no longer equality-checked — see WEB-4949 — so
312
+ // this test is repointed at the gateway-URL case to keep the rc-fallback
313
+ // path covered. Stale process.env + fresh rc value → still healthy.
314
+ test('detectTools: envCheck rc-file fallback (gateway URL env stale, rc holds the configured URL) → healthy', () => {
306
315
  withHome((tmp, th) => {
316
+ const settings = { apiKeyHelper: '/Users/whatever/.claude/anthropic_key.sh' };
317
+ writeFile(path.join(tmp, '.claude', 'settings.json'), JSON.stringify(settings));
318
+ writeFile(path.join(tmp, '.claude', 'anthropic_key.sh'), '#!/bin/sh\necho $UNBOUND_API_KEY\n');
319
+ process.env.UNBOUND_API_KEY = 'k';
320
+ // rcFiles() lists different files per platform; .bashrc is in both.
321
+ writeFile(path.join(tmp, '.bashrc'),
322
+ 'export ANTHROPIC_BASE_URL="https://gateway.example.com"\n');
323
+ process.env.ANTHROPIC_BASE_URL = 'https://stale.example.com';
324
+ try {
325
+ const t = th.detectTools({ gatewayUrl: 'https://gateway.example.com' })
326
+ .find((x) => x.family === 'claude-code');
327
+ assert.equal(t.status, 'healthy');
328
+ } finally {
329
+ delete process.env.UNBOUND_API_KEY;
330
+ delete process.env.ANTHROPIC_BASE_URL;
331
+ }
332
+ });
333
+ });
334
+
335
+ // WEB-4949: validateToolKeys post-processes detectTools output. Verify the
336
+ // three branches: valid → unchanged healthy; invalid → flips to tampered with
337
+ // a meaningful summary; unknown (network error / 5xx) → fail-open healthy.
338
+
339
+ test('validateToolKeys: valid verdict leaves the tool healthy', async () => {
340
+ await withHome(async (tmp, th) => {
307
341
  const script = path.join(tmp, '.cursor', 'hooks', 'unbound.py');
308
342
  writeFile(path.join(tmp, '.cursor', 'hooks.json'), JSON.stringify({ hooks: { PreToolUse: [{ command: script }] } }));
309
343
  writeFile(script, '# unbound');
310
- // rcFiles() lists different files per platform (zprofile on darwin,
311
- // zshrc/bashrc/profile on linux). Write to .bashrc — both lists include it.
312
- writeFile(path.join(tmp, '.bashrc'), 'export UNBOUND_CURSOR_API_KEY="fresh-key"\n');
313
- process.env.UNBOUND_CURSOR_API_KEY = 'stale-key';
344
+ process.env.UNBOUND_CURSOR_API_KEY = 'valid-key';
314
345
  try {
315
- const t = th.detectTools({ apiKey: 'fresh-key' }).find((x) => x.key === 'cursor');
316
- assert.equal(t.status, 'healthy');
346
+ const tools = th.detectTools();
347
+ await th.validateToolKeys(tools, async () => 'valid');
348
+ const cursor = tools.find((t) => t.key === 'cursor');
349
+ assert.equal(cursor.status, 'healthy');
350
+ const keyCheck = cursor.checks.find((c) => c.keyCheck);
351
+ assert.equal(keyCheck.ok, true);
352
+ } finally {
353
+ delete process.env.UNBOUND_CURSOR_API_KEY;
354
+ }
355
+ });
356
+ });
357
+
358
+ test('validateToolKeys: invalid verdict flips the tool to tampered', async () => {
359
+ await withHome(async (tmp, th) => {
360
+ const script = path.join(tmp, '.cursor', 'hooks', 'unbound.py');
361
+ writeFile(path.join(tmp, '.cursor', 'hooks.json'), JSON.stringify({ hooks: { PreToolUse: [{ command: script }] } }));
362
+ writeFile(script, '# unbound');
363
+ process.env.UNBOUND_CURSOR_API_KEY = 'rejected-key';
364
+ try {
365
+ const tools = th.detectTools();
366
+ await th.validateToolKeys(tools, async () => 'invalid');
367
+ const cursor = tools.find((t) => t.key === 'cursor');
368
+ assert.equal(cursor.status, 'tampered');
369
+ const keyCheck = cursor.checks.find((c) => c.keyCheck);
370
+ assert.equal(keyCheck.ok, false);
371
+ assert.match(keyCheck.summary, /invalid/);
372
+ assert.match(keyCheck.detail, /not valid for this tenant/);
373
+ } finally {
374
+ delete process.env.UNBOUND_CURSOR_API_KEY;
375
+ }
376
+ });
377
+ });
378
+
379
+ test('validateToolKeys: unknown verdict fails open — tool stays healthy', async () => {
380
+ await withHome(async (tmp, th) => {
381
+ const script = path.join(tmp, '.cursor', 'hooks', 'unbound.py');
382
+ writeFile(path.join(tmp, '.cursor', 'hooks.json'), JSON.stringify({ hooks: { PreToolUse: [{ command: script }] } }));
383
+ writeFile(script, '# unbound');
384
+ process.env.UNBOUND_CURSOR_API_KEY = 'cant-reach-gateway';
385
+ try {
386
+ const tools = th.detectTools();
387
+ await th.validateToolKeys(tools, async () => 'unknown');
388
+ const cursor = tools.find((t) => t.key === 'cursor');
389
+ assert.equal(cursor.status, 'healthy');
390
+ } finally {
391
+ delete process.env.UNBOUND_CURSOR_API_KEY;
392
+ }
393
+ });
394
+ });
395
+
396
+ test('validateToolKeys: distinct keys across tools are each validated once', async () => {
397
+ await withHome(async (tmp, th) => {
398
+ // Two tools with two distinct env-var values + one shared value.
399
+ const cursorScript = path.join(tmp, '.cursor', 'hooks', 'unbound.py');
400
+ writeFile(path.join(tmp, '.cursor', 'hooks.json'), JSON.stringify({ hooks: { PreToolUse: [{ command: cursorScript }] } }));
401
+ writeFile(cursorScript, '# unbound');
402
+ const copilotCfg = path.join(tmp, '.copilot', 'hooks', 'unbound.json');
403
+ const copilotScript = path.join(tmp, '.copilot', 'hooks', 'unbound.py');
404
+ writeFile(copilotCfg, JSON.stringify({ hooks: { PreToolUse: [{ command: copilotScript }] } }));
405
+ writeFile(copilotScript, '# unbound');
406
+ process.env.UNBOUND_CURSOR_API_KEY = 'key-A';
407
+ process.env.UNBOUND_COPILOT_API_KEY = 'key-B';
408
+ try {
409
+ const tools = th.detectTools();
410
+ const seen = [];
411
+ await th.validateToolKeys(tools, async (k) => { seen.push(k); return 'valid'; });
412
+ // Each unique key is validated exactly once, regardless of how many
413
+ // tools reference it. Order isn't asserted.
414
+ assert.deepEqual(new Set(seen), new Set(['key-A', 'key-B']));
415
+ assert.equal(seen.length, 2);
416
+ } finally {
417
+ delete process.env.UNBOUND_CURSOR_API_KEY;
418
+ delete process.env.UNBOUND_COPILOT_API_KEY;
419
+ }
420
+ });
421
+ });
422
+
423
+ // Real-world common case: one tool's key was rotated and the env wasn't
424
+ // updated. The valid tool stays healthy; the invalid one flips. They share
425
+ // the same fan-out, so a partial verdict must round-trip correctly.
426
+ test('validateToolKeys: mixed verdicts — invalid tool flips, valid tool unchanged', async () => {
427
+ await withHome(async (tmp, th) => {
428
+ const cursorScript = path.join(tmp, '.cursor', 'hooks', 'unbound.py');
429
+ writeFile(path.join(tmp, '.cursor', 'hooks.json'), JSON.stringify({ hooks: { PreToolUse: [{ command: cursorScript }] } }));
430
+ writeFile(cursorScript, '# unbound');
431
+ const copilotCfg = path.join(tmp, '.copilot', 'hooks', 'unbound.json');
432
+ const copilotScript = path.join(tmp, '.copilot', 'hooks', 'unbound.py');
433
+ writeFile(copilotCfg, JSON.stringify({ hooks: { PreToolUse: [{ command: copilotScript }] } }));
434
+ writeFile(copilotScript, '# unbound');
435
+ process.env.UNBOUND_CURSOR_API_KEY = 'good';
436
+ process.env.UNBOUND_COPILOT_API_KEY = 'bad';
437
+ try {
438
+ const tools = th.detectTools();
439
+ await th.validateToolKeys(tools, async (k) => k === 'good' ? 'valid' : 'invalid');
440
+ const cursor = tools.find((t) => t.key === 'cursor');
441
+ const copilot = tools.find((t) => t.key === 'copilot');
442
+ assert.equal(cursor.status, 'healthy');
443
+ assert.equal(copilot.status, 'tampered');
444
+ } finally {
445
+ delete process.env.UNBOUND_CURSOR_API_KEY;
446
+ delete process.env.UNBOUND_COPILOT_API_KEY;
447
+ }
448
+ });
449
+ });
450
+
451
+ // Defensive: a validator that throws (a future api.validateApiKey bug, a
452
+ // transport regression) must NOT take down `unbound status`/`doctor`. Each
453
+ // thrown key is coerced to 'unknown' so the tool stays healthy and the skip
454
+ // counter surfaces the issue to the operator.
455
+ test('validateToolKeys: throwing validator coerces to unknown (does not propagate)', async () => {
456
+ await withHome(async (tmp, th) => {
457
+ const script = path.join(tmp, '.cursor', 'hooks', 'unbound.py');
458
+ writeFile(path.join(tmp, '.cursor', 'hooks.json'), JSON.stringify({ hooks: { PreToolUse: [{ command: script }] } }));
459
+ writeFile(script, '# unbound');
460
+ process.env.UNBOUND_CURSOR_API_KEY = 'k';
461
+ try {
462
+ const tools = th.detectTools();
463
+ await assert.doesNotReject(
464
+ th.validateToolKeys(tools, async () => { throw new Error('boom'); })
465
+ );
466
+ const cursor = tools.find((t) => t.key === 'cursor');
467
+ assert.equal(cursor.status, 'healthy');
468
+ const keyCheck = cursor.checks.find((c) => c.keyCheck);
469
+ assert.equal(keyCheck.validationSkipped, true);
470
+ assert.equal(th.countValidationSkipped(tools), 1);
471
+ } finally {
472
+ delete process.env.UNBOUND_CURSOR_API_KEY;
473
+ }
474
+ });
475
+ });
476
+
477
+ // Tag for offline surfacing — without this the operator can't distinguish a
478
+ // fully-validated run from one that silently failed open.
479
+ test('validateToolKeys: unknown verdict tags the check with validationSkipped + countValidationSkipped reports it', async () => {
480
+ await withHome(async (tmp, th) => {
481
+ const script = path.join(tmp, '.cursor', 'hooks', 'unbound.py');
482
+ writeFile(path.join(tmp, '.cursor', 'hooks.json'), JSON.stringify({ hooks: { PreToolUse: [{ command: script }] } }));
483
+ writeFile(script, '# unbound');
484
+ process.env.UNBOUND_CURSOR_API_KEY = 'k';
485
+ try {
486
+ const tools = th.detectTools();
487
+ await th.validateToolKeys(tools, async () => 'unknown');
488
+ assert.equal(th.countValidationSkipped(tools), 1);
489
+ const cursor = tools.find((t) => t.key === 'cursor');
490
+ const keyCheck = cursor.checks.find((c) => c.keyCheck);
491
+ assert.equal(keyCheck.validationSkipped, true);
492
+ assert.equal(keyCheck.ok, true); // fail-open: still healthy
317
493
  } finally {
318
494
  delete process.env.UNBOUND_CURSOR_API_KEY;
319
495
  }
320
496
  });
321
497
  });
498
+
499
+ // Bot regression (Bugbot Medium / Greptile P1): validateToolKeys was
500
+ // unconditionally re-running statusOf, which overwrites `managed-by-mdm`
501
+ // (since `statusOf` doesn't know about that sentinel) back to `healthy`,
502
+ // erasing the MDM badge from both human + JSON output. The fix is to only
503
+ // re-derive status when a check's `ok` actually flipped.
504
+ test('validateToolKeys: managed-by-mdm status survives validation when nothing flips', async () => {
505
+ await withHome(async (tmp, th) => {
506
+ const mdmDir = path.join(tmp, 'mdm', 'ClaudeCode');
507
+ writeFile(path.join(mdmDir, 'managed-settings.json'),
508
+ JSON.stringify({ hooks: { PreToolUse: [{ command: path.join(mdmDir, 'hooks', 'unbound.py') }] } }));
509
+ writeFile(path.join(mdmDir, 'hooks', 'unbound.py'), '#');
510
+ const tools = th.detectTools({ _mdmDirs: { 'claude-code': mdmDir } });
511
+ const claude = tools.find((t) => t.family === 'claude-code');
512
+ assert.equal(claude.status, 'managed-by-mdm', 'pre-validation sanity');
513
+ // No keyCheck entries on the MDM variant, so the validator is never
514
+ // consulted. Status must NOT collapse to plain 'healthy'.
515
+ let called = false;
516
+ await th.validateToolKeys(tools, async () => { called = true; return 'valid'; });
517
+ assert.equal(claude.status, 'managed-by-mdm', 'MDM badge was overwritten');
518
+ assert.equal(claude.scope, 'mdm');
519
+ assert.equal(called, false);
520
+ });
521
+ });
522
+
523
+ // W2 defensive guards.
524
+ test('validateToolKeys: null/empty tools — no throw, no validator calls', async () => {
525
+ const th = require('../src/toolHealth');
526
+ let called = false;
527
+ await th.validateToolKeys(null, async () => { called = true; });
528
+ await th.validateToolKeys(undefined, async () => { called = true; });
529
+ await th.validateToolKeys([], async () => { called = true; });
530
+ assert.equal(called, false);
531
+ });
532
+
533
+ test('validateToolKeys: missing key (no env var) → tampered without consulting the validator', async () => {
534
+ await withHome(async (tmp, th) => {
535
+ const script = path.join(tmp, '.cursor', 'hooks', 'unbound.py');
536
+ writeFile(path.join(tmp, '.cursor', 'hooks.json'), JSON.stringify({ hooks: { PreToolUse: [{ command: script }] } }));
537
+ writeFile(script, '# unbound');
538
+ delete process.env.UNBOUND_CURSOR_API_KEY;
539
+ let called = false;
540
+ const tools = th.detectTools();
541
+ await th.validateToolKeys(tools, async () => { called = true; return 'valid'; });
542
+ const cursor = tools.find((t) => t.key === 'cursor');
543
+ assert.equal(cursor.status, 'tampered');
544
+ // The validator is never called for missing keys — there's no value to check.
545
+ assert.equal(called, false);
546
+ });
547
+ });