unbound-cli 0.8.1 → 0.9.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.
@@ -52,21 +52,29 @@ Examples:
52
52
  let setupSucceeded = false;
53
53
  let discoveryDomain;
54
54
  try {
55
- // Persist explicitly-passed URL flags BEFORE login so tenant URLs land
56
- // in config before any backend call (whoami, setup completion ping) uses
57
- // them. setUrls is atomic a malformed URL throws before any disk write,
58
- // so the three URLs never end up out of sync.
59
- config.setUrls({
55
+ // Persist URLs first, then login, then setup order matters so the
56
+ // login validates against the new backend and setup wires tools at the
57
+ // new gateway. setUrls is atomic; a malformed URL throws before any
58
+ // disk write so the three URLs never end up out of sync.
59
+ const written = config.setUrls({
60
60
  backend: opts.backendUrl,
61
61
  frontend: opts.frontendUrl,
62
62
  gateway: opts.gatewayUrl,
63
63
  });
64
- const backendUrl = config.getBaseUrl();
65
- const frontendUrl = config.getFrontendUrl();
66
- const gatewayUrl = config.getGatewayUrl();
64
+ // Prefer the values we JUST persisted over the env-var-aware getters —
65
+ // a stale UNBOUND_*_URL from a prior shell session could otherwise
66
+ // silently shadow the user's explicit --*-url flag and route login or
67
+ // setup at the wrong tenant.
68
+ const backendUrl = written.base_url || config.getBaseUrl();
69
+ const frontendUrl = written.frontend_url || config.getFrontendUrl();
70
+ const gatewayUrl = written.gateway_url || config.getGatewayUrl();
67
71
  discoveryDomain = opts.domain || backendUrl;
68
72
 
69
- await ensureLoggedIn({ apiKey: opts.apiKey });
73
+ await ensureLoggedIn({
74
+ apiKey: opts.apiKey,
75
+ baseUrl: written.base_url,
76
+ frontendUrl: written.frontend_url,
77
+ });
70
78
  const apiKey = config.getApiKey();
71
79
 
72
80
  console.log('');
@@ -124,15 +132,16 @@ Examples:
124
132
  let setupSucceeded = false;
125
133
  let discoveryDomain;
126
134
  try {
127
- // Persist explicitly-passed URL flags so subsequent commands on this
128
- // device (e.g. unbound discover, unbound setup) hit the same tenant.
129
- // setUrls is atomic a malformed URL throws before any disk write.
130
- config.setUrls({
135
+ // Persist URLs first, then login, then setup order matters so this
136
+ // MDM run wires tools at the new tenant. Prefer just-written values
137
+ // over env-var-aware getters so a stale UNBOUND_*_URL can't shadow the
138
+ // user's explicit --*-url flag.
139
+ const written = config.setUrls({
131
140
  backend: opts.backendUrl,
132
141
  gateway: opts.gatewayUrl,
133
142
  });
134
- const backendUrl = config.getBaseUrl();
135
- const gatewayUrl = config.getGatewayUrl();
143
+ const backendUrl = written.base_url || config.getBaseUrl();
144
+ const gatewayUrl = written.gateway_url || config.getGatewayUrl();
136
145
  discoveryDomain = opts.domain || backendUrl;
137
146
 
138
147
  checkRoot('onboard-mdm');
@@ -367,21 +367,26 @@ automatically to authenticate before proceeding.
367
367
  `)
368
368
  .action(async (tools, opts) => {
369
369
  try {
370
- // Persist explicitly-passed URL flags BEFORE ensureLoggedIn runs so a
371
- // tenant-onboarding `unbound setup --gateway-url ... --api-key ...` lands
372
- // tenant URLs in config before any backend call uses them. setUrls is
373
- // atomic — no partial writes if any value is malformed.
374
- config.setUrls({
370
+ // Persist URLs first, login, then setup. setUrls is atomic; a malformed
371
+ // URL throws before any disk write.
372
+ const written = config.setUrls({
375
373
  backend: opts.backendUrl,
376
374
  frontend: opts.frontendUrl,
377
375
  gateway: opts.gatewayUrl,
378
376
  });
379
-
380
- await ensureLoggedIn({ apiKey: opts.apiKey });
377
+ // Prefer just-persisted values over env-var-aware getters so a stale
378
+ // UNBOUND_*_URL from a prior shell session can't silently shadow the
379
+ // user's explicit --*-url flag and route login/setup at the wrong tenant.
380
+ const backendUrl = written.base_url || config.getBaseUrl();
381
+ const frontendUrl = written.frontend_url || config.getFrontendUrl();
382
+ const gatewayUrl = written.gateway_url || config.getGatewayUrl();
383
+
384
+ await ensureLoggedIn({
385
+ apiKey: opts.apiKey,
386
+ baseUrl: written.base_url,
387
+ frontendUrl: written.frontend_url,
388
+ });
381
389
  const apiKey = config.getApiKey();
382
- const backendUrl = config.getBaseUrl();
383
- const frontendUrl = config.getFrontendUrl();
384
- const gatewayUrl = config.getGatewayUrl();
385
390
  const urlOpts = { backendUrl, frontendUrl, gatewayUrl };
386
391
 
387
392
  // --all expands to the default bundle. Cannot be combined with explicit tool names.
@@ -593,15 +598,16 @@ Examples:
593
598
  // --backend-url, --frontend-url, --gateway-url are defined only on the parent `setup` command.
594
599
  // Use optsWithGlobals() so they all work regardless of position relative to `mdm`.
595
600
  const globalOpts = command.optsWithGlobals();
596
- // Persist explicitly-passed URL flags so this MDM run also configures
597
- // the CLI for any subsequent non-MDM commands on the same machine.
598
- // setUrls is atomic no partial writes if any value is malformed.
599
- config.setUrls({
601
+ // Persist URLs first so this MDM run wires tools at the new tenant
602
+ // and any subsequent non-MDM command on the same machine inherits.
603
+ // Prefer just-persisted values over env-var-aware getters so a stale
604
+ // UNBOUND_*_URL can't shadow the explicit --*-url flag.
605
+ const written = config.setUrls({
600
606
  backend: globalOpts.backendUrl,
601
607
  gateway: globalOpts.gatewayUrl,
602
608
  });
603
- const backendUrl = config.getBaseUrl();
604
- const gatewayUrl = config.getGatewayUrl();
609
+ const backendUrl = written.base_url || config.getBaseUrl();
610
+ const gatewayUrl = written.gateway_url || config.getGatewayUrl();
605
611
 
606
612
  if (globalOpts.all && tools.length > 0) {
607
613
  output.error('Cannot combine --all with specific tool names. Use one or the other.');
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
@@ -151,6 +151,49 @@ test('setUrls is atomic — partial failure leaves config unchanged', () => {
151
151
  }
152
152
  });
153
153
 
154
+ // WEB-4107: setUrls returns the just-written values so callers can use them
155
+ // directly instead of going back through getBaseUrl/etc. (which give env-var
156
+ // precedence over config). A stale UNBOUND_*_URL from a prior shell session
157
+ // would otherwise silently shadow the user's explicit --*-url flag.
158
+ test('setUrls return value reflects what was persisted, bypassing env-var shadowing', () => {
159
+ const fs = require('node:fs');
160
+ const os = require('node:os');
161
+ const path = require('node:path');
162
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'unbound-cli-test-'));
163
+ const origHome = process.env.HOME;
164
+ const origGw = process.env.UNBOUND_GATEWAY_URL;
165
+ const origBe = process.env.UNBOUND_API_URL;
166
+ process.env.HOME = tmp;
167
+ // Stale env vars from a prior tenant — these would otherwise win in getXxxUrl().
168
+ process.env.UNBOUND_GATEWAY_URL = 'https://gw.STALE.com';
169
+ process.env.UNBOUND_API_URL = 'https://be.STALE.com';
170
+ delete require.cache[require.resolve('../src/config')];
171
+ const c = require('../src/config');
172
+ try {
173
+ const written = c.setUrls({
174
+ backend: 'be.NEW.com',
175
+ frontend: 'fe.NEW.com',
176
+ gateway: 'gw.NEW.com',
177
+ });
178
+ // The return value reflects WHAT WAS WRITTEN — the user's explicit input.
179
+ assert.equal(written.base_url, 'https://be.new.com');
180
+ assert.equal(written.frontend_url, 'https://fe.new.com');
181
+ assert.equal(written.gateway_url, 'https://gw.new.com');
182
+ // The getters would still respect the env vars (existing convention).
183
+ // Callers that need the user's intent must use the setUrls return value.
184
+ assert.equal(c.getGatewayUrl(), 'https://gw.STALE.com');
185
+ assert.equal(c.getBaseUrl(), 'https://be.STALE.com');
186
+ } finally {
187
+ process.env.HOME = origHome;
188
+ if (origGw === undefined) delete process.env.UNBOUND_GATEWAY_URL;
189
+ else process.env.UNBOUND_GATEWAY_URL = origGw;
190
+ if (origBe === undefined) delete process.env.UNBOUND_API_URL;
191
+ else process.env.UNBOUND_API_URL = origBe;
192
+ delete require.cache[require.resolve('../src/config')];
193
+ fs.rmSync(tmp, { recursive: true, force: true });
194
+ }
195
+ });
196
+
154
197
  // WEB-4103: backfillUserInfo must refresh email/org_name when the API returns
155
198
  // a different value (e.g. user switched tenants), not just fill if missing.
156
199
  // Defensive: must NOT blank the cached value on a partial/empty API response.
@@ -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
+ });