unbound-cli 1.3.2 → 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/PLAN-web-4887.md +477 -0
- package/PLAN.md +117 -0
- package/README.md +8 -2
- package/package.json +1 -1
- package/src/commands/policy.js +183 -10
- package/src/commands/setup.js +4 -2
- package/src/lib/policy-ai-assist.js +503 -0
- package/test/eval/README.md +45 -0
- package/test/eval/policy-prompts.json +122 -0
- package/test/eval/run-eval.js +57 -0
- package/test/policy-ai-assist-mcp.test.js +606 -0
- package/test/policy-ai-assist-preflight.test.js +66 -0
- package/test/policy-ai-assist.test.js +884 -0
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
|
-
#
|
|
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
|
-
#
|
|
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
package/src/commands/policy.js
CHANGED
|
@@ -1429,18 +1429,27 @@ Subcommands:
|
|
|
1429
1429
|
tool
|
|
1430
1430
|
.command('create-terminal')
|
|
1431
1431
|
.description('Create a TERMINAL_COMMAND policy: monitor or block shell commands run by AI coding tools.')
|
|
1432
|
-
.
|
|
1433
|
-
.
|
|
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`.')
|
|
1434
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])
|
|
1435
|
-
.
|
|
1436
|
+
.option('--action <action>', `Action to take: ${TOOL_ACTIONS.join(' | ')}`)
|
|
1436
1437
|
.option('--description <text>', 'Human-readable description')
|
|
1437
1438
|
.option('--custom-message <text>', 'Message shown to the user when the policy fires. Required when --action is BLOCK or WARN.')
|
|
1438
1439
|
.option('--group <names|ids>', 'Comma-separated user group names or IDs to scope this policy to')
|
|
1439
1440
|
.option('--disabled', 'Create the policy in disabled state')
|
|
1440
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)')
|
|
1441
1443
|
.option('--json', 'Output raw JSON of the created policy')
|
|
1442
1444
|
.addHelpText('after', `
|
|
1443
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
|
+
|
|
1444
1453
|
$ unbound policy tool create-terminal --name "Block rm -rf" \\
|
|
1445
1454
|
--command-family filesystem --field command='rm -rf*' --action BLOCK \\
|
|
1446
1455
|
--custom-message "Destructive commands are blocked."
|
|
@@ -1460,6 +1469,83 @@ Learn more: ${DOCS_TOOL}
|
|
|
1460
1469
|
.action(async (opts) => {
|
|
1461
1470
|
try {
|
|
1462
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
|
+
|
|
1463
1549
|
const action = normalizeAction(opts.action, TOOL_ACTIONS, '--action');
|
|
1464
1550
|
if ((action === 'BLOCK' || action === 'WARN') && !opts.customMessage) {
|
|
1465
1551
|
throw new Error('--custom-message is required when --action is BLOCK or WARN.');
|
|
@@ -1515,24 +1601,35 @@ Learn more: ${DOCS_TOOL}
|
|
|
1515
1601
|
tool
|
|
1516
1602
|
.command('create-mcp')
|
|
1517
1603
|
.description('Create an MCP_TOOL policy: monitor or block MCP server tool calls.')
|
|
1518
|
-
.
|
|
1519
|
-
.
|
|
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.')
|
|
1520
1607
|
.option('--mcp-tool <tool>', 'Specific tool name on the MCP server (e.g. create_issue). Mutually exclusive with --mcp-action-type.')
|
|
1521
1608
|
.option('--mcp-action-type <type>', `Match by tool action type: ${MCP_ACTION_TYPES.join(' | ')}. Mutually exclusive with --mcp-tool.`)
|
|
1522
|
-
.
|
|
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.')
|
|
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.')
|
|
1525
1612
|
.option('--group <names|ids>', 'Comma-separated user group names or IDs to scope this policy to')
|
|
1526
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)')
|
|
1527
1616
|
.option('--json', 'Output raw JSON of the created policy')
|
|
1528
1617
|
.addHelpText('after', `
|
|
1529
1618
|
Examples:
|
|
1530
|
-
|
|
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):
|
|
1531
1628
|
$ unbound policy tool create-mcp --name "Block Linear writes" \\
|
|
1532
1629
|
--mcp-server linear --mcp-tool create_issue --action BLOCK \\
|
|
1533
1630
|
--custom-message "Issue creation is blocked. Contact admin."
|
|
1534
1631
|
|
|
1535
|
-
Match all destructive tools on a server:
|
|
1632
|
+
# Match all destructive tools on a server:
|
|
1536
1633
|
$ unbound policy tool create-mcp --name "Audit all destructive Slack" \\
|
|
1537
1634
|
--mcp-server slack --mcp-action-type destructive --action AUDIT
|
|
1538
1635
|
|
|
@@ -1545,6 +1642,79 @@ Learn more: ${DOCS_TOOL}
|
|
|
1545
1642
|
.action(async (opts) => {
|
|
1546
1643
|
try {
|
|
1547
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
|
+
|
|
1548
1718
|
const action = normalizeAction(opts.action, TOOL_ACTIONS, '--action');
|
|
1549
1719
|
if ((action === 'BLOCK' || action === 'WARN') && !opts.customMessage) {
|
|
1550
1720
|
throw new Error('--custom-message is required when --action is BLOCK or WARN.');
|
|
@@ -1575,6 +1745,9 @@ Learn more: ${DOCS_TOOL}
|
|
|
1575
1745
|
enabled: !opts.disabled,
|
|
1576
1746
|
config: {},
|
|
1577
1747
|
};
|
|
1748
|
+
if (opts.config) {
|
|
1749
|
+
body.config = parseJsonOrThrow(opts.config);
|
|
1750
|
+
}
|
|
1578
1751
|
if (hasTool) body.mcp_tool = opts.mcpTool;
|
|
1579
1752
|
if (hasActionType) body.mcp_tool_action_type = opts.mcpActionType;
|
|
1580
1753
|
if (opts.customMessage) body.custom_message = opts.customMessage;
|
package/src/commands/setup.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 = {
|