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 +1 -1
- package/src/toolHealth.js +42 -26
- package/test/tool-health.test.js +92 -0
package/package.json
CHANGED
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
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
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
|
-
|
|
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
|
|
508
|
-
if (
|
|
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,
|
package/test/tool-health.test.js
CHANGED
|
@@ -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');
|