unbound-cli 0.8.2 → 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 +32 -0
- package/src/commands/oacb.js +931 -0
- package/src/index.js +1 -0
- package/test/oacb.test.js +307 -0
package/package.json
CHANGED
package/src/api.js
CHANGED
|
@@ -104,10 +104,42 @@ function request(method, path, { body, query, apiKey, baseUrl } = {}) {
|
|
|
104
104
|
});
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
+
// Plain unauthenticated GET to an arbitrary URL. Returns the raw response body
|
|
108
|
+
// as a UTF-8 string. Callers that want JSON must do JSON.parse() themselves.
|
|
109
|
+
// Used by `unbound oacb` to fetch baseline JSONs and hook scripts from GitHub.
|
|
110
|
+
function getRaw(url) {
|
|
111
|
+
return new Promise((resolve, reject) => {
|
|
112
|
+
const parsed = new URL(url);
|
|
113
|
+
const transport = parsed.protocol === 'https:' ? https : http;
|
|
114
|
+
const req = transport.get(url, { headers: { 'User-Agent': USER_AGENT } }, (res) => {
|
|
115
|
+
const chunks = [];
|
|
116
|
+
res.on('data', (chunk) => chunks.push(chunk));
|
|
117
|
+
res.on('end', () => {
|
|
118
|
+
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
119
|
+
resolve(Buffer.concat(chunks).toString('utf8'));
|
|
120
|
+
} else {
|
|
121
|
+
reject(new ApiError(res.statusCode, { error: `HTTP ${res.statusCode} fetching ${url}` }));
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
res.on('error', (err) => {
|
|
125
|
+
reject(new Error(`Network error fetching ${parsed.host}: ${err.message}`));
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
req.setTimeout(30000, () => {
|
|
129
|
+
req.destroy();
|
|
130
|
+
reject(new Error(`Request timed out fetching ${parsed.host}`));
|
|
131
|
+
});
|
|
132
|
+
req.on('error', (err) => {
|
|
133
|
+
reject(new Error(`Network error fetching ${parsed.host}: ${err.message}`));
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
107
138
|
module.exports = {
|
|
108
139
|
ApiError,
|
|
109
140
|
get: (path, opts) => request('GET', path, opts),
|
|
110
141
|
post: (path, opts) => request('POST', path, opts),
|
|
111
142
|
put: (path, opts) => request('PUT', path, opts),
|
|
112
143
|
del: (path, opts) => request('DELETE', path, opts),
|
|
144
|
+
getRaw,
|
|
113
145
|
};
|
|
@@ -0,0 +1,931 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs/promises');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const os = require('node:os');
|
|
6
|
+
const { spawnSync } = require('node:child_process');
|
|
7
|
+
const api = require('../api');
|
|
8
|
+
const config = require('../config');
|
|
9
|
+
const output = require('../output');
|
|
10
|
+
|
|
11
|
+
const OACB_RAW_BASE = 'https://raw.githubusercontent.com/websentry-ai/oacb';
|
|
12
|
+
const OACB_PINNED_REF = 'v0.1.1';
|
|
13
|
+
const TIERS = ['shadow', 'baseline', 'strict', 'paranoid'];
|
|
14
|
+
const HOOKS_DIR = path.join(os.homedir(), '.claude', 'hooks');
|
|
15
|
+
const SETTINGS_PATH = path.join(os.homedir(), '.claude', 'settings.json');
|
|
16
|
+
const SETTINGS_BACKUP_PATH = path.join(os.homedir(), '.claude', 'settings.json.oacb-backup');
|
|
17
|
+
const HOOK_NAMES = ['oacb-enforce.sh', 'oacb-prompt-guard.sh', 'oacb-mcp-guard.sh', 'oacb-config-audit.sh', 'oacb-post-tool.sh'];
|
|
18
|
+
const HOOK_NAME_MAP = {
|
|
19
|
+
enforce: 'oacb-enforce.sh',
|
|
20
|
+
'prompt-guard': 'oacb-prompt-guard.sh',
|
|
21
|
+
'mcp-guard': 'oacb-mcp-guard.sh',
|
|
22
|
+
'config-audit': 'oacb-config-audit.sh',
|
|
23
|
+
'post-tool': 'oacb-post-tool.sh',
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
function register(program) {
|
|
27
|
+
const cmd = program
|
|
28
|
+
.command('oacb')
|
|
29
|
+
.description('OACB — Open Autonomous Coding-agent Baseline. Apply, audit, and manage the security baseline for Claude Code.')
|
|
30
|
+
.addHelpText('after', `
|
|
31
|
+
Quick start:
|
|
32
|
+
unbound oacb check # verify install state
|
|
33
|
+
unbound oacb apply # pick a tier interactively
|
|
34
|
+
unbound oacb apply --tier shadow # shadow tier (log-only, safe first step)
|
|
35
|
+
unbound oacb apply --tier baseline # turn on enforcement
|
|
36
|
+
unbound oacb apply --tier strict # regulated / pre-prod
|
|
37
|
+
unbound oacb apply --tier paranoid # FedRAMP / high-sensitivity
|
|
38
|
+
unbound oacb doctor # verify hooks block what they should
|
|
39
|
+
unbound oacb audit # score current settings vs baseline
|
|
40
|
+
unbound oacb diff --to baseline # see what changes at the next tier
|
|
41
|
+
|
|
42
|
+
Read the framework at https://github.com/websentry-ai/oacb before applying baseline or higher.
|
|
43
|
+
`);
|
|
44
|
+
|
|
45
|
+
cmd.command('check')
|
|
46
|
+
.description('Pre-flight: verify Claude Code install, version, and OACB state')
|
|
47
|
+
.action(async () => {
|
|
48
|
+
try { await handleCheck(); }
|
|
49
|
+
catch (e) { output.error(e.message); process.exitCode = 1; }
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
cmd.command('audit')
|
|
53
|
+
.description('Score current Claude Code settings against the OACB baseline; report gaps by ASI ID')
|
|
54
|
+
.option('--tier <tier>', `tier to compare against (${TIERS.join('|')})`, 'baseline')
|
|
55
|
+
.option('--format <fmt>', 'output format: table | json', 'table')
|
|
56
|
+
.action(async (opts) => {
|
|
57
|
+
try { await handleAudit(opts); }
|
|
58
|
+
catch (e) { output.error(e.message); process.exitCode = 1; }
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
cmd.command('apply')
|
|
62
|
+
.description('Apply an OACB tier: download hooks + write ~/.claude/settings.json overlay')
|
|
63
|
+
.option('--tier <tier>', `tier to apply (${TIERS.join('|')}); prompted if omitted`)
|
|
64
|
+
.option('--overrides <path>', 'path to a local JSON file layered on top of the baseline tier')
|
|
65
|
+
.option('--dry-run', 'show what would be written without writing anything', false)
|
|
66
|
+
.option('--local-hooks <dir>', 'dev: copy hook scripts from a local directory instead of downloading from GitHub (overrides OACB_LOCAL_HOOKS_DIR)')
|
|
67
|
+
.action(async (opts) => {
|
|
68
|
+
try { await handleApply(opts); }
|
|
69
|
+
catch (e) { output.error(e.message); process.exitCode = 1; }
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
cmd.command('doctor')
|
|
73
|
+
.description('Run the OACB conformance suite against installed hooks to verify enforcement')
|
|
74
|
+
.option('--tier <tier>', `tier to test (${TIERS.join('|')})`, 'baseline')
|
|
75
|
+
.option('--format <fmt>', 'output format: table | json', 'table')
|
|
76
|
+
.option('--verbose', 'print each test case result as it runs', false)
|
|
77
|
+
.action(async (opts) => {
|
|
78
|
+
try { await handleDoctor(opts); }
|
|
79
|
+
catch (e) { output.error(e.message); process.exitCode = 1; }
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
cmd.command('diff')
|
|
83
|
+
.description('Show the settings delta between two OACB tiers')
|
|
84
|
+
.option('--from <tier>', 'source tier (auto-detected from settings.json if omitted)')
|
|
85
|
+
.option('--to <tier>', 'target tier', 'baseline')
|
|
86
|
+
.action(async (opts) => {
|
|
87
|
+
try { await handleDiff(opts); }
|
|
88
|
+
catch (e) { output.error(e.message); process.exitCode = 1; }
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
cmd.command('remove')
|
|
92
|
+
.description('Remove OACB from ~/.claude/settings.json and delete installed hook scripts')
|
|
93
|
+
.option('--dry-run', 'show what would be removed without writing anything', false)
|
|
94
|
+
.action(async (opts) => {
|
|
95
|
+
try { await handleRemove(opts); }
|
|
96
|
+
catch (e) { output.error(e.message); process.exitCode = 1; }
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ─── Handlers ────────────────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
async function handleCheck() {
|
|
103
|
+
const cfg = config.readConfig();
|
|
104
|
+
const hasUnboundSession = !!cfg.api_key;
|
|
105
|
+
|
|
106
|
+
let claudeCodeVersion = null;
|
|
107
|
+
try {
|
|
108
|
+
const { execSync } = require('node:child_process');
|
|
109
|
+
claudeCodeVersion = execSync('claude --version', {
|
|
110
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
111
|
+
timeout: 3000,
|
|
112
|
+
}).toString().trim();
|
|
113
|
+
} catch (_) { /* not on PATH */ }
|
|
114
|
+
|
|
115
|
+
const settingsPath = path.join(os.homedir(), '.claude', 'settings.json');
|
|
116
|
+
let claudeSettingsExist = false;
|
|
117
|
+
try { await fs.access(settingsPath); claudeSettingsExist = true; } catch (_) {}
|
|
118
|
+
|
|
119
|
+
const appliedTier = await detectCurrentTier();
|
|
120
|
+
|
|
121
|
+
const hookStates = await Promise.all(
|
|
122
|
+
HOOK_NAMES.map(async (n) => {
|
|
123
|
+
try { await fs.access(path.join(HOOKS_DIR, n)); return true; }
|
|
124
|
+
catch (_) { return false; }
|
|
125
|
+
})
|
|
126
|
+
);
|
|
127
|
+
const hooksInstalledCount = hookStates.filter(Boolean).length;
|
|
128
|
+
|
|
129
|
+
output.keyValue([
|
|
130
|
+
['Unbound session', hasUnboundSession ? 'logged in' : 'not logged in (optional — login enables backend sync)'],
|
|
131
|
+
['Claude Code on PATH', claudeCodeVersion || 'not detected'],
|
|
132
|
+
['~/.claude/settings.json', claudeSettingsExist ? 'found' : 'not found'],
|
|
133
|
+
['OACB tier applied', appliedTier !== 'none' ? appliedTier : 'none (run `unbound oacb apply`)'],
|
|
134
|
+
['Hooks installed', `${hooksInstalledCount} / ${HOOK_NAMES.length}`],
|
|
135
|
+
['OACB pinned ref', OACB_PINNED_REF],
|
|
136
|
+
]);
|
|
137
|
+
|
|
138
|
+
if (!claudeCodeVersion) {
|
|
139
|
+
output.warn('`claude` not on PATH — is Claude Code installed?');
|
|
140
|
+
} else if (!isVersionSupported(claudeCodeVersion)) {
|
|
141
|
+
output.warn(`Claude Code ${claudeCodeVersion} is outside OACB tested range (>=2.1.83 <3.0.0). Proceed with caution.`);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (appliedTier === 'none') {
|
|
145
|
+
output.warn('No OACB tier applied. Run `unbound oacb apply` to start with shadow tier.');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
output.success('Pre-flight complete.');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function handleAudit({ tier, format }) {
|
|
152
|
+
validateTier(tier);
|
|
153
|
+
const spin = output.spinner(`Fetching OACB ${tier} baseline...`);
|
|
154
|
+
let baseline;
|
|
155
|
+
try {
|
|
156
|
+
baseline = await fetchBaseline(tier);
|
|
157
|
+
spin.stop();
|
|
158
|
+
} catch (e) {
|
|
159
|
+
spin.fail(`Could not fetch baseline: ${e.message}`);
|
|
160
|
+
throw e;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const current = await loadMergedSettings();
|
|
164
|
+
const gaps = computeGaps(current, baseline);
|
|
165
|
+
|
|
166
|
+
if (format === 'json') {
|
|
167
|
+
process.stdout.write(JSON.stringify({ tier, ref: OACB_PINNED_REF, gaps }, null, 2) + '\n');
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
output.info(`OACB audit — tier=${tier} ref=${OACB_PINNED_REF}`);
|
|
172
|
+
if (gaps.length === 0) {
|
|
173
|
+
output.success('No gaps. Current settings satisfy the OACB tier baseline.');
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
output.warn(`${gaps.length} gap(s) found:`);
|
|
178
|
+
output.table(
|
|
179
|
+
gaps.map(g => ({ asi: g.asi, id: g.ruleId, desc: g.description })),
|
|
180
|
+
[
|
|
181
|
+
{ key: 'asi', header: 'ASI' },
|
|
182
|
+
{ key: 'id', header: 'Rule ID' },
|
|
183
|
+
{ key: 'desc', header: 'Description' },
|
|
184
|
+
]
|
|
185
|
+
);
|
|
186
|
+
output.info(`Run \`unbound oacb apply --tier ${tier}\` to remediate.`);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function handleApply({ tier, overrides, dryRun, localHooks }) {
|
|
190
|
+
if (!tier) {
|
|
191
|
+
tier = await output.select('Select OACB security tier:', [
|
|
192
|
+
{ label: 'shadow — log-only, safe first step (recommended start)', value: 'shadow' },
|
|
193
|
+
{ label: 'baseline — enforcement enabled', value: 'baseline' },
|
|
194
|
+
{ label: 'strict — regulated / pre-prod environments', value: 'strict' },
|
|
195
|
+
{ label: 'paranoid — FedRAMP / high-sensitivity', value: 'paranoid' },
|
|
196
|
+
]);
|
|
197
|
+
}
|
|
198
|
+
validateTier(tier);
|
|
199
|
+
|
|
200
|
+
const spin = output.spinner(`Fetching OACB ${tier} baseline from GitHub...`);
|
|
201
|
+
let baseline;
|
|
202
|
+
try {
|
|
203
|
+
baseline = await fetchBaseline(tier);
|
|
204
|
+
spin.stop();
|
|
205
|
+
} catch (e) {
|
|
206
|
+
spin.fail(`Could not fetch baseline: ${e.message}`);
|
|
207
|
+
throw e;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
let effective = deepClone(baseline);
|
|
211
|
+
if (overrides) {
|
|
212
|
+
const overrideJson = JSON.parse(await fs.readFile(overrides, 'utf8'));
|
|
213
|
+
effective = mergeOverrides(effective, overrideJson);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (dryRun) {
|
|
217
|
+
output.info(`--dry-run: showing what would be merged into ${SETTINGS_PATH}`);
|
|
218
|
+
const oacbHooks = buildOacbHookEntries(effective);
|
|
219
|
+
process.stdout.write(JSON.stringify({
|
|
220
|
+
_oacbMeta: { tier, appliedAt: '<now>', ref: OACB_PINNED_REF },
|
|
221
|
+
permissions: effective.permissions,
|
|
222
|
+
autoMode: effective.autoMode,
|
|
223
|
+
hooks: oacbHooks,
|
|
224
|
+
}, null, 2) + '\n');
|
|
225
|
+
output.info(' Hook scripts that would be installed:');
|
|
226
|
+
for (const name of HOOK_NAMES) {
|
|
227
|
+
process.stdout.write(` ${path.join(HOOKS_DIR, name)}\n`);
|
|
228
|
+
}
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (tier !== 'shadow') {
|
|
233
|
+
output.warn(`Applying tier=${tier}. This WILL block tool calls matching deny rules.`);
|
|
234
|
+
output.warn('If you have not run shadow tier for 2+ weeks, consider starting there first.');
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const hooksLocalDir = localHooks || process.env.OACB_LOCAL_HOOKS_DIR || null;
|
|
238
|
+
const hookSpin = output.spinner(
|
|
239
|
+
hooksLocalDir
|
|
240
|
+
? `Installing OACB hook scripts from local path: ${hooksLocalDir}...`
|
|
241
|
+
: 'Downloading and installing OACB hook scripts...'
|
|
242
|
+
);
|
|
243
|
+
try {
|
|
244
|
+
await installHooks(hooksLocalDir);
|
|
245
|
+
hookSpin.succeed(`Installed ${HOOK_NAMES.length} hooks to ${HOOKS_DIR}`);
|
|
246
|
+
} catch (e) {
|
|
247
|
+
hookSpin.fail(`Hook installation failed: ${e.message}`);
|
|
248
|
+
throw e;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const settingsSpin = output.spinner(`Merging OACB settings into ${SETTINGS_PATH}...`);
|
|
252
|
+
try {
|
|
253
|
+
await writeSettingsOverlay(effective, tier);
|
|
254
|
+
settingsSpin.succeed(`Updated ${SETTINGS_PATH} (backup at ${SETTINGS_BACKUP_PATH})`);
|
|
255
|
+
} catch (e) {
|
|
256
|
+
settingsSpin.fail(`Settings write failed: ${e.message}`);
|
|
257
|
+
throw e;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Backend policy push — only if logged in; non-fatal if it fails
|
|
261
|
+
// TODO: use later if required, logic works
|
|
262
|
+
// if (config.isLoggedIn()) {
|
|
263
|
+
// const backendSpin = output.spinner('Pushing deny policies to Unbound backend...');
|
|
264
|
+
// try {
|
|
265
|
+
// const count = await applyRulesToBackend(effective, tier);
|
|
266
|
+
// backendSpin.succeed(`Pushed ${count} policies to backend.`);
|
|
267
|
+
// } catch (e) {
|
|
268
|
+
// backendSpin.fail(`Backend push skipped: ${e.message}`);
|
|
269
|
+
// output.info('Local install succeeded. Run `unbound login` to re-enable backend sync.');
|
|
270
|
+
// }
|
|
271
|
+
// } else {
|
|
272
|
+
// output.info('No Unbound session — local install only. Run `unbound login` to enable backend policy sync.');
|
|
273
|
+
// }
|
|
274
|
+
|
|
275
|
+
output.success(`OACB ${tier} applied.`);
|
|
276
|
+
if (tier === 'shadow') {
|
|
277
|
+
output.info('Shadow: hooks log but never block. Review ~/.claude/hooks/oacb-audit.log for 2 weeks, then `unbound oacb apply --tier baseline`.');
|
|
278
|
+
}
|
|
279
|
+
output.info('Verify with: unbound oacb doctor --tier ' + tier);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async function handleDoctor({ tier, format, verbose }) {
|
|
283
|
+
validateTier(tier);
|
|
284
|
+
|
|
285
|
+
// Verify at least the enforce hook is installed
|
|
286
|
+
const enforcePath = path.join(HOOKS_DIR, 'oacb-enforce.sh');
|
|
287
|
+
try {
|
|
288
|
+
await fs.access(enforcePath);
|
|
289
|
+
} catch {
|
|
290
|
+
output.error(`OACB hooks not installed. Run \`unbound oacb apply --tier ${tier}\` first.`);
|
|
291
|
+
process.exitCode = 1;
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const spin = output.spinner('Fetching conformance corpus...');
|
|
296
|
+
let corpus;
|
|
297
|
+
try {
|
|
298
|
+
corpus = await fetchConformanceCorpus();
|
|
299
|
+
spin.stop();
|
|
300
|
+
} catch (e) {
|
|
301
|
+
spin.fail(`Could not fetch corpus: ${e.message}`);
|
|
302
|
+
throw e;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Run cases that have an expectation for this tier and are not RESIDUAL
|
|
306
|
+
const cases = corpus.filter(c =>
|
|
307
|
+
!c.id.endsWith('-RESIDUAL') &&
|
|
308
|
+
c.tiers &&
|
|
309
|
+
c.tiers[tier] !== undefined
|
|
310
|
+
);
|
|
311
|
+
|
|
312
|
+
if (format !== 'json') {
|
|
313
|
+
output.info(`Running ${cases.length} conformance cases for tier=${tier}...`);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const results = [];
|
|
317
|
+
for (const testCase of cases) {
|
|
318
|
+
const hookBin = path.join(HOOKS_DIR, HOOK_NAME_MAP[testCase.hook || 'enforce']);
|
|
319
|
+
const result = runConformanceCase(testCase, hookBin, tier);
|
|
320
|
+
results.push(result);
|
|
321
|
+
if (format !== 'json' && verbose) {
|
|
322
|
+
const icon = result.passed ? '✔' : '✘';
|
|
323
|
+
process.stderr.write(` ${icon} ${result.id}\n`);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const passed = results.filter(r => r.passed).length;
|
|
328
|
+
const failed = results.filter(r => !r.passed);
|
|
329
|
+
|
|
330
|
+
if (format === 'json') {
|
|
331
|
+
process.stdout.write(JSON.stringify({
|
|
332
|
+
tier,
|
|
333
|
+
ref: OACB_PINNED_REF,
|
|
334
|
+
passed,
|
|
335
|
+
total: cases.length,
|
|
336
|
+
results,
|
|
337
|
+
}, null, 2) + '\n');
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
output.info(`${passed} / ${cases.length} cases passed`);
|
|
342
|
+
|
|
343
|
+
if (failed.length > 0) {
|
|
344
|
+
output.warn('Failures:');
|
|
345
|
+
output.table(
|
|
346
|
+
failed.map(f => ({
|
|
347
|
+
id: f.id,
|
|
348
|
+
expected: `exit ${f.expectedExit}`,
|
|
349
|
+
got: `exit ${f.actualExit}`,
|
|
350
|
+
note: f.note || '',
|
|
351
|
+
})),
|
|
352
|
+
[
|
|
353
|
+
{ key: 'id', header: 'Case ID' },
|
|
354
|
+
{ key: 'expected', header: 'Expected' },
|
|
355
|
+
{ key: 'got', header: 'Got' },
|
|
356
|
+
{ key: 'note', header: 'Note' },
|
|
357
|
+
]
|
|
358
|
+
);
|
|
359
|
+
process.exitCode = 1;
|
|
360
|
+
} else {
|
|
361
|
+
output.success('All conformance cases passed.');
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
async function handleDiff({ from, to }) {
|
|
366
|
+
validateTier(to);
|
|
367
|
+
if (from) validateTier(from);
|
|
368
|
+
|
|
369
|
+
const fromTier = from || await detectCurrentTier();
|
|
370
|
+
const resolvedFrom = fromTier === 'none' ? 'shadow' : fromTier;
|
|
371
|
+
if (!from && fromTier === 'none') {
|
|
372
|
+
output.warn('No OACB tier applied; using shadow as diff base.');
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const spin = output.spinner(`Fetching ${resolvedFrom} and ${to} tier settings...`);
|
|
376
|
+
let a, b;
|
|
377
|
+
try {
|
|
378
|
+
[a, b] = await Promise.all([fetchBaseline(resolvedFrom), fetchBaseline(to)]);
|
|
379
|
+
spin.stop();
|
|
380
|
+
} catch (e) {
|
|
381
|
+
spin.fail(`Fetch failed: ${e.message}`);
|
|
382
|
+
throw e;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
output.info(`Diff: ${resolvedFrom} → ${to}`);
|
|
386
|
+
process.stdout.write(JSON.stringify(computeDeepDiff(a, b), null, 2) + '\n');
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
async function handleRemove({ dryRun }) {
|
|
390
|
+
let settings = {};
|
|
391
|
+
try {
|
|
392
|
+
settings = JSON.parse(await fs.readFile(SETTINGS_PATH, 'utf8'));
|
|
393
|
+
} catch (_) {
|
|
394
|
+
output.warn(`${SETTINGS_PATH} not found — nothing to remove.`);
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const meta = settings._oacbMeta;
|
|
399
|
+
if (!meta) {
|
|
400
|
+
output.warn('No OACB installation found in settings.json (_oacbMeta missing).');
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const tier = meta.tier;
|
|
405
|
+
output.info(`Removing OACB (tier=${tier}) from ${SETTINGS_PATH}...`);
|
|
406
|
+
|
|
407
|
+
// Use the stored contribution snapshot when available — it records exactly what OACB
|
|
408
|
+
// wrote, so user rules that coincidentally match the baseline are not deleted.
|
|
409
|
+
// Fall back to fetching the remote baseline for legacy installs without a snapshot.
|
|
410
|
+
let contribution = meta.contribution;
|
|
411
|
+
if (!contribution) {
|
|
412
|
+
const spin = output.spinner(`Fetching ${tier} baseline to compute what to remove...`);
|
|
413
|
+
try {
|
|
414
|
+
const baseline = await fetchBaseline(tier);
|
|
415
|
+
spin.stop();
|
|
416
|
+
contribution = { permissions: baseline.permissions || {}, autoMode: baseline.autoMode || {} };
|
|
417
|
+
} catch (e) {
|
|
418
|
+
spin.fail(`Could not fetch baseline: ${e.message}`);
|
|
419
|
+
throw e;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const cleaned = deepClone(settings);
|
|
424
|
+
|
|
425
|
+
// Remove permission list entries contributed by OACB
|
|
426
|
+
if (contribution.permissions && cleaned.permissions) {
|
|
427
|
+
const contributedDeny = new Set(contribution.permissions.deny || []);
|
|
428
|
+
const contributedAsk = new Set(contribution.permissions.ask || []);
|
|
429
|
+
const contributedAllow = new Set(contribution.permissions.allow || []);
|
|
430
|
+
if (cleaned.permissions.deny) {
|
|
431
|
+
cleaned.permissions.deny = cleaned.permissions.deny.filter(r => !contributedDeny.has(r));
|
|
432
|
+
if (!cleaned.permissions.deny.length) delete cleaned.permissions.deny;
|
|
433
|
+
}
|
|
434
|
+
if (cleaned.permissions.ask) {
|
|
435
|
+
cleaned.permissions.ask = cleaned.permissions.ask.filter(r => !contributedAsk.has(r));
|
|
436
|
+
if (!cleaned.permissions.ask.length) delete cleaned.permissions.ask;
|
|
437
|
+
}
|
|
438
|
+
if (cleaned.permissions.allow) {
|
|
439
|
+
cleaned.permissions.allow = cleaned.permissions.allow.filter(r => !contributedAllow.has(r));
|
|
440
|
+
if (!cleaned.permissions.allow.length) delete cleaned.permissions.allow;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
for (const scalarKey of ['defaultMode', 'disableBypassPermissionsMode', 'allowManagedHooksOnly', 'allowManagedPermissionRulesOnly', 'allowManagedMcpServersOnly']) {
|
|
444
|
+
const prev = contribution.permissions[scalarKey];
|
|
445
|
+
if (prev !== undefined && cleaned.permissions[scalarKey] === prev) {
|
|
446
|
+
delete cleaned.permissions[scalarKey];
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
if (!Object.keys(cleaned.permissions).length) delete cleaned.permissions;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// Remove hook entries where command matches an OACB hook script
|
|
454
|
+
const oacbHookRe = /oacb-[^/\\]+\.sh$/;
|
|
455
|
+
if (cleaned.hooks) {
|
|
456
|
+
for (const event of Object.keys(cleaned.hooks)) {
|
|
457
|
+
cleaned.hooks[event] = (cleaned.hooks[event] || []).map(entry => {
|
|
458
|
+
if (!entry.hooks) return entry;
|
|
459
|
+
const filteredHooks = entry.hooks.filter(h => !oacbHookRe.test(h.command || ''));
|
|
460
|
+
if (!filteredHooks.length) return null;
|
|
461
|
+
return { ...entry, hooks: filteredHooks };
|
|
462
|
+
}).filter(Boolean);
|
|
463
|
+
if (!cleaned.hooks[event].length) delete cleaned.hooks[event];
|
|
464
|
+
}
|
|
465
|
+
if (!Object.keys(cleaned.hooks).length) delete cleaned.hooks;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// Remove autoMode keys contributed by OACB (only if values still match what was applied)
|
|
469
|
+
if (contribution.autoMode && cleaned.autoMode) {
|
|
470
|
+
for (const k of Object.keys(contribution.autoMode)) {
|
|
471
|
+
if (JSON.stringify(cleaned.autoMode[k]) === JSON.stringify(contribution.autoMode[k])) {
|
|
472
|
+
delete cleaned.autoMode[k];
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
if (!Object.keys(cleaned.autoMode).length) delete cleaned.autoMode;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
delete cleaned._oacbMeta;
|
|
479
|
+
|
|
480
|
+
if (dryRun) {
|
|
481
|
+
output.info('--dry-run: showing cleaned settings.json (not written)');
|
|
482
|
+
process.stdout.write(JSON.stringify(cleaned, null, 2) + '\n');
|
|
483
|
+
output.info('Hook scripts that would be deleted:');
|
|
484
|
+
for (const name of HOOK_NAMES) {
|
|
485
|
+
process.stdout.write(` ${path.join(HOOKS_DIR, name)}\n`);
|
|
486
|
+
}
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
await fs.writeFile(SETTINGS_PATH, JSON.stringify(cleaned, null, 2) + '\n', { mode: 0o600 });
|
|
491
|
+
output.success(`Wrote cleaned ${SETTINGS_PATH}`);
|
|
492
|
+
|
|
493
|
+
const deleted = [];
|
|
494
|
+
const missing = [];
|
|
495
|
+
await Promise.all(
|
|
496
|
+
HOOK_NAMES.map(async (name) => {
|
|
497
|
+
const p = path.join(HOOKS_DIR, name);
|
|
498
|
+
try {
|
|
499
|
+
await fs.unlink(p);
|
|
500
|
+
deleted.push(name);
|
|
501
|
+
} catch (_) {
|
|
502
|
+
missing.push(name);
|
|
503
|
+
}
|
|
504
|
+
})
|
|
505
|
+
);
|
|
506
|
+
|
|
507
|
+
if (deleted.length) output.success(`Deleted hook scripts: ${deleted.join(', ')}`);
|
|
508
|
+
if (missing.length) output.info(`Hook scripts not found (already gone): ${missing.join(', ')}`);
|
|
509
|
+
|
|
510
|
+
output.success('OACB removed. Your original settings have been preserved minus the OACB additions.');
|
|
511
|
+
output.info('Run `unbound oacb check` to verify clean state.');
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// ─── Fetch helpers ────────────────────────────────────────────────────────────
|
|
515
|
+
|
|
516
|
+
async function fetchBaseline(tier) {
|
|
517
|
+
const url = `${OACB_RAW_BASE}/${OACB_PINNED_REF}/baseline/claude-code/managed-settings.${tier}.json`;
|
|
518
|
+
return JSON.parse(await api.getRaw(url));
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
async function fetchConformanceCorpus() {
|
|
522
|
+
const url = `${OACB_RAW_BASE}/${OACB_PINNED_REF}/conformance-tests/expected.json`;
|
|
523
|
+
const parsed = JSON.parse(await api.getRaw(url));
|
|
524
|
+
return Array.isArray(parsed) ? parsed : (parsed.cases || []);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// ─── Settings helpers ─────────────────────────────────────────────────────────
|
|
528
|
+
|
|
529
|
+
async function detectCurrentTier() {
|
|
530
|
+
try {
|
|
531
|
+
const raw = await fs.readFile(SETTINGS_PATH, 'utf8');
|
|
532
|
+
return JSON.parse(raw)._oacbMeta?.tier || 'none';
|
|
533
|
+
} catch (_) {
|
|
534
|
+
return 'none';
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
async function loadMergedSettings() {
|
|
539
|
+
try { return JSON.parse(await fs.readFile(SETTINGS_PATH, 'utf8')); }
|
|
540
|
+
catch (_) { return {}; }
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// ─── Gap analysis ─────────────────────────────────────────────────────────────
|
|
544
|
+
|
|
545
|
+
const RULE_ASI_MAP = [
|
|
546
|
+
{ re: /rm -r[fF]|rm -fr/, asi: 'ASI02', id: 'OACB-RM-001' },
|
|
547
|
+
{ re: /terraform destroy|terraform apply -auto-approve/, asi: 'ASI02', id: 'OACB-TF-001' },
|
|
548
|
+
{ re: /drizzle-kit push --force|prisma migrate reset|alembic downgrade/, asi: 'ASI02', id: 'OACB-DB-001' },
|
|
549
|
+
{ re: /git push --force|git push -f /, asi: 'ASI05', id: 'OACB-GIT-001' },
|
|
550
|
+
{ re: /eval |history /, asi: 'ASI05', id: 'OACB-EVAL-001' },
|
|
551
|
+
{ re: /find \/ -delete|find ~ -delete/, asi: 'ASI02', id: 'OACB-FIND-001' },
|
|
552
|
+
{ re: /\.env|\.ssh|\.aws|\.netrc|\.config\/gh|\.kube|\.docker|\.npmrc|\.pypirc|\.m2|\.gnupg/, asi: 'ASI07', id: 'OACB-READ-001' },
|
|
553
|
+
{ re: /\.claude|\.cursor|\.codex|CLAUDE\.md|AGENTS\.md/, asi: 'ASI04', id: 'OACB-CONFIG-001' },
|
|
554
|
+
];
|
|
555
|
+
|
|
556
|
+
function classifyRule(rulePattern) {
|
|
557
|
+
for (const { re, asi } of RULE_ASI_MAP) {
|
|
558
|
+
if (re.test(rulePattern)) return asi;
|
|
559
|
+
}
|
|
560
|
+
return 'ASI02';
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function ruleIdFromPattern(rulePattern) {
|
|
564
|
+
for (const { re, id } of RULE_ASI_MAP) {
|
|
565
|
+
if (re.test(rulePattern)) return id;
|
|
566
|
+
}
|
|
567
|
+
return 'OACB-DENY';
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function computeGaps(current, baseline) {
|
|
571
|
+
const gaps = [];
|
|
572
|
+
const curDeny = new Set(current.permissions?.deny || []);
|
|
573
|
+
|
|
574
|
+
// Missing deny rules — deduplicate by rule ID so we report one gap per category
|
|
575
|
+
const reportedIds = new Set();
|
|
576
|
+
for (const rule of (baseline.permissions?.deny || [])) {
|
|
577
|
+
if (!curDeny.has(rule)) {
|
|
578
|
+
const ruleId = ruleIdFromPattern(rule);
|
|
579
|
+
if (!reportedIds.has(ruleId)) {
|
|
580
|
+
reportedIds.add(ruleId);
|
|
581
|
+
gaps.push({
|
|
582
|
+
asi: classifyRule(rule),
|
|
583
|
+
ruleId,
|
|
584
|
+
description: `One or more deny rules for ${ruleId} are missing (e.g. "${rule}")`,
|
|
585
|
+
recommendation: `Run \`unbound oacb apply --tier ${baseline._oacb?.tier || 'baseline'}\``,
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// Hooks wired?
|
|
592
|
+
if (!hasOacbHook(current, 'PreToolUse', 'oacb-enforce.sh')) {
|
|
593
|
+
gaps.push({
|
|
594
|
+
asi: 'ASI02',
|
|
595
|
+
ruleId: 'OACB-HOOK-001',
|
|
596
|
+
description: 'oacb-enforce.sh not registered in PreToolUse hooks',
|
|
597
|
+
recommendation: 'Run `unbound oacb apply --tier <tier>` to install and register hooks',
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// disableBypassPermissionsMode
|
|
602
|
+
const baseBypass = baseline.permissions?.disableBypassPermissionsMode;
|
|
603
|
+
const curBypass = current.permissions?.disableBypassPermissionsMode;
|
|
604
|
+
if (baseBypass && curBypass !== baseBypass) {
|
|
605
|
+
gaps.push({
|
|
606
|
+
asi: 'ASI03',
|
|
607
|
+
ruleId: 'OACB-BYPASS-001',
|
|
608
|
+
description: `disableBypassPermissionsMode should be "${baseBypass}", found "${curBypass || 'unset'}"`,
|
|
609
|
+
recommendation: 'Set permissions.disableBypassPermissionsMode in settings.local.json',
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
return gaps;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function hasOacbHook(settings, event, hookFile) {
|
|
617
|
+
const hooks = settings.hooks?.[event] || [];
|
|
618
|
+
return hooks.some(entry =>
|
|
619
|
+
(entry.hooks || []).some(h =>
|
|
620
|
+
typeof h.command === 'string' && h.command.includes(hookFile)
|
|
621
|
+
)
|
|
622
|
+
);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// ─── Hook installation ────────────────────────────────────────────────────────
|
|
626
|
+
|
|
627
|
+
async function installHooks(localDir = null) {
|
|
628
|
+
await fs.mkdir(HOOKS_DIR, { recursive: true });
|
|
629
|
+
await Promise.all(
|
|
630
|
+
HOOK_NAMES.map(async (name) => {
|
|
631
|
+
let content;
|
|
632
|
+
if (localDir) {
|
|
633
|
+
content = await fs.readFile(path.join(localDir, name), 'utf8');
|
|
634
|
+
} else {
|
|
635
|
+
const url = `${OACB_RAW_BASE}/${OACB_PINNED_REF}/baseline/claude-code/hooks/${name}`;
|
|
636
|
+
content = await api.getRaw(url);
|
|
637
|
+
}
|
|
638
|
+
await fs.writeFile(path.join(HOOKS_DIR, name), content, { mode: 0o755 });
|
|
639
|
+
})
|
|
640
|
+
);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// Build the OACB hook entries with ~/.claude/hooks/ paths (not the MDM paths)
|
|
644
|
+
function buildOacbHookEntries(baseline) {
|
|
645
|
+
const hooks = deepClone(baseline.hooks || {});
|
|
646
|
+
for (const entries of Object.values(hooks)) {
|
|
647
|
+
for (const entry of entries) {
|
|
648
|
+
for (const h of entry.hooks || []) {
|
|
649
|
+
if (h.command) {
|
|
650
|
+
h.command = path.join(HOOKS_DIR, path.basename(h.command));
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
return hooks;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// Strip a previously applied OACB overlay from settings, preserving user additions.
|
|
659
|
+
// Uses the contribution snapshot stored in _oacbMeta.contribution when present;
|
|
660
|
+
// falls back to the backup file for legacy installs that predate contribution tracking.
|
|
661
|
+
async function stripOacbFromSettings(settings) {
|
|
662
|
+
const contribution = settings._oacbMeta?.contribution;
|
|
663
|
+
|
|
664
|
+
// Legacy fallback: no contribution stored, restore from frozen backup
|
|
665
|
+
if (!contribution) {
|
|
666
|
+
try {
|
|
667
|
+
return JSON.parse(await fs.readFile(SETTINGS_BACKUP_PATH, 'utf8'));
|
|
668
|
+
} catch (_) {}
|
|
669
|
+
// No backup either — strip hooks via regex and drop meta
|
|
670
|
+
const cleaned = deepClone(settings);
|
|
671
|
+
const oacbHookRe = /oacb-[^/\\]+\.sh$/;
|
|
672
|
+
if (cleaned.hooks) {
|
|
673
|
+
for (const event of Object.keys(cleaned.hooks)) {
|
|
674
|
+
cleaned.hooks[event] = (cleaned.hooks[event] || []).map(entry => {
|
|
675
|
+
if (!entry.hooks) return entry;
|
|
676
|
+
const filtered = entry.hooks.filter(h => !oacbHookRe.test(h.command || ''));
|
|
677
|
+
if (!filtered.length) return null;
|
|
678
|
+
return { ...entry, hooks: filtered };
|
|
679
|
+
}).filter(Boolean);
|
|
680
|
+
if (!cleaned.hooks[event].length) delete cleaned.hooks[event];
|
|
681
|
+
}
|
|
682
|
+
if (!Object.keys(cleaned.hooks).length) delete cleaned.hooks;
|
|
683
|
+
}
|
|
684
|
+
delete cleaned._oacbMeta;
|
|
685
|
+
return cleaned;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
const cleaned = deepClone(settings);
|
|
689
|
+
|
|
690
|
+
// Remove permission list entries contributed by OACB
|
|
691
|
+
if (contribution.permissions && cleaned.permissions) {
|
|
692
|
+
for (const listKey of ['deny', 'ask', 'allow']) {
|
|
693
|
+
const contributed = new Set(contribution.permissions[listKey] || []);
|
|
694
|
+
if (contributed.size && cleaned.permissions[listKey]) {
|
|
695
|
+
cleaned.permissions[listKey] = cleaned.permissions[listKey].filter(r => !contributed.has(r));
|
|
696
|
+
if (!cleaned.permissions[listKey].length) delete cleaned.permissions[listKey];
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
for (const scalarKey of ['defaultMode', 'disableBypassPermissionsMode', 'allowManagedHooksOnly', 'allowManagedPermissionRulesOnly', 'allowManagedMcpServersOnly']) {
|
|
700
|
+
const prev = contribution.permissions[scalarKey];
|
|
701
|
+
if (prev !== undefined && cleaned.permissions[scalarKey] === prev) {
|
|
702
|
+
delete cleaned.permissions[scalarKey];
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
if (!Object.keys(cleaned.permissions).length) delete cleaned.permissions;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
// Remove OACB hook entries by command path pattern
|
|
709
|
+
const oacbHookRe = /oacb-[^/\\]+\.sh$/;
|
|
710
|
+
if (cleaned.hooks) {
|
|
711
|
+
for (const event of Object.keys(cleaned.hooks)) {
|
|
712
|
+
cleaned.hooks[event] = (cleaned.hooks[event] || []).map(entry => {
|
|
713
|
+
if (!entry.hooks) return entry;
|
|
714
|
+
const filtered = entry.hooks.filter(h => !oacbHookRe.test(h.command || ''));
|
|
715
|
+
if (!filtered.length) return null;
|
|
716
|
+
return { ...entry, hooks: filtered };
|
|
717
|
+
}).filter(Boolean);
|
|
718
|
+
if (!cleaned.hooks[event].length) delete cleaned.hooks[event];
|
|
719
|
+
}
|
|
720
|
+
if (!Object.keys(cleaned.hooks).length) delete cleaned.hooks;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
// Remove autoMode keys contributed by OACB (only if values still match what was applied)
|
|
724
|
+
if (contribution.autoMode && cleaned.autoMode) {
|
|
725
|
+
for (const k of Object.keys(contribution.autoMode)) {
|
|
726
|
+
if (JSON.stringify(cleaned.autoMode[k]) === JSON.stringify(contribution.autoMode[k])) {
|
|
727
|
+
delete cleaned.autoMode[k];
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
if (!Object.keys(cleaned.autoMode).length) delete cleaned.autoMode;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
delete cleaned._oacbMeta;
|
|
734
|
+
return cleaned;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// Merge OACB settings into the existing settings.json.
|
|
738
|
+
// On first apply: backup settings.json first.
|
|
739
|
+
// On re-apply: strip the previous OACB contribution from the live file so user
|
|
740
|
+
// additions made since the last apply are preserved.
|
|
741
|
+
async function writeSettingsOverlay(baseline, tier) {
|
|
742
|
+
await fs.mkdir(path.dirname(SETTINGS_PATH), { recursive: true });
|
|
743
|
+
|
|
744
|
+
let existing = {};
|
|
745
|
+
try { existing = JSON.parse(await fs.readFile(SETTINGS_PATH, 'utf8')); } catch (_) {}
|
|
746
|
+
|
|
747
|
+
if (existing._oacbMeta) {
|
|
748
|
+
// Re-apply: strip previous OACB contribution, preserving user additions
|
|
749
|
+
existing = await stripOacbFromSettings(existing);
|
|
750
|
+
} else {
|
|
751
|
+
// First apply: write backup for disaster recovery
|
|
752
|
+
await fs.writeFile(SETTINGS_BACKUP_PATH, JSON.stringify(existing, null, 2) + '\n', { mode: 0o600 });
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
const merged = deepClone(existing);
|
|
756
|
+
|
|
757
|
+
// permissions: union deny/ask arrays, set scalar keys
|
|
758
|
+
merged.permissions = merged.permissions || {};
|
|
759
|
+
const perm = baseline.permissions || {};
|
|
760
|
+
for (const listKey of ['deny', 'ask', 'allow']) {
|
|
761
|
+
if (perm[listKey]?.length) {
|
|
762
|
+
merged.permissions[listKey] = [
|
|
763
|
+
...new Set([...(merged.permissions[listKey] || []), ...perm[listKey]]),
|
|
764
|
+
];
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
for (const scalarKey of ['defaultMode', 'disableBypassPermissionsMode', 'allowManagedHooksOnly', 'allowManagedPermissionRulesOnly', 'allowManagedMcpServersOnly']) {
|
|
768
|
+
if (perm[scalarKey] !== undefined) merged.permissions[scalarKey] = perm[scalarKey];
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
// hooks: add OACB hook entries alongside existing entries (don't replace)
|
|
772
|
+
const oacbHooks = buildOacbHookEntries(baseline);
|
|
773
|
+
merged.hooks = merged.hooks || {};
|
|
774
|
+
for (const [event, entries] of Object.entries(oacbHooks)) {
|
|
775
|
+
merged.hooks[event] = [...(merged.hooks[event] || []), ...entries];
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// autoMode: deep merge
|
|
779
|
+
if (baseline.autoMode) {
|
|
780
|
+
merged.autoMode = mergeOverrides(merged.autoMode || {}, baseline.autoMode);
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
// Tracking metadata — contribution snapshot enables clean stripping on next re-apply
|
|
784
|
+
merged._oacbMeta = {
|
|
785
|
+
tier,
|
|
786
|
+
appliedAt: new Date().toISOString(),
|
|
787
|
+
ref: OACB_PINNED_REF,
|
|
788
|
+
contribution: {
|
|
789
|
+
permissions: deepClone(perm),
|
|
790
|
+
autoMode: deepClone(baseline.autoMode || {}),
|
|
791
|
+
},
|
|
792
|
+
};
|
|
793
|
+
|
|
794
|
+
await fs.writeFile(SETTINGS_PATH, JSON.stringify(merged, null, 2) + '\n', { mode: 0o600 });
|
|
795
|
+
return merged;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// ─── Backend policy push ──────────────────────────────────────────────────────
|
|
799
|
+
|
|
800
|
+
// TODO: implement later if required, logic is sound - Dinesh Veluswamy
|
|
801
|
+
// async function applyRulesToBackend(settings, tier) {
|
|
802
|
+
// const denyRules = settings.permissions?.deny || [];
|
|
803
|
+
// let count = 0;
|
|
804
|
+
// for (const rule of denyRules) {
|
|
805
|
+
// try {
|
|
806
|
+
// await api.post('/api/v1/command-policies/', {
|
|
807
|
+
// body: { name: `oacb/${tier}/${rule}`, action: 'BLOCK', rule, source: 'oacb', tier },
|
|
808
|
+
// });
|
|
809
|
+
// count++;
|
|
810
|
+
// } catch (e) {
|
|
811
|
+
// if (e.statusCode === 409) { count++; } // already exists — idempotent
|
|
812
|
+
// else throw e;
|
|
813
|
+
// }
|
|
814
|
+
// }
|
|
815
|
+
// return count;
|
|
816
|
+
// }
|
|
817
|
+
|
|
818
|
+
// ─── Conformance runner ───────────────────────────────────────────────────────
|
|
819
|
+
|
|
820
|
+
function runConformanceCase(testCase, hookBin, tier) {
|
|
821
|
+
const tierExpect = testCase.tiers[tier];
|
|
822
|
+
const stdinData = JSON.stringify(testCase.stdin || {});
|
|
823
|
+
|
|
824
|
+
const result = spawnSync(hookBin, [], {
|
|
825
|
+
input: stdinData,
|
|
826
|
+
env: { ...process.env, OACB_TIER: tier },
|
|
827
|
+
encoding: 'utf8',
|
|
828
|
+
timeout: 10000,
|
|
829
|
+
});
|
|
830
|
+
|
|
831
|
+
if (result.error) {
|
|
832
|
+
return {
|
|
833
|
+
id: testCase.id,
|
|
834
|
+
passed: false,
|
|
835
|
+
expectedExit: tierExpect.exit,
|
|
836
|
+
actualExit: -1,
|
|
837
|
+
note: result.error.message,
|
|
838
|
+
};
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
const actualExit = result.status ?? 1;
|
|
842
|
+
const expectedExit = tierExpect.exit;
|
|
843
|
+
const stderrCheck = tierExpect.stderr_contains;
|
|
844
|
+
|
|
845
|
+
let passed = actualExit === expectedExit;
|
|
846
|
+
let note;
|
|
847
|
+
if (passed && stderrCheck && !(result.stderr || '').includes(stderrCheck)) {
|
|
848
|
+
passed = false;
|
|
849
|
+
note = `stderr missing expected string: "${stderrCheck}"`;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
return { id: testCase.id, passed, expectedExit, actualExit, note };
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
// ─── Deep diff ────────────────────────────────────────────────────────────────
|
|
856
|
+
|
|
857
|
+
function computeDeepDiff(a, b) {
|
|
858
|
+
const diff = {};
|
|
859
|
+
const allKeys = new Set([...Object.keys(a || {}), ...Object.keys(b || {})]);
|
|
860
|
+
for (const k of allKeys) {
|
|
861
|
+
if (k.startsWith('_')) continue;
|
|
862
|
+
if (!(k in a)) {
|
|
863
|
+
diff[k] = { added: b[k] };
|
|
864
|
+
} else if (!(k in b)) {
|
|
865
|
+
diff[k] = { removed: a[k] };
|
|
866
|
+
} else if (Array.isArray(a[k]) && Array.isArray(b[k])) {
|
|
867
|
+
const added = b[k].filter(x => !a[k].includes(x));
|
|
868
|
+
const removed = a[k].filter(x => !b[k].includes(x));
|
|
869
|
+
if (added.length || removed.length) diff[k] = { added, removed };
|
|
870
|
+
} else if (typeof a[k] === 'object' && a[k] !== null && typeof b[k] === 'object' && b[k] !== null) {
|
|
871
|
+
const nested = computeDeepDiff(a[k], b[k]);
|
|
872
|
+
if (Object.keys(nested).length) diff[k] = nested;
|
|
873
|
+
} else if (a[k] !== b[k]) {
|
|
874
|
+
diff[k] = { from: a[k], to: b[k] };
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
return diff;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
// ─── Pure utilities ───────────────────────────────────────────────────────────
|
|
881
|
+
|
|
882
|
+
function validateTier(t) {
|
|
883
|
+
if (!TIERS.includes(t)) throw new Error(`Invalid tier '${t}'. Expected one of: ${TIERS.join(', ')}`);
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
function mergeOverrides(base, overrides) {
|
|
887
|
+
const result = deepClone(base);
|
|
888
|
+
for (const [k, v] of Object.entries(overrides)) {
|
|
889
|
+
if (k === '_oacbMeta') continue;
|
|
890
|
+
if (Array.isArray(result[k]) && Array.isArray(v)) {
|
|
891
|
+
result[k] = [...new Set([...result[k], ...v])];
|
|
892
|
+
} else if (typeof result[k] === 'object' && result[k] !== null && typeof v === 'object' && v !== null && !Array.isArray(v)) {
|
|
893
|
+
result[k] = mergeOverrides(result[k], v);
|
|
894
|
+
} else {
|
|
895
|
+
result[k] = v;
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
return result;
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
function deepClone(obj) {
|
|
902
|
+
return JSON.parse(JSON.stringify(obj));
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
function isVersionSupported(v) {
|
|
906
|
+
const m = v.match(/(\d+)\.(\d+)\.(\d+)/);
|
|
907
|
+
if (!m) return false;
|
|
908
|
+
const [maj, min, pat] = [m[1], m[2], m[3]].map(Number);
|
|
909
|
+
if (maj < 2) return false;
|
|
910
|
+
if (maj === 2 && min < 1) return false;
|
|
911
|
+
if (maj === 2 && min === 1 && pat < 83) return false;
|
|
912
|
+
if (maj >= 3) return false;
|
|
913
|
+
return true;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
module.exports = {
|
|
917
|
+
register,
|
|
918
|
+
// exported for unit tests only — not part of the public API
|
|
919
|
+
__test__: {
|
|
920
|
+
validateTier,
|
|
921
|
+
isVersionSupported,
|
|
922
|
+
computeGaps,
|
|
923
|
+
mergeOverrides,
|
|
924
|
+
computeDeepDiff,
|
|
925
|
+
buildOacbHookEntries,
|
|
926
|
+
ruleIdFromPattern,
|
|
927
|
+
classifyRule,
|
|
928
|
+
hasOacbHook,
|
|
929
|
+
runConformanceCase,
|
|
930
|
+
},
|
|
931
|
+
};
|
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
|
+
});
|