unbound-cli 1.4.0 → 1.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,363 @@
1
+ const { test, beforeEach, after } = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const { Command } = require('commander');
4
+
5
+ // Integration tests for the --no-ai steering guard (WEB-4887, layers 1+2+3).
6
+ // Invokes `unbound policy tool create-terminal` / `create-mcp` against a fresh
7
+ // Command instance with src/api.js and src/config.js stubbed. We never hit the
8
+ // network and never read/write user config.
9
+ //
10
+ // Harness mirrors test/policy-ai-assist.test.js (loadFreshModules, buildHarness,
11
+ // per-test process.exitCode reset). See PLAN risk R7.
12
+
13
+ function loadFreshModules() {
14
+ // Drop the module cache so each test gets a clean module-level
15
+ // `_privilegesCache` in policy-ai-assist and a clean stub surface.
16
+ for (const m of [
17
+ '../src/commands/policy',
18
+ '../src/lib/no-ai-guard',
19
+ '../src/lib/policy-ai-assist',
20
+ '../src/api',
21
+ '../src/config',
22
+ '../src/output',
23
+ ]) {
24
+ delete require.cache[require.resolve(m)];
25
+ }
26
+ const api = require('../src/api');
27
+ const config = require('../src/config');
28
+ const output = require('../src/output');
29
+ return { api, config, output };
30
+ }
31
+
32
+ function buildHarness({
33
+ privilegesResponse = { is_admin: true, is_manager: false, is_member: false },
34
+ assistResponse = null,
35
+ assistMcpResponse = null,
36
+ createResponse = { id: 1, name: 'ok' },
37
+ } = {}) {
38
+ const { api, config, output } = loadFreshModules();
39
+
40
+ config.isLoggedIn = () => true;
41
+ config.getApiKey = () => 'fake-key';
42
+ config.getBaseUrl = () => 'https://b.acme';
43
+
44
+ const calls = { posts: [], gets: [] };
45
+ api.get = async (path, opts) => {
46
+ calls.gets.push({ path, opts });
47
+ if (path === '/api/v1/users/privileges/') return privilegesResponse;
48
+ throw new Error(`unexpected GET ${path}`);
49
+ };
50
+ api.post = async (path, opts) => {
51
+ calls.posts.push({ path, body: opts && opts.body });
52
+ if (path === '/api/v1/command-policies/assist/') return assistResponse;
53
+ if (path === '/api/v1/command-policies/assist-mcp/') return assistMcpResponse;
54
+ if (path === '/api/v1/command-policies/') return createResponse;
55
+ throw new Error(`unexpected POST ${path}`);
56
+ };
57
+
58
+ const captured = { success: [], error: [], warn: [], info: [], stdout: [] };
59
+ output.success = (m) => captured.success.push(m);
60
+ output.error = (m) => captured.error.push(m);
61
+ output.warn = (m) => captured.warn.push(m);
62
+ output.info = (m) => captured.info.push(m);
63
+ const origLog = console.log;
64
+ console.log = (m) => captured.stdout.push(String(m || ''));
65
+ const restoreLog = () => { console.log = origLog; };
66
+
67
+ return { api, config, output, calls, captured, restoreLog };
68
+ }
69
+
70
+ async function runArgv(argv) {
71
+ const { register } = require('../src/commands/policy');
72
+ const program = new Command();
73
+ program.exitOverride();
74
+ register(program);
75
+ await program.parseAsync(['node', 'unbound', ...argv]);
76
+ }
77
+
78
+ // Save+restore the two env vars the guard reads. Always set them to a known
79
+ // state on entry (undefined unless explicitly overridden) so a stale value from
80
+ // a previous test or the ambient shell cannot leak into this test.
81
+ function withEnv(overrides, fn) {
82
+ const keys = ['CLAUDECODE', 'UNBOUND_ALLOW_NO_AI_UNDER_CLAUDE'];
83
+ const saved = {};
84
+ for (const k of keys) {
85
+ saved[k] = process.env[k];
86
+ if (Object.prototype.hasOwnProperty.call(overrides, k)) {
87
+ if (overrides[k] === undefined) delete process.env[k];
88
+ else process.env[k] = overrides[k];
89
+ } else {
90
+ delete process.env[k];
91
+ }
92
+ }
93
+ return Promise.resolve()
94
+ .then(fn)
95
+ .finally(() => {
96
+ for (const k of keys) {
97
+ if (saved[k] === undefined) delete process.env[k];
98
+ else process.env[k] = saved[k];
99
+ }
100
+ });
101
+ }
102
+
103
+ beforeEach(() => {
104
+ process.exitCode = 0;
105
+ });
106
+
107
+ // Several tests intentionally set process.exitCode to non-zero values to
108
+ // observe the CLI's exit-code routing. Reset it at the end so node:test does
109
+ // not interpret the file itself as having failed.
110
+ after(() => {
111
+ process.exitCode = 0;
112
+ });
113
+
114
+ // T1 — Layer 1, create-terminal: no --prompt + no --no-ai → exit 2, AI steering message.
115
+ test('T1: create-terminal without --prompt and without --no-ai exits 2 with AI-assist steering message', async () => {
116
+ const h = buildHarness();
117
+ await withEnv({}, async () => {
118
+ try {
119
+ await runArgv(['policy', 'tool', 'create-terminal']);
120
+ assert.equal(process.exitCode, 2);
121
+ const errs = h.captured.error.join('\n');
122
+ assert.ok(errs.includes('AI-assisted'), `expected "AI-assisted" in error: ${errs}`);
123
+ assert.ok(errs.includes('Retry with'), `expected "Retry with" in error: ${errs}`);
124
+ assert.ok(errs.includes('--prompt'), `expected "--prompt" in error: ${errs}`);
125
+ assert.equal(h.calls.posts.length, 0, 'no network call should have fired');
126
+ } finally {
127
+ h.restoreLog();
128
+ }
129
+ });
130
+ });
131
+
132
+ // T2 — Layer 1, create-mcp: same shape as T1, scoped to create-mcp.
133
+ test('T2: create-mcp without --prompt and without --no-ai exits 2 with AI-assist steering message', async () => {
134
+ const h = buildHarness();
135
+ await withEnv({}, async () => {
136
+ try {
137
+ await runArgv(['policy', 'tool', 'create-mcp']);
138
+ assert.equal(process.exitCode, 2);
139
+ const errs = h.captured.error.join('\n');
140
+ assert.ok(errs.includes('AI-assisted'), `expected "AI-assisted" in error: ${errs}`);
141
+ assert.ok(errs.includes('Retry with'), `expected "Retry with" in error: ${errs}`);
142
+ assert.ok(errs.includes('--prompt'), `expected "--prompt" in error: ${errs}`);
143
+ assert.equal(h.calls.posts.length, 0, 'no network call should have fired');
144
+ } finally {
145
+ h.restoreLog();
146
+ }
147
+ });
148
+ });
149
+
150
+ // T3 — Layer 1 happy path, create-terminal: --no-ai + raw flags → create POST fires.
151
+ test('T3: create-terminal --no-ai with required raw flags POSTs to /command-policies/ with exit 0', async () => {
152
+ const h = buildHarness();
153
+ await withEnv({}, async () => {
154
+ try {
155
+ await runArgv([
156
+ 'policy', 'tool', 'create-terminal',
157
+ '--no-ai',
158
+ '--name', 'X',
159
+ '--command-family', 'git',
160
+ '--field', 'command=git push*',
161
+ '--action', 'AUDIT',
162
+ ]);
163
+ assert.equal(process.exitCode || 0, 0);
164
+ assert.ok(
165
+ h.calls.posts.some((c) => c.path === '/api/v1/command-policies/'),
166
+ `expected create POST, got: ${JSON.stringify(h.calls.posts)}`
167
+ );
168
+ } finally {
169
+ h.restoreLog();
170
+ }
171
+ });
172
+ });
173
+
174
+ // T4 — Layer 1 happy path, create-mcp.
175
+ test('T4: create-mcp --no-ai with required raw flags POSTs to /command-policies/ with exit 0', async () => {
176
+ const h = buildHarness();
177
+ await withEnv({}, async () => {
178
+ try {
179
+ await runArgv([
180
+ 'policy', 'tool', 'create-mcp',
181
+ '--no-ai',
182
+ '--name', 'X',
183
+ '--mcp-server', 'linear',
184
+ '--mcp-action-type', 'read',
185
+ '--action', 'AUDIT',
186
+ ]);
187
+ assert.equal(process.exitCode || 0, 0);
188
+ assert.ok(
189
+ h.calls.posts.some((c) => c.path === '/api/v1/command-policies/'),
190
+ `expected create POST, got: ${JSON.stringify(h.calls.posts)}`
191
+ );
192
+ } finally {
193
+ h.restoreLog();
194
+ }
195
+ });
196
+ });
197
+
198
+ // T5 — Mutex (layer 1): --prompt + --no-ai exits 2 with "not both" wording.
199
+ test('T5: create-terminal --prompt + --no-ai exits 2 with "not both" wording and no network call', async () => {
200
+ const h = buildHarness();
201
+ await withEnv({}, async () => {
202
+ try {
203
+ await runArgv(['policy', 'tool', 'create-terminal', '--prompt', 'block rm -rf', '--no-ai']);
204
+ assert.equal(process.exitCode, 2);
205
+ const errs = h.captured.error.join('\n');
206
+ assert.ok(errs.includes('not both'), `expected "not both" in error: ${errs}`);
207
+ assert.equal(h.calls.posts.length, 0, 'no network call should have fired');
208
+ } finally {
209
+ h.restoreLog();
210
+ }
211
+ });
212
+ });
213
+
214
+ // T6 — Layer 2: --no-ai under CLAUDECODE=1 without escape hatch → exit 2.
215
+ test('T6: create-terminal --no-ai under CLAUDECODE=1 exits 2 with human-only wording and no network call', async () => {
216
+ const h = buildHarness();
217
+ await withEnv({ CLAUDECODE: '1' }, async () => {
218
+ try {
219
+ await runArgv([
220
+ 'policy', 'tool', 'create-terminal',
221
+ '--no-ai',
222
+ '--name', 'X',
223
+ '--command-family', 'git',
224
+ '--field', 'command=git push*',
225
+ '--action', 'AUDIT',
226
+ ]);
227
+ assert.equal(process.exitCode, 2);
228
+ const errs = h.captured.error.join('\n');
229
+ assert.ok(errs.includes('CLAUDECODE=1'), `expected "CLAUDECODE=1" in error: ${errs}`);
230
+ assert.ok(
231
+ errs.includes('intended for interactive humans'),
232
+ `expected "intended for interactive humans" in error: ${errs}`
233
+ );
234
+ assert.equal(h.calls.posts.length, 0, 'no network call should have fired');
235
+ } finally {
236
+ h.restoreLog();
237
+ }
238
+ });
239
+ });
240
+
241
+ // T7 — Layer 2 escape hatch: with UNBOUND_ALLOW_NO_AI_UNDER_CLAUDE=1, layer 2 is
242
+ // bypassed and the existing flag-path then errors out on missing --name.
243
+ test('T7: --no-ai under CLAUDECODE=1 + UNBOUND_ALLOW_NO_AI_UNDER_CLAUDE=1 bypasses layer 2 and falls through to flag-path required-field error', async () => {
244
+ const h = buildHarness();
245
+ await withEnv({ CLAUDECODE: '1', UNBOUND_ALLOW_NO_AI_UNDER_CLAUDE: '1' }, async () => {
246
+ try {
247
+ await runArgv(['policy', 'tool', 'create-terminal', '--no-ai']);
248
+ assert.equal(process.exitCode, 1, 'flag-path required-field error must use exit 1, not the guard exit 2');
249
+ const errs = h.captured.error.join('\n');
250
+ assert.ok(errs.includes('--name is required'), `expected "--name is required" in error: ${errs}`);
251
+ assert.equal(h.calls.posts.length, 0, 'no network call should have fired');
252
+ } finally {
253
+ h.restoreLog();
254
+ }
255
+ });
256
+ });
257
+
258
+ // T8 — Layer 2 does not interfere with --prompt under CLAUDECODE=1.
259
+ test('T8: create-terminal --prompt under CLAUDECODE=1 routes to the assist endpoint', async () => {
260
+ const h = buildHarness({
261
+ assistResponse: {
262
+ success: true,
263
+ form_updates: {
264
+ command_family: 'filesystem',
265
+ selected_field: 'command',
266
+ field_value: 'rm -rf*',
267
+ action: 'AUDIT',
268
+ name: 'X',
269
+ },
270
+ explanation: 'ok',
271
+ },
272
+ });
273
+ await withEnv({ CLAUDECODE: '1' }, async () => {
274
+ try {
275
+ await runArgv(['policy', 'tool', 'create-terminal', '--prompt', 'block rm -rf', '--yes']);
276
+ assert.ok(
277
+ h.calls.posts.some((c) => c.path === '/api/v1/command-policies/assist/'),
278
+ `expected assist POST, got: ${JSON.stringify(h.calls.posts.map((c) => c.path))}`
279
+ );
280
+ } finally {
281
+ h.restoreLog();
282
+ }
283
+ });
284
+ });
285
+
286
+ // T9 — Layer 3: create-terminal --help banner appears before "Usage:".
287
+ test('T9: create-terminal --help renders AI-ASSISTED banner before "Usage:"', async () => {
288
+ const h = buildHarness();
289
+ let captured = '';
290
+ const origWrite = process.stdout.write.bind(process.stdout);
291
+ process.stdout.write = (s) => { captured += s; return true; };
292
+ try {
293
+ await withEnv({}, async () => {
294
+ try {
295
+ await runArgv(['policy', 'tool', 'create-terminal', '--help']);
296
+ } catch (err) {
297
+ // commander.exitOverride() throws on --help with code 'commander.helpDisplayed'.
298
+ if (err && err.code && err.code !== 'commander.helpDisplayed') throw err;
299
+ }
300
+ });
301
+ } finally {
302
+ process.stdout.write = origWrite;
303
+ h.restoreLog();
304
+ }
305
+ const bannerIdx = captured.indexOf('AI-ASSISTED (preferred):');
306
+ const usageIdx = captured.indexOf('Usage:');
307
+ assert.notEqual(bannerIdx, -1, `expected "AI-ASSISTED (preferred):" banner in help output: ${captured}`);
308
+ assert.notEqual(usageIdx, -1, `expected "Usage:" in help output: ${captured}`);
309
+ assert.ok(bannerIdx < usageIdx, `banner (idx ${bannerIdx}) should precede Usage (idx ${usageIdx})`);
310
+ });
311
+
312
+ // T10 — Layer 3: create-mcp --help banner appears before "Usage:".
313
+ test('T10: create-mcp --help renders AI-ASSISTED banner before "Usage:"', async () => {
314
+ const h = buildHarness();
315
+ let captured = '';
316
+ const origWrite = process.stdout.write.bind(process.stdout);
317
+ process.stdout.write = (s) => { captured += s; return true; };
318
+ try {
319
+ await withEnv({}, async () => {
320
+ try {
321
+ await runArgv(['policy', 'tool', 'create-mcp', '--help']);
322
+ } catch (err) {
323
+ if (err && err.code && err.code !== 'commander.helpDisplayed') throw err;
324
+ }
325
+ });
326
+ } finally {
327
+ process.stdout.write = origWrite;
328
+ h.restoreLog();
329
+ }
330
+ const bannerIdx = captured.indexOf('AI-ASSISTED (preferred):');
331
+ const usageIdx = captured.indexOf('Usage:');
332
+ assert.notEqual(bannerIdx, -1, `expected "AI-ASSISTED (preferred):" banner in help output: ${captured}`);
333
+ assert.notEqual(usageIdx, -1, `expected "Usage:" in help output: ${captured}`);
334
+ assert.ok(bannerIdx < usageIdx, `banner (idx ${bannerIdx}) should precede Usage (idx ${usageIdx})`);
335
+ });
336
+
337
+ // T11 — Regression smoke: existing --prompt happy path still reaches assist endpoint.
338
+ test('T11: create-terminal --prompt without CLAUDECODE still routes to the assist endpoint (Phase-1 regression)', async () => {
339
+ const h = buildHarness({
340
+ assistResponse: {
341
+ success: true,
342
+ form_updates: {
343
+ command_family: 'git',
344
+ selected_field: 'command',
345
+ field_value: 'npm install*',
346
+ action: 'AUDIT',
347
+ name: 'X',
348
+ },
349
+ explanation: 'ok',
350
+ },
351
+ });
352
+ await withEnv({}, async () => {
353
+ try {
354
+ await runArgv(['policy', 'tool', 'create-terminal', '--prompt', 'audit npm installs', '--yes']);
355
+ assert.ok(
356
+ h.calls.posts.some((c) => c.path === '/api/v1/command-policies/assist/'),
357
+ `expected assist POST, got: ${JSON.stringify(h.calls.posts.map((c) => c.path))}`
358
+ );
359
+ } finally {
360
+ h.restoreLog();
361
+ }
362
+ });
363
+ });
@@ -1,6 +1,9 @@
1
1
  const { test } = require('node:test');
2
2
  const assert = require('node:assert/strict');
3
- const { buildScriptArgs, scriptSupportsBackfill, resolveSetupAllTools } = require('../src/commands/setup');
3
+ const fs = require('fs');
4
+ const os = require('os');
5
+ const path = require('path');
6
+ const { buildScriptArgs, scriptSupportsBackfill, resolveSetupAllTools, clearUnboundEnvsEverywhere, NUKE_ENV_VARS } = require('../src/commands/setup');
4
7
 
5
8
  // shellEscape single-quotes every value, so a real key surfaces as
6
9
  // --api-key '<key>' at the head of the argv tail.
@@ -147,3 +150,72 @@ test('resolveSetupAllTools(true): clear-all covers every tool incl. gateway mode
147
150
  assert.ok(tools.includes(t), `clear-all missing install-bundle tool ${t}`);
148
151
  }
149
152
  });
153
+
154
+ // WEB-4886: nuke must strip stale UNBOUND_* / ANTHROPIC_BASE_URL lines from
155
+ // EVERY candidate rc file, not just the one the python --clear script reaches.
156
+ // Skip on Windows (rc-file sweep is POSIX-only; the registry path runs `reg`).
157
+ if (process.platform !== 'win32') {
158
+ test('clearUnboundEnvsEverywhere: removes Unbound exports from every candidate rc, preserves other lines', () => {
159
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'unbound-nuke-'));
160
+ const origHome = process.env.HOME;
161
+ process.env.HOME = tmp;
162
+ try {
163
+ // .bashrc and .zshrc are in the candidate list on both darwin and linux,
164
+ // so the test is hermetic across CI (linux) and dev (darwin).
165
+ const bashrc = path.join(tmp, '.bashrc');
166
+ const zshrc = path.join(tmp, '.zshrc');
167
+ fs.writeFileSync(bashrc, [
168
+ '# user content',
169
+ 'export UNBOUND_CURSOR_API_KEY="old"',
170
+ 'export PATH=/usr/local/bin:$PATH',
171
+ 'export UNBOUND_CLAUDE_API_KEY=abc',
172
+ '',
173
+ ].join('\n'));
174
+ fs.writeFileSync(zshrc, [
175
+ 'export ANTHROPIC_BASE_URL="https://gateway.example"',
176
+ 'alias ll="ls -la"',
177
+ '',
178
+ ].join('\n'));
179
+
180
+ const cleared = clearUnboundEnvsEverywhere();
181
+ assert.ok(cleared.length > 0, `expected something cleared, got ${JSON.stringify(cleared)}`);
182
+
183
+ const bNow = fs.readFileSync(bashrc, 'utf8');
184
+ assert.ok(!bNow.includes('UNBOUND_CURSOR_API_KEY'), bNow);
185
+ assert.ok(!bNow.includes('UNBOUND_CLAUDE_API_KEY'), bNow);
186
+ assert.ok(bNow.includes('# user content'), bNow);
187
+ assert.ok(bNow.includes('export PATH=/usr/local/bin:$PATH'), bNow);
188
+
189
+ const zNow = fs.readFileSync(zshrc, 'utf8');
190
+ assert.ok(!zNow.includes('ANTHROPIC_BASE_URL'), zNow);
191
+ assert.ok(zNow.includes('alias ll="ls -la"'), zNow);
192
+ } finally {
193
+ if (origHome === undefined) delete process.env.HOME;
194
+ else process.env.HOME = origHome;
195
+ fs.rmSync(tmp, { recursive: true, force: true });
196
+ }
197
+ });
198
+
199
+ test('clearUnboundEnvsEverywhere: leaves OPENAI_API_KEY alone (out of sweep scope)', () => {
200
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'unbound-nuke-'));
201
+ const origHome = process.env.HOME;
202
+ process.env.HOME = tmp;
203
+ try {
204
+ const rc = path.join(tmp, '.zshrc');
205
+ fs.writeFileSync(rc, 'export OPENAI_API_KEY="user-owned"\n');
206
+ clearUnboundEnvsEverywhere();
207
+ assert.ok(fs.readFileSync(rc, 'utf8').includes('OPENAI_API_KEY'));
208
+ } finally {
209
+ if (origHome === undefined) delete process.env.HOME;
210
+ else process.env.HOME = origHome;
211
+ fs.rmSync(tmp, { recursive: true, force: true });
212
+ }
213
+ });
214
+
215
+ test('NUKE_ENV_VARS covers every UNBOUND_* env var named in the variant set + ANTHROPIC_BASE_URL', () => {
216
+ for (const name of ['UNBOUND_API_KEY', 'UNBOUND_CLAUDE_API_KEY', 'UNBOUND_CODEX_API_KEY',
217
+ 'UNBOUND_COPILOT_API_KEY', 'UNBOUND_CURSOR_API_KEY', 'ANTHROPIC_BASE_URL']) {
218
+ assert.ok(NUKE_ENV_VARS.includes(name), `missing: ${name}`);
219
+ }
220
+ });
221
+ }
@@ -208,3 +208,114 @@ test('only four tools are reported (Gemini CLI is omitted)', () => {
208
208
  assert.deepEqual(new Set(keys), new Set(['cursor', 'claude-code', 'codex', 'copilot']));
209
209
  });
210
210
  });
211
+
212
+ test('detectTools: cursor MDM binary (unbound-hook in hooks.json, binary installed) → managed-by-mdm', () => {
213
+ withHome((tmp, th) => {
214
+ const mdmDir = path.join(tmp, 'mdm', 'Cursor');
215
+ const fakeBin = path.join(tmp, 'opt', 'unbound-hook');
216
+ writeFile(path.join(mdmDir, 'hooks.json'), JSON.stringify({
217
+ hooks: { PreToolUse: [{ command: '/opt/unbound/current/unbound-hook/unbound-hook hook cursor PreToolUse' }] },
218
+ }));
219
+ writeFile(fakeBin, '');
220
+ const t = th.detectTools({ apiKey: 'k', _mdmDirs: { cursor: mdmDir }, _binaryPath: fakeBin }).find((x) => x.family === 'cursor');
221
+ assert.equal(t.status, 'managed-by-mdm');
222
+ assert.equal(t.scope, 'mdm');
223
+ });
224
+ });
225
+
226
+ test('detectTools: cursor MDM binary (unbound-hook in hooks.json, binary MISSING) → tampered', () => {
227
+ withHome((tmp, th) => {
228
+ const mdmDir = path.join(tmp, 'mdm', 'Cursor');
229
+ writeFile(path.join(mdmDir, 'hooks.json'), JSON.stringify({
230
+ hooks: { PreToolUse: [{ command: '/opt/unbound/current/unbound-hook/unbound-hook hook cursor PreToolUse' }] },
231
+ }));
232
+ const t = th.detectTools({
233
+ apiKey: 'k', _mdmDirs: { cursor: mdmDir },
234
+ _binaryPath: path.join(tmp, 'no-such-binary'),
235
+ }).find((x) => x.family === 'cursor');
236
+ assert.equal(t.status, 'tampered');
237
+ assert.equal(t.scope, 'mdm');
238
+ });
239
+ });
240
+
241
+ test('detectTools: claude-code MDM binary (unbound-hook in managed-settings.json, binary installed) → managed-by-mdm', () => {
242
+ withHome((tmp, th) => {
243
+ const mdmDir = path.join(tmp, 'mdm', 'ClaudeCode');
244
+ const fakeBin = path.join(tmp, 'opt', 'unbound-hook');
245
+ writeFile(path.join(mdmDir, 'managed-settings.json'), JSON.stringify({
246
+ hooks: { PreToolUse: [{ command: '/opt/unbound/current/unbound-hook/unbound-hook hook claude-code PreToolUse' }] },
247
+ }));
248
+ writeFile(fakeBin, '');
249
+ const t = th.detectTools({ apiKey: 'k', _mdmDirs: { 'claude-code': mdmDir }, _binaryPath: fakeBin }).find((x) => x.family === 'claude-code');
250
+ assert.equal(t.status, 'managed-by-mdm');
251
+ assert.equal(t.scope, 'mdm');
252
+ });
253
+ });
254
+
255
+ test('detectTools: copilot user binary (unbound-hook in ~/.copilot/hooks/unbound.json, binary installed) → healthy', () => {
256
+ withHome((tmp, th) => {
257
+ const fakeBin = path.join(tmp, 'opt', 'unbound-hook');
258
+ writeFile(path.join(tmp, '.copilot', 'hooks', 'unbound.json'), JSON.stringify({
259
+ hooks: { PreToolUse: [{ command: '/opt/unbound/current/unbound-hook/unbound-hook hook copilot PreToolUse' }] },
260
+ }));
261
+ writeFile(fakeBin, '');
262
+ process.env.UNBOUND_COPILOT_API_KEY = 'k';
263
+ try {
264
+ const t = th.detectTools({ apiKey: 'k', _binaryPath: fakeBin }).find((x) => x.family === 'copilot');
265
+ assert.equal(t.status, 'healthy');
266
+ } finally {
267
+ delete process.env.UNBOUND_COPILOT_API_KEY;
268
+ }
269
+ });
270
+ });
271
+
272
+ test('detectTools: copilot user binary (unbound-hook in unbound.json, binary MISSING) → tampered', () => {
273
+ withHome((tmp, th) => {
274
+ writeFile(path.join(tmp, '.copilot', 'hooks', 'unbound.json'), JSON.stringify({
275
+ hooks: { PreToolUse: [{ command: '/opt/unbound/current/unbound-hook/unbound-hook hook copilot PreToolUse' }] },
276
+ }));
277
+ process.env.UNBOUND_COPILOT_API_KEY = 'k';
278
+ try {
279
+ const t = th.detectTools({ apiKey: 'k', _binaryPath: path.join(tmp, 'no-such-binary') }).find((x) => x.family === 'copilot');
280
+ assert.equal(t.status, 'tampered');
281
+ } finally {
282
+ delete process.env.UNBOUND_COPILOT_API_KEY;
283
+ }
284
+ });
285
+ });
286
+
287
+ test('detectTools: codex binary regression (wrapper at ~/.codex/hooks/unbound.py, hooks.json refs wrapper, config.toml flag, env) → healthy', () => {
288
+ withHome((tmp, th) => {
289
+ const wrapper = path.join(tmp, '.codex', 'hooks', 'unbound.py');
290
+ writeFile(path.join(tmp, '.codex', 'hooks.json'), JSON.stringify({
291
+ hooks: { PreToolUse: [{ command: wrapper }] },
292
+ }));
293
+ writeFile(wrapper, '#!/bin/sh\nexec /opt/unbound/current/unbound-hook/unbound-hook hook codex "$@"\n');
294
+ writeFile(path.join(tmp, '.codex', 'config.toml'), 'codex_hooks = true\n');
295
+ process.env.UNBOUND_CODEX_API_KEY = 'k';
296
+ try {
297
+ const t = th.detectTools({ apiKey: 'k' }).find((x) => x.family === 'codex');
298
+ assert.equal(t.status, 'healthy');
299
+ } finally {
300
+ delete process.env.UNBOUND_CODEX_API_KEY;
301
+ }
302
+ });
303
+ });
304
+
305
+ test('detectTools: envCheck rc-file fallback (process.env stale, shell rc holds expected value) → healthy', () => {
306
+ withHome((tmp, th) => {
307
+ const script = path.join(tmp, '.cursor', 'hooks', 'unbound.py');
308
+ writeFile(path.join(tmp, '.cursor', 'hooks.json'), JSON.stringify({ hooks: { PreToolUse: [{ command: script }] } }));
309
+ writeFile(script, '# unbound');
310
+ // rcFiles() lists different files per platform (zprofile on darwin,
311
+ // zshrc/bashrc/profile on linux). Write to .bashrc — both lists include it.
312
+ writeFile(path.join(tmp, '.bashrc'), 'export UNBOUND_CURSOR_API_KEY="fresh-key"\n');
313
+ process.env.UNBOUND_CURSOR_API_KEY = 'stale-key';
314
+ try {
315
+ const t = th.detectTools({ apiKey: 'fresh-key' }).find((x) => x.key === 'cursor');
316
+ assert.equal(t.status, 'healthy');
317
+ } finally {
318
+ delete process.env.UNBOUND_CURSOR_API_KEY;
319
+ }
320
+ });
321
+ });