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.
package/README.md CHANGED
@@ -195,6 +195,8 @@ unbound policy security create --name "429 Fallback" --sub-type error-code-routi
195
195
 
196
196
  Tool policy examples:
197
197
 
198
+ > **BREAKING CHANGE in 1.5.0:** `create-terminal` and `create-mcp` now require either `--prompt` (AI-assisted creation) or an explicit `--no-ai` opt-out. Invocations with raw classification flags but no `--no-ai` exit with code 2 and a remediation message. Under Claude Code (`CLAUDECODE=1`), even `--no-ai` is rejected unless you also set `UNBOUND_ALLOW_NO_AI_UNDER_CLAUDE=1` — this is intended for interactive humans, not agents.
199
+
198
200
  ```bash
199
201
  # AI-assisted (preferred): describe the policy in natural language.
200
202
  unbound policy tool create-terminal --prompt "block rm -rf"
@@ -207,11 +209,11 @@ unbound policy tool families
207
209
  unbound policy tool mcp-servers
208
210
 
209
211
  # Flag-based fallback: block destructive shell commands explicitly
210
- unbound policy tool create-terminal --name "Block rm -rf" --command-family filesystem \
212
+ unbound policy tool create-terminal --no-ai --name "Block rm -rf" --command-family filesystem \
211
213
  --field command='rm -rf*' --action BLOCK --custom-message "Destructive command blocked."
212
214
 
213
215
  # Flag-based MCP fallback: audit Linear write operations
214
- unbound policy tool create-mcp --name "Audit Linear writes" --mcp-server Linear \
216
+ unbound policy tool create-mcp --no-ai --name "Audit Linear writes" --mcp-server Linear \
215
217
  --mcp-action-type write --action AUDIT
216
218
 
217
219
  # List, get, delete
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unbound-cli",
3
- "version": "1.4.0",
3
+ "version": "1.6.2",
4
4
  "description": "CLI tool for Unbound - AI Gateway management",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -90,7 +90,7 @@ Examples:
90
90
  }
91
91
 
92
92
  const C = output.colors;
93
- const labelOf = (t) => t.label + (t.mode ? ` (${t.mode})` : '');
93
+ const labelOf = (t) => t.label + (t.mode && t.mode !== 'subscription' ? ` (${t.mode})` : '');
94
94
  const W = Math.max(7, ...tools.map((t) => labelOf(t).length)); // 7 = "API key"
95
95
 
96
96
  console.log('');
@@ -150,7 +150,8 @@ Examples:
150
150
  return;
151
151
  }
152
152
  const fixNow = root ? allFix : userFix;
153
- output.info(`Reinstalling: ${fixNow.join(', ')}${root ? ' (org-wide, all users)' : ''}`);
153
+ const fixDisplay = (root ? tampered : userTampered).map(labelOf).join(', ');
154
+ output.info(`Reinstalling: ${fixDisplay}${root ? ' (org-wide, all users)' : ''}`);
154
155
  console.log('');
155
156
  const r = spawnSync(process.argv[0], [process.argv[1], 'setup', ...fixNow], { stdio: 'inherit' });
156
157
  if (!root && mdmFix.length) console.error(` ${mdmNames} ${mdmFix.length === 1 ? 'is' : 'are'} set up by your organization — run ${C.bold('sudo unbound doctor --fix')} to repair ${mdmFix.length === 1 ? 'it' : 'them'}.`);
@@ -2,6 +2,7 @@ const config = require('../config');
2
2
  const api = require('../api');
3
3
  const output = require('../output');
4
4
  const { formatDate, confirm, parseCommaSeparated } = require('../utils');
5
+ const { assertSteering, helpBannerFor } = require('../lib/no-ai-guard');
5
6
 
6
7
  // ============================================================================
7
8
  // Constants and docs URLs
@@ -1430,6 +1431,7 @@ Subcommands:
1430
1431
  .command('create-terminal')
1431
1432
  .description('Create a TERMINAL_COMMAND policy: monitor or block shell commands run by AI coding tools.')
1432
1433
  .option('--prompt <text>', 'Natural-language description; routes through Unbound AI assist. Mutually exclusive with --command-family/--field/--config. Compatible with --name/--description/--action overrides.')
1434
+ .option('--no-ai', 'Opt out of the AI-assist guard and use raw classification flags. Mutually exclusive with --prompt.')
1433
1435
  .option('--name <name>', 'Policy name (required unless --prompt is used)')
1434
1436
  .option('--command-family <family>', 'Command family (e.g. git, filesystem, network). See `policy tool families`.')
1435
1437
  .option('--field <key=pattern>', 'Match field: <key>=<pattern>. Repeatable — multiple --field are ANDed (all must match), one per field, for a more specific policy. At least one required unless --config is used.', (val, prev = []) => [...prev, val])
@@ -1441,6 +1443,7 @@ Subcommands:
1441
1443
  .option('--config <json>', 'Advanced: raw config JSON (skips --field builder)')
1442
1444
  .option('--yes', 'Skip the confirmation prompt for --prompt flows (preview is still printed)')
1443
1445
  .option('--json', 'Output raw JSON of the created policy')
1446
+ .addHelpText('before', helpBannerFor('create-terminal'))
1444
1447
  .addHelpText('after', `
1445
1448
  Examples:
1446
1449
  # AI-assisted (preferred): describe the policy in natural language.
@@ -1450,15 +1453,15 @@ Examples:
1450
1453
  $ unbound policy tool create-terminal --prompt "block rm -rf" \\
1451
1454
  --name "Block recursive deletes" --action WARN
1452
1455
 
1453
- $ unbound policy tool create-terminal --name "Block rm -rf" \\
1456
+ $ unbound policy tool create-terminal --no-ai --name "Block rm -rf" \\
1454
1457
  --command-family filesystem --field command='rm -rf*' --action BLOCK \\
1455
1458
  --custom-message "Destructive commands are blocked."
1456
1459
 
1457
- $ unbound policy tool create-terminal --name "Audit git push" \\
1460
+ $ unbound policy tool create-terminal --no-ai --name "Audit git push" \\
1458
1461
  --command-family git --field command='git push*' --action AUDIT
1459
1462
 
1460
1463
  # Multiple --field are ANDed — this fires only on a push to main on origin:
1461
- $ unbound policy tool create-terminal --name "Block push to main on origin" \\
1464
+ $ unbound policy tool create-terminal --no-ai --name "Block push to main on origin" \\
1462
1465
  --command-family git_action \\
1463
1466
  --field operation=push --field branch=main --field remote='*origin*' \\
1464
1467
  --action BLOCK --custom-message "Pushing to main on origin is blocked."
@@ -1469,6 +1472,7 @@ Learn more: ${DOCS_TOOL}
1469
1472
  .action(async (opts) => {
1470
1473
  try {
1471
1474
  requireLogin();
1475
+ assertSteering(opts, { subcommandName: 'create-terminal' });
1472
1476
 
1473
1477
  if (opts.prompt !== undefined) {
1474
1478
  if (typeof opts.prompt !== 'string' || opts.prompt.trim() === '') {
@@ -1594,7 +1598,7 @@ Learn more: ${DOCS_TOOL}
1594
1598
  displayToolPolicy(unwrapToolPolicy(data));
1595
1599
  } catch (err) {
1596
1600
  output.error(err.message);
1597
- process.exitCode = 1;
1601
+ process.exitCode = err.exitCode || 1;
1598
1602
  }
1599
1603
  });
1600
1604
 
@@ -1602,6 +1606,7 @@ Learn more: ${DOCS_TOOL}
1602
1606
  .command('create-mcp')
1603
1607
  .description('Create an MCP_TOOL policy: monitor or block MCP server tool calls.')
1604
1608
  .option('--prompt <text>', 'Natural-language description; routes through Unbound AI assist. Mutually exclusive with --mcp-server/--mcp-tool/--mcp-action-type/--config. Compatible with --name/--description/--action overrides.')
1609
+ .option('--no-ai', 'Opt out of the AI-assist guard and use raw classification flags. Mutually exclusive with --prompt.')
1605
1610
  .option('--name <name>', 'Policy name (required unless --prompt is used). Override AI suggestion when used with --prompt.')
1606
1611
  .option('--mcp-server <server>', 'MCP server name as shown by `policy tool mcp-servers` (e.g. linear, github). Resolved to its canonical group automatically.')
1607
1612
  .option('--mcp-tool <tool>', 'Specific tool name on the MCP server (e.g. create_issue). Mutually exclusive with --mcp-action-type.')
@@ -1614,6 +1619,7 @@ Learn more: ${DOCS_TOOL}
1614
1619
  .option('--config <json>', 'Advanced: raw config JSON')
1615
1620
  .option('--yes', 'Skip the confirmation prompt for --prompt flows (preview is still printed)')
1616
1621
  .option('--json', 'Output raw JSON of the created policy')
1622
+ .addHelpText('before', helpBannerFor('create-mcp'))
1617
1623
  .addHelpText('after', `
1618
1624
  Examples:
1619
1625
  # AI-assisted (preferred): describe the policy in natural language.
@@ -1625,12 +1631,12 @@ Examples:
1625
1631
  --custom-message "Linear writes are monitored."
1626
1632
 
1627
1633
  # Match a specific tool on a server (flag-based fallback):
1628
- $ unbound policy tool create-mcp --name "Block Linear writes" \\
1634
+ $ unbound policy tool create-mcp --no-ai --name "Block Linear writes" \\
1629
1635
  --mcp-server linear --mcp-tool create_issue --action BLOCK \\
1630
1636
  --custom-message "Issue creation is blocked. Contact admin."
1631
1637
 
1632
1638
  # Match all destructive tools on a server:
1633
- $ unbound policy tool create-mcp --name "Audit all destructive Slack" \\
1639
+ $ unbound policy tool create-mcp --no-ai --name "Audit all destructive Slack" \\
1634
1640
  --mcp-server slack --mcp-action-type destructive --action AUDIT
1635
1641
 
1636
1642
  The server name is resolved to its canonical group automatically, so pass the
@@ -1642,6 +1648,7 @@ Learn more: ${DOCS_TOOL}
1642
1648
  .action(async (opts) => {
1643
1649
  try {
1644
1650
  requireLogin();
1651
+ assertSteering(opts, { subcommandName: 'create-mcp' });
1645
1652
 
1646
1653
  if (opts.prompt !== undefined) {
1647
1654
  if (typeof opts.prompt !== 'string' || opts.prompt.trim() === '') {
@@ -1766,7 +1773,7 @@ Learn more: ${DOCS_TOOL}
1766
1773
  displayToolPolicy(unwrapToolPolicy(data));
1767
1774
  } catch (err) {
1768
1775
  output.error(err.message);
1769
- process.exitCode = 1;
1776
+ process.exitCode = err.exitCode || 1;
1770
1777
  }
1771
1778
  });
1772
1779
 
@@ -35,14 +35,16 @@ const SETUP_TOOLS = [
35
35
  { label: 'Codex \u2014 gateway (gateway)', value: 'codex-gw', script: 'codex/gateway/setup.py', group: 'codex' },
36
36
  ];
37
37
 
38
+ // Labels drop the `(subscription)` suffix on the default mode and keep
39
+ // `(gateway)` as the differentiator — matches what doctor/status render.
38
40
  const MDM_TOOLS = {
39
- 'cursor': { label: 'Cursor', script: 'cursor/mdm/setup.py' },
40
- 'copilot': { label: 'GitHub Copilot', script: 'copilot/hooks/mdm/setup.py' },
41
- 'claude-code-subscription': { label: 'Claude Code (subscription)', script: 'claude-code/hooks/mdm/setup.py' },
42
- 'claude-code-gateway': { label: 'Claude Code (gateway)', script: 'claude-code/gateway/mdm/setup.py' },
43
- 'gemini-cli': { label: 'Gemini CLI', script: 'gemini-cli/gateway/mdm/setup.py' },
44
- 'codex-subscription': { label: 'Codex (subscription)', script: 'codex/hooks/mdm/setup.py' },
45
- 'codex-gateway': { label: 'Codex (gateway)', script: 'codex/gateway/mdm/setup.py' },
41
+ 'cursor': { label: 'Cursor', script: 'cursor/mdm/setup.py' },
42
+ 'copilot': { label: 'GitHub Copilot', script: 'copilot/hooks/mdm/setup.py' },
43
+ 'claude-code-subscription': { label: 'Claude Code', script: 'claude-code/hooks/mdm/setup.py' },
44
+ 'claude-code-gateway': { label: 'Claude Code (gateway)', script: 'claude-code/gateway/mdm/setup.py' },
45
+ 'gemini-cli': { label: 'Gemini CLI', script: 'gemini-cli/gateway/mdm/setup.py' },
46
+ 'codex-subscription': { label: 'Codex', script: 'codex/hooks/mdm/setup.py' },
47
+ 'codex-gateway': { label: 'Codex (gateway)', script: 'codex/gateway/mdm/setup.py' },
46
48
  };
47
49
 
48
50
  // Default MDM tools for `sudo unbound onboard` (subscription mode for Claude Code/Codex since only one can be active)
@@ -59,13 +61,13 @@ const SETUP_ALL_TOOLS = ['cursor', 'claude-code-subscription', 'codex-subscripti
59
61
 
60
62
  // Tool name → script mapping for automated tools
61
63
  const SETUP_TOOL_MAP = {
62
- 'cursor': { label: 'Cursor', script: 'cursor/setup.py' },
63
- 'copilot': { label: 'GitHub Copilot', script: 'copilot/hooks/setup.py' },
64
- 'claude-code-subscription': { label: 'Claude Code (subscription)', script: 'claude-code/hooks/setup.py' },
65
- 'claude-code-gateway': { label: 'Claude Code (gateway)', script: 'claude-code/gateway/setup.py' },
66
- 'gemini-cli': { label: 'Gemini CLI', script: 'gemini-cli/gateway/setup.py' },
67
- 'codex-subscription': { label: 'Codex (subscription)', script: 'codex/hooks/setup.py' },
68
- 'codex-gateway': { label: 'Codex (gateway)', script: 'codex/gateway/setup.py' },
64
+ 'cursor': { label: 'Cursor', script: 'cursor/setup.py' },
65
+ 'copilot': { label: 'GitHub Copilot', script: 'copilot/hooks/setup.py' },
66
+ 'claude-code-subscription': { label: 'Claude Code', script: 'claude-code/hooks/setup.py' },
67
+ 'claude-code-gateway': { label: 'Claude Code (gateway)', script: 'claude-code/gateway/setup.py' },
68
+ 'gemini-cli': { label: 'Gemini CLI', script: 'gemini-cli/gateway/setup.py' },
69
+ 'codex-subscription': { label: 'Codex', script: 'codex/hooks/setup.py' },
70
+ 'codex-gateway': { label: 'Codex (gateway)', script: 'codex/gateway/setup.py' },
69
71
  };
70
72
 
71
73
  /**
@@ -338,6 +340,56 @@ function runScriptPiped(scriptPath, args) {
338
340
  });
339
341
  }
340
342
 
343
+ // Env vars Unbound writes during setup. The python `--clear` scripts strip
344
+ // these from ONE rc file (the current shell's), so a user who installed under
345
+ // zsh and runs nuke under bash leaves a stale `export …` in the other rc and
346
+ // then `status` reports tampered. Sweep every candidate rc to close that gap.
347
+ // Conservative: only UNBOUND_* names + ANTHROPIC_BASE_URL. Skip OPENAI_API_KEY
348
+ // — it's a generic user-owned var that can have non-Unbound uses.
349
+ const NUKE_ENV_VARS = [
350
+ 'UNBOUND_API_KEY',
351
+ 'UNBOUND_CLAUDE_API_KEY',
352
+ 'UNBOUND_CODEX_API_KEY',
353
+ 'UNBOUND_COPILOT_API_KEY',
354
+ 'UNBOUND_CURSOR_API_KEY',
355
+ 'ANTHROPIC_BASE_URL',
356
+ ];
357
+
358
+ function nukeRcFiles() {
359
+ if (process.platform === 'win32') return [];
360
+ if (process.platform === 'darwin') return ['~/.zprofile', '~/.bash_profile', '~/.zshrc', '~/.bashrc'];
361
+ return ['~/.zshrc', '~/.bashrc', '~/.profile'];
362
+ }
363
+
364
+ // Removes `export NAME=...` lines for NUKE_ENV_VARS from every candidate rc.
365
+ // On Windows, best-effort `reg delete` for each name. Returns a summary of
366
+ // what changed so the nuke command can surface it.
367
+ function clearUnboundEnvsEverywhere() {
368
+ const cleared = [];
369
+ if (process.platform === 'win32') {
370
+ for (const name of NUKE_ENV_VARS) {
371
+ const r = spawnSync('reg', ['delete', 'HKCU\\Environment', '/F', '/V', name],
372
+ { stdio: 'ignore', windowsHide: true });
373
+ if (r.status === 0) cleared.push(`${name} (registry)`);
374
+ }
375
+ return cleared;
376
+ }
377
+ const home = os.homedir();
378
+ const exportRe = new RegExp(`^\\s*export\\s+(${NUKE_ENV_VARS.join('|')})=`);
379
+ for (const rc of nukeRcFiles()) {
380
+ const rcPath = rc.replace(/^~/, home);
381
+ let text;
382
+ try { text = fs.readFileSync(rcPath, 'utf8'); } catch { continue; }
383
+ const lines = text.split('\n');
384
+ const kept = lines.filter((line) => !exportRe.test(line));
385
+ if (kept.length !== lines.length) {
386
+ fs.writeFileSync(rcPath, kept.join('\n'));
387
+ cleared.push(`${lines.length - kept.length} line(s) from ${path.basename(rcPath)}`);
388
+ }
389
+ }
390
+ return cleared;
391
+ }
392
+
341
393
  /**
342
394
  * Returns true when the process has the privileges needed to touch system-level
343
395
  * (MDM) configuration. On Windows, `net session` succeeds only when elevated, so
@@ -911,6 +963,13 @@ Examples:
911
963
  output.info('Skipped MDM (system-level) config — that needs root. Re-run with sudo to remove it too.');
912
964
  }
913
965
 
966
+ // Sweep stale `export UNBOUND_*` / `ANTHROPIC_BASE_URL` lines from EVERY
967
+ // candidate rc file (the per-tool python clears only touch the current
968
+ // shell's rc, so a stale entry in another rc would survive and re-trip
969
+ // `unbound status` as tampered on the next run).
970
+ const envCleared = clearUnboundEnvsEverywhere();
971
+ if (envCleared.length) output.info(`Removed Unbound env: ${envCleared.join(', ')}.`);
972
+
914
973
  // Wipe credentials + settings last, regardless of tool-clear outcomes.
915
974
  config.clearConfig();
916
975
  output.success('Stored credentials and settings removed.');
@@ -988,4 +1047,6 @@ module.exports = {
988
1047
  buildScriptArgs,
989
1048
  scriptSupportsBackfill,
990
1049
  resolveSetupAllTools,
1050
+ clearUnboundEnvsEverywhere,
1051
+ NUKE_ENV_VARS,
991
1052
  };
@@ -105,7 +105,7 @@ Examples:
105
105
  console.log(` ${C.dim('None set up yet.')} Run ${C.bold('unbound setup')} to wire a tool.`);
106
106
  } else {
107
107
  for (const t of connected) {
108
- const mode = t.mode ? C.dim(` (${t.mode})`) : '';
108
+ const mode = t.mode && t.mode !== 'subscription' ? C.dim(` (${t.mode})`) : '';
109
109
  let mark = C.green('✓');
110
110
  let note = '';
111
111
  if (t.status === 'tampered') { mark = C.red('✗'); note = C.dim(' — run `unbound doctor`'); }
@@ -0,0 +1,77 @@
1
+ // Steering guard for `policy tool create-terminal` / `create-mcp`.
2
+ //
3
+ // Three layers, evaluated in order. Routing happens BEFORE src/lib/policy-ai-assist.js
4
+ // is reached — this module never touches the network and has no external deps.
5
+ //
6
+ // Layer 1 (assertSteering): require either --prompt or an explicit --no-ai opt-out.
7
+ // Reject --prompt + --no-ai (mutex).
8
+ // Layer 2 (assertSteering): under CLAUDECODE=1, reject --no-ai unless the env-gated
9
+ // escape hatch UNBOUND_ALLOW_NO_AI_UNDER_CLAUDE=1 is set.
10
+ // Layer 3 (helpBannerFor): banner-first --help so the AI-assisted form is the
11
+ // one a reader sees first.
12
+ //
13
+ // Errors carry `err.exitCode = 2` so the .action() catch block in src/commands/policy.js
14
+ // can propagate via `process.exitCode = err.exitCode || 1`.
15
+
16
+ function makeGuardError(message) {
17
+ const err = new Error(message);
18
+ err.exitCode = 2;
19
+ return err;
20
+ }
21
+
22
+ // Layer 1 + Layer 2. Throws a guard error (exitCode 2) on rejection.
23
+ // `opts` is the parsed commander options object; `--no-ai` arrives as `opts.ai === false`.
24
+ // "prompt provided" means `--prompt` appeared on the command line at all (even
25
+ // empty-string) — the empty-prompt case is validated by the existing
26
+ // `opts.prompt.trim() === ''` check in the prompt branch so that callers see the
27
+ // dedicated "--prompt cannot be empty." error rather than the steering message.
28
+ function assertSteering(opts, { subcommandName, env = process.env } = {}) {
29
+ const hasPrompt = opts.prompt !== undefined;
30
+ const noAi = opts.ai === false;
31
+
32
+ // Mutex (layer 1): --prompt + --no-ai is incoherent — pick one.
33
+ if (hasPrompt && noAi) {
34
+ throw makeGuardError(
35
+ 'Pass --prompt for AI-assisted creation or --no-ai for raw flags, not both.'
36
+ );
37
+ }
38
+
39
+ // Layer 1: neither --prompt nor --no-ai → steer to the AI-assisted form.
40
+ if (!hasPrompt && !noAi) {
41
+ throw makeGuardError(
42
+ `${subcommandName} requires AI-assisted creation. Retry with --prompt "<natural language description>". To use raw classification flags instead, opt out explicitly with --no-ai.`
43
+ );
44
+ }
45
+
46
+ // Layer 2: under Claude Code, even --no-ai is rejected unless the human-only
47
+ // escape hatch is set. This is steering, not security — the env var is in
48
+ // plain sight in the error message. See PLAN risk R2.
49
+ if (noAi && env.CLAUDECODE === '1' && env.UNBOUND_ALLOW_NO_AI_UNDER_CLAUDE !== '1') {
50
+ throw makeGuardError(
51
+ `--no-ai is disabled under CLAUDECODE=1. This is intended for interactive humans, not for agents. Retry with --prompt "<natural language description>", or set UNBOUND_ALLOW_NO_AI_UNDER_CLAUDE=1 if you are an interactive human.`
52
+ );
53
+ }
54
+ }
55
+
56
+ // Layer 3: banner rendered above commander's auto-generated `Usage:` line by
57
+ // `.addHelpText('before', helpBannerFor(...))`. Keep it short so the help screen
58
+ // is still scannable.
59
+ function helpBannerFor(subcommandName) {
60
+ const examples = {
61
+ 'create-terminal': 'unbound policy tool create-terminal --prompt "block rm -rf"',
62
+ 'create-mcp': 'unbound policy tool create-mcp --prompt "audit all Linear writes"',
63
+ };
64
+ if (!Object.prototype.hasOwnProperty.call(examples, subcommandName)) {
65
+ throw new Error(`helpBannerFor: unknown subcommand "${subcommandName}"`);
66
+ }
67
+ const promptExample = examples[subcommandName];
68
+ return [
69
+ 'AI-ASSISTED (preferred):',
70
+ ` $ ${promptExample}`,
71
+ '',
72
+ 'To use raw classification flags instead, opt out explicitly with --no-ai.',
73
+ '',
74
+ ].join('\n');
75
+ }
76
+
77
+ module.exports = { assertSteering, helpBannerFor };
package/src/toolHealth.js CHANGED
@@ -20,6 +20,11 @@ const { spawnSync } = require('child_process');
20
20
 
21
21
  const HOME = os.homedir();
22
22
  const GATEWAY_DEFAULT = 'https://api.getunbound.ai';
23
+ // Anchored to the install root + binary name so a hypothetical neighbor
24
+ // (e.g. `unbound-hooks-v2`) can't accidentally satisfy the substring.
25
+ const BINARY_MARKER = 'unbound-hook/unbound-hook';
26
+ // Hard-coded by the ai.getunbound.runtime pkg (binary/src/unbound_hook/_resources.py).
27
+ const BINARY_PATH = '/opt/unbound/current/unbound-hook/unbound-hook';
23
28
 
24
29
  function expand(p) {
25
30
  return p.startsWith('~') ? path.join(HOME, p.slice(1)) : p;
@@ -129,6 +134,22 @@ function envCheck(label, name, expected, kind = 'aux') {
129
134
  // A value that doesn't match what setup wrote (stale key, wrong gateway URL) is a
130
135
  // real misconfiguration: mark it not-ok so the tool reports tampered, not healthy.
131
136
  if (expected && r.value !== expected) {
137
+ // process.env can be a stale snapshot from a shell loaded before setup re-wrote
138
+ // the rc, so consult the rc files as a fallback before declaring a mismatch.
139
+ if (r.source === 'process env' && process.platform !== 'win32') {
140
+ const re = new RegExp(`^\\s*export\\s+${name}=(.*)$`, 'm');
141
+ for (const rc of rcFiles()) {
142
+ const text = readText(rc);
143
+ if (!text) continue;
144
+ const m = text.match(re);
145
+ if (!m) continue;
146
+ const rcVal = m[1].trim().replace(/^["']|["']$/g, '');
147
+ if (rcVal === expected) {
148
+ const rcSource = path.basename(expand(rc));
149
+ return { name: label, ok: true, kind, detail: `${name} set (${rcSource})`, summary: `${base} set` };
150
+ }
151
+ }
152
+ }
132
153
  return { name: label, ok: false, kind, warn: true, summary: `${base} differs from setup`, detail: `${name} set (${r.source}) but differs from what setup configured` };
133
154
  }
134
155
  return { name: label, ok: true, kind, detail: `${name} set (${r.source})`, summary: `${base} set` };
@@ -139,7 +160,7 @@ function envCheck(label, name, expected, kind = 'aux') {
139
160
  // A plain managed-settings.json (Claude Enterprise / generic MDM) does NOT count,
140
161
  // and a managed config that points at a missing hook script is a broken (tampered)
141
162
  // MDM install, not a healthy one. Returns { status: 'healthy'|'tampered'|null, checks }.
142
- function mdmDetect(family, dirOverride) {
163
+ function mdmDetect(family, dirOverride, binaryPath) {
143
164
  // Only Cursor / Claude Code / Codex have a managed (MDM) directory. Copilot and
144
165
  // Gemini have none — their org install writes the same per-user config into
145
166
  // every profile, so they're checked exactly like a user-level tool.
@@ -158,7 +179,28 @@ function mdmDetect(family, dirOverride) {
158
179
  const scriptPath = path.join(dir, 'hooks', 'unbound.py');
159
180
 
160
181
  const cfgText = readText(configPath);
161
- if (cfgText == null || !cfgText.includes('unbound.py')) return { status: null, checks: [] };
182
+ if (cfgText == null) return { status: null, checks: [] };
183
+ const hasBinary = cfgText.includes(BINARY_MARKER);
184
+ const hasPython = cfgText.includes('unbound.py');
185
+ if (!hasBinary && !hasPython) return { status: null, checks: [] };
186
+
187
+ // Binary install wins even when an `unbound.py` substring lingers (e.g.
188
+ // mid-migration the config can mention both; the per-MDM unbound.py script
189
+ // is no longer expected once binary is in play). Mirror the python branch's
190
+ // existence guard so a missing binary surfaces as tampered, not healthy.
191
+ if (hasBinary) {
192
+ const bp = binaryPath || BINARY_PATH;
193
+ const binaryOk = fileExists(bp);
194
+ return {
195
+ status: binaryOk ? 'healthy' : 'tampered',
196
+ checks: [
197
+ { name: 'MDM config', ok: true, kind: 'structural', detail: configPath, summary: 'managed config (binary)' },
198
+ { name: 'Hook binary', ok: binaryOk, kind: 'structural',
199
+ summary: binaryOk ? 'hook binary installed' : 'hook binary missing',
200
+ detail: binaryOk ? bp : `managed config references the hook binary but it isn't installed (${bp})` },
201
+ ],
202
+ };
203
+ }
162
204
 
163
205
  const scriptOk = fileExists(scriptPath);
164
206
  const checks = [
@@ -170,12 +212,13 @@ function mdmDetect(family, dirOverride) {
170
212
 
171
213
  // Marker that a claude/codex/cursor hooks block references unbound.py.
172
214
  function refsUnbound(obj) {
173
- return JSON.stringify(obj || {}).includes('unbound.py');
215
+ const s = JSON.stringify(obj || {});
216
+ return s.includes('unbound.py') || s.includes(BINARY_MARKER);
174
217
  }
175
218
 
176
219
  // One descriptor per (tool, mode). `family` groups the two-mode tools so the
177
220
  // collapsed view shows a single line per product.
178
- function buildVariants(gatewayUrl, apiKey) {
221
+ function buildVariants(gatewayUrl, apiKey, binaryPath) {
179
222
  const gw = (gatewayUrl || GATEWAY_DEFAULT).replace(/\/+$/, ''); // setup rstrips too
180
223
  return [
181
224
  {
@@ -221,14 +264,21 @@ function buildVariants(gatewayUrl, apiKey) {
221
264
  },
222
265
  {
223
266
  key: 'copilot', label: 'GitHub Copilot', family: 'copilot', mode: null,
224
- checks: () => [
267
+ checks: () => {
225
268
  // Copilot has no managed (MDM) directory: the org install writes the same
226
269
  // ~/.copilot config into every user profile, so it's checked like a
227
270
  // user-level tool and never reports "managed by MDM".
228
- configCheck('Config', '~/.copilot/hooks/unbound.json', { json: true, test: refsUnbound }),
229
- scriptCheck('Hook script', '~/.copilot/hooks/unbound.py'),
230
- envCheck('API key env', 'UNBOUND_COPILOT_API_KEY', apiKey),
231
- ],
271
+ // Binary install of Copilot replaces the per-user `unbound.py` script with
272
+ // a config that points straight at the binary; skip the script check in
273
+ // that case so a clean binary install doesn't read as tampered.
274
+ const cfgText = readText('~/.copilot/hooks/unbound.json');
275
+ const hasBinary = cfgText != null && cfgText.includes(BINARY_MARKER);
276
+ const checks = [configCheck('Config', '~/.copilot/hooks/unbound.json', { json: true, test: refsUnbound })];
277
+ if (hasBinary) checks.push(scriptCheck('Hook binary', binaryPath || BINARY_PATH));
278
+ else checks.push(scriptCheck('Hook script', '~/.copilot/hooks/unbound.py'));
279
+ checks.push(envCheck('API key env', 'UNBOUND_COPILOT_API_KEY', apiKey));
280
+ return checks;
281
+ },
232
282
  },
233
283
  // Gemini CLI is intentionally omitted here — it isn't part of `setup --all`
234
284
  // and has no managed directory. Add it back when its scope is settled.
@@ -253,8 +303,10 @@ function detectVariant(variant) {
253
303
  // not-installed. This is what both `doctor` and `status` render.
254
304
  // `_mdmDirs` (test-only) overrides the system MDM directories per family so the
255
305
  // org-managed scenarios can be exercised without writing under /Library or /etc.
256
- function detectTools({ gatewayUrl, apiKey, _mdmDirs } = {}) {
257
- const variants = buildVariants(gatewayUrl, apiKey).map(detectVariant);
306
+ // `_binaryPath` (test-only) overrides the system hook-binary path so binary-mode
307
+ // scenarios can be exercised without writing under /opt.
308
+ function detectTools({ gatewayUrl, apiKey, _mdmDirs, _binaryPath } = {}) {
309
+ const variants = buildVariants(gatewayUrl, apiKey, _binaryPath).map(detectVariant);
258
310
  const families = [];
259
311
  const seen = new Set();
260
312
  for (const v of variants) {
@@ -272,7 +324,7 @@ function detectTools({ gatewayUrl, apiKey, _mdmDirs } = {}) {
272
324
  } else {
273
325
  const family = v.family;
274
326
  const label = v.label.replace(/ \(.*\)$/, '');
275
- const mdm = mdmDetect(family, _mdmDirs && _mdmDirs[family]);
327
+ const mdm = mdmDetect(family, _mdmDirs && _mdmDirs[family], _binaryPath);
276
328
  if (mdm.status === 'healthy') {
277
329
  families.push({ key: family, label, family, mode: null, status: 'managed-by-mdm', checks: mdm.checks, scope: 'mdm' });
278
330
  } else if (mdm.status === 'tampered') {