unbound-cli 1.1.3 → 1.1.6

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unbound-cli",
3
- "version": "1.1.3",
3
+ "version": "1.1.6",
4
4
  "description": "CLI tool for Unbound - AI Gateway management",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -12,8 +12,8 @@ const output = require('../output');
12
12
  const crypto = require('node:crypto');
13
13
 
14
14
  const OACB_RAW_BASE = 'https://raw.githubusercontent.com/websentry-ai/oacb';
15
- const OACB_PINNED_REF = 'v0.2.0';
16
- const PKG_VERSION = '0.2.0';
15
+ const OACB_PINNED_REF = 'v0.2.1';
16
+ const PKG_VERSION = '0.2.1';
17
17
  const TIERS = ['shadow', 'receipts', 'baseline', 'strict', 'paranoid'];
18
18
  const AGENTS = ['claude-code', 'codex'];
19
19
 
@@ -334,7 +334,10 @@ async function handleApply({ agent: agentFlag, tier, overrides, dryRun, previewO
334
334
  } else {
335
335
  let effective = deepClone(baseline);
336
336
  if (overrides) effective = mergeOverrides(effective, JSON.parse(await fs.readFile(overrides, 'utf8')));
337
- const oacbHooks = buildOacbHookEntries(effective);
337
+ // Mirror the real apply path so the printed policy matches what
338
+ // would actually land in settings.json.
339
+ const extraEnv = { OACB_SHARED_DIR: CLAUDE_HOOKS_DIR };
340
+ const oacbHooks = buildOacbHookEntries(effective, extraEnv);
338
341
  process.stdout.write(JSON.stringify({
339
342
  _oacbMeta: { tier, appliedAt: '<now>', ref: OACB_PINNED_REF },
340
343
  permissions: effective.permissions,
@@ -360,7 +363,8 @@ async function handleApply({ agent: agentFlag, tier, overrides, dryRun, previewO
360
363
  let effective = deepClone(baseline);
361
364
  if (overrides) effective = mergeOverrides(effective, JSON.parse(await fs.readFile(overrides, 'utf8')));
362
365
  output.info(`--dry-run: showing what would be merged into ${CLAUDE_SETTINGS_PATH}`);
363
- const oacbHooks = buildOacbHookEntries(effective);
366
+ const extraEnv = { OACB_SHARED_DIR: CLAUDE_HOOKS_DIR };
367
+ const oacbHooks = buildOacbHookEntries(effective, extraEnv);
364
368
  process.stdout.write(JSON.stringify({
365
369
  _oacbMeta: { tier, appliedAt: '<now>', ref: OACB_PINNED_REF },
366
370
  permissions: effective.permissions,
@@ -443,9 +447,13 @@ async function _applyClaudeCode({ baseline, tier, overrides, localHooks }) {
443
447
  }
444
448
 
445
449
  const expires = tier === 'receipts' ? computeExpiryDate(30) : null;
450
+ const extraEnv = {
451
+ OACB_SHARED_DIR: CLAUDE_HOOKS_DIR,
452
+ ...(expires ? { OACB_EXPIRES: expires } : {}),
453
+ };
446
454
  const settingsSpin = output.spinner(`Merging OACB settings into ${CLAUDE_SETTINGS_PATH}...`);
447
455
  try {
448
- await writeSettingsOverlay(effective, tier, expires ? { OACB_EXPIRES: expires } : undefined);
456
+ await writeSettingsOverlay(effective, tier, extraEnv);
449
457
  settingsSpin.succeed(`Updated ${CLAUDE_SETTINGS_PATH} (backup at ${CLAUDE_SETTINGS_BACKUP_PATH})`);
450
458
  } catch (e) {
451
459
  settingsSpin.fail(`Settings write failed: ${e.message}`);
@@ -663,20 +671,44 @@ async function handleRemove({ agent, dryRun }) {
663
671
 
664
672
  const cleaned = deepClone(settings);
665
673
 
674
+ // Read the pre-apply backup, if present. We use it to avoid stripping
675
+ // user rules that happened to overlap with OACB's contribution — without
676
+ // it, a user who had `Bash(git status *)` before apply would lose it
677
+ // after remove, because the same string is in OACB's allowlist.
678
+ let preState = { deny: new Set(), ask: new Set(), allow: new Set() };
679
+ try {
680
+ const backup = JSON.parse(await fs.readFile(CLAUDE_SETTINGS_BACKUP_PATH, 'utf8'));
681
+ preState = {
682
+ deny: new Set(backup.permissions?.deny || []),
683
+ ask: new Set(backup.permissions?.ask || []),
684
+ allow: new Set(backup.permissions?.allow || []),
685
+ };
686
+ } catch (_) {
687
+ // No backup → current (less-careful) behavior is the only option.
688
+ }
689
+
666
690
  if (contribution.permissions && cleaned.permissions) {
667
691
  const contributedDeny = new Set(contribution.permissions.deny || []);
668
692
  const contributedAsk = new Set(contribution.permissions.ask || []);
669
693
  const contributedAllow = new Set(contribution.permissions.allow || []);
694
+ // Strip only rules that are BOTH in OACB's contribution AND NOT in the
695
+ // user's pre-apply backup. Rules the user had before are preserved.
670
696
  if (cleaned.permissions.deny) {
671
- cleaned.permissions.deny = cleaned.permissions.deny.filter(r => !contributedDeny.has(r));
697
+ cleaned.permissions.deny = cleaned.permissions.deny.filter(
698
+ r => !contributedDeny.has(r) || preState.deny.has(r)
699
+ );
672
700
  if (!cleaned.permissions.deny.length) delete cleaned.permissions.deny;
673
701
  }
674
702
  if (cleaned.permissions.ask) {
675
- cleaned.permissions.ask = cleaned.permissions.ask.filter(r => !contributedAsk.has(r));
703
+ cleaned.permissions.ask = cleaned.permissions.ask.filter(
704
+ r => !contributedAsk.has(r) || preState.ask.has(r)
705
+ );
676
706
  if (!cleaned.permissions.ask.length) delete cleaned.permissions.ask;
677
707
  }
678
708
  if (cleaned.permissions.allow) {
679
- cleaned.permissions.allow = cleaned.permissions.allow.filter(r => !contributedAllow.has(r));
709
+ cleaned.permissions.allow = cleaned.permissions.allow.filter(
710
+ r => !contributedAllow.has(r) || preState.allow.has(r)
711
+ );
680
712
  if (!cleaned.permissions.allow.length) delete cleaned.permissions.allow;
681
713
  }
682
714
 
@@ -719,9 +751,10 @@ async function handleRemove({ agent, dryRun }) {
719
751
  output.info('--dry-run: showing cleaned settings.json (not written)');
720
752
  process.stdout.write(JSON.stringify(cleaned, null, 2) + '\n');
721
753
  output.info('Hook scripts that would be deleted:');
722
- for (const name of HOOK_NAMES) {
754
+ for (const name of REMOVABLE_HOOK_FILES) {
723
755
  process.stdout.write(` ${path.join(CLAUDE_HOOKS_DIR, name)}\n`);
724
756
  }
757
+ output.info(`Backup file that would be deleted: ${CLAUDE_SETTINGS_BACKUP_PATH}`);
725
758
  return;
726
759
  }
727
760
 
@@ -729,6 +762,9 @@ async function handleRemove({ agent, dryRun }) {
729
762
  output.success(`Wrote cleaned ${CLAUDE_SETTINGS_PATH}`);
730
763
 
731
764
  try { await fs.unlink(CONSENT_RECEIPT_PATH); } catch (_) {}
765
+ // The backup is no longer load-bearing after a successful remove — clean it
766
+ // up so the user's ~/.claude/ doesn't accumulate stale OACB state.
767
+ try { await fs.unlink(CLAUDE_SETTINGS_BACKUP_PATH); } catch (_) {}
732
768
 
733
769
  await _deleteHooks(CLAUDE_HOOKS_DIR);
734
770
  output.success('OACB removed from Claude Code. Run `unbound oacb check` to verify clean state.');
@@ -759,13 +795,13 @@ async function _removeCodex({ dryRun }) {
759
795
  }
760
796
  }
761
797
 
762
- const cleaned = stripOacbFromCodexConfig(existing);
798
+ const cleaned = stripOacbFromCodexConfig(existing, meta?.preState);
763
799
 
764
800
  if (dryRun) {
765
801
  output.info(`--dry-run: showing cleaned ${CODEX_CONFIG_PATH} (not written)`);
766
802
  process.stdout.write(TOML.stringify(cleaned) + '\n');
767
803
  output.info('Hook scripts that would be deleted:');
768
- for (const name of HOOK_NAMES) {
804
+ for (const name of REMOVABLE_HOOK_FILES) {
769
805
  process.stdout.write(` ${path.join(CODEX_HOOKS_DIR, name)}\n`);
770
806
  }
771
807
  return;
@@ -781,11 +817,17 @@ async function _removeCodex({ dryRun }) {
781
817
  output.success('OACB removed from Codex. Run `unbound oacb check` to verify clean state.');
782
818
  }
783
819
 
820
+ // Files _deleteHooks unlinks: the five named hooks + the shared core that
821
+ // `installHooks` now places alongside hooks for both agents (v1.1.4+). Without
822
+ // the shared core in this list, `oacb remove` would leak the 22KB
823
+ // oacb-enforce-core.sh file in the user's hooks dir.
824
+ const REMOVABLE_HOOK_FILES = [...HOOK_NAMES, 'oacb-enforce-core.sh'];
825
+
784
826
  async function _deleteHooks(hooksDir) {
785
827
  const deleted = [];
786
828
  const missing = [];
787
829
  await Promise.all(
788
- HOOK_NAMES.map(async (name) => {
830
+ REMOVABLE_HOOK_FILES.map(async (name) => {
789
831
  try { await fs.unlink(path.join(hooksDir, name)); deleted.push(name); }
790
832
  catch (_) { missing.push(name); }
791
833
  })
@@ -984,20 +1026,18 @@ async function installHooks(localDir, agent = 'claude-code') {
984
1026
  })
985
1027
  );
986
1028
 
987
- // Codex hooks source a shared core that is not bundled per-agent.
1029
+ // Both agents' enforce.sh source a shared core that is not bundled per-agent.
988
1030
  // 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 });
1031
+ let coreContent;
1032
+ if (localDir) {
1033
+ const sharedDir = process.env.OACB_SHARED_DIR
1034
+ || path.resolve(localDir, '..', '..', 'shared');
1035
+ coreContent = await fs.readFile(path.join(sharedDir, 'oacb-enforce-core.sh'), 'utf8');
1036
+ } else {
1037
+ const url = `${OACB_RAW_BASE}/${OACB_PINNED_REF}/baseline/shared/oacb-enforce-core.sh`;
1038
+ coreContent = await api.getRaw(url);
1000
1039
  }
1040
+ await fs.writeFile(path.join(hooksDir, 'oacb-enforce-core.sh'), coreContent, { mode: 0o755 });
1001
1041
  }
1002
1042
 
1003
1043
  // Build the OACB hook entries with agent-specific local paths.
@@ -1071,7 +1111,7 @@ function mergeCodexConfig(existing, oacbConfig, extraEnv) {
1071
1111
  // Pure: strip OACB hook entries from a parsed Codex TOML config.
1072
1112
  // Policy scalars (approval_policy, sandbox_mode) are left in place —
1073
1113
  // they may have been set by the user independently.
1074
- function stripOacbFromCodexConfig(existing) {
1114
+ function stripOacbFromCodexConfig(existing, preState) {
1075
1115
  const result = Object.assign({}, existing);
1076
1116
  const oacbHookRe = /oacb-[^/\\]+\.sh/;
1077
1117
 
@@ -1087,6 +1127,25 @@ function stripOacbFromCodexConfig(existing) {
1087
1127
  if (!Object.keys(result.hooks).length) delete result.hooks;
1088
1128
  }
1089
1129
 
1130
+ // Restore scalars/blocks OACB overwrote during apply. preState is the
1131
+ // snapshot writeCodexConfig captures on the FIRST apply (idempotent
1132
+ // across re-applies). null means "user had no value here" → delete.
1133
+ if (preState) {
1134
+ for (const scalar of ['approval_policy', 'sandbox_mode']) {
1135
+ if (preState[scalar] === null || preState[scalar] === undefined) {
1136
+ delete result[scalar];
1137
+ } else {
1138
+ result[scalar] = preState[scalar];
1139
+ }
1140
+ }
1141
+ if (preState.shell_environment_policy === null
1142
+ || preState.shell_environment_policy === undefined) {
1143
+ delete result.shell_environment_policy;
1144
+ } else {
1145
+ result.shell_environment_policy = preState.shell_environment_policy;
1146
+ }
1147
+ }
1148
+
1090
1149
  return result;
1091
1150
  }
1092
1151
 
@@ -1098,6 +1157,30 @@ async function writeCodexConfig(oacbConfig, tier) {
1098
1157
  existing = TOML.parse(await fs.readFile(CODEX_CONFIG_PATH, 'utf8'));
1099
1158
  } catch (_) {}
1100
1159
 
1160
+ // Capture the pre-apply state of fields OACB overwrites so `oacb remove` can
1161
+ // restore them. Without this, a user who set approval_policy="untrusted"
1162
+ // before OACB ends up with the OACB tier's value (e.g. "on-request") after
1163
+ // an apply/remove cycle. A `null` entry in preState distinguishes "user had
1164
+ // no value" (delete on remove) from "user had this value" (restore on remove).
1165
+ // Only persist the first such snapshot — re-running apply must not overwrite
1166
+ // the original user state with our own.
1167
+ let preState;
1168
+ let prevMeta = null;
1169
+ try {
1170
+ prevMeta = JSON.parse(await fs.readFile(CODEX_META_PATH, 'utf8'));
1171
+ } catch (_) {}
1172
+ if (prevMeta && prevMeta.preState) {
1173
+ preState = prevMeta.preState;
1174
+ } else {
1175
+ preState = {
1176
+ approval_policy: existing.approval_policy ?? null,
1177
+ sandbox_mode: existing.sandbox_mode ?? null,
1178
+ shell_environment_policy: existing.shell_environment_policy
1179
+ ? deepClone(existing.shell_environment_policy)
1180
+ : null,
1181
+ };
1182
+ }
1183
+
1101
1184
  const extraEnv = {
1102
1185
  OACB_SHARED_DIR: CODEX_HOOKS_DIR,
1103
1186
  ...(tier === 'receipts' ? { OACB_EXPIRES: computeExpiryDate(30) } : {}),
@@ -1114,6 +1197,7 @@ async function writeCodexConfig(oacbConfig, tier) {
1114
1197
  ts: new Date().toISOString(),
1115
1198
  oacb_version: PKG_VERSION,
1116
1199
  agent: 'codex',
1200
+ preState,
1117
1201
  ...(tier === 'receipts' ? { expires: computeExpiryDate(30) } : {}),
1118
1202
  }, null, 2) + '\n', { mode: 0o600 });
1119
1203
 
@@ -1778,6 +1862,7 @@ module.exports = {
1778
1862
  writeConsentReceipt,
1779
1863
  handleWhy,
1780
1864
  handleStatus,
1865
+ REMOVABLE_HOOK_FILES,
1781
1866
  RULE_REGISTRY,
1782
1867
  },
1783
1868
  };
@@ -101,7 +101,7 @@ Examples:
101
101
 
102
102
  console.log('');
103
103
  output.info('Step 1/2: Installing tool bundle');
104
- const ok = await runSetupAllBundle(apiKey, {
104
+ const { ok, skipped } = await runSetupAllBundle(apiKey, {
105
105
  backendUrl, frontendUrl, gatewayUrl, backfill: !!opts.backfill,
106
106
  });
107
107
  if (!ok) return;
@@ -127,7 +127,9 @@ Examples:
127
127
  }
128
128
 
129
129
  console.log('');
130
- output.success('Onboarding complete');
130
+ output.success(skipped && skipped.length
131
+ ? 'Onboarding complete — tools managed by MDM were skipped (see above)'
132
+ : 'Onboarding complete');
131
133
  } catch (err) {
132
134
  if (!err.displayed) output.error(err.message);
133
135
  if (discoverySucceeded && opts.setCron) {
@@ -237,7 +239,7 @@ Examples:
237
239
 
238
240
  console.log('');
239
241
  output.info('Step 1/2: Installing MDM tool bundle');
240
- const ok = await runMdmSetupAllBundle(adminApiKey, {
242
+ const { ok, skipped } = await runMdmSetupAllBundle(adminApiKey, {
241
243
  backendUrl, gatewayUrl, backfill: !!opts.backfill,
242
244
  });
243
245
  if (!ok) return;
@@ -249,7 +251,9 @@ Examples:
249
251
  await runDiscoveryScan({ apiKey: opts.discoveryKey, domain: discoveryDomain });
250
252
 
251
253
  console.log('');
252
- output.success('MDM onboarding complete');
254
+ output.success(skipped && skipped.length
255
+ ? 'MDM onboarding complete — tools managed by MDM were skipped (see above)'
256
+ : 'MDM onboarding complete');
253
257
  } catch (err) {
254
258
  if (!err.displayed) output.error(err.message);
255
259
  if (setupSucceeded) {
@@ -11,6 +11,14 @@ const { confirm } = require('../utils');
11
11
 
12
12
  const SETUP_BASE_URL = 'https://raw.githubusercontent.com/websentry-ai/setup/refs/heads/main';
13
13
 
14
+ // A setup script exits with this code when it detects an existing MDM (managed)
15
+ // install for that tool and skips itself. It is a deliberate, non-fatal signal —
16
+ // not a failure — so the CLI suppresses the script's own output and reports the
17
+ // skip cleanly instead. Must stay in sync with the setup repo's setup.py scripts.
18
+ // Only emitted by setup runs: the scripts run the MDM check AFTER the --clear
19
+ // branch, so clear/nuke operations never produce this code.
20
+ const EXIT_MDM_PRESENT = 3;
21
+
14
22
  // WSL reports as Linux via uname; only native Windows (cmd.exe / PowerShell)
15
23
  // takes the Windows code path. WSL keeps using the Linux curl|python3 pipe.
16
24
  function isWindowsNative() {
@@ -200,7 +208,9 @@ async function runPythonScriptWindows(scriptPath, args, { capture }) {
200
208
  await downloadToFile(url, tmp);
201
209
  const py = resolveWindowsPython();
202
210
  try {
203
- await new Promise((resolve, reject) => {
211
+ // `return await` so the resolved value (e.g. { mdmSkipped: true }) reaches
212
+ // the caller; the finally below still runs before the function returns.
213
+ return await new Promise((resolve, reject) => {
204
214
  const child = spawn(py.cmd, [...py.prefix, tmp, ...parsePosixArgs(args)], {
205
215
  stdio: capture ? ['pipe', 'pipe', 'pipe'] : 'inherit',
206
216
  shell: false,
@@ -215,6 +225,8 @@ async function runPythonScriptWindows(scriptPath, args, { capture }) {
215
225
  }
216
226
  child.on('close', (code) => {
217
227
  if (code === 0) return resolve();
228
+ // Managed by MDM — drop any captured output and signal a skip.
229
+ if (code === EXIT_MDM_PRESENT) return resolve({ mdmSkipped: true });
218
230
  const err = new Error(out.trim() || `Setup script failed with exit code ${code}`);
219
231
  if (capture) err.setupOutput = out.trim();
220
232
  reject(err);
@@ -276,12 +288,14 @@ async function runSetupScript(scriptPath, apiKey, { clear = false, backendUrl, f
276
288
  const args = buildScriptArgs(apiKey, { backendUrl, frontendUrl, gatewayUrl, clear, backfill });
277
289
  console.log('');
278
290
  if (isWindowsNative()) {
279
- await runPythonScriptWindows(scriptPath, args, { capture: false });
280
- return;
291
+ return runPythonScriptWindows(scriptPath, args, { capture: false });
281
292
  }
282
293
  try {
283
294
  execSync(buildSetupCommand(scriptPath, args), { stdio: 'inherit' });
284
295
  } catch (err) {
296
+ // Managed by MDM — the script already printed its one-line notice (stdio is
297
+ // inherited here). Signal a skip so the caller can add the CLI notice.
298
+ if (err.status === EXIT_MDM_PRESENT) return { mdmSkipped: true };
285
299
  throw new Error(`Setup script failed with exit code ${err.status || 1}. Check the output above for details.`);
286
300
  }
287
301
  }
@@ -309,6 +323,9 @@ function runScriptPiped(scriptPath, args) {
309
323
  child.on('close', (code) => {
310
324
  if (code === 0) {
311
325
  resolve();
326
+ } else if (code === EXIT_MDM_PRESENT) {
327
+ // Managed by MDM — drop the captured script output and signal a skip.
328
+ resolve({ mdmSkipped: true });
312
329
  } else {
313
330
  const err = new Error(captured.trim() || `Setup failed with exit code ${code}`);
314
331
  err.setupOutput = captured.trim();
@@ -350,24 +367,61 @@ function hasRootPrivileges() {
350
367
  }
351
368
 
352
369
  /**
353
- * Runs a batch of tools sequentially with spinners.
354
- * Stops on first failure. Returns true if all succeeded.
370
+ * Prints a clear, red, end-of-run notice for tools that were skipped because an
371
+ * MDM (organization-managed) setup is already present. Kept separate so callers
372
+ * can surface it at the very end of their flow.
373
+ */
374
+ function reportMdmSkips(labels) {
375
+ if (!labels || labels.length === 0) return;
376
+ console.error('');
377
+ console.error(output.colors.bold(output.colors.red(
378
+ `✗ Skipped — managed by your organization (MDM): ${labels.join(', ')}`)));
379
+ console.error(output.colors.red(" User-level setup can't override MDM. Contact your IT admin to change it."));
380
+ }
381
+
382
+ /**
383
+ * Runs a batch of tools sequentially with spinners. Stops on the first hard
384
+ * failure. A tool that reports an MDM skip is NOT a failure — it's collected and
385
+ * surfaced (in red) at the end, and the batch continues. Returns
386
+ * { ok, skipped } — ok is false only on a hard failure; skipped is the list of
387
+ * MDM-managed tool labels so callers can qualify their own success message.
388
+ * When `summary` is set, a green success line is printed for the configured
389
+ * tools before the MDM notice.
355
390
  */
356
- async function runBatch(tools, runFn, { clear = false } = {}) {
391
+ async function runBatch(tools, runFn, { clear = false, summary = null } = {}) {
357
392
  const action = clear ? 'Clearing' : 'Setting up';
393
+ const mdmSkipped = [];
358
394
  for (const tool of tools) {
359
395
  const s = output.spinner(`${action} ${tool.label}...`);
360
396
  try {
361
- await runFn(tool);
362
- s.succeed(tool.label);
397
+ const result = await runFn(tool);
398
+ if (result && result.mdmSkipped) {
399
+ // Stop the spinner and leave an inline marker so the tool doesn't just
400
+ // vanish mid-batch; the actionable red summary still prints at the end.
401
+ s.stop();
402
+ console.error(output.colors.dim(` Skipped ${tool.label} — managed by MDM`));
403
+ mdmSkipped.push(tool.label);
404
+ } else {
405
+ s.succeed(tool.label);
406
+ }
363
407
  } catch (err) {
364
408
  s.fail(`Failed: ${tool.label}`);
365
409
  if (err.setupOutput) console.error('\n' + err.setupOutput);
366
410
  process.exitCode = 1;
367
- return false;
411
+ // Still surface any tools skipped before this failure so the notice isn't lost.
412
+ reportMdmSkips(mdmSkipped);
413
+ return { ok: false, skipped: mdmSkipped };
368
414
  }
369
415
  }
370
- return true;
416
+ const configured = tools.length - mdmSkipped.length;
417
+ if (summary && configured > 0) {
418
+ console.log('');
419
+ // Some tools may have been MDM-managed, so report the actual count and the
420
+ // matching verb. (clear never produces skips, but keep the verb consistent.)
421
+ output.success(mdmSkipped.length ? `${configured} of ${tools.length} tools ${clear ? 'cleared' : 'configured'}` : summary);
422
+ }
423
+ reportMdmSkips(mdmSkipped);
424
+ return { ok: true, skipped: mdmSkipped };
371
425
  }
372
426
 
373
427
  /**
@@ -468,6 +522,11 @@ Examples:
468
522
  When setting up, if you are not logged in and --api-key is not provided, the
469
523
  browser opens automatically to authenticate first. Clearing (--clear) never
470
524
  requires authentication.
525
+
526
+ If an MDM (organization-managed) setup is already present for a tool, user-level
527
+ setup for that tool is skipped automatically — the managed configuration already
528
+ enforces Unbound for every user on the device. To change it, an administrator
529
+ must update the MDM configuration.
471
530
  `)
472
531
  .action(async (tools, opts) => {
473
532
  try {
@@ -527,18 +586,14 @@ requires authentication.
527
586
  if (!scriptSupportsBackfill(tool.script)) noteBackfillUnsupported(tool.label, tool.script);
528
587
  }
529
588
  }
530
- const ok = await runBatch(selectedTools, (tool) => {
589
+ await runBatch(selectedTools, (tool) => {
531
590
  const toolArgs = buildScriptArgs(apiKey, {
532
591
  ...urlOpts,
533
592
  clear: opts.clear,
534
593
  backfill: opts.backfill && scriptSupportsBackfill(tool.script),
535
594
  });
536
595
  return runScriptPiped(tool.script, toolArgs);
537
- }, { clear: opts.clear });
538
- if (!ok) return;
539
-
540
- console.log('');
541
- output.success(opts.clear ? 'All tools cleared' : 'All tools configured');
596
+ }, { clear: opts.clear, summary: opts.clear ? 'All tools cleared' : 'All tools configured' });
542
597
  return;
543
598
  }
544
599
 
@@ -604,7 +659,8 @@ requires authentication.
604
659
  const { script, label } = SETUP_TOOL_MAP[toolName];
605
660
  const backfill = opts.backfill && scriptSupportsBackfill(script);
606
661
  if (opts.backfill && !backfill) noteBackfillUnsupported(label, script);
607
- await runSetupScript(script, apiKey, { clear: opts.clear, backfill, ...urlOpts });
662
+ const r = await runSetupScript(script, apiKey, { clear: opts.clear, backfill, ...urlOpts });
663
+ if (r && r.mdmSkipped) reportMdmSkips([label]);
608
664
  } else if (MODE_TOOLS[toolName]) {
609
665
  const mode = MODE_TOOLS[toolName];
610
666
  if (opts.clear) {
@@ -621,7 +677,8 @@ requires authentication.
621
677
  const { script, label } = SETUP_TOOL_MAP[resolved];
622
678
  const backfill = opts.backfill && scriptSupportsBackfill(script);
623
679
  if (opts.backfill && !backfill) noteBackfillUnsupported(label, script);
624
- await runSetupScript(script, apiKey, { ...urlOpts, backfill });
680
+ const r = await runSetupScript(script, apiKey, { ...urlOpts, backfill });
681
+ if (r && r.mdmSkipped) reportMdmSkips([label]);
625
682
  }
626
683
  } else if (INSTRUCTION_TOOLS[toolName]) {
627
684
  output.keyValue(INSTRUCTION_TOOLS[toolName].values(apiKey, frontendUrl));
@@ -664,14 +721,14 @@ requires authentication.
664
721
  if (!scriptSupportsBackfill(tool.script)) noteBackfillUnsupported(tool.label, tool.script);
665
722
  }
666
723
  }
667
- const ok = await runBatch(resolvedScripts, (tool) => {
724
+ const { ok } = await runBatch(resolvedScripts, (tool) => {
668
725
  const toolArgs = buildScriptArgs(apiKey, {
669
726
  ...urlOpts,
670
727
  clear: opts.clear,
671
728
  backfill: opts.backfill && scriptSupportsBackfill(tool.script),
672
729
  });
673
730
  return runScriptPiped(tool.script, toolArgs);
674
- }, { clear: opts.clear });
731
+ }, { clear: opts.clear, summary: opts.clear ? 'All tools cleared' : 'All tools configured' });
675
732
  if (!ok) return;
676
733
  }
677
734
 
@@ -682,10 +739,6 @@ requires authentication.
682
739
  output.keyValue(INSTRUCTION_TOOLS[toolName].values(apiKey, frontendUrl));
683
740
  }
684
741
 
685
- if (resolvedScripts.length > 0) {
686
- console.log('');
687
- output.success(opts.clear ? 'All tools cleared' : 'All tools configured');
688
- }
689
742
  } catch (err) {
690
743
  if (err.message === 'Selection cancelled') return;
691
744
  if (!err.displayed) output.error(err.message);
@@ -832,7 +885,7 @@ Clear examples (no API key required):
832
885
  }
833
886
  }
834
887
 
835
- const ok = await runBatch(
888
+ const { ok } = await runBatch(
836
889
  resolvedTools,
837
890
  (tool) => {
838
891
  const toolArgs = buildScriptArgs(adminApiKey, {
@@ -844,12 +897,9 @@ Clear examples (no API key required):
844
897
  });
845
898
  return runScriptPiped(tool.script, toolArgs);
846
899
  },
847
- { clear: globalOpts.clear }
900
+ { clear: globalOpts.clear, summary: globalOpts.clear ? 'All tools cleared' : 'All tools configured' }
848
901
  );
849
902
  if (!ok) return;
850
-
851
- console.log('');
852
- output.success(globalOpts.clear ? 'All tools cleared' : 'All tools configured');
853
903
  } catch (err) {
854
904
  output.error(err.message);
855
905
  process.exitCode = 1;
package/src/output.js CHANGED
@@ -349,4 +349,4 @@ function multiSelect(message, options) {
349
349
  });
350
350
  }
351
351
 
352
- module.exports = { table, json, keyValue, success, error, warn, info, spinner, select, multiSelect };
352
+ module.exports = { table, json, keyValue, success, error, warn, info, spinner, select, multiSelect, colors: c };
package/test/oacb.test.js CHANGED
@@ -23,6 +23,7 @@ const {
23
23
  writeConsentReceipt,
24
24
  handleWhy,
25
25
  handleStatus,
26
+ REMOVABLE_HOOK_FILES,
26
27
  RULE_REGISTRY,
27
28
  } = require('../src/commands/oacb').__test__;
28
29
 
@@ -521,6 +522,67 @@ test('stripOacbFromCodexConfig: leaves approval_policy and sandbox_mode in place
521
522
  assert.equal(result.sandbox_mode, 'read-only');
522
523
  });
523
524
 
525
+ test('stripOacbFromCodexConfig: with preState, restores scalars to pre-apply values', () => {
526
+ const config = {
527
+ approval_policy: 'on-request', // set by OACB shadow tier
528
+ sandbox_mode: 'workspace-write',
529
+ hooks: {
530
+ PreToolUse: [
531
+ { hooks: [{ type: 'command', command: `${os.homedir()}/.codex/hooks/oacb-enforce.sh` }] },
532
+ ],
533
+ },
534
+ };
535
+ const preState = { approval_policy: 'untrusted', sandbox_mode: 'read-only' };
536
+ const result = stripOacbFromCodexConfig(config, preState);
537
+ assert.equal(result.approval_policy, 'untrusted', 'must restore pre-apply approval_policy');
538
+ assert.equal(result.sandbox_mode, 'read-only', 'must restore pre-apply sandbox_mode');
539
+ });
540
+
541
+ test('stripOacbFromCodexConfig: with preState=null entry, deletes the field', () => {
542
+ // Pre-apply user had no approval_policy → OACB added one → remove deletes it
543
+ const config = { approval_policy: 'on-request', sandbox_mode: 'workspace-write' };
544
+ const preState = { approval_policy: null, sandbox_mode: null };
545
+ const result = stripOacbFromCodexConfig(config, preState);
546
+ assert.equal(result.approval_policy, undefined);
547
+ assert.equal(result.sandbox_mode, undefined);
548
+ });
549
+
550
+ test('stripOacbFromCodexConfig: with preState, restores shell_environment_policy', () => {
551
+ const config = {
552
+ shell_environment_policy: {
553
+ inherit: 'core',
554
+ exclude: ['AWS_*', '*_SECRET*'], // added by OACB apply
555
+ },
556
+ };
557
+ const preState = {
558
+ shell_environment_policy: { inherit: 'core' }, // user's original
559
+ };
560
+ const result = stripOacbFromCodexConfig(config, preState);
561
+ assert.deepEqual(result.shell_environment_policy, { inherit: 'core' });
562
+ });
563
+
564
+ test('stripOacbFromCodexConfig: with preState.shell_environment_policy=null, deletes the block', () => {
565
+ const config = {
566
+ shell_environment_policy: { inherit: 'core', exclude: ['AWS_*'] },
567
+ };
568
+ const preState = { shell_environment_policy: null };
569
+ const result = stripOacbFromCodexConfig(config, preState);
570
+ assert.equal(result.shell_environment_policy, undefined);
571
+ });
572
+
573
+ // ─── REMOVABLE_HOOK_FILES (F1: shared-core leak fix) ──────────────────────────
574
+
575
+ test('REMOVABLE_HOOK_FILES: includes oacb-enforce-core.sh to prevent leak after remove', () => {
576
+ assert.ok(REMOVABLE_HOOK_FILES.includes('oacb-enforce-core.sh'),
577
+ 'oacb-enforce-core.sh must be in the unlink set so it does not leak in the hooks dir after `oacb remove`');
578
+ // All five named hooks must also be there.
579
+ for (const h of ['oacb-enforce.sh', 'oacb-prompt-guard.sh', 'oacb-mcp-guard.sh', 'oacb-config-audit.sh', 'oacb-post-tool.sh']) {
580
+ assert.ok(REMOVABLE_HOOK_FILES.includes(h), `${h} must be in REMOVABLE_HOOK_FILES`);
581
+ }
582
+ // And nothing else: no globs, no user files.
583
+ assert.equal(REMOVABLE_HOOK_FILES.length, 6, 'REMOVABLE_HOOK_FILES should be exactly 6 (5 hooks + 1 shared core)');
584
+ });
585
+
524
586
  test('stripOacbFromCodexConfig: no-op on config with no OACB hooks', () => {
525
587
  const config = {
526
588
  approval_policy: 'on-request',