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.
- package/package.json +1 -1
- package/src/api.js +38 -3
- package/src/auth.js +13 -6
- package/src/commands/login.js +13 -8
- package/src/commands/oacb.js +931 -0
- package/src/commands/onboard.js +24 -15
- package/src/commands/setup.js +22 -16
- package/src/index.js +1 -0
- package/test/config-normalize-url.test.js +43 -0
- package/test/oacb.test.js +307 -0
package/src/commands/onboard.js
CHANGED
|
@@ -52,21 +52,29 @@ Examples:
|
|
|
52
52
|
let setupSucceeded = false;
|
|
53
53
|
let discoveryDomain;
|
|
54
54
|
try {
|
|
55
|
-
// Persist
|
|
56
|
-
//
|
|
57
|
-
//
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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({
|
|
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
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
|
|
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');
|
package/src/commands/setup.js
CHANGED
|
@@ -367,21 +367,26 @@ automatically to authenticate before proceeding.
|
|
|
367
367
|
`)
|
|
368
368
|
.action(async (tools, opts) => {
|
|
369
369
|
try {
|
|
370
|
-
// Persist
|
|
371
|
-
//
|
|
372
|
-
|
|
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
|
-
|
|
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
|
|
597
|
-
//
|
|
598
|
-
//
|
|
599
|
-
|
|
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
|
+
});
|