unbound-cli 1.1.2 → 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 +1 -1
- package/src/auth.js +8 -1
- package/src/commands/login.js +5 -1
- package/src/commands/oacb.js +110 -25
- package/src/commands/status.js +5 -1
- package/src/commands/whoami.js +5 -1
- package/src/device-serial.js +159 -0
- package/test/device-serial.test.js +37 -0
- package/test/oacb.test.js +62 -0
package/package.json
CHANGED
package/src/auth.js
CHANGED
|
@@ -7,6 +7,7 @@ const { URL } = require('url');
|
|
|
7
7
|
const config = require('./config');
|
|
8
8
|
const output = require('./output');
|
|
9
9
|
const api = require('./api');
|
|
10
|
+
const { getDeviceSerial } = require('./device-serial');
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* Opens the browser for authentication and waits for the callback.
|
|
@@ -117,7 +118,13 @@ async function loginWithApiKey(apiKey, { baseUrl } = {}) {
|
|
|
117
118
|
const spin = output.spinner('Authenticating...');
|
|
118
119
|
let response;
|
|
119
120
|
try {
|
|
120
|
-
|
|
121
|
+
// Report this machine's serial so the backend can map the device to the
|
|
122
|
+
// authenticated user. Optional + best-effort: omitted when undeterminable.
|
|
123
|
+
// Awaited (not sync) so the probe never blocks the spinner on a cache miss.
|
|
124
|
+
const deviceSerial = await getDeviceSerial();
|
|
125
|
+
response = await api.get('/api/v1/users/privileges/', {
|
|
126
|
+
apiKey, baseUrl, query: { device_serial: deviceSerial },
|
|
127
|
+
});
|
|
121
128
|
} catch (err) {
|
|
122
129
|
let msg;
|
|
123
130
|
if (err.statusCode === 401 || err.statusCode === 403) {
|
package/src/commands/login.js
CHANGED
|
@@ -3,6 +3,7 @@ const config = require('../config');
|
|
|
3
3
|
const output = require('../output');
|
|
4
4
|
const api = require('../api');
|
|
5
5
|
const { loginWithBrowser, loginWithApiKey } = require('../auth');
|
|
6
|
+
const { getDeviceSerial } = require('../device-serial');
|
|
6
7
|
|
|
7
8
|
function register(program) {
|
|
8
9
|
program
|
|
@@ -79,7 +80,10 @@ Examples:
|
|
|
79
80
|
// Validate the just-stored key against the explicit backend if one
|
|
80
81
|
// was just persisted; otherwise fall back to the env-var-aware
|
|
81
82
|
// getter. api.get reads the stored key from config internally.
|
|
82
|
-
|
|
83
|
+
const deviceSerial = await getDeviceSerial();
|
|
84
|
+
await api.get('/api/v1/users/privileges/', {
|
|
85
|
+
baseUrl: explicitBaseUrl, query: { device_serial: deviceSerial },
|
|
86
|
+
});
|
|
83
87
|
}
|
|
84
88
|
|
|
85
89
|
const cfg = config.readConfig();
|
package/src/commands/oacb.js
CHANGED
|
@@ -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.
|
|
16
|
-
const PKG_VERSION = '0.2.
|
|
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
|
-
|
|
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
|
|
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,
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
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/src/commands/status.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
const config = require('../config');
|
|
2
2
|
const api = require('../api');
|
|
3
3
|
const output = require('../output');
|
|
4
|
+
const { getDeviceSerial } = require('../device-serial');
|
|
4
5
|
|
|
5
6
|
function register(program) {
|
|
6
7
|
program
|
|
@@ -36,7 +37,10 @@ Examples:
|
|
|
36
37
|
if (loggedIn) {
|
|
37
38
|
const spin = output.spinner('Checking API connectivity...');
|
|
38
39
|
try {
|
|
39
|
-
const
|
|
40
|
+
const deviceSerial = await getDeviceSerial();
|
|
41
|
+
const privileges = await api.get('/api/v1/users/privileges/', {
|
|
42
|
+
query: { device_serial: deviceSerial },
|
|
43
|
+
});
|
|
40
44
|
config.backfillUserInfo(privileges);
|
|
41
45
|
spin.stop();
|
|
42
46
|
connectivity = 'Connected';
|
package/src/commands/whoami.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
const config = require('../config');
|
|
2
2
|
const api = require('../api');
|
|
3
3
|
const output = require('../output');
|
|
4
|
+
const { getDeviceSerial } = require('../device-serial');
|
|
4
5
|
|
|
5
6
|
function roleFromPrivileges(privileges) {
|
|
6
7
|
if (privileges.is_admin) return 'Admin';
|
|
@@ -34,7 +35,10 @@ Examples:
|
|
|
34
35
|
const cfg = config.readConfig();
|
|
35
36
|
const spin = output.spinner('Fetching user info...');
|
|
36
37
|
try {
|
|
37
|
-
const
|
|
38
|
+
const deviceSerial = await getDeviceSerial();
|
|
39
|
+
const privileges = await api.get('/api/v1/users/privileges/', {
|
|
40
|
+
query: { device_serial: deviceSerial },
|
|
41
|
+
});
|
|
38
42
|
spin.stop();
|
|
39
43
|
config.backfillUserInfo(privileges);
|
|
40
44
|
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
const { execFile } = require('child_process');
|
|
5
|
+
const { promisify } = require('util');
|
|
6
|
+
const { CONFIG_DIR } = require('./config');
|
|
7
|
+
|
|
8
|
+
const execFileAsync = promisify(execFile);
|
|
9
|
+
|
|
10
|
+
// Shared with the endpoint hooks: they cache the same value under this key so a
|
|
11
|
+
// machine reports one identity across the CLI and the installed hooks.
|
|
12
|
+
const IDENTITY_CACHE_FILE = path.join(CONFIG_DIR, 'identity.json');
|
|
13
|
+
const PROBE_TIMEOUT_MS = 3000;
|
|
14
|
+
|
|
15
|
+
// DMI/BIOS serial fields are often unset on VMs and OEM boards and come back as a
|
|
16
|
+
// shared sentinel (with a zero exit code), which would map many machines onto one
|
|
17
|
+
// fake serial. Treat these as "no serial" and fall through. Mirrors the hooks +
|
|
18
|
+
// discovery scanner so all three agree on a machine's identity.
|
|
19
|
+
const PLACEHOLDER_SERIALS = new Set([
|
|
20
|
+
'', '0', '00000000', '000000000', '0000000000', 'none', 'na', 'n/a',
|
|
21
|
+
'unknown', 'default', 'default string', 'to be filled by o.e.m.',
|
|
22
|
+
'to be filled by oem', 'system serial number', 'serial number',
|
|
23
|
+
'not applicable', 'not specified', 'not available', 'oem', 'o.e.m.',
|
|
24
|
+
'invalid', '123456789', 'xxxxxxxx',
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
// Best-effort serial is intentionally non-fatal, so failures are swallowed rather
|
|
28
|
+
// than surfaced to the user. Set UNBOUND_DEBUG=1 to trace why a serial was omitted.
|
|
29
|
+
function debug(msg) {
|
|
30
|
+
if (process.env.UNBOUND_DEBUG) {
|
|
31
|
+
try { process.stderr.write(`[unbound] device-serial: ${msg}\n`); } catch { /* ignore */ }
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function isValidSerial(value) {
|
|
36
|
+
return typeof value === 'string'
|
|
37
|
+
&& value.trim() !== ''
|
|
38
|
+
&& !PLACEHOLDER_SERIALS.has(value.trim().toLowerCase());
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Run a probe command asynchronously so the event loop (and the caller's spinner)
|
|
42
|
+
// stays alive during the probe. Bounded by a timeout, never throws, resolves to
|
|
43
|
+
// stdout or null (nonzero exit / timeout / missing binary all map to null).
|
|
44
|
+
async function run(cmd, args) {
|
|
45
|
+
try {
|
|
46
|
+
const { stdout } = await execFileAsync(cmd, args, {
|
|
47
|
+
timeout: PROBE_TIMEOUT_MS, windowsHide: true, encoding: 'utf8',
|
|
48
|
+
});
|
|
49
|
+
return typeof stdout === 'string' ? stdout : null;
|
|
50
|
+
} catch (err) {
|
|
51
|
+
debug(`probe '${cmd}' failed: ${err && err.message}`);
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Best-effort hardware serial, mirroring the MDM setup scripts and discovery
|
|
57
|
+
// scanner. Filters OEM/VM placeholders so two machines never collide on the same
|
|
58
|
+
// fake serial, falling through to a stable per-install id (machine-id / MachineGuid).
|
|
59
|
+
async function probeSerial() {
|
|
60
|
+
const platform = os.platform();
|
|
61
|
+
|
|
62
|
+
if (platform === 'darwin') {
|
|
63
|
+
const out = await run('system_profiler', ['SPHardwareDataType']);
|
|
64
|
+
if (out) {
|
|
65
|
+
for (const line of out.split('\n')) {
|
|
66
|
+
if (line.includes('Serial Number')) {
|
|
67
|
+
const idx = line.indexOf(': ');
|
|
68
|
+
if (idx !== -1) {
|
|
69
|
+
const value = line.slice(idx + 2).trim();
|
|
70
|
+
if (isValidSerial(value)) return value;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (platform === 'linux') {
|
|
79
|
+
const dmi = await run('dmidecode', ['-s', 'system-serial-number']); // needs root; falls through otherwise
|
|
80
|
+
if (dmi && isValidSerial(dmi)) return dmi.trim();
|
|
81
|
+
for (const p of ['/etc/machine-id', '/var/lib/dbus/machine-id']) {
|
|
82
|
+
try {
|
|
83
|
+
const value = fs.readFileSync(p, 'utf8').trim();
|
|
84
|
+
if (isValidSerial(value)) return value;
|
|
85
|
+
} catch {
|
|
86
|
+
// try the next path
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (platform === 'win32') {
|
|
93
|
+
const bios = await run('powershell', ['-NoProfile', '-Command',
|
|
94
|
+
'(Get-CimInstance -ClassName Win32_BIOS).SerialNumber']);
|
|
95
|
+
if (bios && isValidSerial(bios)) return bios.trim();
|
|
96
|
+
const guid = await run('powershell', ['-NoProfile', '-Command',
|
|
97
|
+
"(Get-ItemProperty 'HKLM:\\SOFTWARE\\Microsoft\\Cryptography').MachineGuid"]);
|
|
98
|
+
if (guid && isValidSerial(guid)) return guid.trim();
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
debug(`unsupported platform: ${platform}`);
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function readCache() {
|
|
107
|
+
try {
|
|
108
|
+
const data = JSON.parse(fs.readFileSync(IDENTITY_CACHE_FILE, 'utf8'));
|
|
109
|
+
if (data && typeof data === 'object') return data;
|
|
110
|
+
} catch (err) {
|
|
111
|
+
if (err.code !== 'ENOENT') debug(`cache read failed: ${err.message}`);
|
|
112
|
+
}
|
|
113
|
+
return {};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Merge + atomic write so a torn file can't corrupt the cache the hooks share.
|
|
117
|
+
function writeCache(data) {
|
|
118
|
+
try {
|
|
119
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
120
|
+
const tmp = path.join(CONFIG_DIR, `.identity.${process.pid}.tmp`);
|
|
121
|
+
fs.writeFileSync(tmp, JSON.stringify(data), { mode: 0o600 });
|
|
122
|
+
fs.renameSync(tmp, IDENTITY_CACHE_FILE);
|
|
123
|
+
} catch (err) {
|
|
124
|
+
// an unwritable cache is fine — we still return the probed value
|
|
125
|
+
debug(`cache write failed: ${err.message}`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Best-effort hardware serial, shared with the endpoint hooks via
|
|
131
|
+
* ~/.unbound/identity.json. Reads the cache first (instant); probes asynchronously
|
|
132
|
+
* and persists only when missing, so the caller's spinner keeps animating. Never
|
|
133
|
+
* throws — resolves to null when the serial can't be determined, so callers simply
|
|
134
|
+
* omit it from the request. Set UNBOUND_DEBUG=1 to trace omissions.
|
|
135
|
+
*/
|
|
136
|
+
async function getDeviceSerial() {
|
|
137
|
+
try {
|
|
138
|
+
const data = readCache();
|
|
139
|
+
// Validate the cached value, not just its presence — an older CLI or hook could
|
|
140
|
+
// have persisted a placeholder (e.g. "System Serial Number"). Treat junk as a
|
|
141
|
+
// miss and re-probe, so a polluted cache self-heals instead of leaking a sentinel.
|
|
142
|
+
const cached = data.device_serial;
|
|
143
|
+
if (isValidSerial(cached)) return cached.trim();
|
|
144
|
+
|
|
145
|
+
const serial = await probeSerial();
|
|
146
|
+
if (serial) {
|
|
147
|
+
data.device_serial = serial;
|
|
148
|
+
writeCache(data);
|
|
149
|
+
} else {
|
|
150
|
+
debug('no serial could be determined; omitting device_serial');
|
|
151
|
+
}
|
|
152
|
+
return serial || null;
|
|
153
|
+
} catch (err) {
|
|
154
|
+
debug(`getDeviceSerial failed: ${err && err.message}`);
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
module.exports = { getDeviceSerial, isValidSerial };
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
const { test } = require('node:test');
|
|
2
|
+
const assert = require('node:assert');
|
|
3
|
+
const { isValidSerial, getDeviceSerial } = require('../src/device-serial');
|
|
4
|
+
|
|
5
|
+
test('isValidSerial: accepts real hardware serials', () => {
|
|
6
|
+
assert.equal(isValidSerial('M6KYTR4M2Q'), true);
|
|
7
|
+
assert.equal(isValidSerial('DP4Y2K37VV'), true);
|
|
8
|
+
// Parallels VM serial (spaces are part of the value, not a separator)
|
|
9
|
+
assert.equal(isValidSerial('Parallels-74 09 F2 8A 55 09 40 3D AA 13 19 BD CC 96 0F 0B'), true);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test('isValidSerial: rejects OEM/VM placeholder sentinels', () => {
|
|
13
|
+
for (const junk of [
|
|
14
|
+
'', '0', '00000000', 'To Be Filled By O.E.M.', 'System Serial Number',
|
|
15
|
+
'Default String', 'None', 'N/A', 'unknown', 'invalid', '123456789',
|
|
16
|
+
]) {
|
|
17
|
+
assert.equal(isValidSerial(junk), false, `expected placeholder rejected: ${JSON.stringify(junk)}`);
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('isValidSerial: placeholder match is case- and whitespace-insensitive', () => {
|
|
22
|
+
assert.equal(isValidSerial(' to be filled by o.e.m. '), false);
|
|
23
|
+
assert.equal(isValidSerial('SYSTEM SERIAL NUMBER'), false);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('isValidSerial: rejects non-string input', () => {
|
|
27
|
+
assert.equal(isValidSerial(null), false);
|
|
28
|
+
assert.equal(isValidSerial(undefined), false);
|
|
29
|
+
assert.equal(isValidSerial(12345), false);
|
|
30
|
+
assert.equal(isValidSerial({}), false);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('getDeviceSerial: is async and resolves to a string or null without throwing', async () => {
|
|
34
|
+
const result = await getDeviceSerial();
|
|
35
|
+
assert.ok(result === null || (typeof result === 'string' && result.length > 0),
|
|
36
|
+
`expected string|null, got ${typeof result}: ${JSON.stringify(result)}`);
|
|
37
|
+
});
|
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',
|