unbound-cli 0.9.1 → 0.9.2
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 +2 -1
- package/src/commands/oacb.js +1049 -197
- package/test/oacb.test.js +439 -2
package/src/commands/oacb.js
CHANGED
|
@@ -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.
|
|
13
|
-
const
|
|
14
|
-
const
|
|
15
|
-
const
|
|
16
|
-
|
|
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
|
|
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
|
|
33
|
-
unbound oacb apply
|
|
34
|
-
unbound oacb apply --tier
|
|
35
|
-
unbound oacb apply --tier
|
|
36
|
-
unbound oacb apply --tier
|
|
37
|
-
unbound oacb apply --tier
|
|
38
|
-
unbound oacb
|
|
39
|
-
unbound oacb
|
|
40
|
-
unbound oacb
|
|
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
|
|
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
|
|
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
|
|
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', '
|
|
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
|
|
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 (_) {
|
|
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(
|
|
181
|
+
try { await fs.access(CLAUDE_SETTINGS_PATH); claudeSettingsExist = true; } catch (_) {}
|
|
118
182
|
|
|
119
|
-
|
|
183
|
+
let codexConfigExists = false;
|
|
184
|
+
try { await fs.access(CODEX_CONFIG_PATH); codexConfigExists = true; } catch (_) {}
|
|
120
185
|
|
|
121
|
-
const
|
|
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(
|
|
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
|
|
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',
|
|
134
|
-
['Hooks installed', `${
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
164
|
-
|
|
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 (
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
201
|
-
|
|
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
|
|
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
|
|
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 (
|
|
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 ${
|
|
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
|
|
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 ${
|
|
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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
|
|
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
|
-
|
|
286
|
-
const enforcePath = path.join(
|
|
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
|
-
|
|
306
|
-
|
|
307
|
-
!c.
|
|
308
|
-
|
|
309
|
-
c.
|
|
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
|
|
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(
|
|
636
|
+
settings = JSON.parse(await fs.readFile(CLAUDE_SETTINGS_PATH, 'utf8'));
|
|
393
637
|
} catch (_) {
|
|
394
|
-
output.warn(`${
|
|
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 ${
|
|
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(
|
|
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(
|
|
491
|
-
output.success(`Wrote cleaned ${
|
|
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
|
-
|
|
498
|
-
|
|
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(
|
|
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(
|
|
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 =
|
|
628
|
-
|
|
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/
|
|
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(
|
|
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
|
|
644
|
-
|
|
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(
|
|
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
|
-
//
|
|
659
|
-
|
|
660
|
-
//
|
|
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(
|
|
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
|
-
|
|
738
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
};
|