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/PLAN-web-4887.md +515 -0
- package/PLAN.md +106 -0
- package/README.md +12 -4
- package/package.json +1 -1
- package/src/commands/policy.js +197 -17
- package/src/commands/setup.js +4 -2
- package/src/lib/no-ai-guard.js +77 -0
- 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/no-ai-guard.test.js +363 -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
|
@@ -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
|
-
#
|
|
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
|
-
#
|
|
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
package/src/commands/policy.js
CHANGED
|
@@ -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
|
-
.
|
|
1433
|
-
.
|
|
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
|
-
.
|
|
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
|
-
|
|
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
|
-
.
|
|
1519
|
-
.
|
|
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
|
-
.
|
|
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
|
-
|
|
1531
|
-
$ unbound policy tool create-mcp --
|
|
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
|
|
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 = {
|
|
@@ -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 };
|