unbound-cli 0.8.2 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/api.js +32 -0
- package/src/commands/oacb.js +931 -0
- package/src/commands/onboard.js +10 -2
- package/src/commands/setup.js +111 -26
- package/src/index.js +1 -0
- package/test/oacb.test.js +307 -0
package/src/commands/onboard.js
CHANGED
|
@@ -26,6 +26,7 @@ function register(program) {
|
|
|
26
26
|
.requiredOption('--api-key <key>', 'User API key (for tool setup and login)')
|
|
27
27
|
.requiredOption('--discovery-key <key>', 'Discovery API key (for device scan)')
|
|
28
28
|
.option('--domain <url>', 'Backend URL for discovery (defaults to configured backend)')
|
|
29
|
+
.option('--backfill', 'Seed historical Claude Code / Codex sessions from local transcripts into Unbound analytics (Cursor skipped automatically)')
|
|
29
30
|
.addOption(new Option('--backend-url <url>', 'Override backend URL for setup scripts (dev only)').hideHelp())
|
|
30
31
|
.addOption(new Option('--frontend-url <url>', 'Override frontend URL for setup scripts (dev only)').hideHelp())
|
|
31
32
|
.addOption(new Option('--gateway-url <url>', 'Override gateway URL for setup scripts (dev only)').hideHelp())
|
|
@@ -46,6 +47,7 @@ For admin device enrollment via MDM, use \`unbound onboard-mdm\` instead.
|
|
|
46
47
|
|
|
47
48
|
Examples:
|
|
48
49
|
$ unbound onboard --api-key <USER_KEY> --discovery-key <DISCOVERY_KEY>
|
|
50
|
+
$ unbound onboard --api-key <USER_KEY> --discovery-key <DISCOVERY_KEY> --backfill
|
|
49
51
|
$ sudo unbound onboard --api-key <USER_KEY> --discovery-key <DISCOVERY_KEY>
|
|
50
52
|
`)
|
|
51
53
|
.action(async (opts) => {
|
|
@@ -79,7 +81,9 @@ Examples:
|
|
|
79
81
|
|
|
80
82
|
console.log('');
|
|
81
83
|
output.info('Step 1/2: Installing tool bundle');
|
|
82
|
-
const ok = await runSetupAllBundle(apiKey, {
|
|
84
|
+
const ok = await runSetupAllBundle(apiKey, {
|
|
85
|
+
backendUrl, frontendUrl, gatewayUrl, backfill: !!opts.backfill,
|
|
86
|
+
});
|
|
83
87
|
if (!ok) return;
|
|
84
88
|
setupSucceeded = true;
|
|
85
89
|
|
|
@@ -112,6 +116,7 @@ Examples:
|
|
|
112
116
|
.requiredOption('--admin-api-key <key>', 'Admin API key for MDM enrollment')
|
|
113
117
|
.requiredOption('--discovery-key <key>', 'Discovery API key (for device scan)')
|
|
114
118
|
.option('--domain <url>', 'Backend URL for discovery (defaults to configured backend)')
|
|
119
|
+
.option('--backfill', 'Seed historical Claude Code / Codex sessions from local transcripts into Unbound analytics (Cursor skipped automatically)')
|
|
115
120
|
.addOption(new Option('--backend-url <url>', 'Override backend URL for setup scripts (dev only)').hideHelp())
|
|
116
121
|
.addOption(new Option('--frontend-url <url>', 'Override frontend URL for setup scripts (dev only)').hideHelp())
|
|
117
122
|
.addOption(new Option('--gateway-url <url>', 'Override gateway URL for setup scripts (dev only)').hideHelp())
|
|
@@ -127,6 +132,7 @@ For end-user onboarding (non-MDM), use \`unbound onboard\` instead.
|
|
|
127
132
|
|
|
128
133
|
Examples:
|
|
129
134
|
$ sudo unbound onboard-mdm --admin-api-key <ADMIN_KEY> --discovery-key <DISCOVERY_KEY>
|
|
135
|
+
$ sudo unbound onboard-mdm --admin-api-key <ADMIN_KEY> --discovery-key <DISCOVERY_KEY> --backfill
|
|
130
136
|
`)
|
|
131
137
|
.action(async (opts) => {
|
|
132
138
|
let setupSucceeded = false;
|
|
@@ -148,7 +154,9 @@ Examples:
|
|
|
148
154
|
|
|
149
155
|
console.log('');
|
|
150
156
|
output.info('Step 1/2: Installing MDM tool bundle');
|
|
151
|
-
const ok = await runMdmSetupAllBundle(opts.adminApiKey, {
|
|
157
|
+
const ok = await runMdmSetupAllBundle(opts.adminApiKey, {
|
|
158
|
+
backendUrl, gatewayUrl, backfill: !!opts.backfill,
|
|
159
|
+
});
|
|
152
160
|
if (!ok) return;
|
|
153
161
|
setupSucceeded = true;
|
|
154
162
|
|
package/src/commands/setup.js
CHANGED
|
@@ -212,20 +212,42 @@ async function runPythonScriptWindows(scriptPath, args, { capture }) {
|
|
|
212
212
|
* own defaults. `mdm: true` skips --domain (frontend URL) since MDM scripts
|
|
213
213
|
* have no browser-auth flow.
|
|
214
214
|
*/
|
|
215
|
-
function buildScriptArgs(apiKey, { backendUrl, frontendUrl, gatewayUrl, clear, mdm } = {}) {
|
|
215
|
+
function buildScriptArgs(apiKey, { backendUrl, frontendUrl, gatewayUrl, clear, mdm, backfill } = {}) {
|
|
216
216
|
let args = `--api-key ${shellEscape(apiKey)}`;
|
|
217
217
|
if (backendUrl) args += ` --backend-url ${shellEscape(backendUrl)}`;
|
|
218
218
|
if (gatewayUrl) args += ` --gateway-url ${shellEscape(gatewayUrl)}`;
|
|
219
219
|
if (!mdm && frontendUrl) args += ` --domain ${shellEscape(frontendUrl)}`;
|
|
220
220
|
if (clear) args += ' --clear';
|
|
221
|
+
if (backfill) args += ' --backfill';
|
|
221
222
|
return args;
|
|
222
223
|
}
|
|
223
224
|
|
|
225
|
+
// Backfill only applies to the hooks variants of Claude Code / Codex; gateway
|
|
226
|
+
// mode and Cursor have no local transcripts to seed.
|
|
227
|
+
function scriptSupportsBackfill(scriptPath) {
|
|
228
|
+
return scriptPath.includes('/hooks/') && (
|
|
229
|
+
scriptPath.startsWith('claude-code/') || scriptPath.startsWith('codex/')
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Surfaces an early note when --backfill was requested for a tool that can't
|
|
234
|
+
// use it. The setup scripts no-op safely too, but earlier user signal is better UX.
|
|
235
|
+
function noteBackfillUnsupported(label, scriptPath) {
|
|
236
|
+
if (scriptPath.startsWith('cursor/')) {
|
|
237
|
+
output.info(`${label} backfill is not supported (no historical transcript data on disk). Continuing without backfill for ${label}.`);
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
if (scriptPath.includes('/gateway/')) {
|
|
241
|
+
output.info(`--backfill is not supported in gateway mode for ${label}. Continuing without backfill for ${label}.`);
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
224
246
|
/**
|
|
225
247
|
* Runs a Python setup script from the setup repo with inherited stdio (live output).
|
|
226
248
|
*/
|
|
227
|
-
async function runSetupScript(scriptPath, apiKey, { clear = false, backendUrl, frontendUrl, gatewayUrl } = {}) {
|
|
228
|
-
const args = buildScriptArgs(apiKey, { backendUrl, frontendUrl, gatewayUrl, clear });
|
|
249
|
+
async function runSetupScript(scriptPath, apiKey, { clear = false, backendUrl, frontendUrl, gatewayUrl, backfill = false } = {}) {
|
|
250
|
+
const args = buildScriptArgs(apiKey, { backendUrl, frontendUrl, gatewayUrl, clear, backfill });
|
|
229
251
|
console.log('');
|
|
230
252
|
if (isWindowsNative()) {
|
|
231
253
|
await runPythonScriptWindows(scriptPath, args, { capture: false });
|
|
@@ -318,6 +340,7 @@ function register(program) {
|
|
|
318
340
|
.option('--subscription', 'Use subscription mode for Claude Code / Codex (hooks only)')
|
|
319
341
|
.option('--gateway', 'Use gateway mode for Claude Code / Codex (Unbound as AI provider)')
|
|
320
342
|
.option('--all', 'Set up the default bundle: Cursor, Claude Code (hooks), Codex (hooks)')
|
|
343
|
+
.option('--backfill', 'Seed historical Claude Code / Codex sessions from local transcripts into Unbound analytics (subscription/hooks mode only; Cursor unsupported)')
|
|
321
344
|
.addOption(new Option('--backend-url <url>', 'Override backend URL for setup scripts (dev only)').hideHelp())
|
|
322
345
|
.addOption(new Option('--frontend-url <url>', 'Override frontend URL for setup scripts (dev only)').hideHelp())
|
|
323
346
|
.addOption(new Option('--gateway-url <url>', 'Override gateway URL for setup scripts (dev only)').hideHelp())
|
|
@@ -349,6 +372,10 @@ Examples:
|
|
|
349
372
|
$ unbound setup --all Set up the default bundle
|
|
350
373
|
$ unbound setup --all --api-key <key> Login + set up the bundle
|
|
351
374
|
|
|
375
|
+
Seed historical sessions (Claude Code / Codex subscription mode only):
|
|
376
|
+
$ unbound setup claude-code --subscription --backfill Install hooks AND backfill local history
|
|
377
|
+
$ unbound setup codex --subscription --backfill Install hooks AND backfill local history
|
|
378
|
+
|
|
352
379
|
One-step login and setup:
|
|
353
380
|
$ unbound setup cursor --api-key <key> Login + set up Cursor
|
|
354
381
|
$ unbound setup cursor claude-code-gateway --api-key <key>
|
|
@@ -414,10 +441,18 @@ automatically to authenticate before proceeding.
|
|
|
414
441
|
const selectedTools = SETUP_TOOLS.filter(t => selected.includes(t.value));
|
|
415
442
|
console.log('');
|
|
416
443
|
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
444
|
+
if (opts.backfill) {
|
|
445
|
+
for (const tool of selectedTools) {
|
|
446
|
+
if (!scriptSupportsBackfill(tool.script)) noteBackfillUnsupported(tool.label, tool.script);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
const ok = await runBatch(selectedTools, (tool) => {
|
|
450
|
+
const toolArgs = buildScriptArgs(apiKey, {
|
|
451
|
+
...urlOpts,
|
|
452
|
+
backfill: opts.backfill && scriptSupportsBackfill(tool.script),
|
|
453
|
+
});
|
|
454
|
+
return runScriptPiped(tool.script, toolArgs);
|
|
455
|
+
});
|
|
421
456
|
if (!ok) return;
|
|
422
457
|
|
|
423
458
|
console.log('');
|
|
@@ -481,7 +516,10 @@ automatically to authenticate before proceeding.
|
|
|
481
516
|
const toolName = tools[0];
|
|
482
517
|
|
|
483
518
|
if (SETUP_TOOL_MAP[toolName]) {
|
|
484
|
-
|
|
519
|
+
const { script, label } = SETUP_TOOL_MAP[toolName];
|
|
520
|
+
const backfill = opts.backfill && scriptSupportsBackfill(script);
|
|
521
|
+
if (opts.backfill && !backfill) noteBackfillUnsupported(label, script);
|
|
522
|
+
await runSetupScript(script, apiKey, { clear: opts.clear, backfill, ...urlOpts });
|
|
485
523
|
} else if (MODE_TOOLS[toolName]) {
|
|
486
524
|
const mode = MODE_TOOLS[toolName];
|
|
487
525
|
if (opts.clear) {
|
|
@@ -495,7 +533,10 @@ automatically to authenticate before proceeding.
|
|
|
495
533
|
useSubscription = choice === 'subscription';
|
|
496
534
|
}
|
|
497
535
|
const resolved = useSubscription ? mode.subscription : mode.gateway;
|
|
498
|
-
|
|
536
|
+
const { script, label } = SETUP_TOOL_MAP[resolved];
|
|
537
|
+
const backfill = opts.backfill && scriptSupportsBackfill(script);
|
|
538
|
+
if (opts.backfill && !backfill) noteBackfillUnsupported(label, script);
|
|
539
|
+
await runSetupScript(script, apiKey, { ...urlOpts, backfill });
|
|
499
540
|
}
|
|
500
541
|
} else if (INSTRUCTION_TOOLS[toolName]) {
|
|
501
542
|
output.keyValue(INSTRUCTION_TOOLS[toolName].values(apiKey, frontendUrl));
|
|
@@ -533,10 +574,19 @@ automatically to authenticate before proceeding.
|
|
|
533
574
|
// Run automated tools with spinners
|
|
534
575
|
if (resolvedScripts.length > 0) {
|
|
535
576
|
console.log('');
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
577
|
+
if (opts.backfill) {
|
|
578
|
+
for (const tool of resolvedScripts) {
|
|
579
|
+
if (!scriptSupportsBackfill(tool.script)) noteBackfillUnsupported(tool.label, tool.script);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
const ok = await runBatch(resolvedScripts, (tool) => {
|
|
583
|
+
const toolArgs = buildScriptArgs(apiKey, {
|
|
584
|
+
...urlOpts,
|
|
585
|
+
clear: opts.clear,
|
|
586
|
+
backfill: opts.backfill && scriptSupportsBackfill(tool.script),
|
|
587
|
+
});
|
|
588
|
+
return runScriptPiped(tool.script, toolArgs);
|
|
589
|
+
}, { clear: opts.clear });
|
|
540
590
|
if (!ok) return;
|
|
541
591
|
}
|
|
542
592
|
|
|
@@ -572,6 +622,7 @@ automatically to authenticate before proceeding.
|
|
|
572
622
|
.requiredOption('--admin-api-key <key>', 'Admin API key for MDM enrollment')
|
|
573
623
|
.option('--clear', 'Remove Unbound configuration for the specified tools')
|
|
574
624
|
.option('--all', 'Set up all available tools')
|
|
625
|
+
.option('--backfill', 'Seed historical Claude Code / Codex sessions from local transcripts into Unbound analytics (subscription/hooks mode only; Cursor unsupported)')
|
|
575
626
|
.addHelpText('after', `
|
|
576
627
|
Available tools:
|
|
577
628
|
cursor Cursor IDE
|
|
@@ -590,6 +641,8 @@ Examples:
|
|
|
590
641
|
$ sudo unbound setup mdm --admin-api-key KEY cursor claude-code-subscription codex-subscription
|
|
591
642
|
$ sudo unbound setup mdm --admin-api-key KEY --all
|
|
592
643
|
$ sudo unbound setup mdm --admin-api-key KEY --clear cursor codex-subscription
|
|
644
|
+
$ sudo unbound setup mdm --admin-api-key KEY claude-code-subscription --backfill
|
|
645
|
+
Install hooks AND backfill local history
|
|
593
646
|
`)
|
|
594
647
|
.action(async (tools, opts, command) => {
|
|
595
648
|
try {
|
|
@@ -650,16 +703,24 @@ Examples:
|
|
|
650
703
|
const resolvedTools = toolNames.map(name => ({ name, ...MDM_TOOLS[name] }));
|
|
651
704
|
console.log('');
|
|
652
705
|
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
});
|
|
706
|
+
if (globalOpts.backfill) {
|
|
707
|
+
for (const tool of resolvedTools) {
|
|
708
|
+
if (!scriptSupportsBackfill(tool.script)) noteBackfillUnsupported(tool.label, tool.script);
|
|
709
|
+
}
|
|
710
|
+
}
|
|
659
711
|
|
|
660
712
|
const ok = await runBatch(
|
|
661
713
|
resolvedTools,
|
|
662
|
-
(tool) =>
|
|
714
|
+
(tool) => {
|
|
715
|
+
const toolArgs = buildScriptArgs(opts.adminApiKey, {
|
|
716
|
+
backendUrl,
|
|
717
|
+
gatewayUrl,
|
|
718
|
+
clear: globalOpts.clear,
|
|
719
|
+
mdm: true,
|
|
720
|
+
backfill: globalOpts.backfill && scriptSupportsBackfill(tool.script),
|
|
721
|
+
});
|
|
722
|
+
return runScriptPiped(tool.script, toolArgs);
|
|
723
|
+
},
|
|
663
724
|
{ clear: globalOpts.clear }
|
|
664
725
|
);
|
|
665
726
|
if (!ok) return;
|
|
@@ -678,10 +739,24 @@ Examples:
|
|
|
678
739
|
* Assumes the caller has already ensured the user is logged in.
|
|
679
740
|
* Returns true on success, false on failure.
|
|
680
741
|
*/
|
|
681
|
-
async function runSetupAllBundle(apiKey, { backendUrl, frontendUrl, gatewayUrl } = {}) {
|
|
742
|
+
async function runSetupAllBundle(apiKey, { backendUrl, frontendUrl, gatewayUrl, backfill = false } = {}) {
|
|
682
743
|
const resolvedTools = ALL_TOOLS.map(name => ({ name, ...SETUP_TOOL_MAP[name] }));
|
|
683
|
-
|
|
684
|
-
|
|
744
|
+
if (backfill) {
|
|
745
|
+
for (const tool of resolvedTools) {
|
|
746
|
+
if (!scriptSupportsBackfill(tool.script)) noteBackfillUnsupported(tool.label, tool.script);
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
// Build args per-tool so --backfill only goes to tools whose script
|
|
750
|
+
// actually supports it (Claude Code hooks and Codex hooks). Cursor would
|
|
751
|
+
// print "not supported"; passing the flag to gateway-mode scripts would
|
|
752
|
+
// error out — `scriptSupportsBackfill` checks for both.
|
|
753
|
+
return runBatch(resolvedTools, (tool) => {
|
|
754
|
+
const args = buildScriptArgs(apiKey, {
|
|
755
|
+
backendUrl, frontendUrl, gatewayUrl,
|
|
756
|
+
backfill: backfill && scriptSupportsBackfill(tool.script),
|
|
757
|
+
});
|
|
758
|
+
return runScriptPiped(tool.script, args);
|
|
759
|
+
});
|
|
685
760
|
}
|
|
686
761
|
|
|
687
762
|
/**
|
|
@@ -689,10 +764,20 @@ async function runSetupAllBundle(apiKey, { backendUrl, frontendUrl, gatewayUrl }
|
|
|
689
764
|
* Caller must ensure the process is running as root.
|
|
690
765
|
* Returns true on success, false on failure.
|
|
691
766
|
*/
|
|
692
|
-
async function runMdmSetupAllBundle(adminApiKey, { backendUrl, gatewayUrl } = {}) {
|
|
767
|
+
async function runMdmSetupAllBundle(adminApiKey, { backendUrl, gatewayUrl, backfill = false } = {}) {
|
|
693
768
|
const resolvedTools = MDM_ALL_TOOLS.map(name => ({ name, ...MDM_TOOLS[name] }));
|
|
694
|
-
|
|
695
|
-
|
|
769
|
+
if (backfill) {
|
|
770
|
+
for (const tool of resolvedTools) {
|
|
771
|
+
if (!scriptSupportsBackfill(tool.script)) noteBackfillUnsupported(tool.label, tool.script);
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
return runBatch(resolvedTools, (tool) => {
|
|
775
|
+
const args = buildScriptArgs(adminApiKey, {
|
|
776
|
+
backendUrl, gatewayUrl, mdm: true,
|
|
777
|
+
backfill: backfill && scriptSupportsBackfill(tool.script),
|
|
778
|
+
});
|
|
779
|
+
return runScriptPiped(tool.script, args);
|
|
780
|
+
});
|
|
696
781
|
}
|
|
697
782
|
|
|
698
783
|
module.exports = {
|
package/src/index.js
CHANGED
|
@@ -181,6 +181,7 @@ require('./commands/setup').register(program);
|
|
|
181
181
|
require('./commands/discover').register(program);
|
|
182
182
|
require('./commands/onboard').register(program);
|
|
183
183
|
require('./commands/chat').register(program);
|
|
184
|
+
require('./commands/oacb').register(program);
|
|
184
185
|
|
|
185
186
|
// config command for managing CLI settings
|
|
186
187
|
const configCmd = program
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
const { test } = require('node:test');
|
|
2
|
+
const assert = require('node:assert/strict');
|
|
3
|
+
const os = require('node:os');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
|
|
6
|
+
const {
|
|
7
|
+
validateTier,
|
|
8
|
+
isVersionSupported,
|
|
9
|
+
computeGaps,
|
|
10
|
+
mergeOverrides,
|
|
11
|
+
computeDeepDiff,
|
|
12
|
+
buildOacbHookEntries,
|
|
13
|
+
ruleIdFromPattern,
|
|
14
|
+
classifyRule,
|
|
15
|
+
hasOacbHook,
|
|
16
|
+
} = require('../src/commands/oacb').__test__;
|
|
17
|
+
|
|
18
|
+
// ─── validateTier ─────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
test('validateTier: accepts all four valid tiers', () => {
|
|
21
|
+
for (const t of ['shadow', 'baseline', 'strict', 'paranoid']) {
|
|
22
|
+
assert.doesNotThrow(() => validateTier(t));
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('validateTier: rejects unknown tier', () => {
|
|
27
|
+
assert.throws(() => validateTier('custom'), /Invalid tier/);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('validateTier: rejects empty string', () => {
|
|
31
|
+
assert.throws(() => validateTier(''), /Invalid tier/);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// ─── isVersionSupported ───────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
test('isVersionSupported: supported version returns true', () => {
|
|
37
|
+
assert.equal(isVersionSupported('2.1.83'), true);
|
|
38
|
+
assert.equal(isVersionSupported('2.5.0'), true);
|
|
39
|
+
assert.equal(isVersionSupported('2.99.0'), true);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('isVersionSupported: version below floor returns false', () => {
|
|
43
|
+
assert.equal(isVersionSupported('2.1.82'), false);
|
|
44
|
+
assert.equal(isVersionSupported('2.0.99'), false);
|
|
45
|
+
assert.equal(isVersionSupported('1.99.0'), false);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('isVersionSupported: v3+ returns false', () => {
|
|
49
|
+
assert.equal(isVersionSupported('3.0.0'), false);
|
|
50
|
+
assert.equal(isVersionSupported('3.1.0'), false);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('isVersionSupported: non-semver string returns false', () => {
|
|
54
|
+
assert.equal(isVersionSupported(''), false);
|
|
55
|
+
assert.equal(isVersionSupported('latest'), false);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('isVersionSupported: handles version prefix in claude --version output', () => {
|
|
59
|
+
// `claude --version` may return "2.3.7" or "claude 2.3.7" — match by first semver
|
|
60
|
+
assert.equal(isVersionSupported('claude 2.3.7'), true);
|
|
61
|
+
assert.equal(isVersionSupported('claude 1.0.0'), false);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// ─── ruleIdFromPattern / classifyRule ────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
test('ruleIdFromPattern: RM pattern', () => {
|
|
67
|
+
assert.equal(ruleIdFromPattern('Bash(rm -rf /*)'), 'OACB-RM-001');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('ruleIdFromPattern: TF pattern', () => {
|
|
71
|
+
assert.equal(ruleIdFromPattern('Bash(terraform destroy *)'), 'OACB-TF-001');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('ruleIdFromPattern: credential read pattern', () => {
|
|
75
|
+
assert.equal(ruleIdFromPattern('Read(**/.aws/credentials)'), 'OACB-READ-001');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('classifyRule: rm maps to ASI02', () => {
|
|
79
|
+
assert.equal(classifyRule('Bash(rm -rf /*)'), 'ASI02');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('classifyRule: credential read maps to ASI07', () => {
|
|
83
|
+
assert.equal(classifyRule('Read(**/.env)'), 'ASI07');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// ─── mergeOverrides ───────────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
test('mergeOverrides: scalar override wins', () => {
|
|
89
|
+
const base = { a: 1, b: 2 };
|
|
90
|
+
const override = { b: 99 };
|
|
91
|
+
assert.deepEqual(mergeOverrides(base, override), { a: 1, b: 99 });
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('mergeOverrides: array union (no duplicates)', () => {
|
|
95
|
+
const base = { deny: ['A', 'B'] };
|
|
96
|
+
const override = { deny: ['B', 'C'] };
|
|
97
|
+
const result = mergeOverrides(base, override);
|
|
98
|
+
assert.deepEqual(result.deny.sort(), ['A', 'B', 'C']);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('mergeOverrides: nested object recurses', () => {
|
|
102
|
+
const base = { permissions: { deny: ['A'], defaultMode: 'acceptEdits' } };
|
|
103
|
+
const override = { permissions: { deny: ['B'], defaultMode: 'auto' } };
|
|
104
|
+
const result = mergeOverrides(base, override);
|
|
105
|
+
assert.deepEqual(result.permissions.deny.sort(), ['A', 'B']);
|
|
106
|
+
assert.equal(result.permissions.defaultMode, 'auto');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('mergeOverrides: _oacbMeta key is skipped', () => {
|
|
110
|
+
const base = { a: 1 };
|
|
111
|
+
const override = { a: 2, _oacbMeta: { tier: 'baseline' } };
|
|
112
|
+
const result = mergeOverrides(base, override);
|
|
113
|
+
assert.equal(result.a, 2);
|
|
114
|
+
assert.equal(result._oacbMeta, undefined);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('mergeOverrides: does not mutate base', () => {
|
|
118
|
+
const base = { deny: ['A'] };
|
|
119
|
+
mergeOverrides(base, { deny: ['B'] });
|
|
120
|
+
assert.deepEqual(base.deny, ['A']);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// ─── computeDeepDiff ─────────────────────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
test('computeDeepDiff: identical objects produce empty diff', () => {
|
|
126
|
+
const obj = { permissions: { deny: ['A'] }, autoMode: { environment: [] } };
|
|
127
|
+
assert.deepEqual(computeDeepDiff(obj, obj), {});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('computeDeepDiff: added key', () => {
|
|
131
|
+
const a = { x: 1 };
|
|
132
|
+
const b = { x: 1, y: 2 };
|
|
133
|
+
assert.deepEqual(computeDeepDiff(a, b), { y: { added: 2 } });
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('computeDeepDiff: removed key', () => {
|
|
137
|
+
const a = { x: 1, y: 2 };
|
|
138
|
+
const b = { x: 1 };
|
|
139
|
+
assert.deepEqual(computeDeepDiff(a, b), { y: { removed: 2 } });
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('computeDeepDiff: changed scalar', () => {
|
|
143
|
+
assert.deepEqual(computeDeepDiff({ a: 1 }, { a: 2 }), { a: { from: 1, to: 2 } });
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('computeDeepDiff: array diff shows added/removed items', () => {
|
|
147
|
+
const a = { deny: ['A', 'B'] };
|
|
148
|
+
const b = { deny: ['A', 'C'] };
|
|
149
|
+
const diff = computeDeepDiff(a, b);
|
|
150
|
+
assert.deepEqual(diff.deny.added, ['C']);
|
|
151
|
+
assert.deepEqual(diff.deny.removed, ['B']);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('computeDeepDiff: _-prefixed keys are skipped', () => {
|
|
155
|
+
const a = { _oacb: { tier: 'shadow' }, x: 1 };
|
|
156
|
+
const b = { _oacb: { tier: 'baseline' }, x: 1 };
|
|
157
|
+
assert.deepEqual(computeDeepDiff(a, b), {});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// ─── computeGaps ─────────────────────────────────────────────────────────────
|
|
161
|
+
|
|
162
|
+
const BASELINE_FIXTURE = {
|
|
163
|
+
_oacb: { tier: 'baseline' },
|
|
164
|
+
permissions: {
|
|
165
|
+
deny: ['Bash(rm -rf /*)', 'Bash(terraform destroy *)'],
|
|
166
|
+
disableBypassPermissionsMode: 'disable',
|
|
167
|
+
},
|
|
168
|
+
autoMode: {},
|
|
169
|
+
hooks: {
|
|
170
|
+
PreToolUse: [
|
|
171
|
+
{
|
|
172
|
+
matcher: 'Bash',
|
|
173
|
+
hooks: [{ type: 'command', command: '/usr/local/share/oacb/hooks/oacb-enforce.sh', timeout: 5000 }],
|
|
174
|
+
},
|
|
175
|
+
],
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
test('computeGaps: fully-compliant settings → no gaps', () => {
|
|
180
|
+
const current = {
|
|
181
|
+
permissions: {
|
|
182
|
+
deny: ['Bash(rm -rf /*)', 'Bash(terraform destroy *)'],
|
|
183
|
+
disableBypassPermissionsMode: 'disable',
|
|
184
|
+
},
|
|
185
|
+
hooks: {
|
|
186
|
+
PreToolUse: [
|
|
187
|
+
{ hooks: [{ type: 'command', command: `${os.homedir()}/.claude/hooks/oacb-enforce.sh` }] },
|
|
188
|
+
],
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
assert.deepEqual(computeGaps(current, BASELINE_FIXTURE), []);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test('computeGaps: missing deny rule reports a gap', () => {
|
|
195
|
+
const current = {
|
|
196
|
+
permissions: {
|
|
197
|
+
deny: ['Bash(rm -rf /*)'], // missing terraform destroy
|
|
198
|
+
disableBypassPermissionsMode: 'disable',
|
|
199
|
+
},
|
|
200
|
+
hooks: {
|
|
201
|
+
PreToolUse: [
|
|
202
|
+
{ hooks: [{ type: 'command', command: `${os.homedir()}/.claude/hooks/oacb-enforce.sh` }] },
|
|
203
|
+
],
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
const gaps = computeGaps(current, BASELINE_FIXTURE);
|
|
207
|
+
assert.ok(gaps.some(g => g.ruleId === 'OACB-TF-001'), 'expected OACB-TF-001 gap');
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test('computeGaps: missing hook reports OACB-HOOK-001', () => {
|
|
211
|
+
const current = {
|
|
212
|
+
permissions: { deny: ['Bash(rm -rf /*)', 'Bash(terraform destroy *)'], disableBypassPermissionsMode: 'disable' },
|
|
213
|
+
hooks: {}, // no hooks
|
|
214
|
+
};
|
|
215
|
+
const gaps = computeGaps(current, BASELINE_FIXTURE);
|
|
216
|
+
assert.ok(gaps.some(g => g.ruleId === 'OACB-HOOK-001'), 'expected OACB-HOOK-001 gap');
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test('computeGaps: wrong disableBypassPermissionsMode reports OACB-BYPASS-001', () => {
|
|
220
|
+
const current = {
|
|
221
|
+
permissions: {
|
|
222
|
+
deny: ['Bash(rm -rf /*)', 'Bash(terraform destroy *)'],
|
|
223
|
+
disableBypassPermissionsMode: 'enable', // wrong
|
|
224
|
+
},
|
|
225
|
+
hooks: {
|
|
226
|
+
PreToolUse: [
|
|
227
|
+
{ hooks: [{ type: 'command', command: `${os.homedir()}/.claude/hooks/oacb-enforce.sh` }] },
|
|
228
|
+
],
|
|
229
|
+
},
|
|
230
|
+
};
|
|
231
|
+
const gaps = computeGaps(current, BASELINE_FIXTURE);
|
|
232
|
+
assert.ok(gaps.some(g => g.ruleId === 'OACB-BYPASS-001'), 'expected OACB-BYPASS-001 gap');
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test('computeGaps: empty current settings reports multiple gaps', () => {
|
|
236
|
+
const gaps = computeGaps({}, BASELINE_FIXTURE);
|
|
237
|
+
assert.ok(gaps.length > 0, 'expected gaps for empty settings');
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test('computeGaps: deduplicates gap by rule ID', () => {
|
|
241
|
+
// Both rm patterns map to OACB-RM-001 — should report it only once
|
|
242
|
+
const baseline = {
|
|
243
|
+
_oacb: { tier: 'baseline' },
|
|
244
|
+
permissions: {
|
|
245
|
+
deny: ['Bash(rm -rf /*)', 'Bash(rm -rf ~*)', 'Bash(rm -fr /*)'],
|
|
246
|
+
disableBypassPermissionsMode: 'disable',
|
|
247
|
+
},
|
|
248
|
+
hooks: {
|
|
249
|
+
PreToolUse: [
|
|
250
|
+
{ hooks: [{ type: 'command', command: '/usr/local/share/oacb/hooks/oacb-enforce.sh' }] },
|
|
251
|
+
],
|
|
252
|
+
},
|
|
253
|
+
};
|
|
254
|
+
const current = {
|
|
255
|
+
permissions: { deny: [], disableBypassPermissionsMode: 'disable' },
|
|
256
|
+
hooks: {
|
|
257
|
+
PreToolUse: [
|
|
258
|
+
{ hooks: [{ type: 'command', command: `${os.homedir()}/.claude/hooks/oacb-enforce.sh` }] },
|
|
259
|
+
],
|
|
260
|
+
},
|
|
261
|
+
};
|
|
262
|
+
const gaps = computeGaps(current, baseline);
|
|
263
|
+
const rmGaps = gaps.filter(g => g.ruleId === 'OACB-RM-001');
|
|
264
|
+
assert.equal(rmGaps.length, 1, 'OACB-RM-001 should appear exactly once');
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
// ─── buildOacbHookEntries ─────────────────────────────────────────────────────
|
|
268
|
+
|
|
269
|
+
test('buildOacbHookEntries: hook paths are rewritten to ~/.claude/hooks/', () => {
|
|
270
|
+
const hooks = buildOacbHookEntries(BASELINE_FIXTURE);
|
|
271
|
+
const hookCmd = hooks.PreToolUse[0].hooks[0].command;
|
|
272
|
+
const expectedPath = path.join(os.homedir(), '.claude', 'hooks', 'oacb-enforce.sh');
|
|
273
|
+
assert.equal(hookCmd, expectedPath);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test('buildOacbHookEntries: does not mutate original baseline', () => {
|
|
277
|
+
const originalCmd = BASELINE_FIXTURE.hooks.PreToolUse[0].hooks[0].command;
|
|
278
|
+
buildOacbHookEntries(BASELINE_FIXTURE);
|
|
279
|
+
assert.equal(BASELINE_FIXTURE.hooks.PreToolUse[0].hooks[0].command, originalCmd);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test('buildOacbHookEntries: returns all hook events from baseline', () => {
|
|
283
|
+
const hooks = buildOacbHookEntries(BASELINE_FIXTURE);
|
|
284
|
+
assert.ok(Array.isArray(hooks.PreToolUse), 'PreToolUse should be present');
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
// ─── hasOacbHook ─────────────────────────────────────────────────────────────
|
|
288
|
+
|
|
289
|
+
test('hasOacbHook: detects hook present in settings', () => {
|
|
290
|
+
const settings = {
|
|
291
|
+
hooks: {
|
|
292
|
+
PreToolUse: [
|
|
293
|
+
{ hooks: [{ type: 'command', command: '/Users/test/.claude/hooks/oacb-enforce.sh' }] },
|
|
294
|
+
],
|
|
295
|
+
},
|
|
296
|
+
};
|
|
297
|
+
assert.equal(hasOacbHook(settings, 'PreToolUse', 'oacb-enforce.sh'), true);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
test('hasOacbHook: returns false when hook not present', () => {
|
|
301
|
+
const settings = { hooks: { PreToolUse: [] } };
|
|
302
|
+
assert.equal(hasOacbHook(settings, 'PreToolUse', 'oacb-enforce.sh'), false);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test('hasOacbHook: returns false when event not present', () => {
|
|
306
|
+
assert.equal(hasOacbHook({}, 'PreToolUse', 'oacb-enforce.sh'), false);
|
|
307
|
+
});
|