unbound-cli 0.9.1 → 0.9.3

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.
@@ -4,16 +4,38 @@ const fs = require('node:fs/promises');
4
4
  const path = require('node:path');
5
5
  const os = require('node:os');
6
6
  const { spawnSync } = require('node:child_process');
7
+ const TOML = require('@iarna/toml');
7
8
  const api = require('../api');
8
9
  const config = require('../config');
9
10
  const output = require('../output');
10
11
 
12
+ const crypto = require('node:crypto');
13
+
11
14
  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');
15
+ const OACB_PINNED_REF = 'v0.2.0';
16
+ const PKG_VERSION = '0.2.0';
17
+ const TIERS = ['shadow', 'receipts', 'baseline', 'strict', 'paranoid'];
18
+ const AGENTS = ['claude-code', 'codex'];
19
+
20
+ // Consent receipt path
21
+ const CONSENT_RECEIPT_PATH = path.join(os.homedir(), '.claude', 'oacb-consent.json');
22
+
23
+ // Claude Code paths
24
+ const CLAUDE_HOOKS_DIR = path.join(os.homedir(), '.claude', 'hooks');
25
+ const CLAUDE_SETTINGS_PATH = path.join(os.homedir(), '.claude', 'settings.json');
26
+ const CLAUDE_SETTINGS_BACKUP_PATH = path.join(os.homedir(), '.claude', 'settings.json.oacb-backup');
27
+
28
+ // Codex paths
29
+ const CODEX_HOOKS_DIR = path.join(os.homedir(), '.codex', 'hooks');
30
+ const CODEX_CONFIG_PATH = path.join(os.homedir(), '.codex', 'config.toml');
31
+ const CODEX_META_PATH = path.join(os.homedir(), '.codex', 'oacb-meta.json');
32
+ const CODEX_CONSENT_RECEIPT_PATH = path.join(os.homedir(), '.codex', 'oacb-consent.json');
33
+
34
+ // Legacy aliases kept for backward compat with internal helpers
35
+ const HOOKS_DIR = CLAUDE_HOOKS_DIR;
36
+ const SETTINGS_PATH = CLAUDE_SETTINGS_PATH;
37
+ const SETTINGS_BACKUP_PATH = CLAUDE_SETTINGS_BACKUP_PATH;
38
+
17
39
  const HOOK_NAMES = ['oacb-enforce.sh', 'oacb-prompt-guard.sh', 'oacb-mcp-guard.sh', 'oacb-config-audit.sh', 'oacb-post-tool.sh'];
18
40
  const HOOK_NAME_MAP = {
19
41
  enforce: 'oacb-enforce.sh',
@@ -26,31 +48,33 @@ const HOOK_NAME_MAP = {
26
48
  function register(program) {
27
49
  const cmd = program
28
50
  .command('oacb')
29
- .description('OACB — Open Autonomous Coding-agent Baseline. Apply, audit, and manage the security baseline for Claude Code.')
51
+ .description('OACB — Open Autonomous Coding-agent Baseline. Apply, audit, and manage the security baseline for AI coding agents.')
30
52
  .addHelpText('after', `
31
53
  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
54
+ unbound oacb check # verify install state (all agents)
55
+ unbound oacb apply # pick a tier interactively (claude-code)
56
+ unbound oacb apply --agent codex --tier baseline # apply to Codex CLI
57
+ unbound oacb apply --tier shadow # shadow tier (log-only, safe first step)
58
+ unbound oacb apply --tier baseline # turn on enforcement
59
+ unbound oacb apply --tier strict # regulated / pre-prod
60
+ unbound oacb apply --tier paranoid # FedRAMP / high-sensitivity
61
+ unbound oacb doctor # verify hooks block what they should
62
+ unbound oacb audit # score current settings vs baseline
63
+ unbound oacb diff --to baseline # see what changes at the next tier
41
64
 
42
65
  Read the framework at https://github.com/websentry-ai/oacb before applying baseline or higher.
43
66
  `);
44
67
 
45
68
  cmd.command('check')
46
- .description('Pre-flight: verify Claude Code install, version, and OACB state')
69
+ .description('Pre-flight: verify agent installs, versions, and OACB state')
47
70
  .action(async () => {
48
71
  try { await handleCheck(); }
49
72
  catch (e) { output.error(e.message); process.exitCode = 1; }
50
73
  });
51
74
 
52
75
  cmd.command('audit')
53
- .description('Score current Claude Code settings against the OACB baseline; report gaps by ASI ID')
76
+ .description('Score current agent settings against the OACB baseline; report gaps by ASI ID')
77
+ .option('--agent <agent>', `agent to audit (${AGENTS.join('|')})`, 'claude-code')
54
78
  .option('--tier <tier>', `tier to compare against (${TIERS.join('|')})`, 'baseline')
55
79
  .option('--format <fmt>', 'output format: table | json', 'table')
56
80
  .action(async (opts) => {
@@ -59,11 +83,16 @@ Read the framework at https://github.com/websentry-ai/oacb before applying basel
59
83
  });
60
84
 
61
85
  cmd.command('apply')
62
- .description('Apply an OACB tier: download hooks + write ~/.claude/settings.json overlay')
86
+ .description('Apply an OACB tier: download hooks + write agent config')
87
+ .option('--agent <agent>', `agent to configure (${AGENTS.join('|')}); prompted if omitted`)
63
88
  .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)
89
+ .option('--overrides <path>', 'path to a local JSON file layered on top of the baseline tier (claude-code only)')
90
+ .option('--dry-run', 'go through all steps but do not write files', false)
91
+ .option('--preview-only', 'print policy + dry-run, do not install', false)
92
+ .option('--yes', 'non-interactive (CI / MDM); still writes consent record', false)
93
+ .option('--print-policy', 'emit resolved policy JSON to stdout', false)
66
94
  .option('--local-hooks <dir>', 'dev: copy hook scripts from a local directory instead of downloading from GitHub (overrides OACB_LOCAL_HOOKS_DIR)')
95
+ .option('--local-managed-settings <dir>', 'dev: load managed-settings/config from a local directory instead of downloading from GitHub (overrides OACB_LOCAL_SETTINGS_DIR)')
67
96
  .action(async (opts) => {
68
97
  try { await handleApply(opts); }
69
98
  catch (e) { output.error(e.message); process.exitCode = 1; }
@@ -71,6 +100,7 @@ Read the framework at https://github.com/websentry-ai/oacb before applying basel
71
100
 
72
101
  cmd.command('doctor')
73
102
  .description('Run the OACB conformance suite against installed hooks to verify enforcement')
103
+ .option('--agent <agent>', `agent to test (${AGENTS.join('|')})`, 'claude-code')
74
104
  .option('--tier <tier>', `tier to test (${TIERS.join('|')})`, 'baseline')
75
105
  .option('--format <fmt>', 'output format: table | json', 'table')
76
106
  .option('--verbose', 'print each test case result as it runs', false)
@@ -80,7 +110,7 @@ Read the framework at https://github.com/websentry-ai/oacb before applying basel
80
110
  });
81
111
 
82
112
  cmd.command('diff')
83
- .description('Show the settings delta between two OACB tiers')
113
+ .description('Show the settings delta between two OACB tiers (claude-code only)')
84
114
  .option('--from <tier>', 'source tier (auto-detected from settings.json if omitted)')
85
115
  .option('--to <tier>', 'target tier', 'baseline')
86
116
  .action(async (opts) => {
@@ -89,12 +119,38 @@ Read the framework at https://github.com/websentry-ai/oacb before applying basel
89
119
  });
90
120
 
91
121
  cmd.command('remove')
92
- .description('Remove OACB from ~/.claude/settings.json and delete installed hook scripts')
122
+ .description('Remove OACB from agent config and delete installed hook scripts')
123
+ .option('--agent <agent>', `agent to remove from (${AGENTS.join('|')})`, 'claude-code')
93
124
  .option('--dry-run', 'show what would be removed without writing anything', false)
94
125
  .action(async (opts) => {
95
126
  try { await handleRemove(opts); }
96
127
  catch (e) { output.error(e.message); process.exitCode = 1; }
97
128
  });
129
+
130
+ cmd.command('why')
131
+ .description('Explain the most recent block or warn from the OACB audit log')
132
+ .option('--agent <agent>', `agent to read audit log from (${AGENTS.join('|')})`, 'claude-code')
133
+ .action(async (opts) => {
134
+ try { await handleWhy(opts); }
135
+ catch (e) { output.error(e.message); process.exitCode = 1; }
136
+ });
137
+
138
+ cmd.command('rules')
139
+ .description('Browse the OACB rule registry')
140
+ .option('--risk <level>', 'filter by risk level (low|medium|high|critical)')
141
+ .option('--search <term>', 'substring match on rule ID or description')
142
+ .action(async (opts) => {
143
+ try { await handleRules(opts); }
144
+ catch (e) { output.error(e.message); process.exitCode = 1; }
145
+ });
146
+
147
+ cmd.command('status')
148
+ .description('Show OACB install state: tier, expiry, and last 7 days of audit counts')
149
+ .option('--agent <agent>', `agent to inspect (${AGENTS.join('|')})`, 'claude-code')
150
+ .action(async (opts) => {
151
+ try { await handleStatus(opts); }
152
+ catch (e) { output.error(e.message); process.exitCode = 1; }
153
+ });
98
154
  }
99
155
 
100
156
  // ─── Handlers ────────────────────────────────────────────────────────────────
@@ -103,35 +159,59 @@ async function handleCheck() {
103
159
  const cfg = config.readConfig();
104
160
  const hasUnboundSession = !!cfg.api_key;
105
161
 
162
+ const { execSync } = require('node:child_process');
163
+
106
164
  let claudeCodeVersion = null;
107
165
  try {
108
- const { execSync } = require('node:child_process');
109
166
  claudeCodeVersion = execSync('claude --version', {
110
167
  stdio: ['ignore', 'pipe', 'ignore'],
111
168
  timeout: 3000,
112
169
  }).toString().trim();
113
- } catch (_) { /* not on PATH */ }
170
+ } catch (_) {}
171
+
172
+ let codexVersion = null;
173
+ try {
174
+ codexVersion = execSync('codex --version', {
175
+ stdio: ['ignore', 'pipe', 'ignore'],
176
+ timeout: 3000,
177
+ }).toString().trim();
178
+ } catch (_) {}
114
179
 
115
- const settingsPath = path.join(os.homedir(), '.claude', 'settings.json');
116
180
  let claudeSettingsExist = false;
117
- try { await fs.access(settingsPath); claudeSettingsExist = true; } catch (_) {}
181
+ try { await fs.access(CLAUDE_SETTINGS_PATH); claudeSettingsExist = true; } catch (_) {}
118
182
 
119
- const appliedTier = await detectCurrentTier();
183
+ let codexConfigExists = false;
184
+ try { await fs.access(CODEX_CONFIG_PATH); codexConfigExists = true; } catch (_) {}
120
185
 
121
- const hookStates = await Promise.all(
186
+ const claudeTier = await detectCurrentTier();
187
+ const codexTier = await detectCodexTier();
188
+
189
+ const claudeHookStates = await Promise.all(
190
+ HOOK_NAMES.map(async (n) => {
191
+ try { await fs.access(path.join(CLAUDE_HOOKS_DIR, n)); return true; }
192
+ catch (_) { return false; }
193
+ })
194
+ );
195
+ const codexHookStates = await Promise.all(
122
196
  HOOK_NAMES.map(async (n) => {
123
- try { await fs.access(path.join(HOOKS_DIR, n)); return true; }
197
+ try { await fs.access(path.join(CODEX_HOOKS_DIR, n)); return true; }
124
198
  catch (_) { return false; }
125
199
  })
126
200
  );
127
- const hooksInstalledCount = hookStates.filter(Boolean).length;
128
201
 
129
202
  output.keyValue([
130
- ['Unbound session', hasUnboundSession ? 'logged in' : 'not logged in (optional — login enables backend sync)'],
203
+ ['Unbound session', hasUnboundSession ? 'logged in' : 'not logged in (optional)'],
204
+ ['─── Claude Code ───', ''],
131
205
  ['Claude Code on PATH', claudeCodeVersion || 'not detected'],
132
206
  ['~/.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}`],
207
+ ['OACB tier applied', claudeTier !== 'none' ? claudeTier : 'none (run `unbound oacb apply`)'],
208
+ ['Hooks installed', `${claudeHookStates.filter(Boolean).length} / ${HOOK_NAMES.length}`],
209
+ ['─── Codex CLI ─────', ''],
210
+ ['Codex on PATH', codexVersion || 'not detected'],
211
+ ['~/.codex/config.toml', codexConfigExists ? 'found' : 'not found'],
212
+ ['OACB tier applied', codexTier !== 'none' ? codexTier : 'none (run `unbound oacb apply --agent codex`)'],
213
+ ['Hooks installed', `${codexHookStates.filter(Boolean).length} / ${HOOK_NAMES.length}`],
214
+ ['─────────────────', ''],
135
215
  ['OACB pinned ref', OACB_PINNED_REF],
136
216
  ]);
137
217
 
@@ -141,34 +221,44 @@ async function handleCheck() {
141
221
  output.warn(`Claude Code ${claudeCodeVersion} is outside OACB tested range (>=2.1.83 <3.0.0). Proceed with caution.`);
142
222
  }
143
223
 
144
- if (appliedTier === 'none') {
145
- output.warn('No OACB tier applied. Run `unbound oacb apply` to start with shadow tier.');
224
+ if (claudeTier === 'none' && codexTier === 'none') {
225
+ output.warn('No OACB tier applied to any agent. Run `unbound oacb apply` to start with shadow tier.');
146
226
  }
147
227
 
148
228
  output.success('Pre-flight complete.');
149
229
  }
150
230
 
151
- async function handleAudit({ tier, format }) {
231
+ async function handleAudit({ agent, tier, format }) {
232
+ validateAgent(agent);
152
233
  validateTier(tier);
153
- const spin = output.spinner(`Fetching OACB ${tier} baseline...`);
234
+
235
+ const spin = output.spinner(`Fetching OACB ${agent} ${tier} baseline...`);
154
236
  let baseline;
155
237
  try {
156
- baseline = await fetchBaseline(tier);
238
+ baseline = await fetchBaseline(tier, agent);
157
239
  spin.stop();
158
240
  } catch (e) {
159
241
  spin.fail(`Could not fetch baseline: ${e.message}`);
160
242
  throw e;
161
243
  }
162
244
 
163
- const current = await loadMergedSettings();
164
- const gaps = computeGaps(current, baseline);
245
+ let current;
246
+ if (agent === 'codex') {
247
+ current = await loadCodexConfig();
248
+ } else {
249
+ current = await loadMergedSettings();
250
+ }
251
+
252
+ const gaps = agent === 'codex'
253
+ ? computeCodexGaps(current, baseline)
254
+ : computeGaps(current, baseline);
165
255
 
166
256
  if (format === 'json') {
167
- process.stdout.write(JSON.stringify({ tier, ref: OACB_PINNED_REF, gaps }, null, 2) + '\n');
257
+ process.stdout.write(JSON.stringify({ agent, tier, ref: OACB_PINNED_REF, gaps }, null, 2) + '\n');
168
258
  return;
169
259
  }
170
260
 
171
- output.info(`OACB audit — tier=${tier} ref=${OACB_PINNED_REF}`);
261
+ output.info(`OACB audit — agent=${agent} tier=${tier} ref=${OACB_PINNED_REF}`);
172
262
  if (gaps.length === 0) {
173
263
  output.success('No gaps. Current settings satisfy the OACB tier baseline.');
174
264
  return;
@@ -183,111 +273,255 @@ async function handleAudit({ tier, format }) {
183
273
  { key: 'desc', header: 'Description' },
184
274
  ]
185
275
  );
186
- output.info(`Run \`unbound oacb apply --tier ${tier}\` to remediate.`);
276
+ output.info(`Run \`unbound oacb apply --agent ${agent} --tier ${tier}\` to remediate.`);
187
277
  }
188
278
 
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' },
279
+ async function handleApply({ agent: agentFlag, tier, overrides, dryRun, previewOnly, yes: nonInteractive, printPolicy, localHooks, localManagedSettings }) {
280
+ if (previewOnly) dryRun = true;
281
+
282
+ if (overrides && agentFlag === 'codex') {
283
+ output.warn('--overrides is not supported for the codex agent and will be ignored.');
284
+ overrides = undefined;
285
+ }
286
+
287
+ // ── Agent selection ───────────────────────────────────────────────────────
288
+ // --agent flag: single agent, skip prompt. No flag: multi-select (or CI default).
289
+ let agents;
290
+ if (agentFlag) {
291
+ validateAgent(agentFlag);
292
+ agents = [agentFlag];
293
+ } else if (nonInteractive) {
294
+ agents = ['claude-code'];
295
+ } else {
296
+ agents = await output.multiSelect('Select agents to configure:', [
297
+ { label: 'claude-code — Claude Code CLI', value: 'claude-code' },
298
+ { label: 'codex — OpenAI Codex CLI', value: 'codex' },
196
299
  ]);
300
+ if (agents.length === 0) {
301
+ output.warn('No agents selected — nothing to do.');
302
+ return;
303
+ }
304
+ }
305
+
306
+ // ── Tier selection (shared across agents) ─────────────────────────────────
307
+ if (!tier) {
308
+ if (nonInteractive) {
309
+ tier = 'shadow';
310
+ } else {
311
+ tier = await output.select('Select OACB security tier:', [
312
+ { label: 'shadow — silent observation, logs all commands, blocks nothing', value: 'shadow' },
313
+ { label: 'receipts — visible warnings, engineer sees risky commands, can abort. Expires in 30 days', value: 'receipts' },
314
+ { label: 'baseline — standard enforcement, blocks critical ops, asks before high-risk', value: 'baseline' },
315
+ { label: 'strict — tighter, warns on medium, blocks high and critical', value: 'strict' },
316
+ { label: 'paranoid — maximum, blocks medium/high/critical, disables auto mode', value: 'paranoid' },
317
+ ]);
318
+ }
197
319
  }
198
320
  validateTier(tier);
199
321
 
200
- const spin = output.spinner(`Fetching OACB ${tier} baseline from GitHub...`);
201
- let baseline;
322
+ // ── Shared ceremony (runs once regardless of how many agents are selected)
323
+ // printPolicy and nonInteractive skip the ceremony.
324
+ const runCeremony = !nonInteractive && !printPolicy;
325
+ if (runCeremony) printTierMatrix(tier);
326
+
327
+ // ── printPolicy: emit resolved policy for each agent and return ───────────
328
+ if (printPolicy) {
329
+ for (const agent of agents) {
330
+ if (agents.length > 1) output.info(`\n── ${agent} ──`);
331
+ const baseline = await _fetchBaseline(tier, agent, localManagedSettings);
332
+ if (agent === 'codex') {
333
+ process.stdout.write(TOML.stringify(mergeCodexConfig({}, baseline)) + '\n');
334
+ } else {
335
+ let effective = deepClone(baseline);
336
+ if (overrides) effective = mergeOverrides(effective, JSON.parse(await fs.readFile(overrides, 'utf8')));
337
+ const oacbHooks = buildOacbHookEntries(effective);
338
+ process.stdout.write(JSON.stringify({
339
+ _oacbMeta: { tier, appliedAt: '<now>', ref: OACB_PINNED_REF },
340
+ permissions: effective.permissions,
341
+ autoMode: effective.autoMode,
342
+ hooks: oacbHooks,
343
+ }, null, 2) + '\n');
344
+ }
345
+ }
346
+ return;
347
+ }
348
+
349
+ // ── dryRun: show per-agent diffs, then one shared history dry-run, return ─
350
+ if (dryRun) {
351
+ for (const agent of agents) {
352
+ if (agents.length > 1) output.info(`\n── ${agent} ──`);
353
+ const baseline = await _fetchBaseline(tier, agent, localManagedSettings);
354
+ if (agent === 'codex') {
355
+ output.info(`--dry-run: showing what would be merged into ${CODEX_CONFIG_PATH}`);
356
+ process.stdout.write(TOML.stringify(mergeCodexConfig({}, baseline)) + '\n');
357
+ output.info(' Hook scripts that would be installed:');
358
+ for (const name of HOOK_NAMES) process.stdout.write(` ${path.join(CODEX_HOOKS_DIR, name)}\n`);
359
+ } else {
360
+ let effective = deepClone(baseline);
361
+ if (overrides) effective = mergeOverrides(effective, JSON.parse(await fs.readFile(overrides, 'utf8')));
362
+ output.info(`--dry-run: showing what would be merged into ${CLAUDE_SETTINGS_PATH}`);
363
+ const oacbHooks = buildOacbHookEntries(effective);
364
+ process.stdout.write(JSON.stringify({
365
+ _oacbMeta: { tier, appliedAt: '<now>', ref: OACB_PINNED_REF },
366
+ permissions: effective.permissions,
367
+ autoMode: effective.autoMode,
368
+ hooks: oacbHooks,
369
+ }, null, 2) + '\n');
370
+ output.info(' Hook scripts that would be installed:');
371
+ for (const name of HOOK_NAMES) process.stdout.write(` ${path.join(CLAUDE_HOOKS_DIR, name)}\n`);
372
+ }
373
+ }
374
+ if (runCeremony) await historyDryRun(tier, agents[0]);
375
+ return;
376
+ }
377
+
378
+ // ── History dry-run (once, shared) before writing anything ────────────────
379
+ if (runCeremony) await historyDryRun(tier, agents[0]);
380
+
381
+ // ── Install per agent ─────────────────────────────────────────────────────
382
+ for (const agent of agents) {
383
+ if (agents.length > 1) output.info(`\n── Applying to ${agent} ──`);
384
+ const baseline = await _fetchBaseline(tier, agent, localManagedSettings);
385
+ if (agent === 'codex') {
386
+ await _applyCodex({ baseline, tier, localHooks });
387
+ } else {
388
+ await _applyClaudeCode({ baseline, tier, overrides, localHooks });
389
+ }
390
+ }
391
+ }
392
+
393
+ // Fetch baseline config for a given tier + agent, respecting local override dir.
394
+ async function _fetchBaseline(tier, agent, localManagedSettings) {
395
+ const settingsLocalDir = localManagedSettings || process.env.OACB_LOCAL_SETTINGS_DIR || null;
396
+ const spin = output.spinner(
397
+ settingsLocalDir
398
+ ? `Loading OACB ${agent} ${tier} baseline from local path...`
399
+ : `Fetching OACB ${agent} ${tier} baseline...`
400
+ );
202
401
  try {
203
- baseline = await fetchBaseline(tier);
402
+ let baseline;
403
+ if (settingsLocalDir) {
404
+ if (agent === 'codex') {
405
+ baseline = TOML.parse(await fs.readFile(path.join(settingsLocalDir, `config.${tier}.toml`), 'utf8'));
406
+ } else {
407
+ baseline = JSON.parse(await fs.readFile(path.join(settingsLocalDir, `managed-settings.${tier}.json`), 'utf8'));
408
+ }
409
+ } else {
410
+ baseline = await fetchBaseline(tier, agent);
411
+ }
204
412
  spin.stop();
413
+ return baseline;
205
414
  } catch (e) {
206
- spin.fail(`Could not fetch baseline: ${e.message}`);
415
+ spin.fail(`Could not load baseline: ${e.message}`);
207
416
  throw e;
208
417
  }
418
+ }
209
419
 
420
+ // Install OACB for claude-code (hooks + settings overlay + consent receipt).
421
+ async function _applyClaudeCode({ baseline, tier, overrides, localHooks }) {
210
422
  let effective = deepClone(baseline);
211
423
  if (overrides) {
212
424
  const overrideJson = JSON.parse(await fs.readFile(overrides, 'utf8'));
213
425
  effective = mergeOverrides(effective, overrideJson);
214
426
  }
215
427
 
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') {
428
+ if (tier !== 'shadow' && tier !== 'receipts') {
233
429
  output.warn(`Applying tier=${tier}. This WILL block tool calls matching deny rules.`);
234
430
  output.warn('If you have not run shadow tier for 2+ weeks, consider starting there first.');
235
431
  }
236
432
 
237
433
  const hooksLocalDir = localHooks || process.env.OACB_LOCAL_HOOKS_DIR || null;
238
434
  const hookSpin = output.spinner(
239
- hooksLocalDir
240
- ? `Installing OACB hook scripts from local path: ${hooksLocalDir}...`
241
- : 'Downloading and installing OACB hook scripts...'
435
+ hooksLocalDir ? `Installing hooks from local path: ${hooksLocalDir}...` : 'Downloading and installing OACB hook scripts...'
242
436
  );
243
437
  try {
244
- await installHooks(hooksLocalDir);
245
- hookSpin.succeed(`Installed ${HOOK_NAMES.length} hooks to ${HOOKS_DIR}`);
438
+ await installHooks(hooksLocalDir, 'claude-code');
439
+ hookSpin.succeed(`Installed ${HOOK_NAMES.length} hooks to ${CLAUDE_HOOKS_DIR}`);
246
440
  } catch (e) {
247
441
  hookSpin.fail(`Hook installation failed: ${e.message}`);
248
442
  throw e;
249
443
  }
250
444
 
251
- const settingsSpin = output.spinner(`Merging OACB settings into ${SETTINGS_PATH}...`);
445
+ const expires = tier === 'receipts' ? computeExpiryDate(30) : null;
446
+ const settingsSpin = output.spinner(`Merging OACB settings into ${CLAUDE_SETTINGS_PATH}...`);
252
447
  try {
253
- await writeSettingsOverlay(effective, tier);
254
- settingsSpin.succeed(`Updated ${SETTINGS_PATH} (backup at ${SETTINGS_BACKUP_PATH})`);
448
+ await writeSettingsOverlay(effective, tier, expires ? { OACB_EXPIRES: expires } : undefined);
449
+ settingsSpin.succeed(`Updated ${CLAUDE_SETTINGS_PATH} (backup at ${CLAUDE_SETTINGS_BACKUP_PATH})`);
255
450
  } catch (e) {
256
451
  settingsSpin.fail(`Settings write failed: ${e.message}`);
257
452
  throw e;
258
453
  }
259
454
 
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.`);
455
+ await writeConsentReceipt(tier, 'claude-code').catch(() => {});
456
+
457
+ output.success(`OACB ${tier} applied (claude-code).`);
276
458
  if (tier === 'shadow') {
277
459
  output.info('Shadow: hooks log but never block. Review ~/.claude/hooks/oacb-audit.log for 2 weeks, then `unbound oacb apply --tier baseline`.');
460
+ } else if (tier === 'receipts') {
461
+ output.info('Receipts: engineer sees WARN banners for risky commands and can abort. Expires in 30 days.');
462
+ }
463
+ output.info(`Verify with: unbound oacb doctor --tier ${tier}`);
464
+ output.info('');
465
+ output.info(' unbound oacb status — current tier, expiry, last 7d counts');
466
+ output.info(' unbound oacb why — explain the most recent block or warn');
467
+ output.info(' unbound oacb rules --risk critical — browse the rule list');
468
+ output.info(' unbound oacb remove — uninstall');
469
+ }
470
+
471
+ // Install OACB for codex (hooks + config.toml + consent receipt).
472
+ async function _applyCodex({ baseline, tier, localHooks }) {
473
+ if (tier !== 'shadow' && tier !== 'receipts') {
474
+ output.warn(`Applying Codex tier=${tier}. approval_policy and sandbox_mode will be set accordingly.`);
475
+ }
476
+
477
+ const hooksLocalDir = localHooks || process.env.OACB_LOCAL_HOOKS_DIR || null;
478
+ const hookSpin = output.spinner(
479
+ hooksLocalDir ? `Installing Codex hooks from local path: ${hooksLocalDir}...` : 'Downloading and installing Codex hook scripts...'
480
+ );
481
+ try {
482
+ await installHooks(hooksLocalDir, 'codex');
483
+ hookSpin.succeed(`Installed ${HOOK_NAMES.length} hooks to ${CODEX_HOOKS_DIR}`);
484
+ } catch (e) {
485
+ hookSpin.fail(`Hook installation failed: ${e.message}`);
486
+ throw e;
278
487
  }
279
- output.info('Verify with: unbound oacb doctor --tier ' + tier);
488
+
489
+ const configSpin = output.spinner(`Merging OACB settings into ${CODEX_CONFIG_PATH}...`);
490
+ try {
491
+ await writeCodexConfig(baseline, tier);
492
+ configSpin.succeed(`Updated ${CODEX_CONFIG_PATH}`);
493
+ } catch (e) {
494
+ configSpin.fail(`Config write failed: ${e.message}`);
495
+ throw e;
496
+ }
497
+
498
+ await writeConsentReceipt(tier, 'codex').catch(() => {});
499
+
500
+ output.success(`OACB ${tier} applied (codex).`);
501
+ if (tier === 'shadow') {
502
+ output.info('Shadow: hooks log but never block. Review ~/.codex/hooks/oacb-audit.log for 2 weeks, then `unbound oacb apply --agent codex --tier baseline`.');
503
+ } else if (tier === 'receipts') {
504
+ output.info('Receipts: engineer sees WARN banners for risky commands and can abort. Expires in 30 days.');
505
+ }
506
+ output.info(`Verify with: unbound oacb doctor --agent codex --tier ${tier}`);
507
+ output.info('');
508
+ output.info(' unbound oacb status --agent codex — current tier, expiry, last 7d counts');
509
+ output.info(' unbound oacb why --agent codex — explain the most recent block or warn');
510
+ output.info(' unbound oacb rules --risk critical — browse the rule list');
511
+ output.info(' unbound oacb remove --agent codex — uninstall');
512
+ output.info(` unbound oacb apply --agent codex --tier baseline — graduate to baseline`);
280
513
  }
281
514
 
282
- async function handleDoctor({ tier, format, verbose }) {
515
+ async function handleDoctor({ agent, tier, format, verbose }) {
516
+ validateAgent(agent);
283
517
  validateTier(tier);
284
518
 
285
- // Verify at least the enforce hook is installed
286
- const enforcePath = path.join(HOOKS_DIR, 'oacb-enforce.sh');
519
+ const hooksDir = agent === 'codex' ? CODEX_HOOKS_DIR : CLAUDE_HOOKS_DIR;
520
+ const enforcePath = path.join(hooksDir, 'oacb-enforce.sh');
287
521
  try {
288
522
  await fs.access(enforcePath);
289
523
  } catch {
290
- output.error(`OACB hooks not installed. Run \`unbound oacb apply --tier ${tier}\` first.`);
524
+ output.error(`OACB hooks not installed for ${agent}. Run \`unbound oacb apply --agent ${agent} --tier ${tier}\` first.`);
291
525
  process.exitCode = 1;
292
526
  return;
293
527
  }
@@ -302,20 +536,26 @@ async function handleDoctor({ tier, format, verbose }) {
302
536
  throw e;
303
537
  }
304
538
 
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
- );
539
+ const cases = corpus.filter(c => {
540
+ if (c.id.endsWith('-RESIDUAL')) return false;
541
+ if (!c.tiers || c.tiers[tier] === undefined) return false;
542
+ // Filter by agent: skip cases scoped to other agents
543
+ if (c.agents && !c.agents.includes(agent)) return false;
544
+ return true;
545
+ });
311
546
 
312
547
  if (format !== 'json') {
313
- output.info(`Running ${cases.length} conformance cases for tier=${tier}...`);
548
+ output.info(`Running ${cases.length} conformance cases for agent=${agent} tier=${tier}...`);
314
549
  }
315
550
 
316
551
  const results = [];
317
552
  for (const testCase of cases) {
318
- const hookBin = path.join(HOOKS_DIR, HOOK_NAME_MAP[testCase.hook || 'enforce']);
553
+ const hookFile = HOOK_NAME_MAP[testCase.hook || 'enforce'];
554
+ if (!hookFile) {
555
+ results.push({ id: testCase.id, passed: false, error: `unknown hook name '${testCase.hook}' in conformance corpus` });
556
+ continue;
557
+ }
558
+ const hookBin = path.join(hooksDir, hookFile);
319
559
  const result = runConformanceCase(testCase, hookBin, tier);
320
560
  results.push(result);
321
561
  if (format !== 'json' && verbose) {
@@ -329,11 +569,7 @@ async function handleDoctor({ tier, format, verbose }) {
329
569
 
330
570
  if (format === 'json') {
331
571
  process.stdout.write(JSON.stringify({
332
- tier,
333
- ref: OACB_PINNED_REF,
334
- passed,
335
- total: cases.length,
336
- results,
572
+ agent, tier, ref: OACB_PINNED_REF, passed, total: cases.length, results,
337
573
  }, null, 2) + '\n');
338
574
  return;
339
575
  }
@@ -386,12 +622,20 @@ async function handleDiff({ from, to }) {
386
622
  process.stdout.write(JSON.stringify(computeDeepDiff(a, b), null, 2) + '\n');
387
623
  }
388
624
 
389
- async function handleRemove({ dryRun }) {
625
+ async function handleRemove({ agent, dryRun }) {
626
+ validateAgent(agent);
627
+
628
+ if (agent === 'codex') {
629
+ await _removeCodex({ dryRun });
630
+ return;
631
+ }
632
+
633
+ // ── claude-code path ──────────────────────────────────────────────────────
390
634
  let settings = {};
391
635
  try {
392
- settings = JSON.parse(await fs.readFile(SETTINGS_PATH, 'utf8'));
636
+ settings = JSON.parse(await fs.readFile(CLAUDE_SETTINGS_PATH, 'utf8'));
393
637
  } catch (_) {
394
- output.warn(`${SETTINGS_PATH} not found — nothing to remove.`);
638
+ output.warn(`${CLAUDE_SETTINGS_PATH} not found — nothing to remove.`);
395
639
  return;
396
640
  }
397
641
 
@@ -402,16 +646,13 @@ async function handleRemove({ dryRun }) {
402
646
  }
403
647
 
404
648
  const tier = meta.tier;
405
- output.info(`Removing OACB (tier=${tier}) from ${SETTINGS_PATH}...`);
649
+ output.info(`Removing OACB (tier=${tier}) from ${CLAUDE_SETTINGS_PATH}...`);
406
650
 
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
651
  let contribution = meta.contribution;
411
652
  if (!contribution) {
412
653
  const spin = output.spinner(`Fetching ${tier} baseline to compute what to remove...`);
413
654
  try {
414
- const baseline = await fetchBaseline(tier);
655
+ const baseline = await fetchBaseline(tier, 'claude-code');
415
656
  spin.stop();
416
657
  contribution = { permissions: baseline.permissions || {}, autoMode: baseline.autoMode || {} };
417
658
  } catch (e) {
@@ -422,7 +663,6 @@ async function handleRemove({ dryRun }) {
422
663
 
423
664
  const cleaned = deepClone(settings);
424
665
 
425
- // Remove permission list entries contributed by OACB
426
666
  if (contribution.permissions && cleaned.permissions) {
427
667
  const contributedDeny = new Set(contribution.permissions.deny || []);
428
668
  const contributedAsk = new Set(contribution.permissions.ask || []);
@@ -450,7 +690,6 @@ async function handleRemove({ dryRun }) {
450
690
  if (!Object.keys(cleaned.permissions).length) delete cleaned.permissions;
451
691
  }
452
692
 
453
- // Remove hook entries where command matches an OACB hook script
454
693
  const oacbHookRe = /oacb-[^/\\]+\.sh$/;
455
694
  if (cleaned.hooks) {
456
695
  for (const event of Object.keys(cleaned.hooks)) {
@@ -465,7 +704,6 @@ async function handleRemove({ dryRun }) {
465
704
  if (!Object.keys(cleaned.hooks).length) delete cleaned.hooks;
466
705
  }
467
706
 
468
- // Remove autoMode keys contributed by OACB (only if values still match what was applied)
469
707
  if (contribution.autoMode && cleaned.autoMode) {
470
708
  for (const k of Object.keys(contribution.autoMode)) {
471
709
  if (JSON.stringify(cleaned.autoMode[k]) === JSON.stringify(contribution.autoMode[k])) {
@@ -482,38 +720,87 @@ async function handleRemove({ dryRun }) {
482
720
  process.stdout.write(JSON.stringify(cleaned, null, 2) + '\n');
483
721
  output.info('Hook scripts that would be deleted:');
484
722
  for (const name of HOOK_NAMES) {
485
- process.stdout.write(` ${path.join(HOOKS_DIR, name)}\n`);
723
+ process.stdout.write(` ${path.join(CLAUDE_HOOKS_DIR, name)}\n`);
724
+ }
725
+ return;
726
+ }
727
+
728
+ await fs.writeFile(CLAUDE_SETTINGS_PATH, JSON.stringify(cleaned, null, 2) + '\n', { mode: 0o600 });
729
+ output.success(`Wrote cleaned ${CLAUDE_SETTINGS_PATH}`);
730
+
731
+ try { await fs.unlink(CONSENT_RECEIPT_PATH); } catch (_) {}
732
+
733
+ await _deleteHooks(CLAUDE_HOOKS_DIR);
734
+ output.success('OACB removed from Claude Code. Run `unbound oacb check` to verify clean state.');
735
+ }
736
+
737
+ async function _removeCodex({ dryRun }) {
738
+ let meta = null;
739
+ try {
740
+ meta = JSON.parse(await fs.readFile(CODEX_META_PATH, 'utf8'));
741
+ } catch (_) {}
742
+
743
+ let existing = {};
744
+ try {
745
+ existing = TOML.parse(await fs.readFile(CODEX_CONFIG_PATH, 'utf8'));
746
+ } catch (_) {
747
+ output.warn(`${CODEX_CONFIG_PATH} not found — nothing to remove.`);
748
+ return;
749
+ }
750
+
751
+ if (!meta) {
752
+ // Check for OACB hooks in config even without meta
753
+ const hasHook = Object.values(existing.hooks || {}).flat().some(e =>
754
+ (e.hooks || []).some(h => /oacb-/.test(h.command || ''))
755
+ );
756
+ if (!hasHook) {
757
+ output.warn('No OACB installation found in Codex config.');
758
+ return;
759
+ }
760
+ }
761
+
762
+ const cleaned = stripOacbFromCodexConfig(existing);
763
+
764
+ if (dryRun) {
765
+ output.info(`--dry-run: showing cleaned ${CODEX_CONFIG_PATH} (not written)`);
766
+ process.stdout.write(TOML.stringify(cleaned) + '\n');
767
+ output.info('Hook scripts that would be deleted:');
768
+ for (const name of HOOK_NAMES) {
769
+ process.stdout.write(` ${path.join(CODEX_HOOKS_DIR, name)}\n`);
486
770
  }
487
771
  return;
488
772
  }
489
773
 
490
- await fs.writeFile(SETTINGS_PATH, JSON.stringify(cleaned, null, 2) + '\n', { mode: 0o600 });
491
- output.success(`Wrote cleaned ${SETTINGS_PATH}`);
774
+ await fs.writeFile(CODEX_CONFIG_PATH, TOML.stringify(cleaned), { mode: 0o600 });
775
+ output.success(`Wrote cleaned ${CODEX_CONFIG_PATH}`);
776
+
777
+ try { await fs.unlink(CODEX_META_PATH); } catch (_) {}
778
+ try { await fs.unlink(CODEX_CONSENT_RECEIPT_PATH); } catch (_) {}
779
+
780
+ await _deleteHooks(CODEX_HOOKS_DIR);
781
+ output.success('OACB removed from Codex. Run `unbound oacb check` to verify clean state.');
782
+ }
492
783
 
784
+ async function _deleteHooks(hooksDir) {
493
785
  const deleted = [];
494
786
  const missing = [];
495
787
  await Promise.all(
496
788
  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
- }
789
+ try { await fs.unlink(path.join(hooksDir, name)); deleted.push(name); }
790
+ catch (_) { missing.push(name); }
504
791
  })
505
792
  );
506
-
507
793
  if (deleted.length) output.success(`Deleted hook scripts: ${deleted.join(', ')}`);
508
794
  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
795
  }
513
796
 
514
797
  // ─── Fetch helpers ────────────────────────────────────────────────────────────
515
798
 
516
- async function fetchBaseline(tier) {
799
+ async function fetchBaseline(tier, agent = 'claude-code') {
800
+ if (agent === 'codex') {
801
+ const url = `${OACB_RAW_BASE}/${OACB_PINNED_REF}/baseline/codex/config.${tier}.toml`;
802
+ return TOML.parse(await api.getRaw(url));
803
+ }
517
804
  const url = `${OACB_RAW_BASE}/${OACB_PINNED_REF}/baseline/claude-code/managed-settings.${tier}.json`;
518
805
  return JSON.parse(await api.getRaw(url));
519
806
  }
@@ -528,15 +815,39 @@ async function fetchConformanceCorpus() {
528
815
 
529
816
  async function detectCurrentTier() {
530
817
  try {
531
- const raw = await fs.readFile(SETTINGS_PATH, 'utf8');
818
+ const raw = await fs.readFile(CLAUDE_SETTINGS_PATH, 'utf8');
532
819
  return JSON.parse(raw)._oacbMeta?.tier || 'none';
533
820
  } catch (_) {
534
821
  return 'none';
535
822
  }
536
823
  }
537
824
 
825
+ async function detectCodexTier() {
826
+ try {
827
+ const meta = JSON.parse(await fs.readFile(CODEX_META_PATH, 'utf8'));
828
+ return meta.tier || 'none';
829
+ } catch (_) {}
830
+ // Fallback: scan TOML for OACB_TIER env in hook entries
831
+ try {
832
+ const cfg = TOML.parse(await fs.readFile(CODEX_CONFIG_PATH, 'utf8'));
833
+ for (const entries of Object.values(cfg.hooks || {})) {
834
+ for (const entry of entries) {
835
+ for (const h of entry.hooks || []) {
836
+ if (h.env?.OACB_TIER) return h.env.OACB_TIER;
837
+ }
838
+ }
839
+ }
840
+ } catch (_) {}
841
+ return 'none';
842
+ }
843
+
538
844
  async function loadMergedSettings() {
539
- try { return JSON.parse(await fs.readFile(SETTINGS_PATH, 'utf8')); }
845
+ try { return JSON.parse(await fs.readFile(CLAUDE_SETTINGS_PATH, 'utf8')); }
846
+ catch (_) { return {}; }
847
+ }
848
+
849
+ async function loadCodexConfig() {
850
+ try { return TOML.parse(await fs.readFile(CODEX_CONFIG_PATH, 'utf8')); }
540
851
  catch (_) { return {}; }
541
852
  }
542
853
 
@@ -571,7 +882,6 @@ function computeGaps(current, baseline) {
571
882
  const gaps = [];
572
883
  const curDeny = new Set(current.permissions?.deny || []);
573
884
 
574
- // Missing deny rules — deduplicate by rule ID so we report one gap per category
575
885
  const reportedIds = new Set();
576
886
  for (const rule of (baseline.permissions?.deny || [])) {
577
887
  if (!curDeny.has(rule)) {
@@ -588,7 +898,6 @@ function computeGaps(current, baseline) {
588
898
  }
589
899
  }
590
900
 
591
- // Hooks wired?
592
901
  if (!hasOacbHook(current, 'PreToolUse', 'oacb-enforce.sh')) {
593
902
  gaps.push({
594
903
  asi: 'ASI02',
@@ -598,7 +907,6 @@ function computeGaps(current, baseline) {
598
907
  });
599
908
  }
600
909
 
601
- // disableBypassPermissionsMode
602
910
  const baseBypass = baseline.permissions?.disableBypassPermissionsMode;
603
911
  const curBypass = current.permissions?.disableBypassPermissionsMode;
604
912
  if (baseBypass && curBypass !== baseBypass) {
@@ -613,6 +921,42 @@ function computeGaps(current, baseline) {
613
921
  return gaps;
614
922
  }
615
923
 
924
+ function computeCodexGaps(current, baselineConfig) {
925
+ const gaps = [];
926
+
927
+ if (current.approval_policy !== baselineConfig.approval_policy) {
928
+ gaps.push({
929
+ asi: 'ASI03',
930
+ ruleId: 'OACB-CODEX-POLICY-001',
931
+ description: `approval_policy should be "${baselineConfig.approval_policy}", found "${current.approval_policy || 'unset'}"`,
932
+ recommendation: 'Run `unbound oacb apply --agent codex --tier <tier>` to remediate',
933
+ });
934
+ }
935
+
936
+ if (current.sandbox_mode !== baselineConfig.sandbox_mode) {
937
+ gaps.push({
938
+ asi: 'ASI03',
939
+ ruleId: 'OACB-CODEX-POLICY-002',
940
+ description: `sandbox_mode should be "${baselineConfig.sandbox_mode}", found "${current.sandbox_mode || 'unset'}"`,
941
+ recommendation: 'Run `unbound oacb apply --agent codex --tier <tier>` to remediate',
942
+ });
943
+ }
944
+
945
+ const hasEnforceHook = (current.hooks?.PreToolUse || []).some(e =>
946
+ (e.hooks || []).some(h => /oacb-enforce\.sh/.test(h.command || ''))
947
+ );
948
+ if (!hasEnforceHook) {
949
+ gaps.push({
950
+ asi: 'ASI02',
951
+ ruleId: 'OACB-HOOK-001',
952
+ description: 'oacb-enforce.sh not registered in hooks.PreToolUse',
953
+ recommendation: 'Run `unbound oacb apply --agent codex --tier <tier>` to install hooks',
954
+ });
955
+ }
956
+
957
+ return gaps;
958
+ }
959
+
616
960
  function hasOacbHook(settings, event, hookFile) {
617
961
  const hooks = settings.hooks?.[event] || [];
618
962
  return hooks.some(entry =>
@@ -624,30 +968,50 @@ function hasOacbHook(settings, event, hookFile) {
624
968
 
625
969
  // ─── Hook installation ────────────────────────────────────────────────────────
626
970
 
627
- async function installHooks(localDir = null) {
628
- await fs.mkdir(HOOKS_DIR, { recursive: true });
971
+ async function installHooks(localDir, agent = 'claude-code') {
972
+ const hooksDir = agent === 'codex' ? CODEX_HOOKS_DIR : CLAUDE_HOOKS_DIR;
973
+ await fs.mkdir(hooksDir, { recursive: true });
629
974
  await Promise.all(
630
975
  HOOK_NAMES.map(async (name) => {
631
976
  let content;
632
977
  if (localDir) {
633
978
  content = await fs.readFile(path.join(localDir, name), 'utf8');
634
979
  } else {
635
- const url = `${OACB_RAW_BASE}/${OACB_PINNED_REF}/baseline/claude-code/hooks/${name}`;
980
+ const url = `${OACB_RAW_BASE}/${OACB_PINNED_REF}/baseline/${agent}/hooks/${name}`;
636
981
  content = await api.getRaw(url);
637
982
  }
638
- await fs.writeFile(path.join(HOOKS_DIR, name), content, { mode: 0o755 });
983
+ await fs.writeFile(path.join(hooksDir, name), content, { mode: 0o755 });
639
984
  })
640
985
  );
986
+
987
+ // Codex hooks source a shared core that is not bundled per-agent.
988
+ // Install it alongside the agent hooks so OACB_SHARED_DIR can resolve locally.
989
+ if (agent === 'codex') {
990
+ let coreContent;
991
+ if (localDir) {
992
+ const sharedDir = process.env.OACB_SHARED_DIR
993
+ || path.resolve(localDir, '..', '..', 'shared');
994
+ coreContent = await fs.readFile(path.join(sharedDir, 'oacb-enforce-core.sh'), 'utf8');
995
+ } else {
996
+ const url = `${OACB_RAW_BASE}/${OACB_PINNED_REF}/baseline/shared/oacb-enforce-core.sh`;
997
+ coreContent = await api.getRaw(url);
998
+ }
999
+ await fs.writeFile(path.join(hooksDir, 'oacb-enforce-core.sh'), coreContent, { mode: 0o755 });
1000
+ }
641
1001
  }
642
1002
 
643
- // Build the OACB hook entries with ~/.claude/hooks/ paths (not the MDM paths)
644
- function buildOacbHookEntries(baseline) {
1003
+ // Build the OACB hook entries with agent-specific local paths.
1004
+ // extraEnv: optional object merged into each hook command's env (e.g. { OACB_EXPIRES: '2026-06-12' })
1005
+ function buildOacbHookEntries(baseline, extraEnv) {
645
1006
  const hooks = deepClone(baseline.hooks || {});
646
1007
  for (const entries of Object.values(hooks)) {
647
1008
  for (const entry of entries) {
648
1009
  for (const h of entry.hooks || []) {
649
1010
  if (h.command) {
650
- h.command = path.join(HOOKS_DIR, path.basename(h.command));
1011
+ h.command = path.join(CLAUDE_HOOKS_DIR, path.basename(h.command));
1012
+ }
1013
+ if (extraEnv && Object.keys(extraEnv).length > 0) {
1014
+ h.env = Object.assign({}, h.env || {}, extraEnv);
651
1015
  }
652
1016
  }
653
1017
  }
@@ -655,18 +1019,116 @@ function buildOacbHookEntries(baseline) {
655
1019
  return hooks;
656
1020
  }
657
1021
 
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.
1022
+ // ─── Codex config read/write ──────────────────────────────────────────────────
1023
+
1024
+ // Pure: merge OACB hook entries and policy scalars into an existing parsed TOML config.
1025
+ // Expands ~ in hook command paths to os.homedir().
1026
+ // Existing non-OACB hook entries are preserved.
1027
+ // extraEnv: optional object merged into each hook command's env (e.g. { OACB_EXPIRES: '2026-06-12' })
1028
+ function mergeCodexConfig(existing, oacbConfig, extraEnv) {
1029
+ const result = Object.assign({}, existing);
1030
+
1031
+ if (oacbConfig.approval_policy !== undefined) result.approval_policy = oacbConfig.approval_policy;
1032
+ if (oacbConfig.sandbox_mode !== undefined) result.sandbox_mode = oacbConfig.sandbox_mode;
1033
+
1034
+ if (oacbConfig.shell_environment_policy) {
1035
+ result.shell_environment_policy = Object.assign(
1036
+ {}, result.shell_environment_policy || {}, oacbConfig.shell_environment_policy
1037
+ );
1038
+ }
1039
+
1040
+ const oacbHookRe = /oacb-[^/\\]+\.sh/;
1041
+ result.hooks = Object.assign({}, result.hooks);
1042
+
1043
+ for (const [event, entries] of Object.entries(oacbConfig.hooks || {})) {
1044
+ // Strip any previously installed OACB entries for this event
1045
+ const userEntries = (result.hooks[event] || []).filter(e =>
1046
+ !(e.hooks || []).some(h => oacbHookRe.test(h.command || ''))
1047
+ );
1048
+ // Expand ~ in command paths and inject extraEnv
1049
+ const oacbEntries = entries.map(e => ({
1050
+ ...e,
1051
+ hooks: (e.hooks || []).map(h => {
1052
+ // eslint-disable-next-line no-unused-vars
1053
+ const { async: _async, ...rest } = h; // strip unsupported async field
1054
+ return {
1055
+ ...rest,
1056
+ command: typeof rest.command === 'string'
1057
+ ? rest.command.replace(/^~(?=\/|$)/, os.homedir())
1058
+ : rest.command,
1059
+ ...(extraEnv && Object.keys(extraEnv).length > 0
1060
+ ? { env: Object.assign({}, rest.env || {}, extraEnv) }
1061
+ : {}),
1062
+ };
1063
+ }),
1064
+ }));
1065
+ result.hooks[event] = [...userEntries, ...oacbEntries];
1066
+ }
1067
+
1068
+ return result;
1069
+ }
1070
+
1071
+ // Pure: strip OACB hook entries from a parsed Codex TOML config.
1072
+ // Policy scalars (approval_policy, sandbox_mode) are left in place —
1073
+ // they may have been set by the user independently.
1074
+ function stripOacbFromCodexConfig(existing) {
1075
+ const result = Object.assign({}, existing);
1076
+ const oacbHookRe = /oacb-[^/\\]+\.sh/;
1077
+
1078
+ if (result.hooks) {
1079
+ result.hooks = Object.assign({}, result.hooks);
1080
+ for (const event of Object.keys(result.hooks)) {
1081
+ if (!Array.isArray(result.hooks[event])) continue; // e.g. hooks.state
1082
+ result.hooks[event] = result.hooks[event].filter(e =>
1083
+ !(e.hooks || []).some(h => oacbHookRe.test(h.command || ''))
1084
+ );
1085
+ if (!result.hooks[event].length) delete result.hooks[event];
1086
+ }
1087
+ if (!Object.keys(result.hooks).length) delete result.hooks;
1088
+ }
1089
+
1090
+ return result;
1091
+ }
1092
+
1093
+ async function writeCodexConfig(oacbConfig, tier) {
1094
+ await fs.mkdir(path.dirname(CODEX_CONFIG_PATH), { recursive: true });
1095
+
1096
+ let existing = {};
1097
+ try {
1098
+ existing = TOML.parse(await fs.readFile(CODEX_CONFIG_PATH, 'utf8'));
1099
+ } catch (_) {}
1100
+
1101
+ const extraEnv = {
1102
+ OACB_SHARED_DIR: CODEX_HOOKS_DIR,
1103
+ ...(tier === 'receipts' ? { OACB_EXPIRES: computeExpiryDate(30) } : {}),
1104
+ };
1105
+ const merged = mergeCodexConfig(existing, oacbConfig, extraEnv);
1106
+ await fs.writeFile(CODEX_CONFIG_PATH, TOML.stringify(merged), { mode: 0o600 });
1107
+
1108
+ // Sidecar provenance — Codex ignores this file; OACB uses it for clean removal
1109
+ await fs.writeFile(CODEX_META_PATH, JSON.stringify({
1110
+ tier,
1111
+ appliedAt: new Date().toISOString(),
1112
+ ref: OACB_PINNED_REF,
1113
+ // Fields read by handleStatus (mirrors writeConsentReceipt schema)
1114
+ ts: new Date().toISOString(),
1115
+ oacb_version: PKG_VERSION,
1116
+ agent: 'codex',
1117
+ ...(tier === 'receipts' ? { expires: computeExpiryDate(30) } : {}),
1118
+ }, null, 2) + '\n', { mode: 0o600 });
1119
+
1120
+ return merged;
1121
+ }
1122
+
1123
+ // ─── Settings helpers (Claude Code) ──────────────────────────────────────────
1124
+
661
1125
  async function stripOacbFromSettings(settings) {
662
1126
  const contribution = settings._oacbMeta?.contribution;
663
1127
 
664
- // Legacy fallback: no contribution stored, restore from frozen backup
665
1128
  if (!contribution) {
666
1129
  try {
667
- return JSON.parse(await fs.readFile(SETTINGS_BACKUP_PATH, 'utf8'));
1130
+ return JSON.parse(await fs.readFile(CLAUDE_SETTINGS_BACKUP_PATH, 'utf8'));
668
1131
  } catch (_) {}
669
- // No backup either — strip hooks via regex and drop meta
670
1132
  const cleaned = deepClone(settings);
671
1133
  const oacbHookRe = /oacb-[^/\\]+\.sh$/;
672
1134
  if (cleaned.hooks) {
@@ -687,7 +1149,6 @@ async function stripOacbFromSettings(settings) {
687
1149
 
688
1150
  const cleaned = deepClone(settings);
689
1151
 
690
- // Remove permission list entries contributed by OACB
691
1152
  if (contribution.permissions && cleaned.permissions) {
692
1153
  for (const listKey of ['deny', 'ask', 'allow']) {
693
1154
  const contributed = new Set(contribution.permissions[listKey] || []);
@@ -705,7 +1166,6 @@ async function stripOacbFromSettings(settings) {
705
1166
  if (!Object.keys(cleaned.permissions).length) delete cleaned.permissions;
706
1167
  }
707
1168
 
708
- // Remove OACB hook entries by command path pattern
709
1169
  const oacbHookRe = /oacb-[^/\\]+\.sh$/;
710
1170
  if (cleaned.hooks) {
711
1171
  for (const event of Object.keys(cleaned.hooks)) {
@@ -720,7 +1180,6 @@ async function stripOacbFromSettings(settings) {
720
1180
  if (!Object.keys(cleaned.hooks).length) delete cleaned.hooks;
721
1181
  }
722
1182
 
723
- // Remove autoMode keys contributed by OACB (only if values still match what was applied)
724
1183
  if (contribution.autoMode && cleaned.autoMode) {
725
1184
  for (const k of Object.keys(contribution.autoMode)) {
726
1185
  if (JSON.stringify(cleaned.autoMode[k]) === JSON.stringify(contribution.autoMode[k])) {
@@ -734,27 +1193,20 @@ async function stripOacbFromSettings(settings) {
734
1193
  return cleaned;
735
1194
  }
736
1195
 
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 });
1196
+ async function writeSettingsOverlay(baseline, tier, extraEnv) {
1197
+ await fs.mkdir(path.dirname(CLAUDE_SETTINGS_PATH), { recursive: true });
743
1198
 
744
1199
  let existing = {};
745
- try { existing = JSON.parse(await fs.readFile(SETTINGS_PATH, 'utf8')); } catch (_) {}
1200
+ try { existing = JSON.parse(await fs.readFile(CLAUDE_SETTINGS_PATH, 'utf8')); } catch (_) {}
746
1201
 
747
1202
  if (existing._oacbMeta) {
748
- // Re-apply: strip previous OACB contribution, preserving user additions
749
1203
  existing = await stripOacbFromSettings(existing);
750
1204
  } else {
751
- // First apply: write backup for disaster recovery
752
- await fs.writeFile(SETTINGS_BACKUP_PATH, JSON.stringify(existing, null, 2) + '\n', { mode: 0o600 });
1205
+ await fs.writeFile(CLAUDE_SETTINGS_BACKUP_PATH, JSON.stringify(existing, null, 2) + '\n', { mode: 0o600 });
753
1206
  }
754
1207
 
755
1208
  const merged = deepClone(existing);
756
1209
 
757
- // permissions: union deny/ask arrays, set scalar keys
758
1210
  merged.permissions = merged.permissions || {};
759
1211
  const perm = baseline.permissions || {};
760
1212
  for (const listKey of ['deny', 'ask', 'allow']) {
@@ -768,19 +1220,16 @@ async function writeSettingsOverlay(baseline, tier) {
768
1220
  if (perm[scalarKey] !== undefined) merged.permissions[scalarKey] = perm[scalarKey];
769
1221
  }
770
1222
 
771
- // hooks: add OACB hook entries alongside existing entries (don't replace)
772
- const oacbHooks = buildOacbHookEntries(baseline);
1223
+ const oacbHooks = buildOacbHookEntries(baseline, extraEnv);
773
1224
  merged.hooks = merged.hooks || {};
774
1225
  for (const [event, entries] of Object.entries(oacbHooks)) {
775
1226
  merged.hooks[event] = [...(merged.hooks[event] || []), ...entries];
776
1227
  }
777
1228
 
778
- // autoMode: deep merge
779
1229
  if (baseline.autoMode) {
780
1230
  merged.autoMode = mergeOverrides(merged.autoMode || {}, baseline.autoMode);
781
1231
  }
782
1232
 
783
- // Tracking metadata — contribution snapshot enables clean stripping on next re-apply
784
1233
  merged._oacbMeta = {
785
1234
  tier,
786
1235
  appliedAt: new Date().toISOString(),
@@ -791,30 +1240,10 @@ async function writeSettingsOverlay(baseline, tier) {
791
1240
  },
792
1241
  };
793
1242
 
794
- await fs.writeFile(SETTINGS_PATH, JSON.stringify(merged, null, 2) + '\n', { mode: 0o600 });
1243
+ await fs.writeFile(CLAUDE_SETTINGS_PATH, JSON.stringify(merged, null, 2) + '\n', { mode: 0o600 });
795
1244
  return merged;
796
1245
  }
797
1246
 
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
1247
  // ─── Conformance runner ───────────────────────────────────────────────────────
819
1248
 
820
1249
  function runConformanceCase(testCase, hookBin, tier) {
@@ -879,10 +1308,21 @@ function computeDeepDiff(a, b) {
879
1308
 
880
1309
  // ─── Pure utilities ───────────────────────────────────────────────────────────
881
1310
 
1311
+ // Returns a YYYY-MM-DD string for today + daysOffset days (UTC).
1312
+ function computeExpiryDate(daysOffset) {
1313
+ const d = new Date();
1314
+ d.setUTCDate(d.getUTCDate() + daysOffset);
1315
+ return d.toISOString().slice(0, 10);
1316
+ }
1317
+
882
1318
  function validateTier(t) {
883
1319
  if (!TIERS.includes(t)) throw new Error(`Invalid tier '${t}'. Expected one of: ${TIERS.join(', ')}`);
884
1320
  }
885
1321
 
1322
+ function validateAgent(a) {
1323
+ if (!AGENTS.includes(a)) throw new Error(`Invalid agent '${a}'. Expected one of: ${AGENTS.join(', ')}`);
1324
+ }
1325
+
886
1326
  function mergeOverrides(base, overrides) {
887
1327
  const result = deepClone(base);
888
1328
  for (const [k, v] of Object.entries(overrides)) {
@@ -913,13 +1353,416 @@ function isVersionSupported(v) {
913
1353
  return true;
914
1354
  }
915
1355
 
1356
+ // ─── Rule registry ────────────────────────────────────────────────────────────
1357
+
1358
+ const RULE_REGISTRY = [
1359
+ { id: 'OACB-OBF-001', risk: 'critical', description: 'Backslash-escaped binary (deny-evasion pattern)', mitre: 'T1027' },
1360
+ { id: 'OACB-OBF-002', risk: 'critical', description: 'base64-decode piped to shell (exfil / RCE pattern)', mitre: 'T1059' },
1361
+ { id: 'OACB-OBF-003', risk: 'critical', description: 'Process-substitution exec of remote content', mitre: 'T1059.004' },
1362
+ { id: 'OACB-OBF-004', risk: 'critical', description: '/proc/self/root path traversal (sandbox-bypass)', mitre: 'T1055' },
1363
+ { id: 'OACB-OBF-005', risk: 'critical', description: 'command-substitution resolves to dangerous binary (Flatt-class)', mitre: 'T1027' },
1364
+ { id: 'OACB-OBF-006', risk: 'critical', description: 'Variable-substitution default resolves to dangerous binary', mitre: 'T1027' },
1365
+ { id: 'OACB-COMPOUND-001', risk: 'medium', description: 'Compound command exceeds 10 separators (Adversa CVE class)', mitre: 'T1036' },
1366
+ { id: 'OACB-RM-001', risk: 'critical', description: 'Destructive rm against home/root/system path', mitre: 'T1485' },
1367
+ { id: 'OACB-RM-002', risk: 'critical', description: 'Destructive rm with glob against home/root wildcard', mitre: 'T1485' },
1368
+ { id: 'OACB-RM-003', risk: 'critical', description: 'find -delete against home/root', mitre: 'T1485' },
1369
+ { id: 'OACB-RM-004', risk: 'critical', description: 'find -exec rm against home/root', mitre: 'T1485' },
1370
+ { id: 'OACB-RM-005', risk: 'critical', description: 'dd to block device (disk-wipe class)', mitre: 'T1485' },
1371
+ { id: 'OACB-RM-006', risk: 'critical', description: 'mkfs against block device (disk-format class)', mitre: 'T1485' },
1372
+ { id: 'OACB-GIT-001', risk: 'critical', description: 'Force-push to main/master/release/prod branch', mitre: 'T1565' },
1373
+ { id: 'OACB-TF-001', risk: 'critical', description: 'terraform destroy (irreversible operation)', mitre: 'T1485' },
1374
+ { id: 'OACB-TF-002', risk: 'high', description: 'terraform apply --auto-approve in autonomous mode', mitre: 'T1072' },
1375
+ { id: 'OACB-TF-003', risk: 'high', description: 'terraform state rm (silent state divergence risk)', mitre: 'T1565' },
1376
+ { id: 'OACB-DB-001', risk: 'high', description: 'drizzle-kit push --force against any database', mitre: 'T1485' },
1377
+ { id: 'OACB-DB-002', risk: 'critical', description: 'prisma migrate reset drops all tables', mitre: 'T1485' },
1378
+ { id: 'OACB-DB-003', risk: 'high', description: 'alembic downgrade without human approval', mitre: 'T1485' },
1379
+ { id: 'OACB-NET-001', risk: 'critical', description: 'Remote-to-shell pipe (RCE vector)', mitre: 'T1059' },
1380
+ { id: 'OACB-NET-002', risk: 'high', description: 'netcat/socat/telnet invocation (network egress / reverse-shell)', mitre: 'T1095' },
1381
+ { id: 'OACB-NET-003', risk: 'critical', description: 'bash /dev/tcp reverse-shell pattern', mitre: 'T1059.004' },
1382
+ { id: 'OACB-HIST-001', risk: 'critical', description: 'history -s/-a manipulation (denylist-bypass primitive)', mitre: 'T1562' },
1383
+ { id: 'OACB-EVAL-001', risk: 'critical', description: 'eval prohibited at OACB baseline', mitre: 'T1059' },
1384
+ { id: 'OACB-EXFIL-001', risk: 'high', description: 'Environment enumeration for credentials', mitre: 'T1552' },
1385
+ ];
1386
+
1387
+ // Static MITRE lookup by rule prefix
1388
+ const MITRE_MAP = {
1389
+ 'OACB-OBF': { id: 'T1027', name: 'Obfuscated Files or Information' },
1390
+ 'OACB-RM': { id: 'T1485', name: 'Data Destruction' },
1391
+ 'OACB-GIT': { id: 'T1565', name: 'Data Manipulation' },
1392
+ 'OACB-TF': { id: 'T1072', name: 'Software Deployment Tools / T1485 Data Destruction' },
1393
+ 'OACB-DB': { id: 'T1485', name: 'Data Destruction' },
1394
+ 'OACB-NET': { id: 'T1059', name: 'Command and Scripting Interpreter' },
1395
+ 'OACB-HIST':{ id: 'T1562', name: 'Impair Defenses' },
1396
+ 'OACB-EVAL':{ id: 'T1059', name: 'Command and Scripting Interpreter' },
1397
+ 'OACB-EXFIL':{ id: 'T1552', name: 'Unsecured Credentials' },
1398
+ 'OACB-COMPOUND':{ id: 'T1036', name: 'Masquerading' },
1399
+ };
1400
+
1401
+ function mitreForRule(ruleId) {
1402
+ for (const [prefix, info] of Object.entries(MITRE_MAP)) {
1403
+ if (ruleId.startsWith(prefix)) return info;
1404
+ }
1405
+ return { id: 'unknown', name: 'unknown' };
1406
+ }
1407
+
1408
+ // ─── printTierMatrix ─────────────────────────────────────────────────────────
1409
+
1410
+ function printTierMatrix(selectedTier) {
1411
+ const tiers = ['shadow', 'receipts', 'baseline', 'strict', 'paranoid'];
1412
+ const risks = ['low', 'medium', 'high', 'critical'];
1413
+ const actionLabel = { allow: 'AUDIT', warn: 'WARN ', ask: 'ASK ', block: 'BLOCK' };
1414
+ const col = 9;
1415
+ const lines = [
1416
+ '',
1417
+ ' Policy matrix for this tier (> = selected):',
1418
+ ` ${''.padEnd(14)}${risks.map(r => r.toUpperCase().padEnd(col)).join('')}`,
1419
+ ` ${''.padEnd(14)}${risks.map(() => '─'.repeat(col - 1).padEnd(col)).join('')}`,
1420
+ ];
1421
+ for (const tier of tiers) {
1422
+ const marker = tier === selectedTier ? '> ' : ' ';
1423
+ const row = risks.map(risk => actionLabel[_simulateTierMatrix(tier, risk)].padEnd(col)).join('');
1424
+ lines.push(`${marker}${tier.padEnd(12)} ${row}`);
1425
+ }
1426
+ lines.push('');
1427
+ process.stderr.write(lines.join('\n') + '\n');
1428
+ }
1429
+
1430
+ // ─── historyDryRun ───────────────────────────────────────────────────────────
1431
+
1432
+ async function historyDryRun(tier, agent) {
1433
+ const commands = [];
1434
+
1435
+ // Try Claude Code session logs first (most accurate for auto-mode context)
1436
+ const ccProjectsDir = path.join(os.homedir(), '.claude', 'projects');
1437
+ try {
1438
+ const projectDirs = await fs.readdir(ccProjectsDir);
1439
+ for (const proj of projectDirs.slice(-5)) { // last 5 projects to bound cost
1440
+ const sessDir = path.join(ccProjectsDir, proj, 'sessions');
1441
+ let sessFiles;
1442
+ try { sessFiles = await fs.readdir(sessDir); } catch (_) { continue; }
1443
+ for (const f of sessFiles.slice(-3)) { // last 3 sessions per project
1444
+ try {
1445
+ const filePath = path.join(sessDir, f);
1446
+ const stat = await fs.stat(filePath);
1447
+ if (stat.size > 10 * 1024 * 1024) continue; // skip files > 10MB
1448
+ const content = await fs.readFile(filePath, 'utf8');
1449
+ for (const line of content.trim().split('\n')) {
1450
+ try {
1451
+ const entry = JSON.parse(line);
1452
+ const cmd = entry?.tool_input?.command || entry?.content?.[0]?.input?.command;
1453
+ if (cmd && typeof cmd === 'string') commands.push(cmd.trim());
1454
+ } catch (_) { /* skip malformed lines */ }
1455
+ }
1456
+ } catch (_) { /* skip unreadable files */ }
1457
+ }
1458
+ }
1459
+ } catch (_) { /* no CC session logs — fall through */ }
1460
+
1461
+ // Fallback: shell history (last 30 days by line, capped at 500)
1462
+ if (commands.length === 0) {
1463
+ const histFile = process.env.HISTFILE ||
1464
+ path.join(os.homedir(), process.env.SHELL?.includes('zsh') ? '.zsh_history' : '.bash_history');
1465
+ try {
1466
+ const histStat = await fs.stat(histFile);
1467
+ if (histStat.size > 10 * 1024 * 1024) {
1468
+ output.info(' History dry-run: shell history file exceeds 10MB — skipping.');
1469
+ return;
1470
+ }
1471
+ const raw = await fs.readFile(histFile, 'utf8');
1472
+ for (const line of raw.split('\n')) {
1473
+ // Strip zsh extended-history prefix: ": <ts>:<elapsed>;<cmd>"
1474
+ const stripped = line.replace(/^: \d+:\d+;/, '').trim();
1475
+ if (stripped && !stripped.startsWith('#')) commands.push(stripped);
1476
+ }
1477
+ } catch (_) { /* no shell history available */ }
1478
+ }
1479
+
1480
+ if (commands.length === 0) {
1481
+ output.info(' History dry-run: no command history found — skipping.');
1482
+ return;
1483
+ }
1484
+
1485
+ const sample = commands.slice(-500); // cap at 500 most recent
1486
+ const results = { block: [], warn: [], ask: [], allow: [] };
1487
+ for (const cmd of sample) {
1488
+ const action = simulateTierAction(cmd, tier);
1489
+ results[action].push(cmd);
1490
+ }
1491
+
1492
+ output.info(`\n History dry-run: ${sample.length} commands sampled (agent=${agent}, tier=${tier})`);
1493
+ output.info(` allow ${results.allow.length} warn ${results.warn.length} ask ${results.ask.length} block ${results.block.length}`);
1494
+
1495
+ const notable = [...results.block.slice(0, 3), ...results.warn.slice(0, 2)];
1496
+ if (notable.length > 0) {
1497
+ output.info(' Commands that would be affected:');
1498
+ for (const cmd of notable) {
1499
+ const action = simulateTierAction(cmd, tier);
1500
+ output.info(` [${action.toUpperCase().padEnd(5)}] ${cmd.slice(0, 80)}`);
1501
+ }
1502
+ }
1503
+ output.info('');
1504
+ }
1505
+
1506
+ // ─── simulateTierAction ───────────────────────────────────────────────────────
1507
+ // JS reimplementation of the tier matrix for common rule patterns.
1508
+ // Exported for unit testing and history dry-run.
1509
+
1510
+ function simulateTierAction(cmd, tier) {
1511
+ const risk = _simulateClassifyRisk(cmd);
1512
+ return _simulateTierMatrix(tier, risk);
1513
+ }
1514
+
1515
+ function _simulateClassifyRisk(cmd) {
1516
+ const c = cmd.trim();
1517
+ // Critical patterns
1518
+ if (/\brm\s+(-[rRfF]*[rR][rRfF]*|-r\s+-f|-f\s+-r|--recursive.*--force|--force.*--recursive)\b/.test(c) &&
1519
+ /\s(\/|~|\$HOME|\$\{HOME\}|\/etc\/|\/var\/|\/usr\/)/.test(c)) return 'critical';
1520
+ if (/\bterraform\s+destroy\b/.test(c)) return 'critical';
1521
+ if (/\bgit\s+push\s+.*(--force|-f)\b.*\b(main|master|release|prod)/.test(c)) return 'critical';
1522
+ if (/\bprisma\s+migrate\s+reset\b/.test(c)) return 'critical';
1523
+ if (/\beval\s+/.test(c)) return 'critical';
1524
+ if (/\b(bash|sh|zsh)\b.*\/dev\/tcp\//.test(c)) return 'critical';
1525
+ if (/\bbase64\b.*\|\s*(bash|sh|zsh)\b/.test(c)) return 'critical';
1526
+ if (/(curl|wget)\s+.*\|\s*(bash|sh|zsh)\b/.test(c)) return 'critical';
1527
+ // High patterns
1528
+ if (/\bterraform\s+apply\b.*--auto-approve\b/.test(c)) return 'high';
1529
+ if (/\bterraform\s+state\s+rm\b/.test(c)) return 'high';
1530
+ if (/\bdrizzle-kit\s+push\b.*--force\b/.test(c)) return 'high';
1531
+ if (/\balembic\s+downgrade\b/.test(c)) return 'high';
1532
+ if (/\b(nc|ncat|socat)\s+/.test(c)) return 'high';
1533
+ // Medium patterns
1534
+ const sepCount = (c.match(/(&&|\|\||;)/g) || []).length;
1535
+ if (sepCount > 10) return 'medium';
1536
+ // Default: low
1537
+ return 'low';
1538
+ }
1539
+
1540
+ function _simulateTierMatrix(tier, risk) {
1541
+ const key = `${tier}:${risk}`;
1542
+ const matrix = {
1543
+ 'shadow:low': 'allow', 'shadow:medium': 'allow', 'shadow:high': 'allow', 'shadow:critical': 'allow',
1544
+ 'receipts:low': 'allow', 'receipts:medium': 'warn', 'receipts:high': 'warn', 'receipts:critical': 'warn',
1545
+ 'baseline:low': 'allow', 'baseline:medium': 'allow', 'baseline:high': 'block', 'baseline:critical': 'block',
1546
+ 'strict:low': 'allow', 'strict:medium': 'warn', 'strict:high': 'block', 'strict:critical': 'block',
1547
+ 'paranoid:low': 'allow', 'paranoid:medium': 'block', 'paranoid:high': 'block', 'paranoid:critical': 'block',
1548
+ };
1549
+ return matrix[key] || 'block';
1550
+ }
1551
+
1552
+ // ─── Consent receipt ──────────────────────────────────────────────────────────
1553
+
1554
+ async function writeConsentReceipt(tier, agent) {
1555
+ const userInfo = os.userInfo();
1556
+ const hostname = os.hostname();
1557
+ const machineId = crypto
1558
+ .createHash('sha256')
1559
+ .update(`${hostname}:${userInfo.username}`)
1560
+ .digest('hex')
1561
+ .slice(0, 16);
1562
+
1563
+ const receipt = {
1564
+ ts: new Date().toISOString(),
1565
+ username: userInfo.username,
1566
+ hostname,
1567
+ tier,
1568
+ agent,
1569
+ oacb_version: PKG_VERSION,
1570
+ machine_id: machineId,
1571
+ ...(tier === 'receipts' ? { expires: computeExpiryDate(30) } : {}),
1572
+ };
1573
+
1574
+ const receiptPath = agent === 'codex' ? CODEX_CONSENT_RECEIPT_PATH : CONSENT_RECEIPT_PATH;
1575
+ await fs.mkdir(path.dirname(receiptPath), { recursive: true });
1576
+ await fs.writeFile(receiptPath, JSON.stringify(receipt, null, 2) + '\n', { mode: 0o600 });
1577
+
1578
+ // Fire-and-forget POST to gateway if OACB_GATEWAY_URL is set.
1579
+ // globalThis.fetch is available on Node 18+; no import needed.
1580
+ if (process.env.OACB_GATEWAY_URL && typeof globalThis.fetch === 'function') {
1581
+ globalThis.fetch(process.env.OACB_GATEWAY_URL + '/consent', {
1582
+ method: 'POST',
1583
+ body: JSON.stringify(receipt),
1584
+ headers: { 'Content-Type': 'application/json' },
1585
+ }).catch(() => {});
1586
+ }
1587
+
1588
+ return receipt;
1589
+ }
1590
+
1591
+ async function readConsentReceipt() {
1592
+ try {
1593
+ return JSON.parse(await fs.readFile(CONSENT_RECEIPT_PATH, 'utf8'));
1594
+ } catch (_) {
1595
+ return null;
1596
+ }
1597
+ }
1598
+
1599
+ // ─── handleWhy ───────────────────────────────────────────────────────────────
1600
+
1601
+ async function handleWhy({ agent }) {
1602
+ const logPath = agent === 'codex'
1603
+ ? path.join(os.homedir(), '.codex', 'hooks', 'oacb-audit.log')
1604
+ : path.join(os.homedir(), '.claude', 'hooks', 'oacb-audit.log');
1605
+
1606
+ let entry = null;
1607
+ try {
1608
+ const content = await fs.readFile(logPath, 'utf8');
1609
+ const lines = content.trim().split('\n').filter(Boolean);
1610
+ // Scan backwards: skip abort-only entries (engineer_aborted=true with no cmd)
1611
+ // so we land on the meaningful warn/block that preceded it.
1612
+ for (let i = lines.length - 1; i >= 0; i--) {
1613
+ try {
1614
+ const candidate = JSON.parse(lines[i]);
1615
+ if (candidate.engineer_aborted && !candidate.cmd) continue;
1616
+ entry = candidate;
1617
+ break;
1618
+ } catch (_) { /* skip malformed lines */ }
1619
+ }
1620
+ } catch (_) { /* file not found or unreadable */ }
1621
+
1622
+ if (!entry) {
1623
+ output.info('No recent events found.');
1624
+ return;
1625
+ }
1626
+
1627
+ const mitre = mitreForRule(entry.rule || '');
1628
+ const decisionLabel = (entry.decision || 'allow').toUpperCase();
1629
+
1630
+ process.stdout.write([
1631
+ `Last event: ${decisionLabel} (${entry.ts || 'unknown'})`,
1632
+ `Rule: ${entry.rule || 'unknown'}`,
1633
+ `Risk: ${entry.risk || 'unknown'}`,
1634
+ `Command: ${(entry.cmd || '').slice(0, 80)}`,
1635
+ `Reason: ${entry.reason || 'unknown'}`,
1636
+ '',
1637
+ `MITRE: ${mitre.id} — ${mitre.name}`,
1638
+ '',
1639
+ (entry.decision === 'deny' || entry.decision === 'block')
1640
+ ? 'This command was blocked by OACB enforcement. To allow it, remove OACB or escalate to a human approver.'
1641
+ : entry.decision === 'warn'
1642
+ ? 'This command was allowed with a warning. Engineer was notified.'
1643
+ : 'This command was allowed.',
1644
+ '',
1645
+ ].join('\n'));
1646
+ }
1647
+
1648
+ // ─── handleRules ─────────────────────────────────────────────────────────────
1649
+
1650
+ async function handleRules({ risk, search }) {
1651
+ let rules = RULE_REGISTRY;
1652
+
1653
+ if (risk) {
1654
+ rules = rules.filter(r => r.risk === risk);
1655
+ }
1656
+ if (search) {
1657
+ const term = search.toLowerCase();
1658
+ rules = rules.filter(r =>
1659
+ r.id.toLowerCase().includes(term) ||
1660
+ r.description.toLowerCase().includes(term)
1661
+ );
1662
+ }
1663
+
1664
+ if (rules.length === 0) {
1665
+ output.info('No rules match the given filters.');
1666
+ return;
1667
+ }
1668
+
1669
+ output.table(
1670
+ rules.map(r => ({ id: r.id, risk: r.risk, mitre: r.mitre, desc: r.description })),
1671
+ [
1672
+ { key: 'id', header: 'Rule ID' },
1673
+ { key: 'risk', header: 'Risk' },
1674
+ { key: 'mitre', header: 'MITRE' },
1675
+ { key: 'desc', header: 'Description' },
1676
+ ]
1677
+ );
1678
+ }
1679
+
1680
+ // ─── handleStatus ─────────────────────────────────────────────────────────────
1681
+
1682
+ async function handleStatus({ agent }) {
1683
+ let receipt;
1684
+ if (agent === 'codex') {
1685
+ try { receipt = JSON.parse(await fs.readFile(CODEX_CONSENT_RECEIPT_PATH, 'utf8')); } catch (_) { receipt = null; }
1686
+ } else {
1687
+ receipt = await readConsentReceipt();
1688
+ }
1689
+
1690
+ if (!receipt) {
1691
+ output.warn('OACB does not appear to be installed — run `unbound oacb apply`');
1692
+ return;
1693
+ }
1694
+
1695
+ const logPath = agent === 'codex'
1696
+ ? path.join(os.homedir(), '.codex', 'hooks', 'oacb-audit.log')
1697
+ : path.join(os.homedir(), '.claude', 'hooks', 'oacb-audit.log');
1698
+
1699
+ // Expiry countdown
1700
+ let expiryNote = '';
1701
+ if (receipt.tier === 'receipts' && receipt.expires) {
1702
+ const exp = new Date(receipt.expires);
1703
+ const now = new Date();
1704
+ const daysLeft = Math.ceil((exp - now) / (1000 * 60 * 60 * 24));
1705
+ expiryNote = daysLeft > 0
1706
+ ? ` (expires ${receipt.expires} — ${daysLeft} days remaining)`
1707
+ : ` (EXPIRED ${receipt.expires} — run \`unbound oacb apply\` to renew)`;
1708
+ }
1709
+
1710
+ // Audit log stats — last 7 days
1711
+ const counts = { allow: 0, warn: 0, block: 0, deny: 0, aborted: 0 };
1712
+ const ruleHits = {};
1713
+ const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
1714
+ let logSize = 'not found';
1715
+ let mostHitRule = 'none';
1716
+
1717
+ try {
1718
+ const stat = await fs.stat(logPath);
1719
+ logSize = `${(stat.size / 1024).toFixed(1)} KB`;
1720
+ const content = await fs.readFile(logPath, 'utf8');
1721
+ for (const line of content.trim().split('\n').filter(Boolean)) {
1722
+ try {
1723
+ const e = JSON.parse(line);
1724
+ const entryTime = new Date(e.ts);
1725
+ if (entryTime < sevenDaysAgo) continue;
1726
+ const dec = e.engineer_aborted ? 'aborted' : (e.decision || 'allow');
1727
+ counts[dec] = (counts[dec] || 0) + 1;
1728
+ if (e.rule && e.rule !== 'OACB-DEFAULT-ALLOW' && e.rule !== 'OACB-SHADOW') {
1729
+ ruleHits[e.rule] = (ruleHits[e.rule] || 0) + 1;
1730
+ }
1731
+ } catch (_) {}
1732
+ }
1733
+ // Find most hit rule
1734
+ const topEntry = Object.entries(ruleHits).sort((a, b) => b[1] - a[1])[0];
1735
+ if (topEntry) {
1736
+ const ruleInfo = RULE_REGISTRY.find(r => r.id === topEntry[0]);
1737
+ mostHitRule = ruleInfo
1738
+ ? `${topEntry[0]} (${topEntry[1]}x) — ${ruleInfo.description}`
1739
+ : `${topEntry[0]} (${topEntry[1]}x)`;
1740
+ }
1741
+ } catch (_) {}
1742
+
1743
+ output.keyValue([
1744
+ ['Tier', `${receipt.tier}${expiryNote}`],
1745
+ ['Agent', receipt.agent || agent],
1746
+ ['Installed', receipt.ts || 'unknown'],
1747
+ ['OACB version', receipt.oacb_version || 'unknown'],
1748
+ ['Audit log', `${logPath} (${logSize})`],
1749
+ ['─── Last 7 days ───', ''],
1750
+ ['allow', String(counts.allow)],
1751
+ ['warn', String(counts.warn)],
1752
+ ['block/deny', String((counts.block || 0) + (counts.deny || 0))],
1753
+ ['aborted', String(counts.aborted)],
1754
+ ['Most hit rule', mostHitRule],
1755
+ ]);
1756
+ }
1757
+
916
1758
  module.exports = {
917
1759
  register,
918
- // exported for unit tests only — not part of the public API
919
1760
  __test__: {
920
1761
  validateTier,
1762
+ validateAgent,
921
1763
  isVersionSupported,
922
1764
  computeGaps,
1765
+ computeCodexGaps,
923
1766
  mergeOverrides,
924
1767
  computeDeepDiff,
925
1768
  buildOacbHookEntries,
@@ -927,5 +1770,14 @@ module.exports = {
927
1770
  classifyRule,
928
1771
  hasOacbHook,
929
1772
  runConformanceCase,
1773
+ mergeCodexConfig,
1774
+ stripOacbFromCodexConfig,
1775
+ simulateTierAction,
1776
+ historyDryRun,
1777
+ printTierMatrix,
1778
+ writeConsentReceipt,
1779
+ handleWhy,
1780
+ handleStatus,
1781
+ RULE_REGISTRY,
930
1782
  },
931
1783
  };