unbound-cli 1.3.2 → 1.5.0

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,17 +195,25 @@ 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
201
+ # AI-assisted (preferred): describe the policy in natural language.
202
+ unbound policy tool create-terminal --prompt "block rm -rf"
203
+
204
+ # AI-assisted MCP policy: describe the service and intent in natural language.
205
+ unbound policy tool create-mcp --prompt "audit all Linear writes"
206
+
199
207
  # See what command families and MCP servers are available
200
208
  unbound policy tool families
201
209
  unbound policy tool mcp-servers
202
210
 
203
- # Block destructive shell commands
204
- unbound policy tool create-terminal --name "Block rm -rf" --command-family filesystem \
211
+ # Flag-based fallback: block destructive shell commands explicitly
212
+ unbound policy tool create-terminal --no-ai --name "Block rm -rf" --command-family filesystem \
205
213
  --field command='rm -rf*' --action BLOCK --custom-message "Destructive command blocked."
206
214
 
207
- # Audit Linear write operations via MCP
208
- unbound policy tool create-mcp --name "Audit Linear writes" --mcp-server Linear \
215
+ # Flag-based MCP fallback: audit Linear write operations
216
+ unbound policy tool create-mcp --no-ai --name "Audit Linear writes" --mcp-server Linear \
209
217
  --mcp-action-type write --action AUDIT
210
218
 
211
219
  # List, get, delete
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unbound-cli",
3
- "version": "1.3.2",
3
+ "version": "1.5.0",
4
4
  "description": "CLI tool for Unbound - AI Gateway management",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -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
@@ -1429,27 +1430,38 @@ Subcommands:
1429
1430
  tool
1430
1431
  .command('create-terminal')
1431
1432
  .description('Create a TERMINAL_COMMAND policy: monitor or block shell commands run by AI coding tools.')
1432
- .requiredOption('--name <name>', 'Policy name (required)')
1433
- .requiredOption('--command-family <family>', 'Command family (e.g. git, filesystem, network). See `policy tool families`.')
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.')
1435
+ .option('--name <name>', 'Policy name (required unless --prompt is used)')
1436
+ .option('--command-family <family>', 'Command family (e.g. git, filesystem, network). See `policy tool families`.')
1434
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])
1435
- .requiredOption('--action <action>', `Action to take: ${TOOL_ACTIONS.join(' | ')}`)
1438
+ .option('--action <action>', `Action to take: ${TOOL_ACTIONS.join(' | ')}`)
1436
1439
  .option('--description <text>', 'Human-readable description')
1437
1440
  .option('--custom-message <text>', 'Message shown to the user when the policy fires. Required when --action is BLOCK or WARN.')
1438
1441
  .option('--group <names|ids>', 'Comma-separated user group names or IDs to scope this policy to')
1439
1442
  .option('--disabled', 'Create the policy in disabled state')
1440
1443
  .option('--config <json>', 'Advanced: raw config JSON (skips --field builder)')
1444
+ .option('--yes', 'Skip the confirmation prompt for --prompt flows (preview is still printed)')
1441
1445
  .option('--json', 'Output raw JSON of the created policy')
1446
+ .addHelpText('before', helpBannerFor('create-terminal'))
1442
1447
  .addHelpText('after', `
1443
1448
  Examples:
1444
- $ unbound policy tool create-terminal --name "Block rm -rf" \\
1449
+ # AI-assisted (preferred): describe the policy in natural language.
1450
+ $ unbound policy tool create-terminal --prompt "block rm -rf"
1451
+
1452
+ # AI-assisted, with manual overrides for name and action:
1453
+ $ unbound policy tool create-terminal --prompt "block rm -rf" \\
1454
+ --name "Block recursive deletes" --action WARN
1455
+
1456
+ $ unbound policy tool create-terminal --no-ai --name "Block rm -rf" \\
1445
1457
  --command-family filesystem --field command='rm -rf*' --action BLOCK \\
1446
1458
  --custom-message "Destructive commands are blocked."
1447
1459
 
1448
- $ unbound policy tool create-terminal --name "Audit git push" \\
1460
+ $ unbound policy tool create-terminal --no-ai --name "Audit git push" \\
1449
1461
  --command-family git --field command='git push*' --action AUDIT
1450
1462
 
1451
1463
  # Multiple --field are ANDed — this fires only on a push to main on origin:
1452
- $ 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" \\
1453
1465
  --command-family git_action \\
1454
1466
  --field operation=push --field branch=main --field remote='*origin*' \\
1455
1467
  --action BLOCK --custom-message "Pushing to main on origin is blocked."
@@ -1460,6 +1472,84 @@ Learn more: ${DOCS_TOOL}
1460
1472
  .action(async (opts) => {
1461
1473
  try {
1462
1474
  requireLogin();
1475
+ assertSteering(opts, { subcommandName: 'create-terminal' });
1476
+
1477
+ if (opts.prompt !== undefined) {
1478
+ if (typeof opts.prompt !== 'string' || opts.prompt.trim() === '') {
1479
+ output.error('--prompt cannot be empty.');
1480
+ process.exitCode = 2;
1481
+ return;
1482
+ }
1483
+ // --name, --description, --action are now allowed as AI overrides (merged in mergeAiAndFlags).
1484
+ // --command-family, --field, --config remain mutex with --prompt because they're AI
1485
+ // classification territory; if the AI gets them wrong, the right answer is re-prompting,
1486
+ // not flag overrides (which would need catalog validation we don't want to add here).
1487
+ const mutex = ['commandFamily', 'field', 'config'];
1488
+ for (const k of mutex) {
1489
+ if (opts[k]) {
1490
+ throw new Error('Pass --prompt for AI-assist or the field flags for explicit creation, not both.');
1491
+ }
1492
+ }
1493
+ if (opts.group) {
1494
+ let formData;
1495
+ try {
1496
+ formData = await loadFormData();
1497
+ } catch (err) {
1498
+ const { routeBackendError } = require('../lib/policy-ai-assist');
1499
+ const routed = routeBackendError(err);
1500
+ output.error(routed.message);
1501
+ process.exitCode = routed.exitCode;
1502
+ return;
1503
+ }
1504
+ opts.scopeUserGroupIds = resolveByName(formData.user_groups || [], opts.group, { kind: 'user group' });
1505
+ }
1506
+ const { runTerminalPromptCreate } = require('../lib/policy-ai-assist');
1507
+ let result;
1508
+ try {
1509
+ result = await runTerminalPromptCreate(opts);
1510
+ } catch (err) {
1511
+ if (err.exitCode === 0) {
1512
+ output.warn(err.message);
1513
+ return;
1514
+ }
1515
+ output.error(err.message);
1516
+ process.exitCode = err.exitCode || 1;
1517
+ return;
1518
+ }
1519
+ if (!result.confirmed) {
1520
+ output.warn('Aborted.');
1521
+ return;
1522
+ }
1523
+ const body = result.body;
1524
+ let data;
1525
+ try {
1526
+ data = await api.post('/api/v1/command-policies/', { body });
1527
+ } catch (err) {
1528
+ const { routeBackendError } = require('../lib/policy-ai-assist');
1529
+ const routed = routeBackendError(err);
1530
+ output.error(routed.message);
1531
+ process.exitCode = routed.exitCode;
1532
+ return;
1533
+ }
1534
+ if (opts.json) {
1535
+ output.json(data);
1536
+ return;
1537
+ }
1538
+ output.success(`Terminal policy${body.name ? ` "${body.name}"` : ''} created.`);
1539
+ displayToolPolicy(unwrapToolPolicy(data));
1540
+ return;
1541
+ }
1542
+
1543
+ if (!opts.name) {
1544
+ throw new Error('--name is required (or use --prompt for AI assist).');
1545
+ }
1546
+ if (!opts.commandFamily) {
1547
+ throw new Error('--command-family is required (or use --prompt for AI assist).');
1548
+ }
1549
+ if (!opts.action) {
1550
+ throw new Error('--action is required (or use --prompt for AI assist).');
1551
+ }
1552
+
1463
1553
  const action = normalizeAction(opts.action, TOOL_ACTIONS, '--action');
1464
1554
  if ((action === 'BLOCK' || action === 'WARN') && !opts.customMessage) {
1465
1555
  throw new Error('--custom-message is required when --action is BLOCK or WARN.');
@@ -1508,32 +1598,45 @@ Learn more: ${DOCS_TOOL}
1508
1598
  displayToolPolicy(unwrapToolPolicy(data));
1509
1599
  } catch (err) {
1510
1600
  output.error(err.message);
1511
- process.exitCode = 1;
1601
+ process.exitCode = err.exitCode || 1;
1512
1602
  }
1513
1603
  });
1514
1604
 
1515
1605
  tool
1516
1606
  .command('create-mcp')
1517
1607
  .description('Create an MCP_TOOL policy: monitor or block MCP server tool calls.')
1518
- .requiredOption('--name <name>', 'Policy name (required)')
1519
- .requiredOption('--mcp-server <server>', 'MCP server name as shown by `policy tool mcp-servers` (e.g. linear, github). Resolved to its canonical group automatically.')
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.')
1610
+ .option('--name <name>', 'Policy name (required unless --prompt is used). Override AI suggestion when used with --prompt.')
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.')
1520
1612
  .option('--mcp-tool <tool>', 'Specific tool name on the MCP server (e.g. create_issue). Mutually exclusive with --mcp-action-type.')
1521
1613
  .option('--mcp-action-type <type>', `Match by tool action type: ${MCP_ACTION_TYPES.join(' | ')}. Mutually exclusive with --mcp-tool.`)
1522
- .requiredOption('--action <action>', `Action to take: ${TOOL_ACTIONS.join(' | ')}`)
1523
- .option('--description <text>', 'Human-readable description')
1524
- .option('--custom-message <text>', 'Message shown to the user when the policy fires. Required when --action is BLOCK or WARN.')
1614
+ .option('--action <action>', `Action to take: ${TOOL_ACTIONS.join(' | ')}. Override AI suggestion when used with --prompt.`)
1615
+ .option('--description <text>', 'Human-readable description. Override AI suggestion when used with --prompt.')
1616
+ .option('--custom-message <text>', 'Message shown to the user when the policy fires. Required when --action is BLOCK or WARN. Override AI suggestion when used with --prompt.')
1525
1617
  .option('--group <names|ids>', 'Comma-separated user group names or IDs to scope this policy to')
1526
1618
  .option('--disabled', 'Create the policy in disabled state')
1619
+ .option('--config <json>', 'Advanced: raw config JSON')
1620
+ .option('--yes', 'Skip the confirmation prompt for --prompt flows (preview is still printed)')
1527
1621
  .option('--json', 'Output raw JSON of the created policy')
1622
+ .addHelpText('before', helpBannerFor('create-mcp'))
1528
1623
  .addHelpText('after', `
1529
1624
  Examples:
1530
- Match a specific tool on a server:
1531
- $ unbound policy tool create-mcp --name "Block Linear writes" \\
1625
+ # AI-assisted (preferred): describe the policy in natural language.
1626
+ $ unbound policy tool create-mcp --prompt "audit all Linear writes"
1627
+
1628
+ # AI-assisted, with manual overrides for name and action:
1629
+ $ unbound policy tool create-mcp --prompt "audit all Linear writes" \\
1630
+ --name "Audit Linear writes" --action WARN \\
1631
+ --custom-message "Linear writes are monitored."
1632
+
1633
+ # Match a specific tool on a server (flag-based fallback):
1634
+ $ unbound policy tool create-mcp --no-ai --name "Block Linear writes" \\
1532
1635
  --mcp-server linear --mcp-tool create_issue --action BLOCK \\
1533
1636
  --custom-message "Issue creation is blocked. Contact admin."
1534
1637
 
1535
- Match all destructive tools on a server:
1536
- $ unbound policy tool create-mcp --name "Audit all destructive Slack" \\
1638
+ # Match all destructive tools on a server:
1639
+ $ unbound policy tool create-mcp --no-ai --name "Audit all destructive Slack" \\
1537
1640
  --mcp-server slack --mcp-action-type destructive --action AUDIT
1538
1641
 
1539
1642
  The server name is resolved to its canonical group automatically, so pass the
@@ -1545,6 +1648,80 @@ Learn more: ${DOCS_TOOL}
1545
1648
  .action(async (opts) => {
1546
1649
  try {
1547
1650
  requireLogin();
1651
+ assertSteering(opts, { subcommandName: 'create-mcp' });
1652
+
1653
+ if (opts.prompt !== undefined) {
1654
+ if (typeof opts.prompt !== 'string' || opts.prompt.trim() === '') {
1655
+ output.error('--prompt cannot be empty.');
1656
+ process.exitCode = 2;
1657
+ return;
1658
+ }
1659
+ const mutex = ['mcpServer', 'mcpTool', 'mcpActionType', 'config'];
1660
+ for (const k of mutex) {
1661
+ if (opts[k]) {
1662
+ throw new Error('Pass --prompt for AI-assist or the field flags for explicit creation, not both.');
1663
+ }
1664
+ }
1665
+ if (opts.group) {
1666
+ let formData;
1667
+ try {
1668
+ formData = await loadFormData();
1669
+ } catch (err) {
1670
+ const { routeBackendError } = require('../lib/policy-ai-assist');
1671
+ const routed = routeBackendError(err);
1672
+ output.error(routed.message);
1673
+ process.exitCode = routed.exitCode;
1674
+ return;
1675
+ }
1676
+ opts.scopeUserGroupIds = resolveByName(formData.user_groups || [], opts.group, { kind: 'user group' });
1677
+ }
1678
+ const { runMcpPromptCreate } = require('../lib/policy-ai-assist');
1679
+ let result;
1680
+ try {
1681
+ result = await runMcpPromptCreate(opts);
1682
+ } catch (err) {
1683
+ if (err.exitCode === 0) {
1684
+ output.warn(err.message);
1685
+ return;
1686
+ }
1687
+ output.error(err.message);
1688
+ process.exitCode = err.exitCode || 1;
1689
+ return;
1690
+ }
1691
+ if (!result.confirmed) {
1692
+ output.warn('Aborted.');
1693
+ return;
1694
+ }
1695
+ const body = result.body;
1696
+ let data;
1697
+ try {
1698
+ data = await api.post('/api/v1/command-policies/', { body });
1699
+ } catch (err) {
1700
+ const { routeBackendError } = require('../lib/policy-ai-assist');
1701
+ const routed = routeBackendError(err);
1702
+ output.error(routed.message);
1703
+ process.exitCode = routed.exitCode;
1704
+ return;
1705
+ }
1706
+ if (opts.json) {
1707
+ output.json(data);
1708
+ return;
1709
+ }
1710
+ output.success(`MCP policy${body.name ? ` "${body.name}"` : ''} created.`);
1711
+ displayToolPolicy(unwrapToolPolicy(data));
1712
+ return;
1713
+ }
1714
+
1715
+ if (!opts.name) {
1716
+ throw new Error('--name is required (or use --prompt for AI assist).');
1717
+ }
1718
+ if (!opts.mcpServer) {
1719
+ throw new Error('--mcp-server is required (or use --prompt for AI assist).');
1720
+ }
1721
+ if (!opts.action) {
1722
+ throw new Error('--action is required (or use --prompt for AI assist).');
1723
+ }
1724
+
1548
1725
  const action = normalizeAction(opts.action, TOOL_ACTIONS, '--action');
1549
1726
  if ((action === 'BLOCK' || action === 'WARN') && !opts.customMessage) {
1550
1727
  throw new Error('--custom-message is required when --action is BLOCK or WARN.');
@@ -1575,6 +1752,9 @@ Learn more: ${DOCS_TOOL}
1575
1752
  enabled: !opts.disabled,
1576
1753
  config: {},
1577
1754
  };
1755
+ if (opts.config) {
1756
+ body.config = parseJsonOrThrow(opts.config);
1757
+ }
1578
1758
  if (hasTool) body.mcp_tool = opts.mcpTool;
1579
1759
  if (hasActionType) body.mcp_tool_action_type = opts.mcpActionType;
1580
1760
  if (opts.customMessage) body.custom_message = opts.customMessage;
@@ -1593,7 +1773,7 @@ Learn more: ${DOCS_TOOL}
1593
1773
  displayToolPolicy(unwrapToolPolicy(data));
1594
1774
  } catch (err) {
1595
1775
  output.error(err.message);
1596
- process.exitCode = 1;
1776
+ process.exitCode = err.exitCode || 1;
1597
1777
  }
1598
1778
  });
1599
1779
 
@@ -946,13 +946,14 @@ async function runSetupAllBundle(apiKey, { backendUrl, frontendUrl, gatewayUrl,
946
946
  // actually supports it (Claude Code hooks, Codex hooks, Copilot hooks).
947
947
  // Cursor would print "not supported"; passing the flag to gateway-mode
948
948
  // scripts would error out — `scriptSupportsBackfill` checks for both.
949
- return runBatch(resolvedTools, (tool) => {
949
+ const result = await runBatch(resolvedTools, (tool) => {
950
950
  const args = buildScriptArgs(apiKey, {
951
951
  backendUrl, frontendUrl, gatewayUrl,
952
952
  backfill: backfill && scriptSupportsBackfill(tool.script),
953
953
  });
954
954
  return runScriptPiped(tool.script, args);
955
955
  });
956
+ return result;
956
957
  }
957
958
 
958
959
  /**
@@ -967,13 +968,14 @@ async function runMdmSetupAllBundle(adminApiKey, { backendUrl, frontendUrl, gate
967
968
  if (!scriptSupportsBackfill(tool.script)) noteBackfillUnsupported(tool.label, tool.script);
968
969
  }
969
970
  }
970
- return runBatch(resolvedTools, (tool) => {
971
+ const result = await runBatch(resolvedTools, (tool) => {
971
972
  const args = buildScriptArgs(adminApiKey, {
972
973
  backendUrl, frontendUrl, gatewayUrl, mdm: true,
973
974
  backfill: backfill && scriptSupportsBackfill(tool.script),
974
975
  });
975
976
  return runScriptPiped(tool.script, args);
976
977
  });
978
+ return result;
977
979
  }
978
980
 
979
981
  module.exports = {
@@ -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 };