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 +4 -2
- package/package.json +1 -1
- package/src/commands/doctor.js +3 -2
- package/src/commands/policy.js +14 -7
- package/src/commands/setup.js +75 -14
- package/src/commands/status.js +1 -1
- package/src/lib/no-ai-guard.js +77 -0
- package/src/toolHealth.js +64 -12
- package/test/no-ai-guard.test.js +363 -0
- package/test/setup-args.test.js +73 -1
- package/test/tool-health.test.js +111 -0
- package/PLAN-web-4887.md +0 -477
- package/PLAN.md +0 -117
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
package/src/commands/doctor.js
CHANGED
|
@@ -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
|
-
|
|
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'}.`);
|
package/src/commands/policy.js
CHANGED
|
@@ -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
|
|
package/src/commands/setup.js
CHANGED
|
@@ -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',
|
|
40
|
-
'copilot': { label: 'GitHub Copilot',
|
|
41
|
-
'claude-code-subscription': { label: 'Claude Code
|
|
42
|
-
'claude-code-gateway': { label: 'Claude Code (gateway)',
|
|
43
|
-
'gemini-cli': { label: 'Gemini CLI',
|
|
44
|
-
'codex-subscription': { label: 'Codex
|
|
45
|
-
'codex-gateway': { label: 'Codex (gateway)',
|
|
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',
|
|
63
|
-
'copilot': { label: 'GitHub Copilot',
|
|
64
|
-
'claude-code-subscription': { label: 'Claude Code
|
|
65
|
-
'claude-code-gateway': { label: 'Claude Code (gateway)',
|
|
66
|
-
'gemini-cli': { label: 'Gemini CLI',
|
|
67
|
-
'codex-subscription': { label: 'Codex
|
|
68
|
-
'codex-gateway': { label: 'Codex (gateway)',
|
|
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
|
};
|
package/src/commands/status.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
257
|
-
|
|
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') {
|