unbound-cli 1.8.0 → 1.10.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unbound-cli",
3
- "version": "1.8.0",
3
+ "version": "1.10.0",
4
4
  "description": "CLI tool for Unbound - AI Gateway management",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -312,6 +312,34 @@ function parseFieldSpec(spec) {
312
312
  return [spec.slice(0, eq), spec.slice(eq + 1)];
313
313
  }
314
314
 
315
+ // The `custom` command family has no match-against fields — its single condition is
316
+ // the reserved `command` key, matched as ONE pattern against the whole raw command
317
+ // regardless of the classified family. The families metadata reports it with
318
+ // is_custom:true and no fields (mirrors the dashboard), so surface how to author it.
319
+ const CUSTOM_FAMILY_NAME = 'custom';
320
+ const CUSTOM_FAMILY_HINT =
321
+ "matches ONE pattern against the whole command (exact text, *wildcard*, or regex). " +
322
+ "Author it with --field command='<pattern>', e.g. --field command='make release-prod'.";
323
+
324
+ // Indented detail lines shown under a family name in `policy tool families`. Pure so
325
+ // the custom-family guidance is unit-testable. A custom family (is_custom) reports no
326
+ // fields by design, so explain its single whole-command pattern instead of "(no fields)".
327
+ function familyFieldLines(f) {
328
+ // Custom has no fields by design — return before touching f.fields so its shape is
329
+ // irrelevant for the custom branch.
330
+ if (f.is_custom) {
331
+ return [CUSTOM_FAMILY_HINT];
332
+ }
333
+ const fields = Array.isArray(f.fields) ? f.fields : Object.keys(f.fields || {});
334
+ if (fields.length === 0) {
335
+ return ['(no fields)'];
336
+ }
337
+ return fields.map((field) => {
338
+ const desc = (f.field_descriptions && f.field_descriptions[field]) || '';
339
+ return `${field}${desc ? ` — ${desc}` : ''}`;
340
+ });
341
+ }
342
+
315
343
  // "ANY" is a wildcard across all fields; it can't be ANDed with other conditions.
316
344
  // Mirrors the backend rule so the CLI fails fast before the API call. The check is
317
345
  // case-insensitive so `--field Any=*`/`--field any=*` are caught too.
@@ -1433,8 +1461,8 @@ Subcommands:
1433
1461
  .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
1462
  .option('--no-ai', 'Opt out of the AI-assist guard and use raw classification flags. Mutually exclusive with --prompt.')
1435
1463
  .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`.')
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])
1464
+ .option('--command-family <family>', 'Command family (e.g. git, filesystem, network), or `custom` to match one pattern against the whole command. See `policy tool families`.')
1465
+ .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. For the `custom` family the key is `command`.', (val, prev = []) => [...prev, val])
1438
1466
  .option('--action <action>', `Action to take: ${TOOL_ACTIONS.join(' | ')}`)
1439
1467
  .option('--description <text>', 'Human-readable description')
1440
1468
  .option('--custom-message <text>', 'Message shown to the user when the policy fires. Required when --action is BLOCK or WARN.')
@@ -1466,6 +1494,12 @@ Examples:
1466
1494
  --field operation=push --field branch=main --field remote='*origin*' \\
1467
1495
  --action BLOCK --custom-message "Pushing to main on origin is blocked."
1468
1496
 
1497
+ # 'custom' family: one pattern matched against the WHOLE command (exact / *wildcard* / regex),
1498
+ # regardless of the classified family. The field key is always 'command'.
1499
+ $ unbound policy tool create-terminal --no-ai --name "Block prod release" \\
1500
+ --command-family custom --field command='make release-prod' \\
1501
+ --action BLOCK --custom-message "Prod releases go through CI."
1502
+
1469
1503
  Discover valid command families and their fields: unbound policy tool families
1470
1504
  Learn more: ${DOCS_TOOL}
1471
1505
  `)
@@ -1558,12 +1592,18 @@ Learn more: ${DOCS_TOOL}
1558
1592
  throw new Error('Cannot combine --config with --field.');
1559
1593
  }
1560
1594
 
1595
+ const isCustomFamily = (opts.commandFamily || '').toLowerCase() === CUSTOM_FAMILY_NAME;
1596
+
1561
1597
  let configObj;
1562
1598
  if (opts.config) {
1563
1599
  configObj = parseJsonOrThrow(opts.config);
1564
1600
  } else {
1565
1601
  if (!opts.field || opts.field.length === 0) {
1566
- throw new Error('At least one --field <key>=<pattern> is required (or use --config).');
1602
+ throw new Error(
1603
+ isCustomFamily
1604
+ ? `The 'custom' family ${CUSTOM_FAMILY_HINT}`
1605
+ : 'At least one --field <key>=<pattern> is required (or use --config).'
1606
+ );
1567
1607
  }
1568
1608
  configObj = {};
1569
1609
  for (const spec of opts.field) {
@@ -1874,14 +1914,8 @@ Learn more: ${DOCS_TOOL}
1874
1914
  for (const f of families) {
1875
1915
  console.log('');
1876
1916
  output.info(f.name);
1877
- const fields = Array.isArray(f.fields) ? f.fields : Object.keys(f.fields || {});
1878
- if (fields.length === 0) {
1879
- console.log(' (no fields)');
1880
- } else {
1881
- for (const field of fields) {
1882
- const desc = (f.field_descriptions && f.field_descriptions[field]) || '';
1883
- console.log(` ${field}${desc ? ` — ${desc}` : ''}`);
1884
- }
1917
+ for (const line of familyFieldLines(f)) {
1918
+ console.log(` ${line}`);
1885
1919
  }
1886
1920
  if (f.risk_level) console.log(` risk: ${f.risk_level}`);
1887
1921
  }
@@ -2082,4 +2116,4 @@ Learn more: ${DOCS_POLICIES}
2082
2116
  registerLegacy(policy);
2083
2117
  }
2084
2118
 
2085
- module.exports = { register, assertAnyExclusive };
2119
+ module.exports = { register, assertAnyExclusive, familyFieldLines, CUSTOM_FAMILY_HINT };
@@ -448,6 +448,45 @@ function removeBinaryInstall({
448
448
  return { removed, failed };
449
449
  }
450
450
 
451
+
452
+ // Run the binary's own `unbound-hook clear` to deregister managed configs
453
+ // (strip our hooks from shared configs, remove our scripts + per-tool logs)
454
+ // BEFORE the install tree is deleted. Essential on binary-only machines that
455
+ // have no python3 to run the downloaded per-tool clears, and authoritative for
456
+ // binary installs generally — it reuses the same clear_setup() as the python
457
+ // clears. Fail-open: never throws; returns { ran, ok }. `binaryPath`/`runner`
458
+ // are overridable so tests stay hermetic.
459
+ function runBinaryClear({
460
+ binaryPath = path.join(BINARY_INSTALL_PATHS.optDir, 'current', 'unbound-hook', 'unbound-hook'),
461
+ runner = spawnSync,
462
+ } = {}) {
463
+ // lstatSync (not existsSync, which follows symlinks) so a present-but-broken
464
+ // `current/...` symlink is still detected — we attempt the clear and surface
465
+ // the failure instead of silently skipping it. A truly absent path (python-only
466
+ // install) throws → no-op.
467
+ let present = false;
468
+ try { fs.lstatSync(binaryPath); present = true; } catch (e) { present = false; }
469
+ if (!present) return { ran: false, ok: true };
470
+ try {
471
+ // timeout so a hung clear can never block nuke from removing the binary.
472
+ const r = runner(binaryPath, ['clear'], { stdio: 'pipe', timeout: 120000, killSignal: 'SIGKILL' });
473
+ if (r.error) {
474
+ const why = r.error.code === 'ETIMEDOUT' ? 'timed out (120s)' : r.error.message;
475
+ output.warn(`nuke: unbound-hook clear ${why}; continuing.`);
476
+ return { ran: true, ok: false };
477
+ }
478
+ if (r.status !== 0) {
479
+ const stderr = r.stderr ? r.stderr.toString().trim() : '';
480
+ output.warn(`nuke: unbound-hook clear exited ${r.status}: ${stderr || '(no stderr)'}`);
481
+ return { ran: true, ok: false };
482
+ }
483
+ return { ran: true, ok: true };
484
+ } catch (err) {
485
+ output.warn(`nuke: unbound-hook clear errored: ${err.message}`);
486
+ return { ran: true, ok: false };
487
+ }
488
+ }
489
+
451
490
  /**
452
491
  * Returns true when the process has the privileges needed to touch system-level
453
492
  * (MDM) configuration. On Windows, `net session` succeeds only when elevated, so
@@ -1047,8 +1086,13 @@ Examples:
1047
1086
  // `_confirmProductionPaths` is the explicit "this is the real call site"
1048
1087
  // ack — the function refuses production defaults without it as a guard
1049
1088
  // against tests forgetting to override paths.
1089
+ // Deregister managed configs via the binary's own clear FIRST (works
1090
+ // without python3, which a binary-only machine lacks); reuses the same
1091
+ // clear_setup() as the python clears. Must precede the rm below.
1092
+ const binClear = runBinaryClear();
1050
1093
  const { removed: binaryRemoved, failed: bf } = removeBinaryInstall({ _confirmProductionPaths: true });
1051
1094
  binaryFailed = bf;
1095
+ if (binClear.ran && !binClear.ok) binaryFailed.push('unbound-hook clear');
1052
1096
  if (binaryRemoved.length) {
1053
1097
  output.info(`Removed binary install: ${binaryRemoved.join('; ')}.`);
1054
1098
  }
@@ -1141,5 +1185,6 @@ module.exports = {
1141
1185
  clearUnboundEnvsEverywhere,
1142
1186
  NUKE_ENV_VARS,
1143
1187
  removeBinaryInstall,
1188
+ runBinaryClear,
1144
1189
  BINARY_INSTALL_PATHS,
1145
1190
  };
@@ -3,7 +3,7 @@
3
3
  const { test } = require('node:test');
4
4
  const assert = require('node:assert');
5
5
 
6
- const { assertAnyExclusive } = require('../src/commands/policy');
6
+ const { assertAnyExclusive, familyFieldLines, CUSTOM_FAMILY_HINT } = require('../src/commands/policy');
7
7
 
8
8
  test('assertAnyExclusive: allows a single ANY condition', () => {
9
9
  assert.doesNotThrow(() => assertAnyExclusive({ ANY: '*' }));
@@ -26,3 +26,32 @@ test('assertAnyExclusive: tolerates empty/undefined config', () => {
26
26
  assert.doesNotThrow(() => assertAnyExclusive({}));
27
27
  assert.doesNotThrow(() => assertAnyExclusive(undefined));
28
28
  });
29
+
30
+ // `policy tool families` must teach an agent how to author a custom policy: the
31
+ // custom family reports is_custom + no fields, and must render the command-pattern
32
+ // hint (not "(no fields)", which reads as "unusable").
33
+ test('familyFieldLines: custom family shows the command-pattern hint, not "(no fields)"', () => {
34
+ const lines = familyFieldLines({ name: 'custom', is_custom: true, fields: [] });
35
+ assert.deepStrictEqual(lines, [CUSTOM_FAMILY_HINT]);
36
+ assert.match(lines[0], /--field command=/);
37
+ assert.match(lines[0], /exact text, \*wildcard\*, or regex/);
38
+ });
39
+
40
+ test('familyFieldLines: a normal family with fields lists field — description', () => {
41
+ const lines = familyFieldLines({
42
+ name: 'git_action',
43
+ fields: ['operation', 'branch'],
44
+ field_descriptions: { operation: 'push/pull/…', branch: 'branch name' },
45
+ });
46
+ assert.deepStrictEqual(lines, ['operation — push/pull/…', 'branch — branch name']);
47
+ });
48
+
49
+ test('familyFieldLines: a fieldless non-custom family still shows "(no fields)"', () => {
50
+ assert.deepStrictEqual(familyFieldLines({ name: 'misc', fields: [] }), ['(no fields)']);
51
+ });
52
+
53
+ test('familyFieldLines: custom returns the hint even with no/malformed fields property', () => {
54
+ // The is_custom guard fires before f.fields is read, so its shape is irrelevant.
55
+ assert.deepStrictEqual(familyFieldLines({ name: 'custom', is_custom: true }), [CUSTOM_FAMILY_HINT]);
56
+ assert.deepStrictEqual(familyFieldLines({ is_custom: true, fields: null }), [CUSTOM_FAMILY_HINT]);
57
+ });
@@ -3,7 +3,7 @@ const assert = require('node:assert/strict');
3
3
  const fs = require('fs');
4
4
  const os = require('os');
5
5
  const path = require('path');
6
- const { buildScriptArgs, scriptSupportsBackfill, resolveSetupAllTools, clearUnboundEnvsEverywhere, NUKE_ENV_VARS, removeBinaryInstall, BINARY_INSTALL_PATHS } = require('../src/commands/setup');
6
+ const { buildScriptArgs, scriptSupportsBackfill, resolveSetupAllTools, clearUnboundEnvsEverywhere, NUKE_ENV_VARS, removeBinaryInstall, runBinaryClear, BINARY_INSTALL_PATHS } = require('../src/commands/setup');
7
7
 
8
8
  // shellEscape single-quotes every value, so a real key surfaces as
9
9
  // --api-key '<key>' at the head of the argv tail.
@@ -396,3 +396,58 @@ test('removeBinaryInstall: refuses production-default paths without _confirmProd
396
396
  test('BINARY_INSTALL_PATHS: is frozen', () => {
397
397
  assert.ok(Object.isFrozen(BINARY_INSTALL_PATHS));
398
398
  });
399
+
400
+ test('runBinaryClear: binary present — runs `<bin> clear` before removal, reports ok', () => {
401
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'unbound-binclear-'));
402
+ const bin = path.join(tmp, 'unbound-hook');
403
+ fs.writeFileSync(bin, '#');
404
+ const calls = [];
405
+ try {
406
+ const r = runBinaryClear({ binaryPath: bin, runner: (b, args, opts) => { calls.push([b, args, opts]); return { status: 0 }; } });
407
+ assert.deepEqual(r, { ran: true, ok: true });
408
+ assert.equal(calls.length, 1, 'binary clear should run exactly once');
409
+ assert.equal(calls[0][0], bin);
410
+ assert.deepEqual(calls[0][1], ['clear']);
411
+ assert.ok(calls[0][2].timeout > 0, 'clear must run with a timeout so a hang cannot block nuke');
412
+ } finally {
413
+ fs.rmSync(tmp, { recursive: true, force: true });
414
+ }
415
+ });
416
+
417
+ test('runBinaryClear: dangling binary symlink — still attempts (surfaces, not silently skipped)', () => {
418
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'unbound-binclear-'));
419
+ const link = path.join(tmp, 'unbound-hook');
420
+ fs.symlinkSync(path.join(tmp, 'missing-target'), link); // dangling: target absent
421
+ let called = false;
422
+ try {
423
+ const r = runBinaryClear({ binaryPath: link, runner: () => { called = true; return { error: { code: 'ENOENT', message: 'spawn ENOENT' } }; } });
424
+ assert.equal(called, true, 'a dangling symlink must still be attempted, not treated as absent');
425
+ assert.deepEqual(r, { ran: true, ok: false });
426
+ } finally {
427
+ fs.rmSync(tmp, { recursive: true, force: true });
428
+ }
429
+ });
430
+
431
+ test('runBinaryClear: binary absent (python-only install) — no-op, runner never called', () => {
432
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'unbound-binclear-'));
433
+ let called = false;
434
+ try {
435
+ const r = runBinaryClear({ binaryPath: path.join(tmp, 'nope'), runner: () => { called = true; return { status: 0 }; } });
436
+ assert.deepEqual(r, { ran: false, ok: true });
437
+ assert.equal(called, false);
438
+ } finally {
439
+ fs.rmSync(tmp, { recursive: true, force: true });
440
+ }
441
+ });
442
+
443
+ test('runBinaryClear: clear exits non-zero — fail-open (ran:true, ok:false), never throws', () => {
444
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'unbound-binclear-'));
445
+ const bin = path.join(tmp, 'unbound-hook');
446
+ fs.writeFileSync(bin, '#');
447
+ try {
448
+ const r = runBinaryClear({ binaryPath: bin, runner: () => ({ status: 1, stderr: Buffer.from('boom') }) });
449
+ assert.deepEqual(r, { ran: true, ok: false });
450
+ } finally {
451
+ fs.rmSync(tmp, { recursive: true, force: true });
452
+ }
453
+ });