unbound-cli 1.3.1 → 1.4.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
@@ -196,15 +196,21 @@ unbound policy security create --name "429 Fallback" --sub-type error-code-routi
196
196
  Tool policy examples:
197
197
 
198
198
  ```bash
199
+ # AI-assisted (preferred): describe the policy in natural language.
200
+ unbound policy tool create-terminal --prompt "block rm -rf"
201
+
202
+ # AI-assisted MCP policy: describe the service and intent in natural language.
203
+ unbound policy tool create-mcp --prompt "audit all Linear writes"
204
+
199
205
  # See what command families and MCP servers are available
200
206
  unbound policy tool families
201
207
  unbound policy tool mcp-servers
202
208
 
203
- # Block destructive shell commands
209
+ # Flag-based fallback: block destructive shell commands explicitly
204
210
  unbound policy tool create-terminal --name "Block rm -rf" --command-family filesystem \
205
211
  --field command='rm -rf*' --action BLOCK --custom-message "Destructive command blocked."
206
212
 
207
- # Audit Linear write operations via MCP
213
+ # Flag-based MCP fallback: audit Linear write operations
208
214
  unbound policy tool create-mcp --name "Audit Linear writes" --mcp-server Linear \
209
215
  --mcp-action-type write --action AUDIT
210
216
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unbound-cli",
3
- "version": "1.3.1",
3
+ "version": "1.4.0",
4
4
  "description": "CLI tool for Unbound - AI Gateway management",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -311,6 +311,16 @@ function parseFieldSpec(spec) {
311
311
  return [spec.slice(0, eq), spec.slice(eq + 1)];
312
312
  }
313
313
 
314
+ // "ANY" is a wildcard across all fields; it can't be ANDed with other conditions.
315
+ // Mirrors the backend rule so the CLI fails fast before the API call. The check is
316
+ // case-insensitive so `--field Any=*`/`--field any=*` are caught too.
317
+ function assertAnyExclusive(configObj) {
318
+ const keys = Object.keys(configObj || {});
319
+ if (keys.length > 1 && keys.some((k) => k.toUpperCase() === 'ANY')) {
320
+ throw new Error("The 'ANY' field cannot be combined with other --field conditions (ANY already matches every field).");
321
+ }
322
+ }
323
+
314
324
  /**
315
325
  * Convert a `--sub-type` flag (kebab-case) to the backend's snake_case form.
316
326
  */
@@ -1419,18 +1429,27 @@ Subcommands:
1419
1429
  tool
1420
1430
  .command('create-terminal')
1421
1431
  .description('Create a TERMINAL_COMMAND policy: monitor or block shell commands run by AI coding tools.')
1422
- .requiredOption('--name <name>', 'Policy name (required)')
1423
- .requiredOption('--command-family <family>', 'Command family (e.g. git, filesystem, network). See `policy tool families`.')
1424
- .option('--field <key=pattern>', 'Match field: <key>=<pattern>. Repeatable. At least one required unless --config is used.', (val, prev = []) => [...prev, val])
1425
- .requiredOption('--action <action>', `Action to take: ${TOOL_ACTIONS.join(' | ')}`)
1432
+ .option('--prompt <text>', 'Natural-language description; routes through Unbound AI assist. Mutually exclusive with --command-family/--field/--config. Compatible with --name/--description/--action overrides.')
1433
+ .option('--name <name>', 'Policy name (required unless --prompt is used)')
1434
+ .option('--command-family <family>', 'Command family (e.g. git, filesystem, network). See `policy tool families`.')
1435
+ .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])
1436
+ .option('--action <action>', `Action to take: ${TOOL_ACTIONS.join(' | ')}`)
1426
1437
  .option('--description <text>', 'Human-readable description')
1427
1438
  .option('--custom-message <text>', 'Message shown to the user when the policy fires. Required when --action is BLOCK or WARN.')
1428
1439
  .option('--group <names|ids>', 'Comma-separated user group names or IDs to scope this policy to')
1429
1440
  .option('--disabled', 'Create the policy in disabled state')
1430
1441
  .option('--config <json>', 'Advanced: raw config JSON (skips --field builder)')
1442
+ .option('--yes', 'Skip the confirmation prompt for --prompt flows (preview is still printed)')
1431
1443
  .option('--json', 'Output raw JSON of the created policy')
1432
1444
  .addHelpText('after', `
1433
1445
  Examples:
1446
+ # AI-assisted (preferred): describe the policy in natural language.
1447
+ $ unbound policy tool create-terminal --prompt "block rm -rf"
1448
+
1449
+ # AI-assisted, with manual overrides for name and action:
1450
+ $ unbound policy tool create-terminal --prompt "block rm -rf" \\
1451
+ --name "Block recursive deletes" --action WARN
1452
+
1434
1453
  $ unbound policy tool create-terminal --name "Block rm -rf" \\
1435
1454
  --command-family filesystem --field command='rm -rf*' --action BLOCK \\
1436
1455
  --custom-message "Destructive commands are blocked."
@@ -1438,9 +1457,11 @@ Examples:
1438
1457
  $ unbound policy tool create-terminal --name "Audit git push" \\
1439
1458
  --command-family git --field command='git push*' --action AUDIT
1440
1459
 
1441
- $ unbound policy tool create-terminal --name "Warn curl" \\
1442
- --command-family network --field command='curl*' --action WARN \\
1443
- --custom-message "Outbound HTTP calls are being audited."
1460
+ # 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" \\
1462
+ --command-family git_action \\
1463
+ --field operation=push --field branch=main --field remote='*origin*' \\
1464
+ --action BLOCK --custom-message "Pushing to main on origin is blocked."
1444
1465
 
1445
1466
  Discover valid command families and their fields: unbound policy tool families
1446
1467
  Learn more: ${DOCS_TOOL}
@@ -1448,6 +1469,83 @@ Learn more: ${DOCS_TOOL}
1448
1469
  .action(async (opts) => {
1449
1470
  try {
1450
1471
  requireLogin();
1472
+
1473
+ if (opts.prompt !== undefined) {
1474
+ if (typeof opts.prompt !== 'string' || opts.prompt.trim() === '') {
1475
+ output.error('--prompt cannot be empty.');
1476
+ process.exitCode = 2;
1477
+ return;
1478
+ }
1479
+ // --name, --description, --action are now allowed as AI overrides (merged in mergeAiAndFlags).
1480
+ // --command-family, --field, --config remain mutex with --prompt because they're AI
1481
+ // classification territory; if the AI gets them wrong, the right answer is re-prompting,
1482
+ // not flag overrides (which would need catalog validation we don't want to add here).
1483
+ const mutex = ['commandFamily', 'field', 'config'];
1484
+ for (const k of mutex) {
1485
+ if (opts[k]) {
1486
+ throw new Error('Pass --prompt for AI-assist or the field flags for explicit creation, not both.');
1487
+ }
1488
+ }
1489
+ if (opts.group) {
1490
+ let formData;
1491
+ try {
1492
+ formData = await loadFormData();
1493
+ } catch (err) {
1494
+ const { routeBackendError } = require('../lib/policy-ai-assist');
1495
+ const routed = routeBackendError(err);
1496
+ output.error(routed.message);
1497
+ process.exitCode = routed.exitCode;
1498
+ return;
1499
+ }
1500
+ opts.scopeUserGroupIds = resolveByName(formData.user_groups || [], opts.group, { kind: 'user group' });
1501
+ }
1502
+ const { runTerminalPromptCreate } = require('../lib/policy-ai-assist');
1503
+ let result;
1504
+ try {
1505
+ result = await runTerminalPromptCreate(opts);
1506
+ } catch (err) {
1507
+ if (err.exitCode === 0) {
1508
+ output.warn(err.message);
1509
+ return;
1510
+ }
1511
+ output.error(err.message);
1512
+ process.exitCode = err.exitCode || 1;
1513
+ return;
1514
+ }
1515
+ if (!result.confirmed) {
1516
+ output.warn('Aborted.');
1517
+ return;
1518
+ }
1519
+ const body = result.body;
1520
+ let data;
1521
+ try {
1522
+ data = await api.post('/api/v1/command-policies/', { body });
1523
+ } catch (err) {
1524
+ const { routeBackendError } = require('../lib/policy-ai-assist');
1525
+ const routed = routeBackendError(err);
1526
+ output.error(routed.message);
1527
+ process.exitCode = routed.exitCode;
1528
+ return;
1529
+ }
1530
+ if (opts.json) {
1531
+ output.json(data);
1532
+ return;
1533
+ }
1534
+ output.success(`Terminal policy${body.name ? ` "${body.name}"` : ''} created.`);
1535
+ displayToolPolicy(unwrapToolPolicy(data));
1536
+ return;
1537
+ }
1538
+
1539
+ if (!opts.name) {
1540
+ throw new Error('--name is required (or use --prompt for AI assist).');
1541
+ }
1542
+ if (!opts.commandFamily) {
1543
+ throw new Error('--command-family is required (or use --prompt for AI assist).');
1544
+ }
1545
+ if (!opts.action) {
1546
+ throw new Error('--action is required (or use --prompt for AI assist).');
1547
+ }
1548
+
1451
1549
  const action = normalizeAction(opts.action, TOOL_ACTIONS, '--action');
1452
1550
  if ((action === 'BLOCK' || action === 'WARN') && !opts.customMessage) {
1453
1551
  throw new Error('--custom-message is required when --action is BLOCK or WARN.');
@@ -1469,6 +1567,7 @@ Learn more: ${DOCS_TOOL}
1469
1567
  configObj[k] = v;
1470
1568
  }
1471
1569
  }
1570
+ assertAnyExclusive(configObj);
1472
1571
 
1473
1572
  const body = {
1474
1573
  name: opts.name,
@@ -1502,24 +1601,35 @@ Learn more: ${DOCS_TOOL}
1502
1601
  tool
1503
1602
  .command('create-mcp')
1504
1603
  .description('Create an MCP_TOOL policy: monitor or block MCP server tool calls.')
1505
- .requiredOption('--name <name>', 'Policy name (required)')
1506
- .requiredOption('--mcp-server <server>', 'MCP server name as shown by `policy tool mcp-servers` (e.g. linear, github). Resolved to its canonical group automatically.')
1604
+ .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.')
1605
+ .option('--name <name>', 'Policy name (required unless --prompt is used). Override AI suggestion when used with --prompt.')
1606
+ .option('--mcp-server <server>', 'MCP server name as shown by `policy tool mcp-servers` (e.g. linear, github). Resolved to its canonical group automatically.')
1507
1607
  .option('--mcp-tool <tool>', 'Specific tool name on the MCP server (e.g. create_issue). Mutually exclusive with --mcp-action-type.')
1508
1608
  .option('--mcp-action-type <type>', `Match by tool action type: ${MCP_ACTION_TYPES.join(' | ')}. Mutually exclusive with --mcp-tool.`)
1509
- .requiredOption('--action <action>', `Action to take: ${TOOL_ACTIONS.join(' | ')}`)
1510
- .option('--description <text>', 'Human-readable description')
1511
- .option('--custom-message <text>', 'Message shown to the user when the policy fires. Required when --action is BLOCK or WARN.')
1609
+ .option('--action <action>', `Action to take: ${TOOL_ACTIONS.join(' | ')}. Override AI suggestion when used with --prompt.`)
1610
+ .option('--description <text>', 'Human-readable description. Override AI suggestion when used with --prompt.')
1611
+ .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.')
1512
1612
  .option('--group <names|ids>', 'Comma-separated user group names or IDs to scope this policy to')
1513
1613
  .option('--disabled', 'Create the policy in disabled state')
1614
+ .option('--config <json>', 'Advanced: raw config JSON')
1615
+ .option('--yes', 'Skip the confirmation prompt for --prompt flows (preview is still printed)')
1514
1616
  .option('--json', 'Output raw JSON of the created policy')
1515
1617
  .addHelpText('after', `
1516
1618
  Examples:
1517
- Match a specific tool on a server:
1619
+ # AI-assisted (preferred): describe the policy in natural language.
1620
+ $ unbound policy tool create-mcp --prompt "audit all Linear writes"
1621
+
1622
+ # AI-assisted, with manual overrides for name and action:
1623
+ $ unbound policy tool create-mcp --prompt "audit all Linear writes" \\
1624
+ --name "Audit Linear writes" --action WARN \\
1625
+ --custom-message "Linear writes are monitored."
1626
+
1627
+ # Match a specific tool on a server (flag-based fallback):
1518
1628
  $ unbound policy tool create-mcp --name "Block Linear writes" \\
1519
1629
  --mcp-server linear --mcp-tool create_issue --action BLOCK \\
1520
1630
  --custom-message "Issue creation is blocked. Contact admin."
1521
1631
 
1522
- Match all destructive tools on a server:
1632
+ # Match all destructive tools on a server:
1523
1633
  $ unbound policy tool create-mcp --name "Audit all destructive Slack" \\
1524
1634
  --mcp-server slack --mcp-action-type destructive --action AUDIT
1525
1635
 
@@ -1532,6 +1642,79 @@ Learn more: ${DOCS_TOOL}
1532
1642
  .action(async (opts) => {
1533
1643
  try {
1534
1644
  requireLogin();
1645
+
1646
+ if (opts.prompt !== undefined) {
1647
+ if (typeof opts.prompt !== 'string' || opts.prompt.trim() === '') {
1648
+ output.error('--prompt cannot be empty.');
1649
+ process.exitCode = 2;
1650
+ return;
1651
+ }
1652
+ const mutex = ['mcpServer', 'mcpTool', 'mcpActionType', 'config'];
1653
+ for (const k of mutex) {
1654
+ if (opts[k]) {
1655
+ throw new Error('Pass --prompt for AI-assist or the field flags for explicit creation, not both.');
1656
+ }
1657
+ }
1658
+ if (opts.group) {
1659
+ let formData;
1660
+ try {
1661
+ formData = await loadFormData();
1662
+ } catch (err) {
1663
+ const { routeBackendError } = require('../lib/policy-ai-assist');
1664
+ const routed = routeBackendError(err);
1665
+ output.error(routed.message);
1666
+ process.exitCode = routed.exitCode;
1667
+ return;
1668
+ }
1669
+ opts.scopeUserGroupIds = resolveByName(formData.user_groups || [], opts.group, { kind: 'user group' });
1670
+ }
1671
+ const { runMcpPromptCreate } = require('../lib/policy-ai-assist');
1672
+ let result;
1673
+ try {
1674
+ result = await runMcpPromptCreate(opts);
1675
+ } catch (err) {
1676
+ if (err.exitCode === 0) {
1677
+ output.warn(err.message);
1678
+ return;
1679
+ }
1680
+ output.error(err.message);
1681
+ process.exitCode = err.exitCode || 1;
1682
+ return;
1683
+ }
1684
+ if (!result.confirmed) {
1685
+ output.warn('Aborted.');
1686
+ return;
1687
+ }
1688
+ const body = result.body;
1689
+ let data;
1690
+ try {
1691
+ data = await api.post('/api/v1/command-policies/', { body });
1692
+ } catch (err) {
1693
+ const { routeBackendError } = require('../lib/policy-ai-assist');
1694
+ const routed = routeBackendError(err);
1695
+ output.error(routed.message);
1696
+ process.exitCode = routed.exitCode;
1697
+ return;
1698
+ }
1699
+ if (opts.json) {
1700
+ output.json(data);
1701
+ return;
1702
+ }
1703
+ output.success(`MCP policy${body.name ? ` "${body.name}"` : ''} created.`);
1704
+ displayToolPolicy(unwrapToolPolicy(data));
1705
+ return;
1706
+ }
1707
+
1708
+ if (!opts.name) {
1709
+ throw new Error('--name is required (or use --prompt for AI assist).');
1710
+ }
1711
+ if (!opts.mcpServer) {
1712
+ throw new Error('--mcp-server is required (or use --prompt for AI assist).');
1713
+ }
1714
+ if (!opts.action) {
1715
+ throw new Error('--action is required (or use --prompt for AI assist).');
1716
+ }
1717
+
1535
1718
  const action = normalizeAction(opts.action, TOOL_ACTIONS, '--action');
1536
1719
  if ((action === 'BLOCK' || action === 'WARN') && !opts.customMessage) {
1537
1720
  throw new Error('--custom-message is required when --action is BLOCK or WARN.');
@@ -1562,6 +1745,9 @@ Learn more: ${DOCS_TOOL}
1562
1745
  enabled: !opts.disabled,
1563
1746
  config: {},
1564
1747
  };
1748
+ if (opts.config) {
1749
+ body.config = parseJsonOrThrow(opts.config);
1750
+ }
1565
1751
  if (hasTool) body.mcp_tool = opts.mcpTool;
1566
1752
  if (hasActionType) body.mcp_tool_action_type = opts.mcpActionType;
1567
1753
  if (opts.customMessage) body.custom_message = opts.customMessage;
@@ -1595,7 +1781,7 @@ Learn more: ${DOCS_TOOL}
1595
1781
  .option('--enabled', 'Enable the policy')
1596
1782
  .option('--disabled', 'Disable the policy')
1597
1783
  .option('--command-family <family>', '(TERMINAL only) new command family')
1598
- .option('--field <key=pattern>', '(TERMINAL only) replace config field. Repeatable.', (val, prev = []) => [...prev, val])
1784
+ .option('--field <key=pattern>', '(TERMINAL only) replace config fields. Repeatable — multiple --field are ANDed (all must match), one per field.', (val, prev = []) => [...prev, val])
1599
1785
  .option('--mcp-server <server>', '(MCP only) new MCP server')
1600
1786
  .option('--mcp-tool <tool>', '(MCP only) new MCP tool')
1601
1787
  .option('--mcp-action-type <type>', '(MCP only) new MCP action type')
@@ -1627,6 +1813,7 @@ Learn more: ${DOCS_TOOL}
1627
1813
  } else if (opts.config) {
1628
1814
  body.config = parseJsonOrThrow(opts.config);
1629
1815
  }
1816
+ if (body.config) assertAnyExclusive(body.config);
1630
1817
 
1631
1818
  if (opts.mcpServer) body.mcp_server = opts.mcpServer;
1632
1819
  if (opts.mcpTool) body.mcp_tool = opts.mcpTool;
@@ -1888,4 +2075,4 @@ Learn more: ${DOCS_POLICIES}
1888
2075
  registerLegacy(policy);
1889
2076
  }
1890
2077
 
1891
- module.exports = { register };
2078
+ module.exports = { register, assertAnyExclusive };
@@ -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 = {