unbound-cli 1.3.0 → 1.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unbound-cli",
3
- "version": "1.3.0",
3
+ "version": "1.3.2",
4
4
  "description": "CLI tool for Unbound - AI Gateway management",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -16,7 +16,7 @@ const DISCOVERY_EXIT_UNSUPPORTED_OS = 3;
16
16
  // indefinitely. Discovery enforces this itself — on expiry it releases its lock,
17
17
  // reports the run as failed, and exits non-zero. Kept in sync with
18
18
  // setup/mdm/onboard.py's DISCOVERY_TIMEOUT_SECONDS.
19
- const DISCOVERY_TIMEOUT_SECONDS = 1800;
19
+ const DISCOVERY_TIMEOUT_SECONDS = 5400;
20
20
 
21
21
  // Classifies a discovery subprocess exit code:
22
22
  // 'success' (scan ran), 'unsupported' (skipped on this OS), or 'failure'.
@@ -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
  */
@@ -1421,7 +1431,7 @@ Subcommands:
1421
1431
  .description('Create a TERMINAL_COMMAND policy: monitor or block shell commands run by AI coding tools.')
1422
1432
  .requiredOption('--name <name>', 'Policy name (required)')
1423
1433
  .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])
1434
+ .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])
1425
1435
  .requiredOption('--action <action>', `Action to take: ${TOOL_ACTIONS.join(' | ')}`)
1426
1436
  .option('--description <text>', 'Human-readable description')
1427
1437
  .option('--custom-message <text>', 'Message shown to the user when the policy fires. Required when --action is BLOCK or WARN.')
@@ -1438,9 +1448,11 @@ Examples:
1438
1448
  $ unbound policy tool create-terminal --name "Audit git push" \\
1439
1449
  --command-family git --field command='git push*' --action AUDIT
1440
1450
 
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."
1451
+ # 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" \\
1453
+ --command-family git_action \\
1454
+ --field operation=push --field branch=main --field remote='*origin*' \\
1455
+ --action BLOCK --custom-message "Pushing to main on origin is blocked."
1444
1456
 
1445
1457
  Discover valid command families and their fields: unbound policy tool families
1446
1458
  Learn more: ${DOCS_TOOL}
@@ -1469,6 +1481,7 @@ Learn more: ${DOCS_TOOL}
1469
1481
  configObj[k] = v;
1470
1482
  }
1471
1483
  }
1484
+ assertAnyExclusive(configObj);
1472
1485
 
1473
1486
  const body = {
1474
1487
  name: opts.name,
@@ -1595,7 +1608,7 @@ Learn more: ${DOCS_TOOL}
1595
1608
  .option('--enabled', 'Enable the policy')
1596
1609
  .option('--disabled', 'Disable the policy')
1597
1610
  .option('--command-family <family>', '(TERMINAL only) new command family')
1598
- .option('--field <key=pattern>', '(TERMINAL only) replace config field. Repeatable.', (val, prev = []) => [...prev, val])
1611
+ .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
1612
  .option('--mcp-server <server>', '(MCP only) new MCP server')
1600
1613
  .option('--mcp-tool <tool>', '(MCP only) new MCP tool')
1601
1614
  .option('--mcp-action-type <type>', '(MCP only) new MCP action type')
@@ -1627,6 +1640,7 @@ Learn more: ${DOCS_TOOL}
1627
1640
  } else if (opts.config) {
1628
1641
  body.config = parseJsonOrThrow(opts.config);
1629
1642
  }
1643
+ if (body.config) assertAnyExclusive(body.config);
1630
1644
 
1631
1645
  if (opts.mcpServer) body.mcp_server = opts.mcpServer;
1632
1646
  if (opts.mcpTool) body.mcp_tool = opts.mcpTool;
@@ -1888,4 +1902,4 @@ Learn more: ${DOCS_POLICIES}
1888
1902
  registerLegacy(policy);
1889
1903
  }
1890
1904
 
1891
- module.exports = { register };
1905
+ module.exports = { register, assertAnyExclusive };
@@ -0,0 +1,28 @@
1
+ 'use strict';
2
+
3
+ const { test } = require('node:test');
4
+ const assert = require('node:assert');
5
+
6
+ const { assertAnyExclusive } = require('../src/commands/policy');
7
+
8
+ test('assertAnyExclusive: allows a single ANY condition', () => {
9
+ assert.doesNotThrow(() => assertAnyExclusive({ ANY: '*' }));
10
+ });
11
+
12
+ test('assertAnyExclusive: allows multiple non-ANY fields (AND)', () => {
13
+ assert.doesNotThrow(() => assertAnyExclusive({ path: '/etc/*', operation: 'write' }));
14
+ });
15
+
16
+ test('assertAnyExclusive: rejects ANY combined with another field', () => {
17
+ assert.throws(() => assertAnyExclusive({ ANY: '*', path: '/etc/*' }), /ANY.*cannot be combined/);
18
+ });
19
+
20
+ test('assertAnyExclusive: case-insensitive — rejects lowercase/mixed-case any with another field', () => {
21
+ assert.throws(() => assertAnyExclusive({ any: '*', path: '/etc/*' }), /ANY.*cannot be combined/);
22
+ assert.throws(() => assertAnyExclusive({ Any: '*', path: '/etc/*' }), /ANY.*cannot be combined/);
23
+ });
24
+
25
+ test('assertAnyExclusive: tolerates empty/undefined config', () => {
26
+ assert.doesNotThrow(() => assertAnyExclusive({}));
27
+ assert.doesNotThrow(() => assertAnyExclusive(undefined));
28
+ });