unbound-cli 1.15.2 → 1.16.7
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/LOCAL_DEV.md +3 -3
- package/README.md +2 -2
- package/package.json +1 -1
- package/src/auth.js +29 -2
- package/src/claudePaths.js +82 -0
- package/src/commands/analytics.js +1 -1
- package/src/commands/chat.js +2 -2
- package/src/commands/discover.js +17 -6
- package/src/commands/doctor.js +32 -8
- package/src/commands/download.js +2 -2
- package/src/commands/oacb.js +37 -11
- package/src/commands/onboard.js +34 -39
- package/src/commands/setup.js +62 -5
- package/src/commands/status.js +13 -1
- package/src/device-serial.js +17 -1
- package/src/index.js +3 -3
- package/src/lib/downloads.js +2 -2
- package/src/lib/policy-ai-assist.js +4 -2
- package/src/scheduled.js +3 -6
- package/src/toolHealth.js +115 -40
- package/test/analytics-export-job.test.js +1 -1
- package/test/chat-internals.test.js +1 -1
- package/test/claude-paths.test.js +182 -0
- package/test/discover-exit-code.test.js +16 -1
- package/test/onboard-cron.test.js +13 -11
- package/test/onboard-owner-key.test.js +38 -0
- package/test/onboard-scope.test.js +41 -6
- package/test/setup-args.test.js +44 -1
- package/test/setup-scope.test.js +69 -0
- package/test/tool-health-mdm-paths.test.js +206 -0
- package/test/tool-health.test.js +185 -0
package/LOCAL_DEV.md
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
cd unbound-cli
|
|
12
12
|
npm install
|
|
13
13
|
```
|
|
14
|
-
|
|
14
|
+
|
|
15
15
|
## Run locally (without installing globally)
|
|
16
16
|
|
|
17
17
|
```bash
|
|
@@ -85,7 +85,7 @@ sudo node src/index.js setup --api-key <ADMIN_KEY> --all --backend-url http://lo
|
|
|
85
85
|
# Onboarding (combined setup + discover)
|
|
86
86
|
node src/index.js onboard --api-key <USER_KEY> \
|
|
87
87
|
--backend-url http://localhost:8000 --frontend-url http://localhost:3000
|
|
88
|
-
sudo node src/index.js onboard --api-key <ADMIN_KEY>
|
|
88
|
+
sudo node src/index.js onboard --api-key <ADMIN_KEY> \
|
|
89
89
|
--backend-url http://localhost:8000 --frontend-url http://localhost:3000
|
|
90
90
|
```
|
|
91
91
|
|
|
@@ -186,7 +186,7 @@ node src/index.js setup --all --api-key <key>
|
|
|
186
186
|
node src/index.js onboard --api-key <USER_KEY>
|
|
187
187
|
|
|
188
188
|
# MDM onboarding (admin device enrollment, requires root — MDM scope auto-detected from sudo)
|
|
189
|
-
sudo node src/index.js onboard --api-key <ADMIN_KEY>
|
|
189
|
+
sudo node src/index.js onboard --api-key <ADMIN_KEY>
|
|
190
190
|
```
|
|
191
191
|
|
|
192
192
|
## Unlink when done
|
package/README.md
CHANGED
|
@@ -40,10 +40,10 @@ npm install -g unbound-cli
|
|
|
40
40
|
unbound onboard --api-key <USER_KEY>
|
|
41
41
|
|
|
42
42
|
# Admin enrolling a device via MDM (requires root)
|
|
43
|
-
sudo unbound onboard --api-key <ADMIN_KEY>
|
|
43
|
+
sudo unbound onboard --api-key <ADMIN_KEY>
|
|
44
44
|
```
|
|
45
45
|
|
|
46
|
-
|
|
46
|
+
Only your API key is needed. Run with `sudo` to let the discovery step scan all users on the device (the device is attributed to its owner, resolved from the hardware serial); without it, only the current user is scanned.
|
|
47
47
|
|
|
48
48
|
## Commands
|
|
49
49
|
|
package/package.json
CHANGED
package/src/auth.js
CHANGED
|
@@ -8,7 +8,7 @@ const authPages = require('./auth-pages');
|
|
|
8
8
|
const config = require('./config');
|
|
9
9
|
const output = require('./output');
|
|
10
10
|
const api = require('./api');
|
|
11
|
-
const { getDeviceSerial } = require('./device-serial');
|
|
11
|
+
const { getDeviceSerial, probeDeviceSerialLive } = require('./device-serial');
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
14
|
* Opens the browser for authentication and waits for the callback.
|
|
@@ -223,4 +223,31 @@ async function ensureLoggedIn({ apiKey, baseUrl, frontendUrl } = {}) {
|
|
|
223
223
|
return true;
|
|
224
224
|
}
|
|
225
225
|
|
|
226
|
-
|
|
226
|
+
/**
|
|
227
|
+
* Exchange the admin key + this device's hardware serial for the device owner's
|
|
228
|
+
* application key, so an org-wide scan is attributed to the owner, not the admin.
|
|
229
|
+
* Throws on a missing serial or unusable response so the caller can abort.
|
|
230
|
+
*/
|
|
231
|
+
async function fetchDeviceOwnerKey(adminApiKey, { baseUrl } = {}) {
|
|
232
|
+
// Probe the serial live, never the user-writable cache — it decides whose key
|
|
233
|
+
// gets minted, so a planted cache value must not be trusted here.
|
|
234
|
+
const deviceSerial = await probeDeviceSerialLive();
|
|
235
|
+
if (!deviceSerial) {
|
|
236
|
+
throw new Error(
|
|
237
|
+
"Could not read this device's hardware serial number. "
|
|
238
|
+
+ 'Re-run without sudo to onboard just the current user.'
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
const resp = await api.get('/api/v1/automations/mdm/get_application_api_key/', {
|
|
242
|
+
apiKey: adminApiKey,
|
|
243
|
+
baseUrl,
|
|
244
|
+
query: { serial_number: deviceSerial, app_type: 'default' },
|
|
245
|
+
});
|
|
246
|
+
const ownerKey = resp && resp.api_key;
|
|
247
|
+
if (!ownerKey) {
|
|
248
|
+
throw new Error('The backend did not return a key for this device.');
|
|
249
|
+
}
|
|
250
|
+
return ownerKey;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
module.exports = { loginWithBrowser, loginWithApiKey, ensureLoggedIn, fetchDeviceOwnerKey };
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
const os = require('os');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
|
|
4
|
+
const DEFAULT_CLAUDE_DIR = path.join(os.homedir(), '.claude');
|
|
5
|
+
|
|
6
|
+
// Mirrors Claude Code's own resolution: it reads CLAUDE_CONFIG_DIR verbatim,
|
|
7
|
+
// with no tilde expansion. Expanding "~" would install to $HOME/x while Claude
|
|
8
|
+
// reads ./~/x. A blank value is treated as unset so setup never targets the cwd.
|
|
9
|
+
function resolveClaudeConfigDir() {
|
|
10
|
+
const raw = (process.env.CLAUDE_CONFIG_DIR || '').trim();
|
|
11
|
+
return raw ? path.resolve(raw) : DEFAULT_CLAUDE_DIR;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Claude Code resolves CLAUDE_CONFIG_DIR against the cwd when it is set but
|
|
15
|
+
// empty, rather than falling back to ~/.claude — so a default install is one it
|
|
16
|
+
// never reads. Callers surface this instead of reporting a healthy setup.
|
|
17
|
+
const BLANK_CONFIG_DIR_MESSAGE =
|
|
18
|
+
'CLAUDE_CONFIG_DIR is set but empty — Claude Code reads that as a path relative to the ' +
|
|
19
|
+
'current directory, so it will not load hooks installed in ~/.claude. Unset it, or point ' +
|
|
20
|
+
'it at a real directory.';
|
|
21
|
+
|
|
22
|
+
function isBlankConfigDir() {
|
|
23
|
+
const raw = process.env.CLAUDE_CONFIG_DIR;
|
|
24
|
+
return typeof raw === 'string' && raw.trim() === '';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Windows paths are case-insensitive, so C:\\Users\\jane\\.claude and
|
|
28
|
+
// c:\\users\\jane\\.claude name one directory. Comparing them literally would
|
|
29
|
+
// class the default dir as relocated and forward a --config-dir nobody needs.
|
|
30
|
+
const samePath = process.platform === 'win32'
|
|
31
|
+
? (a, b) => a.toLowerCase() === b.toLowerCase()
|
|
32
|
+
: (a, b) => a === b;
|
|
33
|
+
|
|
34
|
+
function isNonDefaultConfigDir() {
|
|
35
|
+
return !samePath(resolveClaudeConfigDir(), DEFAULT_CLAUDE_DIR);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function claudeHooksDir(dir = resolveClaudeConfigDir()) {
|
|
39
|
+
return path.join(dir, 'hooks');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function claudeSettingsPath(dir = resolveClaudeConfigDir()) {
|
|
43
|
+
return path.join(dir, 'settings.json');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Renders a path under the home dir with a leading ~, so a default install
|
|
47
|
+
// still reads as ~/.claude/... in status output rather than a long absolute path.
|
|
48
|
+
function displayPath(p) {
|
|
49
|
+
const home = os.homedir();
|
|
50
|
+
if (samePath(p, home)) return '~';
|
|
51
|
+
const prefix = home + path.sep;
|
|
52
|
+
return samePath(p.slice(0, prefix.length), prefix) ? '~' + p.slice(home.length) : p;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const CLAUDE_INSTALL_MARKERS = [path.join('hooks', 'unbound.py'), 'anthropic_key.sh'];
|
|
56
|
+
|
|
57
|
+
// Reports hooks sitting in ~/.claude while a different dir is active, so the
|
|
58
|
+
// caller can fail loudly instead of showing a green status Claude never honors.
|
|
59
|
+
function detectConfigDirMismatch(markers = CLAUDE_INSTALL_MARKERS, { fs = require('fs') } = {}) {
|
|
60
|
+
const activeDir = resolveClaudeConfigDir();
|
|
61
|
+
if (samePath(activeDir, DEFAULT_CLAUDE_DIR)) return null;
|
|
62
|
+
const anyExists = (base) => markers.some((rel) => {
|
|
63
|
+
try { return fs.existsSync(path.join(base, rel)); } catch { return false; }
|
|
64
|
+
});
|
|
65
|
+
if (!anyExists(activeDir) && anyExists(DEFAULT_CLAUDE_DIR)) {
|
|
66
|
+
return { activeDir, staleDir: DEFAULT_CLAUDE_DIR };
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
module.exports = {
|
|
72
|
+
DEFAULT_CLAUDE_DIR,
|
|
73
|
+
resolveClaudeConfigDir,
|
|
74
|
+
samePath,
|
|
75
|
+
isNonDefaultConfigDir,
|
|
76
|
+
isBlankConfigDir,
|
|
77
|
+
BLANK_CONFIG_DIR_MESSAGE,
|
|
78
|
+
claudeHooksDir,
|
|
79
|
+
claudeSettingsPath,
|
|
80
|
+
displayPath,
|
|
81
|
+
detectConfigDirMismatch,
|
|
82
|
+
};
|
|
@@ -280,7 +280,7 @@ Examples:
|
|
|
280
280
|
$ unbound analytics tool-use mcp-actions --server claude_ai_notion
|
|
281
281
|
$ unbound analytics tool-use unsanctioned --format csv -o blocked.csv
|
|
282
282
|
|
|
283
|
-
Admin
|
|
283
|
+
Admin or Analytics Viewer. Applies to listing as well as CSV export.
|
|
284
284
|
${FILTER_HELP}${AGENT_HELP}`);
|
|
285
285
|
|
|
286
286
|
attach(toolUse, 'terminal', DATASETS.terminal, `
|
package/src/commands/chat.js
CHANGED
|
@@ -179,7 +179,7 @@ function normalizeAssistantTurn(turn) {
|
|
|
179
179
|
// backend-supplied strings out of the shell line so copy-pastes stay safe.
|
|
180
180
|
function friendlyChatError(err) {
|
|
181
181
|
if (err?.statusCode === 401) return 'Not authenticated. Run `unbound login` and try again.';
|
|
182
|
-
if (err?.statusCode === 403) return 'Chat requires Admin or Manager role. Run `unbound status` to check yours.';
|
|
182
|
+
if (err?.statusCode === 403) return 'Chat requires Admin, Analytics Viewer or Manager role. Run `unbound status` to check yours.';
|
|
183
183
|
if (err?.statusCode === 429) return 'Rate limited. Please wait a moment and try again.';
|
|
184
184
|
if (err?.statusCode === 504) return 'The turn took too long and the connection was closed. Try narrowing the question.';
|
|
185
185
|
return sanitizeForTerminal(err?.message || 'Request failed.');
|
|
@@ -666,7 +666,7 @@ INTERACTIVE SESSION
|
|
|
666
666
|
/exit quit (Ctrl+C also works)
|
|
667
667
|
|
|
668
668
|
NOTES
|
|
669
|
-
* Requires Admin or Manager role in your organization. Run 'unbound status' to check.
|
|
669
|
+
* Requires Admin, Analytics Viewer or Manager role in your organization. Run 'unbound status' to check.
|
|
670
670
|
* A question the assistant asks back is a normal reply, not an error: the turn
|
|
671
671
|
exits 0, and you answer it in the next one with -c.
|
|
672
672
|
* Each -m invocation is its own process and sends no history. -c carries the
|
package/src/commands/discover.js
CHANGED
|
@@ -5,6 +5,7 @@ const os = require('os');
|
|
|
5
5
|
const config = require('../config');
|
|
6
6
|
const output = require('../output');
|
|
7
7
|
const telemetry = require('../telemetry');
|
|
8
|
+
const { Option } = require('commander');
|
|
8
9
|
const { shellEscape, downloadToFile, withSecureTempFile, DISCOVER_BASE_URL } = require('../utils');
|
|
9
10
|
const LAUNCH_AGENT_LABEL = 'ai.getunbound.discovery';
|
|
10
11
|
|
|
@@ -17,7 +18,7 @@ const DISCOVERY_EXIT_UNSUPPORTED_OS = 3;
|
|
|
17
18
|
// indefinitely. Discovery enforces this itself — on expiry it releases its lock,
|
|
18
19
|
// reports the run as failed, and exits non-zero. Kept in sync with
|
|
19
20
|
// setup/mdm/onboard.py's DISCOVERY_TIMEOUT_SECONDS.
|
|
20
|
-
const DISCOVERY_TIMEOUT_SECONDS =
|
|
21
|
+
const DISCOVERY_TIMEOUT_SECONDS = 12000;
|
|
21
22
|
|
|
22
23
|
// Classifies a discovery subprocess exit code:
|
|
23
24
|
// 'success' (scan ran), 'unsupported' (skipped on this OS), or 'failure'.
|
|
@@ -34,8 +35,10 @@ function isWindowsNative() {
|
|
|
34
35
|
}
|
|
35
36
|
|
|
36
37
|
function isRoot() {
|
|
37
|
-
|
|
38
|
-
|
|
38
|
+
// Delegate to the shared, Windows-aware privilege check so `discover` and
|
|
39
|
+
// `onboard` agree on scope on every platform. Lazy require avoids any
|
|
40
|
+
// module-load cycle between discover and setup.
|
|
41
|
+
return require('./setup').hasRootPrivileges();
|
|
39
42
|
}
|
|
40
43
|
|
|
41
44
|
/**
|
|
@@ -194,6 +197,7 @@ function register(program) {
|
|
|
194
197
|
.option('--api-key <key>', 'Discovery API key (required)')
|
|
195
198
|
.option('--domain <url>', 'Backend URL (defaults to configured backend)')
|
|
196
199
|
.option('--set-cron', 'Set up a daily scheduled discovery scan at 09:00 (cross-platform)')
|
|
200
|
+
.addOption(new Option('--discovery-key <key>', 'Deprecated and ignored. The scan uses your API key.').hideHelp())
|
|
197
201
|
.addHelpText('after', `
|
|
198
202
|
Scans this device for installed AI coding tools (Cursor, Claude Code,
|
|
199
203
|
Gemini CLI, Codex, Windsurf, Roo Code, Cline, GitHub Copilot, JetBrains,
|
|
@@ -201,7 +205,7 @@ and more) and reports findings to the Unbound backend.
|
|
|
201
205
|
|
|
202
206
|
Which --api-key to use depends on scope:
|
|
203
207
|
- Single-user scan (no sudo): use your own user API key.
|
|
204
|
-
- All-users scan (sudo): use
|
|
208
|
+
- All-users scan (sudo): use your admin API key.
|
|
205
209
|
The --domain defaults to the backend URL configured via "unbound config set-backend-url"
|
|
206
210
|
(falls back to https://backend.getunbound.ai when unset).
|
|
207
211
|
|
|
@@ -218,8 +222,15 @@ Examples:
|
|
|
218
222
|
`)
|
|
219
223
|
.action(telemetry.wrapAction('discover', async (opts) => {
|
|
220
224
|
let scanSucceeded = false;
|
|
221
|
-
// Register the
|
|
225
|
+
// Register the api key so beforeSend redacts it from any event.
|
|
222
226
|
telemetry.rememberSecret(opts.apiKey);
|
|
227
|
+
// Accept and ignore a stale discovery key (flag or env) instead of erroring;
|
|
228
|
+
// the scan uses the API key. Redact it, then warn.
|
|
229
|
+
const staleDiscoveryKey = opts.discoveryKey || process.env.UNBOUND_DISCOVERY_KEY;
|
|
230
|
+
if (staleDiscoveryKey) {
|
|
231
|
+
telemetry.rememberSecret(staleDiscoveryKey);
|
|
232
|
+
output.warn('--discovery-key is deprecated and ignored — the scan uses your API key.');
|
|
233
|
+
}
|
|
223
234
|
try {
|
|
224
235
|
if (!opts.apiKey) {
|
|
225
236
|
output.error('--api-key is required.');
|
|
@@ -254,7 +265,7 @@ Examples:
|
|
|
254
265
|
const domain = opts.domain || config.getBaseUrl();
|
|
255
266
|
const suffix = domain && domain !== config.DEFAULT_BASE_URL ? ` --domain ${domain}` : '';
|
|
256
267
|
console.error(' Discovery completed successfully — only scheduled-run setup failed.');
|
|
257
|
-
console.error(` Re-run cron setup with: unbound discover --set-cron --api-key <
|
|
268
|
+
console.error(` Re-run cron setup with: unbound discover --set-cron --api-key <KEY>${suffix}`);
|
|
258
269
|
}
|
|
259
270
|
process.exitCode = 1;
|
|
260
271
|
}
|
package/src/commands/doctor.js
CHANGED
|
@@ -4,7 +4,8 @@ const api = require('../api');
|
|
|
4
4
|
const output = require('../output');
|
|
5
5
|
const { getDeviceSerial } = require('../device-serial');
|
|
6
6
|
const { detectTools, validateToolKeys, countValidationSkipped } = require('../toolHealth');
|
|
7
|
-
const { hasRootPrivileges } = require('./setup');
|
|
7
|
+
const { hasRootPrivileges, elevated } = require('./setup');
|
|
8
|
+
const claudePaths = require('../claudePaths');
|
|
8
9
|
|
|
9
10
|
// Validate the stored API key against the backend. Returns one of:
|
|
10
11
|
// valid | invalid | not-logged-in | unverified (network/other error).
|
|
@@ -74,7 +75,9 @@ Examples:
|
|
|
74
75
|
spin.stop();
|
|
75
76
|
|
|
76
77
|
const tampered = tools.filter((t) => t.status === 'tampered');
|
|
77
|
-
const
|
|
78
|
+
const configMismatch = claudePaths.detectConfigDirMismatch();
|
|
79
|
+
const blankConfigDir = claudePaths.isBlankConfigDir();
|
|
80
|
+
const unhealthy = tampered.length > 0 || key.state === 'invalid' || !!configMismatch || blankConfigDir;
|
|
78
81
|
|
|
79
82
|
if (opts.json) {
|
|
80
83
|
output.json({
|
|
@@ -92,12 +95,19 @@ Examples:
|
|
|
92
95
|
})),
|
|
93
96
|
healthy: !unhealthy,
|
|
94
97
|
key_validations_skipped: keySkippedCount,
|
|
98
|
+
claude_config_dir_mismatch: configMismatch,
|
|
99
|
+
claude_config_dir_blank: blankConfigDir,
|
|
95
100
|
});
|
|
96
101
|
if (unhealthy) process.exitCode = 1;
|
|
97
102
|
return;
|
|
98
103
|
}
|
|
99
104
|
|
|
100
105
|
const C = output.colors;
|
|
106
|
+
const reportConfigMismatch = (m) => {
|
|
107
|
+
console.log(C.red('✗ Claude Code is installed where Claude Code won’t read it.'));
|
|
108
|
+
console.log(` Install lives in ${C.bold(m.staleDir)} but ${C.bold('CLAUDE_CONFIG_DIR')} points at ${C.bold(m.activeDir)}.`);
|
|
109
|
+
console.log(` Re-run ${C.bold('unbound setup')} with CLAUDE_CONFIG_DIR set so it installs under the active dir.`);
|
|
110
|
+
};
|
|
101
111
|
const labelOf = (t) => t.label + (t.mode && t.mode !== 'subscription' ? ` (${t.mode})` : '');
|
|
102
112
|
const W = Math.max(7, ...tools.map((t) => labelOf(t).length)); // 7 = "API key"
|
|
103
113
|
|
|
@@ -146,7 +156,16 @@ Examples:
|
|
|
146
156
|
const anyInstalled = tools.some((t) => t.status !== 'not-installed');
|
|
147
157
|
|
|
148
158
|
if (opts.fix) {
|
|
159
|
+
if (blankConfigDir) {
|
|
160
|
+
output.error(claudePaths.BLANK_CONFIG_DIR_MESSAGE);
|
|
161
|
+
console.log('');
|
|
162
|
+
}
|
|
163
|
+
if (configMismatch) {
|
|
164
|
+
reportConfigMismatch(configMismatch);
|
|
165
|
+
console.log('');
|
|
166
|
+
}
|
|
149
167
|
if (!tampered.length) {
|
|
168
|
+
if (configMismatch || blankConfigDir) { process.exitCode = 1; return; }
|
|
150
169
|
if (!anyInstalled) console.log(`${C.yellow('No tools are set up yet')} — nothing to fix. Run ${C.bold('unbound setup')} first.`);
|
|
151
170
|
else output.success('Nothing to fix — all set up tools are healthy.');
|
|
152
171
|
console.log('');
|
|
@@ -157,7 +176,7 @@ Examples:
|
|
|
157
176
|
// tools only, and point at sudo for whatever's org-managed.
|
|
158
177
|
if (!root && !userFix.length) {
|
|
159
178
|
console.log(`${mdmNames} ${mdmFix.length === 1 ? 'is' : 'are'} set up by your organization.`);
|
|
160
|
-
console.log(` Run ${C.bold('
|
|
179
|
+
console.log(` Run ${C.bold(elevated('unbound doctor --fix'))} to repair ${mdmFix.length === 1 ? 'it' : 'them'}.`);
|
|
161
180
|
console.log('');
|
|
162
181
|
process.exitCode = 1;
|
|
163
182
|
return;
|
|
@@ -167,13 +186,13 @@ Examples:
|
|
|
167
186
|
output.info(`Reinstalling: ${fixDisplay}${root ? ' (org-wide, all users)' : ''}`);
|
|
168
187
|
console.log('');
|
|
169
188
|
const r = spawnSync(process.argv[0], [process.argv[1], 'setup', ...fixNow], { stdio: 'inherit' });
|
|
170
|
-
if (!root && mdmFix.length) console.error(` ${mdmNames} ${mdmFix.length === 1 ? 'is' : 'are'} set up by your organization — run ${C.bold('
|
|
171
|
-
process.exitCode = fixExitCode(r.status, root, mdmFix.length > 0);
|
|
189
|
+
if (!root && mdmFix.length) console.error(` ${mdmNames} ${mdmFix.length === 1 ? 'is' : 'are'} set up by your organization — run ${C.bold(elevated('unbound doctor --fix'))} to repair ${mdmFix.length === 1 ? 'it' : 'them'}.`);
|
|
190
|
+
process.exitCode = fixExitCode(r.status, root, mdmFix.length > 0) || ((configMismatch || blankConfigDir) ? 1 : 0);
|
|
172
191
|
return;
|
|
173
192
|
}
|
|
174
193
|
|
|
175
194
|
// No `--fix`: report and show the command(s) that fix it.
|
|
176
|
-
if (!anyInstalled && key.state !== 'invalid') {
|
|
195
|
+
if (!anyInstalled && key.state !== 'invalid' && !configMismatch && !blankConfigDir) {
|
|
177
196
|
console.log(`${C.yellow('No AI tools are set up yet.')} Run ${C.bold('unbound setup')} to get started.`);
|
|
178
197
|
console.log('');
|
|
179
198
|
return;
|
|
@@ -183,17 +202,22 @@ Examples:
|
|
|
183
202
|
console.log('');
|
|
184
203
|
return;
|
|
185
204
|
}
|
|
205
|
+
if (blankConfigDir) {
|
|
206
|
+
output.error(claudePaths.BLANK_CONFIG_DIR_MESSAGE);
|
|
207
|
+
process.exitCode = 1;
|
|
208
|
+
}
|
|
209
|
+
if (configMismatch) reportConfigMismatch(configMismatch);
|
|
186
210
|
if (tampered.length) {
|
|
187
211
|
console.log(C.red(`✗ ${tampered.length} tool${tampered.length === 1 ? ' needs' : 's need'} attention.`));
|
|
188
212
|
if (!mdmFix.length) {
|
|
189
213
|
console.log(` Run ${C.bold('unbound doctor --fix')} to repair ${userNames}.`);
|
|
190
214
|
} else if (!userFix.length) {
|
|
191
215
|
console.log(` ${mdmNames} ${mdmFix.length === 1 ? 'is' : 'are'} set up by your organization.`);
|
|
192
|
-
console.log(` Run ${C.bold('
|
|
216
|
+
console.log(` Run ${C.bold(elevated('unbound doctor --fix'))} to repair ${mdmFix.length === 1 ? 'it' : 'them'}.`);
|
|
193
217
|
} else {
|
|
194
218
|
// Both: a user without sudo can still fix their own tools; sudo fixes everything.
|
|
195
219
|
console.log(` Run ${C.bold('unbound doctor --fix')} to repair ${userNames}.`);
|
|
196
|
-
console.log(` ${mdmNames} ${mdmFix.length === 1 ? 'is' : 'are'} set up by your organization — run ${C.bold('
|
|
220
|
+
console.log(` ${mdmNames} ${mdmFix.length === 1 ? 'is' : 'are'} set up by your organization — run ${C.bold(elevated('unbound doctor --fix'))} to repair ${mdmFix.length === 1 ? 'it' : 'them'} too.`);
|
|
197
221
|
}
|
|
198
222
|
}
|
|
199
223
|
if (key.state === 'invalid') {
|
package/src/commands/download.js
CHANGED
|
@@ -162,7 +162,7 @@ Examples:
|
|
|
162
162
|
$ unbound download start logs-export --check --last 90d --json
|
|
163
163
|
$ unbound download get 4f2a1c9e-1b2c-4d5e-8f90-a1b2c3d4e5f6
|
|
164
164
|
|
|
165
|
-
Admin
|
|
165
|
+
Admin or Analytics Viewer. Exports are kept for ${RETENTION_HOURS} hours.
|
|
166
166
|
`);
|
|
167
167
|
|
|
168
168
|
group
|
|
@@ -203,7 +203,7 @@ Examples:
|
|
|
203
203
|
$ unbound download start analytics-export --filter dataset=terminal-runs --last 7d
|
|
204
204
|
$ unbound download start logs-export --check --last 90d --json
|
|
205
205
|
|
|
206
|
-
Admin
|
|
206
|
+
Admin or Analytics Viewer. Exports are kept for ${RETENTION_HOURS} hours.
|
|
207
207
|
${FILTER_HELP}${AGENT_HELP}`)
|
|
208
208
|
.action(async (kind, opts) => {
|
|
209
209
|
try {
|
package/src/commands/oacb.js
CHANGED
|
@@ -17,13 +17,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
|
|
|
20
|
-
|
|
21
|
-
const
|
|
20
|
+
const claudePaths = require('../claudePaths');
|
|
21
|
+
const CLAUDE_DIR = claudePaths.resolveClaudeConfigDir();
|
|
22
|
+
|
|
23
|
+
const CONSENT_RECEIPT_PATH = path.join(CLAUDE_DIR, 'oacb-consent.json');
|
|
24
|
+
const LEGACY_CONSENT_RECEIPT_PATH = path.join(claudePaths.DEFAULT_CLAUDE_DIR, 'oacb-consent.json');
|
|
22
25
|
|
|
23
26
|
// Claude Code paths
|
|
24
|
-
const CLAUDE_HOOKS_DIR =
|
|
25
|
-
const CLAUDE_SETTINGS_PATH =
|
|
26
|
-
const CLAUDE_SETTINGS_BACKUP_PATH = path.join(
|
|
27
|
+
const CLAUDE_HOOKS_DIR = claudePaths.claudeHooksDir(CLAUDE_DIR);
|
|
28
|
+
const CLAUDE_SETTINGS_PATH = claudePaths.claudeSettingsPath(CLAUDE_DIR);
|
|
29
|
+
const CLAUDE_SETTINGS_BACKUP_PATH = path.join(CLAUDE_DIR, 'settings.json.oacb-backup');
|
|
27
30
|
|
|
28
31
|
// Codex paths
|
|
29
32
|
const CODEX_HOOKS_DIR = path.join(os.homedir(), '.codex', 'hooks');
|
|
@@ -203,7 +206,7 @@ async function handleCheck() {
|
|
|
203
206
|
['Unbound session', hasUnboundSession ? 'logged in' : 'not logged in (optional)'],
|
|
204
207
|
['─── Claude Code ───', ''],
|
|
205
208
|
['Claude Code on PATH', claudeCodeVersion || 'not detected'],
|
|
206
|
-
[
|
|
209
|
+
[claudePaths.displayPath(CLAUDE_SETTINGS_PATH), claudeSettingsExist ? 'found' : 'not found'],
|
|
207
210
|
['OACB tier applied', claudeTier !== 'none' ? claudeTier : 'none (run `unbound oacb apply`)'],
|
|
208
211
|
['Hooks installed', `${claudeHookStates.filter(Boolean).length} / ${HOOK_NAMES.length}`],
|
|
209
212
|
['─── Codex CLI ─────', ''],
|
|
@@ -225,7 +228,22 @@ async function handleCheck() {
|
|
|
225
228
|
output.warn('No OACB tier applied to any agent. Run `unbound oacb apply` to start with shadow tier.');
|
|
226
229
|
}
|
|
227
230
|
|
|
228
|
-
|
|
231
|
+
const blankConfigDir = claudePaths.isBlankConfigDir();
|
|
232
|
+
if (blankConfigDir) {
|
|
233
|
+
output.error(claudePaths.BLANK_CONFIG_DIR_MESSAGE);
|
|
234
|
+
process.exitCode = 1;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const mismatch = claudePaths.detectConfigDirMismatch([path.join('hooks', HOOK_NAMES[0])]);
|
|
238
|
+
if (mismatch) {
|
|
239
|
+
output.error(
|
|
240
|
+
`OACB hooks installed in ${mismatch.staleDir} but CLAUDE_CONFIG_DIR points at ${mismatch.activeDir} — ` +
|
|
241
|
+
'Claude will not load them; re-run `unbound oacb apply`.'
|
|
242
|
+
);
|
|
243
|
+
process.exitCode = 1;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (!mismatch && !blankConfigDir) output.success('Pre-flight complete.');
|
|
229
247
|
}
|
|
230
248
|
|
|
231
249
|
async function handleAudit({ agent, tier, format }) {
|
|
@@ -464,7 +482,7 @@ async function _applyClaudeCode({ baseline, tier, overrides, localHooks }) {
|
|
|
464
482
|
|
|
465
483
|
output.success(`OACB ${tier} applied (claude-code).`);
|
|
466
484
|
if (tier === 'shadow') {
|
|
467
|
-
output.info(
|
|
485
|
+
output.info(`Shadow: hooks log but never block. Review ${path.join(CLAUDE_HOOKS_DIR, 'oacb-audit.log')} for 2 weeks, then \`unbound oacb apply --tier baseline\`.`);
|
|
468
486
|
} else if (tier === 'receipts') {
|
|
469
487
|
output.info('Receipts: engineer sees WARN banners for risky commands and can abort. Expires in 30 days.');
|
|
470
488
|
}
|
|
@@ -762,6 +780,11 @@ async function handleRemove({ agent, dryRun }) {
|
|
|
762
780
|
output.success(`Wrote cleaned ${CLAUDE_SETTINGS_PATH}`);
|
|
763
781
|
|
|
764
782
|
try { await fs.unlink(CONSENT_RECEIPT_PATH); } catch (_) {}
|
|
783
|
+
// readConsentReceipt falls back to the legacy receipt, so a leftover copy
|
|
784
|
+
// would keep reporting OACB as installed after it was removed.
|
|
785
|
+
if (!claudePaths.samePath(CONSENT_RECEIPT_PATH, LEGACY_CONSENT_RECEIPT_PATH)) {
|
|
786
|
+
try { await fs.unlink(LEGACY_CONSENT_RECEIPT_PATH); } catch (_) {}
|
|
787
|
+
}
|
|
765
788
|
// The backup is no longer load-bearing after a successful remove — clean it
|
|
766
789
|
// up so the user's ~/.claude/ doesn't accumulate stale OACB state.
|
|
767
790
|
try { await fs.unlink(CLAUDE_SETTINGS_BACKUP_PATH); } catch (_) {}
|
|
@@ -1517,7 +1540,7 @@ async function historyDryRun(tier, agent) {
|
|
|
1517
1540
|
const commands = [];
|
|
1518
1541
|
|
|
1519
1542
|
// Try Claude Code session logs first (most accurate for auto-mode context)
|
|
1520
|
-
const ccProjectsDir = path.join(
|
|
1543
|
+
const ccProjectsDir = path.join(CLAUDE_DIR, 'projects');
|
|
1521
1544
|
try {
|
|
1522
1545
|
const projectDirs = await fs.readdir(ccProjectsDir);
|
|
1523
1546
|
for (const proj of projectDirs.slice(-5)) { // last 5 projects to bound cost
|
|
@@ -1676,6 +1699,9 @@ async function readConsentReceipt() {
|
|
|
1676
1699
|
try {
|
|
1677
1700
|
return JSON.parse(await fs.readFile(CONSENT_RECEIPT_PATH, 'utf8'));
|
|
1678
1701
|
} catch (_) {
|
|
1702
|
+
if (!claudePaths.samePath(CONSENT_RECEIPT_PATH, LEGACY_CONSENT_RECEIPT_PATH)) {
|
|
1703
|
+
try { return JSON.parse(await fs.readFile(LEGACY_CONSENT_RECEIPT_PATH, 'utf8')); } catch (_) {}
|
|
1704
|
+
}
|
|
1679
1705
|
return null;
|
|
1680
1706
|
}
|
|
1681
1707
|
}
|
|
@@ -1685,7 +1711,7 @@ async function readConsentReceipt() {
|
|
|
1685
1711
|
async function handleWhy({ agent }) {
|
|
1686
1712
|
const logPath = agent === 'codex'
|
|
1687
1713
|
? path.join(os.homedir(), '.codex', 'hooks', 'oacb-audit.log')
|
|
1688
|
-
: path.join(
|
|
1714
|
+
: path.join(CLAUDE_HOOKS_DIR, 'oacb-audit.log');
|
|
1689
1715
|
|
|
1690
1716
|
let entry = null;
|
|
1691
1717
|
try {
|
|
@@ -1778,7 +1804,7 @@ async function handleStatus({ agent }) {
|
|
|
1778
1804
|
|
|
1779
1805
|
const logPath = agent === 'codex'
|
|
1780
1806
|
? path.join(os.homedir(), '.codex', 'hooks', 'oacb-audit.log')
|
|
1781
|
-
: path.join(
|
|
1807
|
+
: path.join(CLAUDE_HOOKS_DIR, 'oacb-audit.log');
|
|
1782
1808
|
|
|
1783
1809
|
// Expiry countdown
|
|
1784
1810
|
let expiryNote = '';
|