unbound-cli 1.16.17 → 1.16.18

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.16.17",
3
+ "version": "1.16.18",
4
4
  "description": "CLI tool for Unbound - AI Gateway management",
5
5
  "main": "src/index.js",
6
6
  "bin": {
package/src/toolHealth.js CHANGED
@@ -192,11 +192,17 @@ function envKeyCheck(label, name, kind = 'aux') {
192
192
  if (!r.found || !r.value) {
193
193
  return { name: label, ok: false, kind, detail: `${name} is not set`, summary: `${base} not set` };
194
194
  }
195
+ // Offer the rc-file keys as fallbacks so a shell opened before setup rewrote
196
+ // the rc (stale key in process.env) isn't misread as tampered. Only for
197
+ // Unbound-owned vars: a user-owned var like OPENAI_API_KEY may carry the
198
+ // user's personal key beside ours, and doctor must never upload that.
199
+ const rcFallback = name.startsWith('UNBOUND_') ? rcEnvValues(name).map((v) => v.value) : [];
200
+ const candidates = [...new Set([r.value, ...rcFallback].filter(Boolean))];
195
201
  return {
196
202
  name: label, ok: true, kind,
197
203
  detail: `${name} set (${r.source})`,
198
204
  summary: `${base} set`,
199
- keyCheck: true, envName: name, value: r.value,
205
+ keyCheck: true, envName: name, value: r.value, candidates,
200
206
  };
201
207
  }
202
208
 
@@ -482,43 +488,53 @@ function detectTools({ gatewayUrl, _mdmDirs, _binaryPath } = {}) {
482
488
  // client can never wipe out the local-state view callers depend on.
483
489
  async function validateToolKeys(tools, validateKey) {
484
490
  if (!tools?.length) return tools;
485
- const unique = new Set();
486
- for (const t of tools) {
487
- for (const c of t.checks || []) {
488
- if (c.keyCheck && c.value) unique.add(c.value);
491
+ const verdict = new Map();
492
+ const check = async (keys) => {
493
+ const pending = [...keys].filter((k) => k && !verdict.has(k));
494
+ for (const [k, r] of await Promise.all(pending.map(async (k) => {
495
+ try { return [k, await validateKey(k)]; } catch { return [k, 'unknown']; }
496
+ }))) verdict.set(k, r);
497
+ };
498
+ // Validate the live value first; consult the rc-file fallbacks only when it is
499
+ // rejected. Avoids needless calls on the healthy path and, with the
500
+ // UNBOUND_-only candidate rule above, keeps rc siblings off the wire until a
501
+ // stale shell forces the search.
502
+ const live = new Set();
503
+ for (const t of tools) for (const c of t.checks || []) if (c.keyCheck && c.value) live.add(c.value);
504
+ if (!live.size) return tools;
505
+ await check(live);
506
+ const fallbacks = new Set();
507
+ for (const t of tools) for (const c of t.checks || []) {
508
+ if (c.keyCheck && c.value && verdict.get(c.value) === 'invalid') {
509
+ for (const v of c.candidates || []) if (v !== c.value) fallbacks.add(v);
489
510
  }
490
511
  }
491
- if (!unique.size) return tools;
492
- const entries = await Promise.all(
493
- [...unique].map(async (k) => {
494
- try {
495
- return [k, await validateKey(k)];
496
- } catch {
497
- return [k, 'unknown'];
498
- }
499
- })
500
- );
501
- const validity = new Map(entries);
512
+ await check(fallbacks);
502
513
  for (const t of tools) {
503
514
  if (!t.checks?.length) continue;
504
515
  let okFlipped = false;
505
516
  for (const c of t.checks) {
506
- if (!c.keyCheck) continue;
507
- const verdict = validity.get(c.value);
508
- if (verdict === 'invalid') {
517
+ if (!c.keyCheck || !c.value) continue;
518
+ const liveVerdict = verdict.get(c.value);
519
+ if (liveVerdict === 'valid') continue;
520
+ // rc fallbacks were fetched only when the live key was invalid.
521
+ const others = liveVerdict === 'invalid'
522
+ ? (c.candidates || []).filter((v) => v !== c.value).map((v) => verdict.get(v))
523
+ : [];
524
+ if (others.includes('valid')) continue; // a configured rc key is valid — wiring intact
525
+ if (liveVerdict !== 'invalid' || others.includes('unknown')) {
526
+ // Gateway unreachable for the live key, or for the only surviving rc
527
+ // candidates — fail open so an offline run isn't read as tampered.
528
+ c.validationSkipped = true;
529
+ c.detail = `${c.detail}; validation skipped — gateway unreachable`;
530
+ } else {
531
+ // Live key rejected and no configured key is valid — genuinely invalid.
509
532
  c.ok = false;
510
533
  c.warn = true;
511
534
  const base = c.name.replace(/ env$/, '');
512
535
  c.summary = `${base} invalid`;
513
536
  c.detail = `${c.envName} set but not valid for this tenant`;
514
537
  okFlipped = true;
515
- } else if (verdict === 'unknown') {
516
- // Stay healthy (fail-open), but record the skip so the caller can
517
- // surface it. Without this tag the operator can't tell an offline
518
- // run from a fully-validated one — that's the WEB-4922 lesson.
519
- // Does NOT flip `ok`, so the `statusOf` re-derivation stays guarded.
520
- c.validationSkipped = true;
521
- c.detail = `${c.detail}; validation skipped — gateway unreachable`;
522
538
  }
523
539
  }
524
540
  // ONLY re-derive when a check's `ok` actually flipped. Without this guard,
@@ -641,6 +641,98 @@ test('validateToolKeys: invalid verdict flips the tool to tampered', async () =>
641
641
  });
642
642
  });
643
643
 
644
+ test('validateToolKeys: stale live-env key invalid but rc file holds a valid key → healthy', async () => {
645
+ await withHome(async (tmp, th) => {
646
+ const script = path.join(tmp, '.cursor', 'hooks', 'unbound.py');
647
+ writeFile(path.join(tmp, '.cursor', 'hooks.json'), JSON.stringify({ hooks: { PreToolUse: [{ command: script }] } }));
648
+ writeFile(script, '# unbound');
649
+ // A shell opened before setup rewrote the rc carries a stale key in the
650
+ // live env, while the valid key sits in a shell rc. Must not be tampered.
651
+ // .zshrc is on both the macOS and Linux rc lists, so the test is portable.
652
+ writeFile(path.join(tmp, '.zshrc'), 'export UNBOUND_CURSOR_API_KEY="rc-valid-key"\n');
653
+ process.env.UNBOUND_CURSOR_API_KEY = 'stale-invalid-key';
654
+ try {
655
+ const seen = [];
656
+ const tools = th.detectTools();
657
+ await th.validateToolKeys(tools, async (k) => { seen.push(k); return k === 'rc-valid-key' ? 'valid' : 'invalid'; });
658
+ const cursor = tools.find((t) => t.key === 'cursor');
659
+ assert.equal(cursor.status, 'healthy');
660
+ assert.equal(cursor.checks.find((c) => c.keyCheck).ok, true);
661
+ // Guard the rc read itself: if the rc file were skipped the tool would
662
+ // still be tampered, so prove the rc key actually reached the validator.
663
+ assert.ok(seen.includes('rc-valid-key'), 'the rc-file key must be validated, not just the live env key');
664
+ } finally {
665
+ delete process.env.UNBOUND_CURSOR_API_KEY;
666
+ }
667
+ });
668
+ });
669
+
670
+ test('validateToolKeys: live-env AND rc keys both invalid → tampered (no false clean)', async () => {
671
+ await withHome(async (tmp, th) => {
672
+ const script = path.join(tmp, '.cursor', 'hooks', 'unbound.py');
673
+ writeFile(path.join(tmp, '.cursor', 'hooks.json'), JSON.stringify({ hooks: { PreToolUse: [{ command: script }] } }));
674
+ writeFile(script, '# unbound');
675
+ writeFile(path.join(tmp, '.zshrc'), 'export UNBOUND_CURSOR_API_KEY="rc-stale-key"\n');
676
+ process.env.UNBOUND_CURSOR_API_KEY = 'env-stale-key';
677
+ try {
678
+ const seen = [];
679
+ const tools = th.detectTools();
680
+ await th.validateToolKeys(tools, async (k) => { seen.push(k); return 'invalid'; });
681
+ const cursor = tools.find((t) => t.key === 'cursor');
682
+ assert.equal(cursor.status, 'tampered');
683
+ assert.equal(cursor.checks.find((c) => c.keyCheck).ok, false);
684
+ // Both candidates must be validated — otherwise a skipped rc file lets
685
+ // this "every candidate rejected" case pass without ever reading the rc.
686
+ assert.ok(seen.includes('env-stale-key') && seen.includes('rc-stale-key'), 'both the live env and rc keys must be validated');
687
+ } finally {
688
+ delete process.env.UNBOUND_CURSOR_API_KEY;
689
+ }
690
+ });
691
+ });
692
+
693
+ test('validateToolKeys: a valid live key short-circuits — rc siblings are never sent to the validator', async () => {
694
+ await withHome(async (tmp, th) => {
695
+ const script = path.join(tmp, '.cursor', 'hooks', 'unbound.py');
696
+ writeFile(path.join(tmp, '.cursor', 'hooks.json'), JSON.stringify({ hooks: { PreToolUse: [{ command: script }] } }));
697
+ writeFile(script, '# unbound');
698
+ // A user-owned var (e.g. OPENAI_API_KEY for codex-gateway) can carry the
699
+ // user's personal key exported beside ours. A valid live key must stop the
700
+ // check before any rc sibling is uploaded to the gateway.
701
+ writeFile(path.join(tmp, '.zshrc'), 'export UNBOUND_CURSOR_API_KEY="personal-secret"\nexport UNBOUND_CURSOR_API_KEY="live-valid-key"\n');
702
+ process.env.UNBOUND_CURSOR_API_KEY = 'live-valid-key';
703
+ try {
704
+ const seen = [];
705
+ const tools = th.detectTools();
706
+ await th.validateToolKeys(tools, async (k) => { seen.push(k); return k === 'live-valid-key' ? 'valid' : 'invalid'; });
707
+ const cursor = tools.find((t) => t.key === 'cursor');
708
+ assert.equal(cursor.status, 'healthy');
709
+ assert.deepEqual(seen, ['live-valid-key'], 'only the live key is validated on the healthy path');
710
+ assert.ok(!seen.includes('personal-secret'), 'a sibling rc key must never be sent');
711
+ } finally {
712
+ delete process.env.UNBOUND_CURSOR_API_KEY;
713
+ }
714
+ });
715
+ });
716
+
717
+ test('validateToolKeys: user-owned OPENAI_API_KEY never has its rc siblings sent, even when the live key is rejected', async () => {
718
+ await withHome(async (tmp, th) => {
719
+ writeFile(path.join(tmp, '.codex', 'config.toml'), 'openai_base_url = "https://gateway.example/v1"\n');
720
+ // The user's personal OpenAI key sits in the rc beside anything Unbound set.
721
+ // A rejected live key must NOT trigger a search that uploads the personal one.
722
+ writeFile(path.join(tmp, '.zshrc'), 'export OPENAI_API_KEY="sk-personal-secret"\n');
723
+ process.env.OPENAI_API_KEY = 'stale-live-key';
724
+ try {
725
+ const seen = [];
726
+ const tools = th.detectTools();
727
+ await th.validateToolKeys(tools, async (k) => { seen.push(k); return 'invalid'; });
728
+ assert.ok(!seen.includes('sk-personal-secret'), 'a personal OpenAI key in the rc must never be sent to the gateway');
729
+ assert.deepEqual(seen, ['stale-live-key'], 'only the live value is ever validated for a user-owned var');
730
+ } finally {
731
+ delete process.env.OPENAI_API_KEY;
732
+ }
733
+ });
734
+ });
735
+
644
736
  test('validateToolKeys: unknown verdict fails open — tool stays healthy', async () => {
645
737
  await withHome(async (tmp, th) => {
646
738
  const script = path.join(tmp, '.cursor', 'hooks', 'unbound.py');