unbound-cli 1.1.3 → 1.1.5

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.5",
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
  };
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',