unbound-cli 0.4.0 → 0.5.1
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/LOCAL_DEV.md +76 -2
- package/README.md +82 -7
- package/package.json +1 -1
- package/src/commands/onboard.js +6 -3
- package/src/commands/policy.js +1704 -212
- package/src/commands/setup.js +38 -20
- package/src/index.js +26 -7
package/src/commands/policy.js
CHANGED
|
@@ -3,17 +3,138 @@ const api = require('../api');
|
|
|
3
3
|
const output = require('../output');
|
|
4
4
|
const { formatDate, confirm, parseCommaSeparated } = require('../utils');
|
|
5
5
|
|
|
6
|
+
// ============================================================================
|
|
7
|
+
// Constants and docs URLs
|
|
8
|
+
// ============================================================================
|
|
9
|
+
|
|
10
|
+
const DOCS_POLICIES = 'https://docs.getunbound.ai/policies';
|
|
11
|
+
const DOCS_COST = 'https://docs.getunbound.ai/policies/cost-policies';
|
|
12
|
+
const DOCS_MODEL = 'https://docs.getunbound.ai/policies/model-policies';
|
|
13
|
+
const DOCS_SECURITY = 'https://docs.getunbound.ai/policies/security-policies';
|
|
14
|
+
const DOCS_TOOL = 'https://docs.getunbound.ai/policies/tool-policies';
|
|
15
|
+
|
|
16
|
+
const POLICY_TYPES = ['SECURITY', 'MODEL', 'COST'];
|
|
17
|
+
const GUARDRAIL_ACTIONS = ['BLOCK', 'REDACT', 'ROUTE', 'AUDIT'];
|
|
18
|
+
const TOOL_ACTIONS = ['AUDIT', 'BLOCK', 'WARN', 'REQUIRE_SLACK_APPROVAL'];
|
|
19
|
+
const SECURITY_SUB_TYPES = ['guardrails', 'default_routing', 'error_code_routing'];
|
|
20
|
+
const MCP_ACTION_TYPES = ['read', 'write', 'destructive'];
|
|
21
|
+
|
|
22
|
+
// ============================================================================
|
|
23
|
+
// Helpers: auth, resolution, validation, display
|
|
24
|
+
// ============================================================================
|
|
25
|
+
|
|
26
|
+
function requireLogin() {
|
|
27
|
+
if (!config.isLoggedIn()) {
|
|
28
|
+
const err = new Error('Not logged in. Run `unbound login` first.');
|
|
29
|
+
err.displayed = false;
|
|
30
|
+
throw err;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Memoized loader for /api/v1/policies/form_data/. Unwraps the {data: ...}
|
|
36
|
+
* envelope so callers see a flat object with user_groups, tool_types,
|
|
37
|
+
* guardrails, ai_models, command_policies.
|
|
38
|
+
*/
|
|
39
|
+
// Module-level cache for /api/v1/policies/form_data/. Each CLI invocation is a
|
|
40
|
+
// fresh Node process, so the cache lifetime is exactly one command and no
|
|
41
|
+
// cross-invocation staleness is possible. Using module-level state (instead of
|
|
42
|
+
// per-call-site factories) guarantees that all call sites inside a single
|
|
43
|
+
// action handler share the same fetched payload — e.g.
|
|
44
|
+
// `policy model create --allowed X --group Y` resolves both flags via one
|
|
45
|
+
// form_data round-trip, not two.
|
|
46
|
+
let _formDataCache = null;
|
|
47
|
+
|
|
48
|
+
async function loadFormData() {
|
|
49
|
+
if (_formDataCache) return _formDataCache;
|
|
50
|
+
const response = await api.get('/api/v1/policies/form_data/');
|
|
51
|
+
_formDataCache = response.data || response;
|
|
52
|
+
return _formDataCache;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Resolves a comma-separated list of names or numeric IDs against a list of
|
|
57
|
+
* {id, name} objects. Returns an array of numeric IDs. Throws a friendly
|
|
58
|
+
* error if any input cannot be matched, listing the available values.
|
|
59
|
+
*/
|
|
60
|
+
function resolveByName(items, input, { kind, matchKey = 'name' } = {}) {
|
|
61
|
+
const names = parseCommaSeparated(input);
|
|
62
|
+
if (!names || names.length === 0) return [];
|
|
63
|
+
|
|
64
|
+
const ids = [];
|
|
65
|
+
for (const raw of names) {
|
|
66
|
+
if (/^\d+$/.test(raw)) {
|
|
67
|
+
const id = Number(raw);
|
|
68
|
+
if (!items.some((item) => item.id === id)) {
|
|
69
|
+
throw new Error(
|
|
70
|
+
`No ${kind} with id ${id}. Available: ${items.map((i) => `${i.id}:${i[matchKey]}`).join(', ') || '(none)'}`
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
ids.push(id);
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const lower = raw.toLowerCase();
|
|
78
|
+
const match = items.find((item) => (item[matchKey] || '').toLowerCase() === lower);
|
|
79
|
+
if (!match) {
|
|
80
|
+
throw new Error(
|
|
81
|
+
`No ${kind} named "${raw}". Available: ${items.map((i) => i[matchKey]).join(', ') || '(none)'}`
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
ids.push(match.id);
|
|
85
|
+
}
|
|
86
|
+
return ids;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Same as resolveByName but returns the full matched objects (not just IDs).
|
|
91
|
+
* Useful when the caller needs extra fields like `id` AND `name`.
|
|
92
|
+
*/
|
|
93
|
+
function resolveFull(items, input, { kind, matchKey = 'name' } = {}) {
|
|
94
|
+
const names = parseCommaSeparated(input);
|
|
95
|
+
if (!names || names.length === 0) return [];
|
|
96
|
+
|
|
97
|
+
const results = [];
|
|
98
|
+
for (const raw of names) {
|
|
99
|
+
if (/^\d+$/.test(raw)) {
|
|
100
|
+
const id = Number(raw);
|
|
101
|
+
const match = items.find((item) => item.id === id);
|
|
102
|
+
if (!match) {
|
|
103
|
+
throw new Error(
|
|
104
|
+
`No ${kind} with id ${id}. Available: ${items.map((i) => `${i.id}:${i[matchKey]}`).join(', ') || '(none)'}`
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
results.push(match);
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const lower = raw.toLowerCase();
|
|
112
|
+
const match = items.find((item) => (item[matchKey] || '').toLowerCase() === lower);
|
|
113
|
+
if (!match) {
|
|
114
|
+
throw new Error(
|
|
115
|
+
`No ${kind} named "${raw}". Available: ${items.map((i) => i[matchKey]).join(', ') || '(none)'}`
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
results.push(match);
|
|
119
|
+
}
|
|
120
|
+
return results;
|
|
121
|
+
}
|
|
122
|
+
|
|
6
123
|
function formatScope(groups) {
|
|
7
|
-
if (!groups || groups.length === 0) return '
|
|
124
|
+
if (!groups || groups.length === 0) return 'Everyone';
|
|
8
125
|
return groups.map((g) => g.name).join(', ');
|
|
9
126
|
}
|
|
10
127
|
|
|
11
128
|
function formatToolTypes(types) {
|
|
12
|
-
if (!types || types.length === 0) return '
|
|
129
|
+
if (!types || types.length === 0) return 'All';
|
|
13
130
|
return types.join(', ');
|
|
14
131
|
}
|
|
15
132
|
|
|
16
133
|
function displayPolicy(policy) {
|
|
134
|
+
if (!policy) {
|
|
135
|
+
output.warn('No policy data to display.');
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
17
138
|
output.keyValue([
|
|
18
139
|
['ID', String(policy.id)],
|
|
19
140
|
['Name', policy.name],
|
|
@@ -24,39 +145,205 @@ function displayPolicy(policy) {
|
|
|
24
145
|
['Scope Groups', formatScope(policy.scope_user_groups)],
|
|
25
146
|
['Scope Tools', formatToolTypes(policy.scope_tool_types)],
|
|
26
147
|
['Config Summary', policy.config_summary || '-'],
|
|
27
|
-
['Config', policy.config ? JSON.stringify(policy.config) : '-'],
|
|
148
|
+
['Config', policy.config ? JSON.stringify(policy.config, null, 2) : '-'],
|
|
28
149
|
['Created', formatDate(policy.created_at)],
|
|
29
150
|
['Updated', formatDate(policy.updated_at)],
|
|
30
151
|
]);
|
|
31
152
|
}
|
|
32
153
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
154
|
+
/**
|
|
155
|
+
* Unwraps the response from /api/v1/command-policies/ endpoints.
|
|
156
|
+
*
|
|
157
|
+
* The command-policies backend returns the serialized policy object
|
|
158
|
+
* UNWRAPPED — e.g. `{id, name, policy_type, ...}` at the top level.
|
|
159
|
+
* This is intentionally different from /api/v1/policies/ which wraps in
|
|
160
|
+
* `{policy: {...}}`. Confirmed in `webapp/api/v1/command_policy_handlers.py`:
|
|
161
|
+
* - create (POST): `return JsonResponse(serialize_policy(policy), status=201)`
|
|
162
|
+
* - get (GET): `return JsonResponse(serialize_policy(policy))`
|
|
163
|
+
* - update (PUT): `return JsonResponse(serialize_policy(policy))`
|
|
164
|
+
*
|
|
165
|
+
* This helper defensively also handles a future wrapped shape
|
|
166
|
+
* (`{policy: ...}` or `{command_policy: ...}`) so a backend change that
|
|
167
|
+
* adds an envelope doesn't silently render every field as undefined.
|
|
168
|
+
*/
|
|
169
|
+
function unwrapToolPolicy(response) {
|
|
170
|
+
if (!response || typeof response !== 'object') return response;
|
|
171
|
+
if (response.command_policy) return response.command_policy;
|
|
172
|
+
// Only unwrap `policy` if it looks like a nested envelope — if the response
|
|
173
|
+
// itself has an `id` at top level, it's already unwrapped and `policy` would
|
|
174
|
+
// be a nested field (not the envelope).
|
|
175
|
+
if (response.policy && typeof response.policy === 'object' && response.id === undefined) {
|
|
176
|
+
return response.policy;
|
|
177
|
+
}
|
|
178
|
+
return response;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function displayToolPolicy(policy) {
|
|
182
|
+
if (!policy) {
|
|
183
|
+
output.warn('No policy data to display.');
|
|
36
184
|
return;
|
|
37
185
|
}
|
|
186
|
+
const rows = [
|
|
187
|
+
['ID', String(policy.id)],
|
|
188
|
+
['Name', policy.name],
|
|
189
|
+
['Description', policy.description || '-'],
|
|
190
|
+
['Policy Type', policy.policy_type],
|
|
191
|
+
['Action', policy.action],
|
|
192
|
+
['Enabled', String(policy.enabled)],
|
|
193
|
+
['Status', policy.status || '-'],
|
|
194
|
+
['Custom Message', policy.custom_message || '-'],
|
|
195
|
+
['Scope Groups', formatScope(policy.scope_user_groups)],
|
|
196
|
+
];
|
|
197
|
+
if (policy.policy_type === 'TERMINAL_COMMAND') {
|
|
198
|
+
rows.push(['Command Family', policy.command_family || '-']);
|
|
199
|
+
rows.push(['Config', policy.config ? JSON.stringify(policy.config, null, 2) : '-']);
|
|
200
|
+
} else if (policy.policy_type === 'MCP_TOOL') {
|
|
201
|
+
rows.push(['MCP Server', policy.mcp_server || '-']);
|
|
202
|
+
rows.push(['MCP Tool', policy.mcp_tool || '-']);
|
|
203
|
+
rows.push(['MCP Action Type', policy.mcp_tool_action_type || '-']);
|
|
204
|
+
}
|
|
205
|
+
rows.push(['Target Pattern', policy.target_pattern || '-']);
|
|
206
|
+
rows.push(['Created', formatDate(policy.created_at)]);
|
|
207
|
+
rows.push(['Updated', formatDate(policy.updated_at)]);
|
|
208
|
+
output.keyValue(rows);
|
|
209
|
+
}
|
|
38
210
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
{ key: 'priority', header: 'Priority', format: (v) => (v != null ? String(v) : '-') },
|
|
46
|
-
{ key: 'config_summary', header: 'Config Summary', format: (v) => v || '-' },
|
|
47
|
-
]);
|
|
211
|
+
function parseJsonOrThrow(str, flagName = '--config') {
|
|
212
|
+
try {
|
|
213
|
+
return JSON.parse(str);
|
|
214
|
+
} catch {
|
|
215
|
+
throw new Error(`Invalid JSON for ${flagName} option.`);
|
|
216
|
+
}
|
|
48
217
|
}
|
|
49
218
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
219
|
+
/**
|
|
220
|
+
* Given a `--guardrail name:action[:threshold]` string, parse into the
|
|
221
|
+
* {guardrail_id, action, threshold, route_model_id, entities, enabled} shape
|
|
222
|
+
* the backend expects. Resolves the name and validates the action.
|
|
223
|
+
*/
|
|
224
|
+
function parseGuardrailSpec(spec, allGuardrails, routeModelId) {
|
|
225
|
+
const parts = spec.split(':');
|
|
226
|
+
if (parts.length < 2 || parts.length > 3) {
|
|
227
|
+
throw new Error(`Invalid --guardrail spec "${spec}". Expected format: <name>:<action>[:<threshold>]`);
|
|
228
|
+
}
|
|
229
|
+
const [name, actionRaw, thresholdRaw] = parts;
|
|
230
|
+
const action = actionRaw.toUpperCase();
|
|
231
|
+
if (!GUARDRAIL_ACTIONS.includes(action)) {
|
|
232
|
+
throw new Error(
|
|
233
|
+
`Invalid guardrail action "${actionRaw}". Must be one of: ${GUARDRAIL_ACTIONS.join(', ')}`
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const matched = resolveFull(allGuardrails, name, { kind: 'guardrail' });
|
|
238
|
+
if (matched.length !== 1) {
|
|
239
|
+
throw new Error(`Invalid --guardrail spec "${spec}": name must resolve to exactly one guardrail`);
|
|
240
|
+
}
|
|
241
|
+
const guardrail = matched[0];
|
|
242
|
+
|
|
243
|
+
if (guardrail.supported_actions && !guardrail.supported_actions.includes(action)) {
|
|
244
|
+
throw new Error(
|
|
245
|
+
`Guardrail "${guardrail.name}" does not support action ${action}. Supported: ${guardrail.supported_actions.join(', ')}`
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (action === 'ROUTE' && !routeModelId) {
|
|
250
|
+
throw new Error(`Guardrail "${guardrail.name}" uses ROUTE but --route-model was not provided.`);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const threshold = thresholdRaw != null ? Number(thresholdRaw) : 1;
|
|
254
|
+
if (Number.isNaN(threshold) || threshold < 1 || threshold > 100) {
|
|
255
|
+
throw new Error(`Invalid threshold "${thresholdRaw}" for guardrail "${guardrail.name}". Must be 1-100.`);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return {
|
|
259
|
+
guardrail_id: guardrail.id,
|
|
260
|
+
enabled: true,
|
|
261
|
+
action,
|
|
262
|
+
threshold,
|
|
263
|
+
route_model_id: action === 'ROUTE' ? routeModelId : null,
|
|
264
|
+
entities: (guardrail.entities || []).map((e) => ({ entity_id: e.id, enabled: true })),
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Given a `--route source:target` string and the full model list, return a
|
|
270
|
+
* {source_model_id, destination_model_id} object with resolved numeric IDs.
|
|
271
|
+
*/
|
|
272
|
+
function parseRouteSpec(spec, allModels) {
|
|
273
|
+
const parts = spec.split(':');
|
|
274
|
+
if (parts.length !== 2) {
|
|
275
|
+
throw new Error(`Invalid --route spec "${spec}". Expected format: <source-model>:<target-model>`);
|
|
276
|
+
}
|
|
277
|
+
const [srcName, tgtName] = parts;
|
|
278
|
+
if (!srcName) throw new Error(`Invalid --route spec "${spec}": source model name is empty`);
|
|
279
|
+
if (!tgtName) throw new Error(`Invalid --route spec "${spec}": target model name is empty`);
|
|
280
|
+
const src = resolveFull(allModels, srcName, { kind: 'model' })[0];
|
|
281
|
+
const tgt = resolveFull(allModels, tgtName, { kind: 'model' })[0];
|
|
282
|
+
return { source_model_id: src.id, destination_model_id: tgt.id };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Given a `--error-route code:source:target` string, return the
|
|
287
|
+
* {error_code, source_model_id, target_model_id} object the backend expects.
|
|
288
|
+
*/
|
|
289
|
+
function parseErrorRouteSpec(spec, allModels) {
|
|
290
|
+
const parts = spec.split(':');
|
|
291
|
+
if (parts.length !== 3) {
|
|
292
|
+
throw new Error(`Invalid --error-route spec "${spec}". Expected format: <error-code>:<source-model>:<target-model>`);
|
|
293
|
+
}
|
|
294
|
+
const [code, srcName, tgtName] = parts;
|
|
295
|
+
if (!code) throw new Error(`Invalid --error-route spec "${spec}": error code is empty`);
|
|
296
|
+
if (!srcName) throw new Error(`Invalid --error-route spec "${spec}": source model name is empty`);
|
|
297
|
+
if (!tgtName) throw new Error(`Invalid --error-route spec "${spec}": target model name is empty`);
|
|
298
|
+
const src = resolveFull(allModels, srcName, { kind: 'model' })[0];
|
|
299
|
+
const tgt = resolveFull(allModels, tgtName, { kind: 'model' })[0];
|
|
300
|
+
return { error_code: code, source_model_id: src.id, target_model_id: tgt.id };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Parse a `--field key=pattern` argument into an [key, pattern] tuple.
|
|
305
|
+
*/
|
|
306
|
+
function parseFieldSpec(spec) {
|
|
307
|
+
const eq = spec.indexOf('=');
|
|
308
|
+
if (eq <= 0 || eq === spec.length - 1) {
|
|
309
|
+
throw new Error(`Invalid --field spec "${spec}". Expected format: <key>=<pattern>`);
|
|
310
|
+
}
|
|
311
|
+
return [spec.slice(0, eq), spec.slice(eq + 1)];
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Convert a `--sub-type` flag (kebab-case) to the backend's snake_case form.
|
|
316
|
+
*/
|
|
317
|
+
function normalizeSubType(subType) {
|
|
318
|
+
if (!subType) return null;
|
|
319
|
+
const normalized = subType.replace(/-/g, '_').toLowerCase();
|
|
320
|
+
if (!SECURITY_SUB_TYPES.includes(normalized)) {
|
|
321
|
+
throw new Error(
|
|
322
|
+
`Invalid --sub-type "${subType}". Must be one of: guardrails, default-routing, error-code-routing`
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
return normalized;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function normalizeAction(action, allowed, flagName) {
|
|
329
|
+
if (!action) return null;
|
|
330
|
+
const up = action.toUpperCase();
|
|
331
|
+
if (!allowed.includes(up)) {
|
|
332
|
+
throw new Error(`Invalid ${flagName} "${action}". Must be one of: ${allowed.join(', ')}`);
|
|
333
|
+
}
|
|
334
|
+
return up;
|
|
335
|
+
}
|
|
54
336
|
|
|
55
|
-
|
|
337
|
+
// ============================================================================
|
|
338
|
+
// Top-level: list, get, delete, form-data, effective
|
|
339
|
+
// ============================================================================
|
|
340
|
+
|
|
341
|
+
function registerTopLevel(policy) {
|
|
342
|
+
// ---- list ----
|
|
56
343
|
policy
|
|
57
344
|
.command('list')
|
|
58
|
-
.description('List
|
|
59
|
-
.option('--type <type>', 'Filter by
|
|
345
|
+
.description('List Cost, Model, and Security policies. For Tool policies use `policy tool list`.')
|
|
346
|
+
.option('--type <type>', 'Filter by type: COST, MODEL, or SECURITY')
|
|
60
347
|
.option('--enabled', 'Show only enabled policies')
|
|
61
348
|
.option('--search <term>', 'Search policies by name')
|
|
62
349
|
.option('--json', 'Output raw JSON instead of a table')
|
|
@@ -64,15 +351,21 @@ function register(program) {
|
|
|
64
351
|
Examples:
|
|
65
352
|
$ unbound policy list
|
|
66
353
|
$ unbound policy list --type SECURITY
|
|
67
|
-
$ unbound policy list --
|
|
68
|
-
$ unbound policy list --search "
|
|
354
|
+
$ unbound policy list --type COST --enabled
|
|
355
|
+
$ unbound policy list --search "budget" --json
|
|
356
|
+
|
|
357
|
+
Learn more: ${DOCS_POLICIES}
|
|
69
358
|
`)
|
|
70
359
|
.action(async (opts) => {
|
|
71
360
|
try {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
361
|
+
requireLogin();
|
|
362
|
+
|
|
363
|
+
if (opts.type) {
|
|
364
|
+
const type = opts.type.toUpperCase();
|
|
365
|
+
if (!POLICY_TYPES.includes(type)) {
|
|
366
|
+
throw new Error(`Invalid --type "${opts.type}". Must be one of: ${POLICY_TYPES.join(', ')}. For tool policies, use \`unbound policy tool list\`.`);
|
|
367
|
+
}
|
|
368
|
+
opts.type = type;
|
|
76
369
|
}
|
|
77
370
|
|
|
78
371
|
const query = {};
|
|
@@ -87,14 +380,20 @@ Examples:
|
|
|
87
380
|
return;
|
|
88
381
|
}
|
|
89
382
|
|
|
90
|
-
|
|
383
|
+
const policies = data.policies || [];
|
|
384
|
+
if (policies.length === 0) {
|
|
385
|
+
output.info('No policies found.');
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
output.table(policies, [
|
|
91
390
|
{ key: 'id', header: 'ID' },
|
|
92
391
|
{ key: 'name', header: 'Name' },
|
|
93
392
|
{ key: 'type', header: 'Type' },
|
|
94
393
|
{ key: 'enabled', header: 'Enabled' },
|
|
95
394
|
{ key: 'priority', header: 'Priority', format: (v) => (v != null ? String(v) : '-') },
|
|
96
395
|
{ key: 'scope_user_groups', header: 'Scope', format: (v) => formatScope(v) },
|
|
97
|
-
{ key: '
|
|
396
|
+
{ key: 'config_summary', header: 'Summary', format: (v) => v || '-' },
|
|
98
397
|
]);
|
|
99
398
|
} catch (err) {
|
|
100
399
|
output.error(err.message);
|
|
@@ -102,11 +401,11 @@ Examples:
|
|
|
102
401
|
}
|
|
103
402
|
});
|
|
104
403
|
|
|
105
|
-
//
|
|
404
|
+
// ---- get ----
|
|
106
405
|
policy
|
|
107
406
|
.command('get <id>')
|
|
108
|
-
.description('Get
|
|
109
|
-
.option('--json', 'Output raw JSON
|
|
407
|
+
.description('Get details of a single Cost, Model, or Security policy. For Tool policies use `policy tool get`.')
|
|
408
|
+
.option('--json', 'Output raw JSON')
|
|
110
409
|
.addHelpText('after', `
|
|
111
410
|
Examples:
|
|
112
411
|
$ unbound policy get 1
|
|
@@ -114,139 +413,12 @@ Examples:
|
|
|
114
413
|
`)
|
|
115
414
|
.action(async (id, opts) => {
|
|
116
415
|
try {
|
|
117
|
-
|
|
118
|
-
output.error('Not logged in. Run `unbound login` first.');
|
|
119
|
-
process.exitCode = 1;
|
|
120
|
-
return;
|
|
121
|
-
}
|
|
122
|
-
|
|
416
|
+
requireLogin();
|
|
123
417
|
const data = await api.get(`/api/v1/policies/${id}/`);
|
|
124
|
-
|
|
125
418
|
if (opts.json) {
|
|
126
419
|
output.json(data);
|
|
127
420
|
return;
|
|
128
421
|
}
|
|
129
|
-
|
|
130
|
-
displayPolicy(data.policy);
|
|
131
|
-
} catch (err) {
|
|
132
|
-
output.error(err.message);
|
|
133
|
-
process.exitCode = 1;
|
|
134
|
-
}
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
// policy create
|
|
138
|
-
policy
|
|
139
|
-
.command('create')
|
|
140
|
-
.description('Create a new policy. Requires a name and type. Optionally set config, scope, and priority.')
|
|
141
|
-
.requiredOption('--name <name>', 'Policy name (required)')
|
|
142
|
-
.requiredOption('--type <type>', 'Policy type: SECURITY, MODEL, or COST (required)')
|
|
143
|
-
.option('--config <json>', 'Policy configuration as a JSON string')
|
|
144
|
-
.option('--enabled', 'Enable the policy (default: true)', true)
|
|
145
|
-
.option('--no-enabled', 'Create the policy in disabled state')
|
|
146
|
-
.option('--scope-groups <ids>', 'Comma-separated user group IDs to scope this policy to')
|
|
147
|
-
.option('--scope-tools <types>', 'Comma-separated tool types to scope this policy to (e.g. CLAUDE_CODE,CURSOR)')
|
|
148
|
-
.option('--priority <n>', 'Policy priority (integer)', parseInt)
|
|
149
|
-
.addHelpText('after', `
|
|
150
|
-
Examples:
|
|
151
|
-
$ unbound policy create --name "Security Policy" --type SECURITY
|
|
152
|
-
$ unbound policy create --name "Model Restrictions" --type MODEL --config '{"allowed_models":["gpt-4"]}'
|
|
153
|
-
$ unbound policy create --name "Cost Limit" --type COST --scope-groups 1,2 --priority 10
|
|
154
|
-
$ unbound policy create --name "Disabled Draft" --type SECURITY --no-enabled
|
|
155
|
-
`)
|
|
156
|
-
.action(async (opts) => {
|
|
157
|
-
try {
|
|
158
|
-
if (!config.isLoggedIn()) {
|
|
159
|
-
output.error('Not logged in. Run `unbound login` first.');
|
|
160
|
-
process.exitCode = 1;
|
|
161
|
-
return;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
const body = {
|
|
165
|
-
name: opts.name,
|
|
166
|
-
type: opts.type,
|
|
167
|
-
enabled: opts.enabled,
|
|
168
|
-
};
|
|
169
|
-
|
|
170
|
-
if (opts.config) {
|
|
171
|
-
try {
|
|
172
|
-
body.config = JSON.parse(opts.config);
|
|
173
|
-
} catch {
|
|
174
|
-
output.error('Invalid JSON for --config option.');
|
|
175
|
-
process.exitCode = 1;
|
|
176
|
-
return;
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
if (opts.scopeGroups) {
|
|
181
|
-
body.scope_user_groups = parseCommaSeparated(opts.scopeGroups).map(Number);
|
|
182
|
-
}
|
|
183
|
-
if (opts.scopeTools) {
|
|
184
|
-
body.scope_tool_types = parseCommaSeparated(opts.scopeTools);
|
|
185
|
-
}
|
|
186
|
-
if (opts.priority != null) {
|
|
187
|
-
body.priority = opts.priority;
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
const data = await api.post('/api/v1/policies/', { body });
|
|
191
|
-
output.success('Policy created.');
|
|
192
|
-
displayPolicy(data.policy);
|
|
193
|
-
} catch (err) {
|
|
194
|
-
output.error(err.message);
|
|
195
|
-
process.exitCode = 1;
|
|
196
|
-
}
|
|
197
|
-
});
|
|
198
|
-
|
|
199
|
-
// policy update
|
|
200
|
-
policy
|
|
201
|
-
.command('update <id>')
|
|
202
|
-
.description('Update an existing policy. Only provided fields will be changed.')
|
|
203
|
-
.option('--name <name>', 'Update policy name')
|
|
204
|
-
.option('--config <json>', 'Update policy configuration (JSON string)')
|
|
205
|
-
.option('--enabled', 'Enable the policy')
|
|
206
|
-
.option('--no-enabled', 'Disable the policy')
|
|
207
|
-
.option('--scope-groups <ids>', 'Comma-separated user group IDs')
|
|
208
|
-
.option('--scope-tools <types>', 'Comma-separated tool types')
|
|
209
|
-
.option('--priority <n>', 'Policy priority (integer)', parseInt)
|
|
210
|
-
.addHelpText('after', `
|
|
211
|
-
Examples:
|
|
212
|
-
$ unbound policy update 1 --name "Updated Name"
|
|
213
|
-
$ unbound policy update 1 --enabled
|
|
214
|
-
$ unbound policy update 1 --no-enabled
|
|
215
|
-
$ unbound policy update 1 --config '{"max_cost":100}' --priority 5
|
|
216
|
-
$ unbound policy update 1 --scope-groups 1,3 --scope-tools CURSOR,CLAUDE_CODE
|
|
217
|
-
`)
|
|
218
|
-
.action(async (id, opts) => {
|
|
219
|
-
try {
|
|
220
|
-
if (!config.isLoggedIn()) {
|
|
221
|
-
output.error('Not logged in. Run `unbound login` first.');
|
|
222
|
-
process.exitCode = 1;
|
|
223
|
-
return;
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
const body = {};
|
|
227
|
-
if (opts.name !== undefined) body.name = opts.name;
|
|
228
|
-
if (opts.enabled !== undefined) body.enabled = opts.enabled;
|
|
229
|
-
if (opts.priority != null) body.priority = opts.priority;
|
|
230
|
-
|
|
231
|
-
if (opts.config) {
|
|
232
|
-
try {
|
|
233
|
-
body.config = JSON.parse(opts.config);
|
|
234
|
-
} catch {
|
|
235
|
-
output.error('Invalid JSON for --config option.');
|
|
236
|
-
process.exitCode = 1;
|
|
237
|
-
return;
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
if (opts.scopeGroups) {
|
|
242
|
-
body.scope_user_groups = parseCommaSeparated(opts.scopeGroups).map(Number);
|
|
243
|
-
}
|
|
244
|
-
if (opts.scopeTools) {
|
|
245
|
-
body.scope_tool_types = parseCommaSeparated(opts.scopeTools);
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
const data = await api.put(`/api/v1/policies/${id}/`, { body });
|
|
249
|
-
output.success('Policy updated.');
|
|
250
422
|
displayPolicy(data.policy);
|
|
251
423
|
} catch (err) {
|
|
252
424
|
output.error(err.message);
|
|
@@ -254,10 +426,10 @@ Examples:
|
|
|
254
426
|
}
|
|
255
427
|
});
|
|
256
428
|
|
|
257
|
-
//
|
|
429
|
+
// ---- delete ----
|
|
258
430
|
policy
|
|
259
431
|
.command('delete <id>')
|
|
260
|
-
.description('Delete a
|
|
432
|
+
.description('Delete a Cost, Model, or Security policy. For Tool policies use `policy tool delete`.')
|
|
261
433
|
.option('--yes', 'Skip confirmation prompt')
|
|
262
434
|
.addHelpText('after', `
|
|
263
435
|
Examples:
|
|
@@ -266,12 +438,7 @@ Examples:
|
|
|
266
438
|
`)
|
|
267
439
|
.action(async (id, opts) => {
|
|
268
440
|
try {
|
|
269
|
-
|
|
270
|
-
output.error('Not logged in. Run `unbound login` first.');
|
|
271
|
-
process.exitCode = 1;
|
|
272
|
-
return;
|
|
273
|
-
}
|
|
274
|
-
|
|
441
|
+
requireLogin();
|
|
275
442
|
if (!opts.yes) {
|
|
276
443
|
const confirmed = await confirm(`Are you sure you want to delete policy ${id}?`);
|
|
277
444
|
if (!confirmed) {
|
|
@@ -279,7 +446,6 @@ Examples:
|
|
|
279
446
|
return;
|
|
280
447
|
}
|
|
281
448
|
}
|
|
282
|
-
|
|
283
449
|
await api.del(`/api/v1/policies/${id}/`);
|
|
284
450
|
output.success(`Policy ${id} deleted.`);
|
|
285
451
|
} catch (err) {
|
|
@@ -288,109 +454,1435 @@ Examples:
|
|
|
288
454
|
}
|
|
289
455
|
});
|
|
290
456
|
|
|
291
|
-
//
|
|
457
|
+
// ---- form-data (the bugfix) ----
|
|
292
458
|
policy
|
|
293
459
|
.command('form-data')
|
|
294
|
-
.description('
|
|
295
|
-
.option('--json', 'Output raw JSON')
|
|
460
|
+
.description('Show reference data for building policies: user groups, tool types, guardrails, AI models, and existing command policies.')
|
|
461
|
+
.option('--json', 'Output raw JSON (flattened — the backend envelope is unwrapped)')
|
|
296
462
|
.addHelpText('after', `
|
|
463
|
+
Use this command before creating a policy to discover what names are
|
|
464
|
+
available for --group, --allowed, --excluded, --guardrail, etc.
|
|
465
|
+
|
|
297
466
|
Examples:
|
|
298
467
|
$ unbound policy form-data
|
|
299
|
-
$ unbound policy form-data --json
|
|
468
|
+
$ unbound policy form-data --json | jq '.user_groups[].name'
|
|
469
|
+
|
|
470
|
+
Learn more: ${DOCS_POLICIES}
|
|
300
471
|
`)
|
|
301
472
|
.action(async (opts) => {
|
|
302
473
|
try {
|
|
303
|
-
|
|
304
|
-
output.error('Not logged in. Run `unbound login` first.');
|
|
305
|
-
process.exitCode = 1;
|
|
306
|
-
return;
|
|
307
|
-
}
|
|
474
|
+
requireLogin();
|
|
308
475
|
|
|
309
|
-
const
|
|
476
|
+
const response = await api.get('/api/v1/policies/form_data/');
|
|
477
|
+
const data = response.data || response;
|
|
310
478
|
|
|
311
479
|
if (opts.json) {
|
|
312
480
|
output.json(data);
|
|
313
481
|
return;
|
|
314
482
|
}
|
|
315
483
|
|
|
316
|
-
//
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
484
|
+
// User groups
|
|
485
|
+
console.log('');
|
|
486
|
+
output.info('User Groups');
|
|
487
|
+
if (data.user_groups && data.user_groups.length > 0) {
|
|
320
488
|
output.table(data.user_groups, [
|
|
321
489
|
{ key: 'id', header: 'ID' },
|
|
322
490
|
{ key: 'name', header: 'Name' },
|
|
491
|
+
{ key: 'all_org_users', header: 'All Org', format: (v) => (v ? 'yes' : 'no') },
|
|
492
|
+
{ key: 'member_count', header: 'Members', format: (v) => (v != null ? String(v) : '-') },
|
|
323
493
|
]);
|
|
494
|
+
} else {
|
|
495
|
+
console.log(' (none)');
|
|
324
496
|
}
|
|
325
497
|
|
|
498
|
+
// Tool types
|
|
499
|
+
console.log('');
|
|
500
|
+
output.info('Tool Types');
|
|
326
501
|
if (data.tool_types && data.tool_types.length > 0) {
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
console.log(` ${
|
|
502
|
+
for (const t of data.tool_types) {
|
|
503
|
+
const value = typeof t === 'string' ? t : t.value;
|
|
504
|
+
const label = typeof t === 'string' ? '' : ` (${t.label})`;
|
|
505
|
+
console.log(` ${value}${label}`);
|
|
331
506
|
}
|
|
507
|
+
} else {
|
|
508
|
+
console.log(' (none)');
|
|
332
509
|
}
|
|
333
510
|
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
511
|
+
// Guardrails (include supported_actions and entity count)
|
|
512
|
+
console.log('');
|
|
513
|
+
output.info('Guardrails');
|
|
514
|
+
if (data.guardrails && data.guardrails.length > 0) {
|
|
337
515
|
output.table(data.guardrails, [
|
|
338
516
|
{ key: 'id', header: 'ID' },
|
|
339
517
|
{ key: 'name', header: 'Name' },
|
|
340
|
-
{ key: '
|
|
518
|
+
{ key: 'supported_actions', header: 'Actions', format: (v) => (Array.isArray(v) ? v.join('|') : '-') },
|
|
519
|
+
{ key: 'entities', header: 'Entities', format: (v) => (Array.isArray(v) ? String(v.length) : '0') },
|
|
520
|
+
{ key: 'description', header: 'Description', format: (v) => v || '-' },
|
|
341
521
|
]);
|
|
522
|
+
} else {
|
|
523
|
+
console.log(' (none)');
|
|
342
524
|
}
|
|
343
525
|
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
526
|
+
// AI models grouped by provider
|
|
527
|
+
console.log('');
|
|
528
|
+
output.info('AI Models');
|
|
529
|
+
if (data.ai_models && data.ai_models.length > 0) {
|
|
530
|
+
const byProvider = {};
|
|
531
|
+
for (const m of data.ai_models) {
|
|
532
|
+
const p = m.provider_name || 'Unknown';
|
|
533
|
+
if (!byProvider[p]) byProvider[p] = [];
|
|
534
|
+
byProvider[p].push(m);
|
|
535
|
+
}
|
|
536
|
+
for (const provider of Object.keys(byProvider).sort()) {
|
|
537
|
+
console.log(` ${provider}`);
|
|
538
|
+
for (const m of byProvider[provider]) {
|
|
539
|
+
console.log(` ${m.id}: ${m.name}`);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
} else {
|
|
543
|
+
console.log(' (none)');
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// Command policies (was missing entirely before)
|
|
547
|
+
console.log('');
|
|
548
|
+
output.info('Command Policies (used by Security policies for command_policy/mcp_policy sub-types)');
|
|
549
|
+
if (data.command_policies && data.command_policies.length > 0) {
|
|
550
|
+
output.table(data.command_policies, [
|
|
348
551
|
{ key: 'id', header: 'ID' },
|
|
349
552
|
{ key: 'name', header: 'Name' },
|
|
350
|
-
{ key: '
|
|
553
|
+
{ key: 'policy_type', header: 'Policy Type' },
|
|
554
|
+
{ key: 'command_family', header: 'Family', format: (v) => v || '-' },
|
|
555
|
+
{ key: 'mcp_server', header: 'MCP Server', format: (v) => v || '-' },
|
|
556
|
+
{ key: 'action', header: 'Action' },
|
|
557
|
+
{ key: 'enabled', header: 'Enabled', format: (v) => (v ? 'yes' : 'no') },
|
|
351
558
|
]);
|
|
559
|
+
} else {
|
|
560
|
+
console.log(' (none)');
|
|
352
561
|
}
|
|
562
|
+
|
|
563
|
+
console.log('');
|
|
564
|
+
console.log(`Learn more: ${DOCS_POLICIES}`);
|
|
353
565
|
} catch (err) {
|
|
354
566
|
output.error(err.message);
|
|
355
567
|
process.exitCode = 1;
|
|
356
568
|
}
|
|
357
569
|
});
|
|
358
570
|
|
|
359
|
-
//
|
|
571
|
+
// ---- effective ----
|
|
360
572
|
policy
|
|
361
573
|
.command('effective <id>')
|
|
362
|
-
.description('View effective policies for a user or group.
|
|
363
|
-
.option('--user', 'Resolve
|
|
364
|
-
.option('--group', 'Resolve
|
|
574
|
+
.description('View effective policies for a user or group. Shows the resolved set after inheritance, priority, and merge.')
|
|
575
|
+
.option('--user', 'Resolve for a user ID (default)')
|
|
576
|
+
.option('--group', 'Resolve for a user-group ID')
|
|
365
577
|
.option('--json', 'Output raw JSON')
|
|
366
578
|
.addHelpText('after', `
|
|
367
579
|
Examples:
|
|
368
|
-
$ unbound policy effective 42 #
|
|
369
|
-
$ unbound policy effective 42 --user
|
|
370
|
-
$ unbound policy effective 5 --group #
|
|
580
|
+
$ unbound policy effective 42 # user 42 (default)
|
|
581
|
+
$ unbound policy effective 42 --user
|
|
582
|
+
$ unbound policy effective 5 --group # group 5
|
|
371
583
|
$ unbound policy effective 42 --json
|
|
584
|
+
|
|
585
|
+
Learn more: ${DOCS_POLICIES}
|
|
372
586
|
`)
|
|
373
587
|
.action(async (id, opts) => {
|
|
374
588
|
try {
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
589
|
+
requireLogin();
|
|
590
|
+
const path = opts.group
|
|
591
|
+
? `/api/v1/user_groups/${id}/effective_policies/`
|
|
592
|
+
: `/api/v1/users/${id}/effective_policies/`;
|
|
593
|
+
const data = await api.get(path);
|
|
594
|
+
|
|
595
|
+
if (opts.json) {
|
|
596
|
+
output.json(data);
|
|
378
597
|
return;
|
|
379
598
|
}
|
|
380
599
|
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
600
|
+
const eff = data.effective_policies || {};
|
|
601
|
+
|
|
602
|
+
if (eff.model) {
|
|
603
|
+
console.log('');
|
|
604
|
+
output.info('Model Policy');
|
|
605
|
+
output.keyValue([
|
|
606
|
+
['ID', String(eff.model.id)],
|
|
607
|
+
['Name', eff.model.name],
|
|
608
|
+
['Priority', eff.model.priority != null ? String(eff.model.priority) : '-'],
|
|
609
|
+
['Config', JSON.stringify(eff.model.config || {}, null, 2)],
|
|
610
|
+
]);
|
|
611
|
+
} else {
|
|
612
|
+
console.log('');
|
|
613
|
+
output.info('Model Policy: (none)');
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
if (eff.cost) {
|
|
617
|
+
console.log('');
|
|
618
|
+
output.info('Cost Policy');
|
|
619
|
+
output.keyValue([
|
|
620
|
+
['ID', String(eff.cost.id)],
|
|
621
|
+
['Name', eff.cost.name],
|
|
622
|
+
['Monthly Budget', String((eff.cost.config || {}).monthly_budget ?? '-')],
|
|
623
|
+
]);
|
|
384
624
|
} else {
|
|
385
|
-
|
|
625
|
+
console.log('');
|
|
626
|
+
output.info('Cost Policy: (none)');
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
if (eff.security) {
|
|
630
|
+
console.log('');
|
|
631
|
+
output.info('Security Policy (merged)');
|
|
632
|
+
output.keyValue([
|
|
633
|
+
['Guardrails', String((eff.security.guardrails || []).length)],
|
|
634
|
+
['Default Routing Rules', String((eff.security.default_routing_rules || []).length)],
|
|
635
|
+
['Error Routing Rules', String((eff.security.error_routing_rules || []).length)],
|
|
636
|
+
['Command Policies', String((eff.security.command_policy_ids || []).length)],
|
|
637
|
+
]);
|
|
638
|
+
} else {
|
|
639
|
+
console.log('');
|
|
640
|
+
output.info('Security Policy: (none)');
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
if (eff.tool && eff.tool.length > 0) {
|
|
644
|
+
console.log('');
|
|
645
|
+
output.info('Tool Policies');
|
|
646
|
+
output.table(eff.tool, [
|
|
647
|
+
{ key: 'id', header: 'ID' },
|
|
648
|
+
{ key: 'name', header: 'Name' },
|
|
649
|
+
{ key: 'policy_type', header: 'Type' },
|
|
650
|
+
{ key: 'action', header: 'Action' },
|
|
651
|
+
{ key: 'command_family', header: 'Family', format: (v) => v || '-' },
|
|
652
|
+
{ key: 'mcp_server', header: 'MCP Server', format: (v) => v || '-' },
|
|
653
|
+
]);
|
|
654
|
+
} else {
|
|
655
|
+
console.log('');
|
|
656
|
+
output.info('Tool Policies: (none)');
|
|
386
657
|
}
|
|
658
|
+
} catch (err) {
|
|
659
|
+
output.error(err.message);
|
|
660
|
+
process.exitCode = 1;
|
|
661
|
+
}
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// ============================================================================
|
|
666
|
+
// Cost policies
|
|
667
|
+
// ============================================================================
|
|
668
|
+
|
|
669
|
+
function registerCost(policy) {
|
|
670
|
+
const cost = policy
|
|
671
|
+
.command('cost')
|
|
672
|
+
.description('Manage Cost policies. Set monthly budget limits per user group.')
|
|
673
|
+
.addHelpText('after', `
|
|
674
|
+
Cost policies cap AI spending per user group on a monthly basis.
|
|
675
|
+
Learn more: ${DOCS_COST}
|
|
387
676
|
|
|
388
|
-
|
|
677
|
+
Subcommands:
|
|
678
|
+
list List all cost policies
|
|
679
|
+
create [options] Create a new cost policy
|
|
680
|
+
update <id> [options] Update an existing cost policy
|
|
681
|
+
`);
|
|
682
|
+
|
|
683
|
+
cost
|
|
684
|
+
.command('list')
|
|
685
|
+
.description('List all Cost policies.')
|
|
686
|
+
.option('--json', 'Output raw JSON')
|
|
687
|
+
.action(async (opts) => {
|
|
688
|
+
try {
|
|
689
|
+
requireLogin();
|
|
690
|
+
const data = await api.get('/api/v1/policies/', { query: { type: 'COST' } });
|
|
691
|
+
if (opts.json) {
|
|
692
|
+
output.json(data);
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
const policies = data.policies || [];
|
|
696
|
+
if (policies.length === 0) {
|
|
697
|
+
output.info('No cost policies found.');
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
700
|
+
output.table(policies, [
|
|
701
|
+
{ key: 'id', header: 'ID' },
|
|
702
|
+
{ key: 'name', header: 'Name' },
|
|
703
|
+
{ key: 'priority', header: 'Priority', format: (v) => (v != null ? String(v) : '-') },
|
|
704
|
+
{ key: 'scope_user_groups', header: 'Scope', format: (v) => formatScope(v) },
|
|
705
|
+
{ key: 'config', header: 'Monthly Budget', format: (v) => (v && v.monthly_budget != null ? String(v.monthly_budget) : '-') },
|
|
706
|
+
{ key: 'enabled', header: 'Enabled' },
|
|
707
|
+
]);
|
|
389
708
|
} catch (err) {
|
|
390
709
|
output.error(err.message);
|
|
391
710
|
process.exitCode = 1;
|
|
392
711
|
}
|
|
393
712
|
});
|
|
713
|
+
|
|
714
|
+
cost
|
|
715
|
+
.command('create')
|
|
716
|
+
.description('Create a new Cost policy with a monthly budget.')
|
|
717
|
+
.requiredOption('--name <name>', 'Policy name (required)')
|
|
718
|
+
.option('--monthly-budget <amount>', 'Monthly budget in USD (number). Required unless --config is used.', parseFloat)
|
|
719
|
+
.option('--group <names|ids>', 'Comma-separated user group names or IDs to scope this policy to (default: all users)')
|
|
720
|
+
.option('--priority <n>', 'Priority (lower = higher precedence). Auto-assigned if omitted.', parseInt)
|
|
721
|
+
.option('--disabled', 'Create the policy in disabled state')
|
|
722
|
+
.option('--config <json>', 'Advanced: raw config JSON. Mutually exclusive with --monthly-budget.')
|
|
723
|
+
.option('--json', 'Output raw JSON of the created policy')
|
|
724
|
+
.addHelpText('after', `
|
|
725
|
+
Examples:
|
|
726
|
+
$ unbound policy cost create --name "Eng Budget" --monthly-budget 1000 --group engg
|
|
727
|
+
$ unbound policy cost create --name "Everyone $500" --monthly-budget 500
|
|
728
|
+
$ unbound policy cost create --name "Top Priority" --monthly-budget 2000 --priority 1
|
|
729
|
+
$ unbound policy cost create --name "Draft" --monthly-budget 100 --disabled
|
|
730
|
+
|
|
731
|
+
To see available user group names, run: unbound policy form-data
|
|
732
|
+
Learn more: ${DOCS_COST}
|
|
733
|
+
`)
|
|
734
|
+
.action(async (opts) => {
|
|
735
|
+
try {
|
|
736
|
+
requireLogin();
|
|
737
|
+
|
|
738
|
+
if (opts.config && opts.monthlyBudget != null) {
|
|
739
|
+
throw new Error('Cannot combine --config with type-specific flags (--monthly-budget).');
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
let configObj;
|
|
743
|
+
if (opts.config) {
|
|
744
|
+
configObj = parseJsonOrThrow(opts.config);
|
|
745
|
+
} else {
|
|
746
|
+
if (opts.monthlyBudget == null || Number.isNaN(opts.monthlyBudget)) {
|
|
747
|
+
throw new Error('--monthly-budget <amount> is required (or use --config to supply a raw config).');
|
|
748
|
+
}
|
|
749
|
+
configObj = { monthly_budget: opts.monthlyBudget };
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
const body = {
|
|
753
|
+
name: opts.name,
|
|
754
|
+
type: 'COST',
|
|
755
|
+
config: configObj,
|
|
756
|
+
enabled: !opts.disabled,
|
|
757
|
+
};
|
|
758
|
+
|
|
759
|
+
if (opts.group) {
|
|
760
|
+
const formData = await loadFormData();
|
|
761
|
+
body.scope_user_group_ids = resolveByName(formData.user_groups || [], opts.group, { kind: 'user group' });
|
|
762
|
+
}
|
|
763
|
+
if (opts.priority != null) body.priority = opts.priority;
|
|
764
|
+
|
|
765
|
+
const data = await api.post('/api/v1/policies/', { body });
|
|
766
|
+
if (opts.json) {
|
|
767
|
+
output.json(data);
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
output.success(`Cost policy "${opts.name}" created.`);
|
|
771
|
+
displayPolicy(data.policy);
|
|
772
|
+
} catch (err) {
|
|
773
|
+
output.error(err.message);
|
|
774
|
+
process.exitCode = 1;
|
|
775
|
+
}
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
cost
|
|
779
|
+
.command('update <id>')
|
|
780
|
+
.description('Update an existing Cost policy. Only provided fields are changed.')
|
|
781
|
+
.option('--name <name>', 'New name')
|
|
782
|
+
.option('--monthly-budget <amount>', 'New monthly budget in USD', parseFloat)
|
|
783
|
+
.option('--group <names|ids>', 'Replace scope: comma-separated user group names or IDs')
|
|
784
|
+
.option('--priority <n>', 'New priority', parseInt)
|
|
785
|
+
.option('--enabled', 'Enable the policy')
|
|
786
|
+
.option('--disabled', 'Disable the policy')
|
|
787
|
+
.option('--config <json>', 'Advanced: replace config with raw JSON')
|
|
788
|
+
.option('--json', 'Output raw JSON')
|
|
789
|
+
.addHelpText('after', `
|
|
790
|
+
Examples:
|
|
791
|
+
$ unbound policy cost update 5 --monthly-budget 1500
|
|
792
|
+
$ unbound policy cost update 5 --disabled
|
|
793
|
+
$ unbound policy cost update 5 --group engg,everyone --priority 2
|
|
794
|
+
|
|
795
|
+
Learn more: ${DOCS_COST}
|
|
796
|
+
`)
|
|
797
|
+
.action(async (id, opts) => {
|
|
798
|
+
try {
|
|
799
|
+
requireLogin();
|
|
800
|
+
|
|
801
|
+
if (opts.enabled && opts.disabled) {
|
|
802
|
+
throw new Error('Cannot combine --enabled and --disabled.');
|
|
803
|
+
}
|
|
804
|
+
if (opts.config && opts.monthlyBudget != null) {
|
|
805
|
+
throw new Error('Cannot combine --config with --monthly-budget.');
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
const body = {};
|
|
809
|
+
if (opts.name !== undefined) body.name = opts.name;
|
|
810
|
+
if (opts.priority != null) body.priority = opts.priority;
|
|
811
|
+
if (opts.enabled) body.enabled = true;
|
|
812
|
+
if (opts.disabled) body.enabled = false;
|
|
813
|
+
|
|
814
|
+
if (opts.config) {
|
|
815
|
+
body.config = parseJsonOrThrow(opts.config);
|
|
816
|
+
} else if (opts.monthlyBudget != null) {
|
|
817
|
+
if (Number.isNaN(opts.monthlyBudget)) throw new Error('Invalid --monthly-budget value.');
|
|
818
|
+
body.config = { monthly_budget: opts.monthlyBudget };
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
if (opts.group !== undefined) {
|
|
822
|
+
const formData = await loadFormData();
|
|
823
|
+
body.scope_user_group_ids = resolveByName(formData.user_groups || [], opts.group, { kind: 'user group' });
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
const data = await api.put(`/api/v1/policies/${id}/`, { body });
|
|
827
|
+
if (opts.json) {
|
|
828
|
+
output.json(data);
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
831
|
+
output.success(`Cost policy ${id} updated.`);
|
|
832
|
+
displayPolicy(data.policy);
|
|
833
|
+
} catch (err) {
|
|
834
|
+
output.error(err.message);
|
|
835
|
+
process.exitCode = 1;
|
|
836
|
+
}
|
|
837
|
+
});
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
// ============================================================================
|
|
841
|
+
// Model policies
|
|
842
|
+
// ============================================================================
|
|
843
|
+
|
|
844
|
+
function registerModel(policy) {
|
|
845
|
+
const model = policy
|
|
846
|
+
.command('model')
|
|
847
|
+
.description('Manage Model policies. Control which AI models are available to a user group.')
|
|
848
|
+
.addHelpText('after', `
|
|
849
|
+
Model policies restrict which AI models users can invoke. Choose one of:
|
|
850
|
+
--all-models Allow everything (with --excluded as optional exceptions)
|
|
851
|
+
--allowed <names> Allow only the listed models
|
|
852
|
+
--excluded <names> Allow everything except the listed models (implies --all-models)
|
|
853
|
+
|
|
854
|
+
Learn more: ${DOCS_MODEL}
|
|
855
|
+
|
|
856
|
+
Subcommands:
|
|
857
|
+
list List all model policies
|
|
858
|
+
create [options] Create a new model policy
|
|
859
|
+
update <id> [options] Update an existing model policy
|
|
860
|
+
`);
|
|
861
|
+
|
|
862
|
+
model
|
|
863
|
+
.command('list')
|
|
864
|
+
.description('List all Model policies.')
|
|
865
|
+
.option('--json', 'Output raw JSON')
|
|
866
|
+
.action(async (opts) => {
|
|
867
|
+
try {
|
|
868
|
+
requireLogin();
|
|
869
|
+
const data = await api.get('/api/v1/policies/', { query: { type: 'MODEL' } });
|
|
870
|
+
if (opts.json) {
|
|
871
|
+
output.json(data);
|
|
872
|
+
return;
|
|
873
|
+
}
|
|
874
|
+
const policies = data.policies || [];
|
|
875
|
+
if (policies.length === 0) {
|
|
876
|
+
output.info('No model policies found.');
|
|
877
|
+
return;
|
|
878
|
+
}
|
|
879
|
+
output.table(policies, [
|
|
880
|
+
{ key: 'id', header: 'ID' },
|
|
881
|
+
{ key: 'name', header: 'Name' },
|
|
882
|
+
{ key: 'priority', header: 'Priority', format: (v) => (v != null ? String(v) : '-') },
|
|
883
|
+
{ key: 'scope_user_groups', header: 'Scope', format: (v) => formatScope(v) },
|
|
884
|
+
{ key: 'config_summary', header: 'Summary', format: (v) => v || '-' },
|
|
885
|
+
{ key: 'enabled', header: 'Enabled' },
|
|
886
|
+
]);
|
|
887
|
+
} catch (err) {
|
|
888
|
+
output.error(err.message);
|
|
889
|
+
process.exitCode = 1;
|
|
890
|
+
}
|
|
891
|
+
});
|
|
892
|
+
|
|
893
|
+
model
|
|
894
|
+
.command('create')
|
|
895
|
+
.description('Create a new Model policy.')
|
|
896
|
+
.requiredOption('--name <name>', 'Policy name (required)')
|
|
897
|
+
.option('--all-models', 'Allow all models (use with --excluded for exceptions)')
|
|
898
|
+
.option('--allowed <names>', 'Allow only the listed models (comma-separated names or IDs)')
|
|
899
|
+
.option('--excluded <names>', 'When used with --all-models: exclude the listed models')
|
|
900
|
+
.option('--group <names|ids>', 'Comma-separated user group names or IDs to scope this policy to')
|
|
901
|
+
.option('--priority <n>', 'Priority (lower = higher precedence). Auto-assigned if omitted.', parseInt)
|
|
902
|
+
.option('--disabled', 'Create the policy in disabled state')
|
|
903
|
+
.option('--config <json>', 'Advanced: raw config JSON')
|
|
904
|
+
.option('--json', 'Output raw JSON of the created policy')
|
|
905
|
+
.addHelpText('after', `
|
|
906
|
+
Examples:
|
|
907
|
+
$ unbound policy model create --name "Anthropic Only" --allowed claude-3-5-sonnet,claude-3-opus
|
|
908
|
+
$ unbound policy model create --name "No Opus" --all-models --excluded claude-3-opus
|
|
909
|
+
$ unbound policy model create --name "Everything" --all-models --group engg
|
|
910
|
+
$ unbound policy model create --name "Sonnet For Everyone" --allowed claude-3-5-sonnet
|
|
911
|
+
|
|
912
|
+
To see available model names, run: unbound policy form-data
|
|
913
|
+
Learn more: ${DOCS_MODEL}
|
|
914
|
+
`)
|
|
915
|
+
.action(async (opts) => {
|
|
916
|
+
try {
|
|
917
|
+
requireLogin();
|
|
918
|
+
|
|
919
|
+
const usingConfig = !!opts.config;
|
|
920
|
+
const usingFlags = opts.allModels || opts.allowed || opts.excluded;
|
|
921
|
+
if (usingConfig && usingFlags) {
|
|
922
|
+
throw new Error('Cannot combine --config with type-specific flags (--all-models, --allowed, --excluded).');
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
let configObj;
|
|
926
|
+
if (usingConfig) {
|
|
927
|
+
configObj = parseJsonOrThrow(opts.config);
|
|
928
|
+
} else {
|
|
929
|
+
if (!usingFlags) {
|
|
930
|
+
throw new Error('Must provide one of --all-models, --allowed, or --excluded.');
|
|
931
|
+
}
|
|
932
|
+
if (opts.allowed && (opts.allModels || opts.excluded)) {
|
|
933
|
+
throw new Error('--allowed is mutually exclusive with --all-models and --excluded.');
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
const formData = await loadFormData();
|
|
937
|
+
const models = formData.ai_models || [];
|
|
938
|
+
|
|
939
|
+
configObj = { support_all_models: false, allowed_model_ids: [], excluded_model_ids: [] };
|
|
940
|
+
|
|
941
|
+
if (opts.allowed) {
|
|
942
|
+
configObj.allowed_model_ids = resolveByName(models, opts.allowed, { kind: 'model' });
|
|
943
|
+
} else if (opts.allModels) {
|
|
944
|
+
configObj.support_all_models = true;
|
|
945
|
+
if (opts.excluded) {
|
|
946
|
+
configObj.excluded_model_ids = resolveByName(models, opts.excluded, { kind: 'model' });
|
|
947
|
+
}
|
|
948
|
+
} else if (opts.excluded) {
|
|
949
|
+
// --excluded alone implies --all-models
|
|
950
|
+
configObj.support_all_models = true;
|
|
951
|
+
configObj.excluded_model_ids = resolveByName(models, opts.excluded, { kind: 'model' });
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
const body = {
|
|
956
|
+
name: opts.name,
|
|
957
|
+
type: 'MODEL',
|
|
958
|
+
config: configObj,
|
|
959
|
+
enabled: !opts.disabled,
|
|
960
|
+
};
|
|
961
|
+
|
|
962
|
+
if (opts.group) {
|
|
963
|
+
const formData = await loadFormData();
|
|
964
|
+
body.scope_user_group_ids = resolveByName(formData.user_groups || [], opts.group, { kind: 'user group' });
|
|
965
|
+
}
|
|
966
|
+
if (opts.priority != null) body.priority = opts.priority;
|
|
967
|
+
|
|
968
|
+
const data = await api.post('/api/v1/policies/', { body });
|
|
969
|
+
if (opts.json) {
|
|
970
|
+
output.json(data);
|
|
971
|
+
return;
|
|
972
|
+
}
|
|
973
|
+
output.success(`Model policy "${opts.name}" created.`);
|
|
974
|
+
displayPolicy(data.policy);
|
|
975
|
+
} catch (err) {
|
|
976
|
+
output.error(err.message);
|
|
977
|
+
process.exitCode = 1;
|
|
978
|
+
}
|
|
979
|
+
});
|
|
980
|
+
|
|
981
|
+
model
|
|
982
|
+
.command('update <id>')
|
|
983
|
+
.description('Update an existing Model policy. Only provided fields are changed.')
|
|
984
|
+
.option('--name <name>', 'New name')
|
|
985
|
+
.option('--all-models', 'Replace config with "allow all"')
|
|
986
|
+
.option('--allowed <names>', 'Replace allowed-model list')
|
|
987
|
+
.option('--excluded <names>', 'Replace excluded-model list (implies all-models)')
|
|
988
|
+
.option('--group <names|ids>', 'Replace scope')
|
|
989
|
+
.option('--priority <n>', 'New priority', parseInt)
|
|
990
|
+
.option('--enabled', 'Enable the policy')
|
|
991
|
+
.option('--disabled', 'Disable the policy')
|
|
992
|
+
.option('--config <json>', 'Advanced: replace config with raw JSON')
|
|
993
|
+
.option('--json', 'Output raw JSON')
|
|
994
|
+
.addHelpText('after', `
|
|
995
|
+
Examples:
|
|
996
|
+
$ unbound policy model update 3 --allowed claude-3-5-sonnet
|
|
997
|
+
$ unbound policy model update 3 --all-models --excluded claude-3-opus
|
|
998
|
+
$ unbound policy model update 3 --disabled
|
|
999
|
+
|
|
1000
|
+
Learn more: ${DOCS_MODEL}
|
|
1001
|
+
`)
|
|
1002
|
+
.action(async (id, opts) => {
|
|
1003
|
+
try {
|
|
1004
|
+
requireLogin();
|
|
1005
|
+
|
|
1006
|
+
if (opts.enabled && opts.disabled) throw new Error('Cannot combine --enabled and --disabled.');
|
|
1007
|
+
const flagConfig = opts.allModels || opts.allowed || opts.excluded;
|
|
1008
|
+
if (opts.config && flagConfig) {
|
|
1009
|
+
throw new Error('Cannot combine --config with type-specific flags.');
|
|
1010
|
+
}
|
|
1011
|
+
if (opts.allowed && (opts.allModels || opts.excluded)) {
|
|
1012
|
+
throw new Error('--allowed is mutually exclusive with --all-models and --excluded.');
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
const body = {};
|
|
1016
|
+
if (opts.name !== undefined) body.name = opts.name;
|
|
1017
|
+
if (opts.priority != null) body.priority = opts.priority;
|
|
1018
|
+
if (opts.enabled) body.enabled = true;
|
|
1019
|
+
if (opts.disabled) body.enabled = false;
|
|
1020
|
+
|
|
1021
|
+
if (opts.config) {
|
|
1022
|
+
body.config = parseJsonOrThrow(opts.config);
|
|
1023
|
+
} else if (flagConfig) {
|
|
1024
|
+
const formData = await loadFormData();
|
|
1025
|
+
const models = formData.ai_models || [];
|
|
1026
|
+
body.config = { support_all_models: false, allowed_model_ids: [], excluded_model_ids: [] };
|
|
1027
|
+
if (opts.allowed) {
|
|
1028
|
+
body.config.allowed_model_ids = resolveByName(models, opts.allowed, { kind: 'model' });
|
|
1029
|
+
} else if (opts.allModels) {
|
|
1030
|
+
body.config.support_all_models = true;
|
|
1031
|
+
if (opts.excluded) body.config.excluded_model_ids = resolveByName(models, opts.excluded, { kind: 'model' });
|
|
1032
|
+
} else if (opts.excluded) {
|
|
1033
|
+
body.config.support_all_models = true;
|
|
1034
|
+
body.config.excluded_model_ids = resolveByName(models, opts.excluded, { kind: 'model' });
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
if (opts.group !== undefined) {
|
|
1039
|
+
const formData = await loadFormData();
|
|
1040
|
+
body.scope_user_group_ids = resolveByName(formData.user_groups || [], opts.group, { kind: 'user group' });
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
const data = await api.put(`/api/v1/policies/${id}/`, { body });
|
|
1044
|
+
if (opts.json) {
|
|
1045
|
+
output.json(data);
|
|
1046
|
+
return;
|
|
1047
|
+
}
|
|
1048
|
+
output.success(`Model policy ${id} updated.`);
|
|
1049
|
+
displayPolicy(data.policy);
|
|
1050
|
+
} catch (err) {
|
|
1051
|
+
output.error(err.message);
|
|
1052
|
+
process.exitCode = 1;
|
|
1053
|
+
}
|
|
1054
|
+
});
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
// ============================================================================
|
|
1058
|
+
// Security policies
|
|
1059
|
+
// ============================================================================
|
|
1060
|
+
|
|
1061
|
+
function registerSecurity(policy) {
|
|
1062
|
+
const security = policy
|
|
1063
|
+
.command('security')
|
|
1064
|
+
.description('Manage Security policies. Guardrails for PII/secrets and model routing rules.')
|
|
1065
|
+
.addHelpText('after', `
|
|
1066
|
+
Security policies come in three sub-types:
|
|
1067
|
+
guardrails Detect/block/redact sensitive content (PII, secrets, etc.)
|
|
1068
|
+
default-routing Route one model's traffic to another
|
|
1069
|
+
error-code-routing Re-route on specific HTTP error codes (e.g. 429 → fallback model)
|
|
1070
|
+
|
|
1071
|
+
Learn more: ${DOCS_SECURITY}
|
|
1072
|
+
|
|
1073
|
+
Subcommands:
|
|
1074
|
+
list List all security policies
|
|
1075
|
+
create [options] Create a new security policy
|
|
1076
|
+
update <id> [options] Update an existing security policy
|
|
1077
|
+
`);
|
|
1078
|
+
|
|
1079
|
+
security
|
|
1080
|
+
.command('list')
|
|
1081
|
+
.description('List all Security policies.')
|
|
1082
|
+
.option('--json', 'Output raw JSON')
|
|
1083
|
+
.action(async (opts) => {
|
|
1084
|
+
try {
|
|
1085
|
+
requireLogin();
|
|
1086
|
+
const data = await api.get('/api/v1/policies/', { query: { type: 'SECURITY' } });
|
|
1087
|
+
if (opts.json) {
|
|
1088
|
+
output.json(data);
|
|
1089
|
+
return;
|
|
1090
|
+
}
|
|
1091
|
+
const policies = data.policies || [];
|
|
1092
|
+
if (policies.length === 0) {
|
|
1093
|
+
output.info('No security policies found.');
|
|
1094
|
+
return;
|
|
1095
|
+
}
|
|
1096
|
+
output.table(policies, [
|
|
1097
|
+
{ key: 'id', header: 'ID' },
|
|
1098
|
+
{ key: 'name', header: 'Name' },
|
|
1099
|
+
{ key: 'config', header: 'Sub-type', format: (v) => (v && v.sub_type) || '-' },
|
|
1100
|
+
{ key: 'scope_user_groups', header: 'Scope', format: (v) => formatScope(v) },
|
|
1101
|
+
{ key: 'config_summary', header: 'Summary', format: (v) => v || '-' },
|
|
1102
|
+
{ key: 'enabled', header: 'Enabled' },
|
|
1103
|
+
]);
|
|
1104
|
+
} catch (err) {
|
|
1105
|
+
output.error(err.message);
|
|
1106
|
+
process.exitCode = 1;
|
|
1107
|
+
}
|
|
1108
|
+
});
|
|
1109
|
+
|
|
1110
|
+
security
|
|
1111
|
+
.command('create')
|
|
1112
|
+
.description('Create a new Security policy.')
|
|
1113
|
+
.requiredOption('--name <name>', 'Policy name (required)')
|
|
1114
|
+
.option('--sub-type <type>', 'Sub-type: guardrails | default-routing | error-code-routing')
|
|
1115
|
+
.option('--guardrail <spec>', 'Guardrail spec: <name>:<action>[:<threshold>]. Repeatable. For guardrails sub-type.', (val, prev = []) => [...prev, val])
|
|
1116
|
+
.option('--route-model <name>', 'Target model name for guardrail ROUTE actions')
|
|
1117
|
+
.option('--route <spec>', 'Default routing rule: <source-model>:<target-model>. Repeatable. For default-routing sub-type.', (val, prev = []) => [...prev, val])
|
|
1118
|
+
.option('--error-route <spec>', 'Error routing rule: <error-code>:<source-model>:<target-model>. Repeatable. For error-code-routing sub-type.', (val, prev = []) => [...prev, val])
|
|
1119
|
+
.option('--group <names|ids>', 'Comma-separated user group names or IDs to scope this policy to')
|
|
1120
|
+
.option('--priority <n>', 'Priority (lower = higher precedence)', parseInt)
|
|
1121
|
+
.option('--disabled', 'Create the policy in disabled state')
|
|
1122
|
+
.option('--config <json>', 'Advanced: raw config JSON (skips flag-based builder)')
|
|
1123
|
+
.option('--json', 'Output raw JSON of the created policy')
|
|
1124
|
+
.addHelpText('after', `
|
|
1125
|
+
Examples:
|
|
1126
|
+
Guardrails sub-type:
|
|
1127
|
+
$ unbound policy security create --name "Block PII" --sub-type guardrails \\
|
|
1128
|
+
--guardrail PII:BLOCK --guardrail Secrets:REDACT --group engg
|
|
1129
|
+
|
|
1130
|
+
$ unbound policy security create --name "Route PII To Safe Model" --sub-type guardrails \\
|
|
1131
|
+
--guardrail PII:ROUTE --route-model claude-3-5-sonnet
|
|
1132
|
+
|
|
1133
|
+
Default routing sub-type:
|
|
1134
|
+
$ unbound policy security create --name "Prefer Sonnet" --sub-type default-routing \\
|
|
1135
|
+
--route gpt-4:claude-3-5-sonnet
|
|
1136
|
+
|
|
1137
|
+
Error code routing sub-type:
|
|
1138
|
+
$ unbound policy security create --name "429 Fallback" --sub-type error-code-routing \\
|
|
1139
|
+
--error-route 429:gpt-4:claude-3-5-sonnet
|
|
1140
|
+
|
|
1141
|
+
To see available guardrail and model names: unbound policy form-data
|
|
1142
|
+
Learn more: ${DOCS_SECURITY}
|
|
1143
|
+
`)
|
|
1144
|
+
.action(async (opts) => {
|
|
1145
|
+
try {
|
|
1146
|
+
requireLogin();
|
|
1147
|
+
|
|
1148
|
+
const flagMode = opts.subType || (opts.guardrail && opts.guardrail.length) || (opts.route && opts.route.length) || (opts.errorRoute && opts.errorRoute.length);
|
|
1149
|
+
if (opts.config && flagMode) {
|
|
1150
|
+
throw new Error('Cannot combine --config with type-specific flags (--sub-type, --guardrail, --route, --error-route).');
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
let configObj;
|
|
1154
|
+
if (opts.config) {
|
|
1155
|
+
configObj = parseJsonOrThrow(opts.config);
|
|
1156
|
+
} else {
|
|
1157
|
+
if (!opts.subType) {
|
|
1158
|
+
throw new Error('--sub-type is required (one of: guardrails, default-routing, error-code-routing).');
|
|
1159
|
+
}
|
|
1160
|
+
const subType = normalizeSubType(opts.subType);
|
|
1161
|
+
configObj = { sub_type: subType };
|
|
1162
|
+
|
|
1163
|
+
if (subType === 'guardrails') {
|
|
1164
|
+
if (!opts.guardrail || opts.guardrail.length === 0) {
|
|
1165
|
+
throw new Error('At least one --guardrail is required for sub-type guardrails.');
|
|
1166
|
+
}
|
|
1167
|
+
const formData = await loadFormData();
|
|
1168
|
+
const guardrails = formData.guardrails || [];
|
|
1169
|
+
const models = formData.ai_models || [];
|
|
1170
|
+
|
|
1171
|
+
let routeModelId = null;
|
|
1172
|
+
if (opts.routeModel) {
|
|
1173
|
+
const m = resolveFull(models, opts.routeModel, { kind: 'model' })[0];
|
|
1174
|
+
routeModelId = m.id;
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
configObj.guardrails = opts.guardrail.map((spec) => parseGuardrailSpec(spec, guardrails, routeModelId));
|
|
1178
|
+
} else if (subType === 'default_routing') {
|
|
1179
|
+
if (!opts.route || opts.route.length === 0) {
|
|
1180
|
+
throw new Error('At least one --route is required for sub-type default-routing.');
|
|
1181
|
+
}
|
|
1182
|
+
const formData = await loadFormData();
|
|
1183
|
+
const models = formData.ai_models || [];
|
|
1184
|
+
configObj.default_routing_rules = opts.route.map((spec) => parseRouteSpec(spec, models));
|
|
1185
|
+
} else if (subType === 'error_code_routing') {
|
|
1186
|
+
if (!opts.errorRoute || opts.errorRoute.length === 0) {
|
|
1187
|
+
throw new Error('At least one --error-route is required for sub-type error-code-routing.');
|
|
1188
|
+
}
|
|
1189
|
+
const formData = await loadFormData();
|
|
1190
|
+
const models = formData.ai_models || [];
|
|
1191
|
+
configObj.error_routing_rules = opts.errorRoute.map((spec) => parseErrorRouteSpec(spec, models));
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
const body = {
|
|
1196
|
+
name: opts.name,
|
|
1197
|
+
type: 'SECURITY',
|
|
1198
|
+
config: configObj,
|
|
1199
|
+
enabled: !opts.disabled,
|
|
1200
|
+
};
|
|
1201
|
+
|
|
1202
|
+
if (opts.group) {
|
|
1203
|
+
const formData = await loadFormData();
|
|
1204
|
+
body.scope_user_group_ids = resolveByName(formData.user_groups || [], opts.group, { kind: 'user group' });
|
|
1205
|
+
}
|
|
1206
|
+
if (opts.priority != null) body.priority = opts.priority;
|
|
1207
|
+
|
|
1208
|
+
const data = await api.post('/api/v1/policies/', { body });
|
|
1209
|
+
if (opts.json) {
|
|
1210
|
+
output.json(data);
|
|
1211
|
+
return;
|
|
1212
|
+
}
|
|
1213
|
+
output.success(`Security policy "${opts.name}" created.`);
|
|
1214
|
+
displayPolicy(data.policy);
|
|
1215
|
+
} catch (err) {
|
|
1216
|
+
output.error(err.message);
|
|
1217
|
+
process.exitCode = 1;
|
|
1218
|
+
}
|
|
1219
|
+
});
|
|
1220
|
+
|
|
1221
|
+
security
|
|
1222
|
+
.command('update <id>')
|
|
1223
|
+
.description('Update an existing Security policy. Only provided fields are changed.')
|
|
1224
|
+
.option('--name <name>', 'New name')
|
|
1225
|
+
.option('--group <names|ids>', 'Replace scope')
|
|
1226
|
+
.option('--priority <n>', 'New priority', parseInt)
|
|
1227
|
+
.option('--enabled', 'Enable the policy')
|
|
1228
|
+
.option('--disabled', 'Disable the policy')
|
|
1229
|
+
.option('--config <json>', 'Replace config with raw JSON (required for config changes on security policies)')
|
|
1230
|
+
.option('--json', 'Output raw JSON')
|
|
1231
|
+
.addHelpText('after', `
|
|
1232
|
+
Security policy config updates require --config <json> because the shape
|
|
1233
|
+
is sub-type specific. Fetch the current config with:
|
|
1234
|
+
$ unbound policy get <id> --json | jq '.policy.config'
|
|
1235
|
+
|
|
1236
|
+
Then pass the updated config back:
|
|
1237
|
+
$ unbound policy security update <id> --config '{"sub_type":"guardrails","guardrails":[...]}'
|
|
1238
|
+
|
|
1239
|
+
For simple metadata changes, use the other flags.
|
|
1240
|
+
|
|
1241
|
+
Learn more: ${DOCS_SECURITY}
|
|
1242
|
+
`)
|
|
1243
|
+
.action(async (id, opts) => {
|
|
1244
|
+
try {
|
|
1245
|
+
requireLogin();
|
|
1246
|
+
if (opts.enabled && opts.disabled) throw new Error('Cannot combine --enabled and --disabled.');
|
|
1247
|
+
|
|
1248
|
+
const body = {};
|
|
1249
|
+
if (opts.name !== undefined) body.name = opts.name;
|
|
1250
|
+
if (opts.priority != null) body.priority = opts.priority;
|
|
1251
|
+
if (opts.enabled) body.enabled = true;
|
|
1252
|
+
if (opts.disabled) body.enabled = false;
|
|
1253
|
+
if (opts.config) body.config = parseJsonOrThrow(opts.config);
|
|
1254
|
+
|
|
1255
|
+
if (opts.group !== undefined) {
|
|
1256
|
+
const formData = await loadFormData();
|
|
1257
|
+
body.scope_user_group_ids = resolveByName(formData.user_groups || [], opts.group, { kind: 'user group' });
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
const data = await api.put(`/api/v1/policies/${id}/`, { body });
|
|
1261
|
+
if (opts.json) {
|
|
1262
|
+
output.json(data);
|
|
1263
|
+
return;
|
|
1264
|
+
}
|
|
1265
|
+
output.success(`Security policy ${id} updated.`);
|
|
1266
|
+
displayPolicy(data.policy);
|
|
1267
|
+
} catch (err) {
|
|
1268
|
+
output.error(err.message);
|
|
1269
|
+
process.exitCode = 1;
|
|
1270
|
+
}
|
|
1271
|
+
});
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
// ============================================================================
|
|
1275
|
+
// Tool policies (routes to /api/v1/command-policies/)
|
|
1276
|
+
// ============================================================================
|
|
1277
|
+
|
|
1278
|
+
function registerTool(policy) {
|
|
1279
|
+
const tool = policy
|
|
1280
|
+
.command('tool')
|
|
1281
|
+
.description('Manage Tool policies: controls for shell commands (TERMINAL_COMMAND) and MCP tool calls (MCP_TOOL).')
|
|
1282
|
+
.addHelpText('after', `
|
|
1283
|
+
Tool policies live in a separate backend table from Cost/Model/Security
|
|
1284
|
+
policies and are reached via /api/v1/command-policies/.
|
|
1285
|
+
|
|
1286
|
+
Learn more: ${DOCS_TOOL}
|
|
1287
|
+
|
|
1288
|
+
Subcommands:
|
|
1289
|
+
list List all tool policies
|
|
1290
|
+
get <id> Get a single tool policy
|
|
1291
|
+
delete <id> Delete a tool policy
|
|
1292
|
+
create-terminal [options] Create a terminal command policy
|
|
1293
|
+
create-mcp [options] Create an MCP tool policy
|
|
1294
|
+
update <id> [options] Update an existing tool policy
|
|
1295
|
+
families List terminal command families and their fields
|
|
1296
|
+
mcp-servers List known MCP servers and their tools
|
|
1297
|
+
`);
|
|
1298
|
+
|
|
1299
|
+
tool
|
|
1300
|
+
.command('list')
|
|
1301
|
+
.description('List tool (command) policies, paginated.')
|
|
1302
|
+
.option('--type <type>', 'Filter by policy type: TERMINAL or MCP')
|
|
1303
|
+
.option('--search <term>', 'Search by name, description, or command family')
|
|
1304
|
+
.option('--page <n>', 'Page number (default 1)', parseInt)
|
|
1305
|
+
.option('--page-size <n>', 'Page size (default 50)', parseInt)
|
|
1306
|
+
.option('--all', 'Fetch all pages (may be slow for large orgs)')
|
|
1307
|
+
.option('--json', 'Output raw JSON')
|
|
1308
|
+
.action(async (opts) => {
|
|
1309
|
+
try {
|
|
1310
|
+
requireLogin();
|
|
1311
|
+
|
|
1312
|
+
let policyType;
|
|
1313
|
+
if (opts.type) {
|
|
1314
|
+
const up = opts.type.toUpperCase();
|
|
1315
|
+
if (up === 'TERMINAL' || up === 'TERMINAL_COMMAND') policyType = 'TERMINAL_COMMAND';
|
|
1316
|
+
else if (up === 'MCP' || up === 'MCP_TOOL') policyType = 'MCP_TOOL';
|
|
1317
|
+
else throw new Error(`Invalid --type "${opts.type}". Must be TERMINAL or MCP.`);
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
const fetchPage = async (page, pageSize) => {
|
|
1321
|
+
const query = { page, page_size: pageSize };
|
|
1322
|
+
if (policyType) query.policy_type = policyType;
|
|
1323
|
+
if (opts.search) query.search = opts.search;
|
|
1324
|
+
return api.get('/api/v1/command-policies/', { query });
|
|
1325
|
+
};
|
|
1326
|
+
|
|
1327
|
+
const pageSize = opts.pageSize || 50;
|
|
1328
|
+
let policies = [];
|
|
1329
|
+
let meta;
|
|
1330
|
+
|
|
1331
|
+
if (opts.all) {
|
|
1332
|
+
let page = 1;
|
|
1333
|
+
while (true) {
|
|
1334
|
+
const data = await fetchPage(page, pageSize);
|
|
1335
|
+
policies = policies.concat(data.policies || []);
|
|
1336
|
+
meta = data;
|
|
1337
|
+
if (!data.total_pages || page >= data.total_pages) break;
|
|
1338
|
+
page += 1;
|
|
1339
|
+
}
|
|
1340
|
+
} else {
|
|
1341
|
+
const page = opts.page || 1;
|
|
1342
|
+
const data = await fetchPage(page, pageSize);
|
|
1343
|
+
policies = data.policies || [];
|
|
1344
|
+
meta = data;
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
if (opts.json) {
|
|
1348
|
+
output.json({ policies, total: meta.total, page: meta.page, page_size: meta.page_size, total_pages: meta.total_pages });
|
|
1349
|
+
return;
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
if (policies.length === 0) {
|
|
1353
|
+
output.info('No tool policies found.');
|
|
1354
|
+
return;
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
output.table(policies, [
|
|
1358
|
+
{ key: 'id', header: 'ID' },
|
|
1359
|
+
{ key: 'name', header: 'Name' },
|
|
1360
|
+
{ key: 'policy_type', header: 'Type' },
|
|
1361
|
+
{ key: 'type_display', header: 'Family/Server', format: (v) => v || '-' },
|
|
1362
|
+
{ key: 'target_pattern', header: 'Target', format: (v) => v || '-' },
|
|
1363
|
+
{ key: 'action', header: 'Action' },
|
|
1364
|
+
{ key: 'enabled', header: 'Enabled', format: (v) => (v ? 'yes' : 'no') },
|
|
1365
|
+
{ key: 'scope_user_groups', header: 'Scope', format: (v) => formatScope(v) },
|
|
1366
|
+
]);
|
|
1367
|
+
|
|
1368
|
+
if (!opts.all && meta.total_pages && meta.total_pages > 1) {
|
|
1369
|
+
console.log('');
|
|
1370
|
+
output.info(`Page ${meta.page} of ${meta.total_pages} (total: ${meta.total}). Use --all to fetch every page.`);
|
|
1371
|
+
}
|
|
1372
|
+
} catch (err) {
|
|
1373
|
+
output.error(err.message);
|
|
1374
|
+
process.exitCode = 1;
|
|
1375
|
+
}
|
|
1376
|
+
});
|
|
1377
|
+
|
|
1378
|
+
tool
|
|
1379
|
+
.command('get <id>')
|
|
1380
|
+
.description('Get a single tool (command) policy by ID.')
|
|
1381
|
+
.option('--json', 'Output raw JSON')
|
|
1382
|
+
.action(async (id, opts) => {
|
|
1383
|
+
try {
|
|
1384
|
+
requireLogin();
|
|
1385
|
+
const data = await api.get(`/api/v1/command-policies/${id}/`);
|
|
1386
|
+
if (opts.json) {
|
|
1387
|
+
output.json(data);
|
|
1388
|
+
return;
|
|
1389
|
+
}
|
|
1390
|
+
displayToolPolicy(unwrapToolPolicy(data));
|
|
1391
|
+
} catch (err) {
|
|
1392
|
+
output.error(err.message);
|
|
1393
|
+
process.exitCode = 1;
|
|
1394
|
+
}
|
|
1395
|
+
});
|
|
1396
|
+
|
|
1397
|
+
tool
|
|
1398
|
+
.command('delete <id>')
|
|
1399
|
+
.description('Delete a tool (command) policy by ID.')
|
|
1400
|
+
.option('--yes', 'Skip confirmation prompt')
|
|
1401
|
+
.action(async (id, opts) => {
|
|
1402
|
+
try {
|
|
1403
|
+
requireLogin();
|
|
1404
|
+
if (!opts.yes) {
|
|
1405
|
+
const confirmed = await confirm(`Are you sure you want to delete tool policy ${id}?`);
|
|
1406
|
+
if (!confirmed) {
|
|
1407
|
+
output.warn('Aborted.');
|
|
1408
|
+
return;
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
await api.del(`/api/v1/command-policies/${id}/`);
|
|
1412
|
+
output.success(`Tool policy ${id} deleted.`);
|
|
1413
|
+
} catch (err) {
|
|
1414
|
+
output.error(err.message);
|
|
1415
|
+
process.exitCode = 1;
|
|
1416
|
+
}
|
|
1417
|
+
});
|
|
1418
|
+
|
|
1419
|
+
tool
|
|
1420
|
+
.command('create-terminal')
|
|
1421
|
+
.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(' | ')}`)
|
|
1426
|
+
.option('--description <text>', 'Human-readable description')
|
|
1427
|
+
.option('--custom-message <text>', 'Message shown to the user when the policy fires. Required when --action is BLOCK or WARN.')
|
|
1428
|
+
.option('--group <names|ids>', 'Comma-separated user group names or IDs to scope this policy to')
|
|
1429
|
+
.option('--disabled', 'Create the policy in disabled state')
|
|
1430
|
+
.option('--config <json>', 'Advanced: raw config JSON (skips --field builder)')
|
|
1431
|
+
.option('--json', 'Output raw JSON of the created policy')
|
|
1432
|
+
.addHelpText('after', `
|
|
1433
|
+
Examples:
|
|
1434
|
+
$ unbound policy tool create-terminal --name "Block rm -rf" \\
|
|
1435
|
+
--command-family filesystem --field command='rm -rf*' --action BLOCK \\
|
|
1436
|
+
--custom-message "Destructive commands are blocked."
|
|
1437
|
+
|
|
1438
|
+
$ unbound policy tool create-terminal --name "Audit git push" \\
|
|
1439
|
+
--command-family git --field command='git push*' --action AUDIT
|
|
1440
|
+
|
|
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."
|
|
1444
|
+
|
|
1445
|
+
Discover valid command families and their fields: unbound policy tool families
|
|
1446
|
+
Learn more: ${DOCS_TOOL}
|
|
1447
|
+
`)
|
|
1448
|
+
.action(async (opts) => {
|
|
1449
|
+
try {
|
|
1450
|
+
requireLogin();
|
|
1451
|
+
const action = normalizeAction(opts.action, TOOL_ACTIONS, '--action');
|
|
1452
|
+
if ((action === 'BLOCK' || action === 'WARN') && !opts.customMessage) {
|
|
1453
|
+
throw new Error('--custom-message is required when --action is BLOCK or WARN.');
|
|
1454
|
+
}
|
|
1455
|
+
if (opts.config && opts.field) {
|
|
1456
|
+
throw new Error('Cannot combine --config with --field.');
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
let configObj;
|
|
1460
|
+
if (opts.config) {
|
|
1461
|
+
configObj = parseJsonOrThrow(opts.config);
|
|
1462
|
+
} else {
|
|
1463
|
+
if (!opts.field || opts.field.length === 0) {
|
|
1464
|
+
throw new Error('At least one --field <key>=<pattern> is required (or use --config).');
|
|
1465
|
+
}
|
|
1466
|
+
configObj = {};
|
|
1467
|
+
for (const spec of opts.field) {
|
|
1468
|
+
const [k, v] = parseFieldSpec(spec);
|
|
1469
|
+
configObj[k] = v;
|
|
1470
|
+
}
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
const body = {
|
|
1474
|
+
name: opts.name,
|
|
1475
|
+
description: opts.description || '',
|
|
1476
|
+
policy_type: 'TERMINAL_COMMAND',
|
|
1477
|
+
command_family: opts.commandFamily,
|
|
1478
|
+
config: configObj,
|
|
1479
|
+
action,
|
|
1480
|
+
enabled: !opts.disabled,
|
|
1481
|
+
};
|
|
1482
|
+
if (opts.customMessage) body.custom_message = opts.customMessage;
|
|
1483
|
+
|
|
1484
|
+
if (opts.group) {
|
|
1485
|
+
const formData = await loadFormData();
|
|
1486
|
+
body.scope_user_group_ids = resolveByName(formData.user_groups || [], opts.group, { kind: 'user group' });
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
const data = await api.post('/api/v1/command-policies/', { body });
|
|
1490
|
+
if (opts.json) {
|
|
1491
|
+
output.json(data);
|
|
1492
|
+
return;
|
|
1493
|
+
}
|
|
1494
|
+
output.success(`Terminal policy "${opts.name}" created.`);
|
|
1495
|
+
displayToolPolicy(unwrapToolPolicy(data));
|
|
1496
|
+
} catch (err) {
|
|
1497
|
+
output.error(err.message);
|
|
1498
|
+
process.exitCode = 1;
|
|
1499
|
+
}
|
|
1500
|
+
});
|
|
1501
|
+
|
|
1502
|
+
tool
|
|
1503
|
+
.command('create-mcp')
|
|
1504
|
+
.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 (e.g. Linear, Slack). See `policy tool mcp-servers`.')
|
|
1507
|
+
.option('--mcp-tool <tool>', 'Specific tool name on the MCP server (e.g. create_issue). Mutually exclusive with --mcp-action-type.')
|
|
1508
|
+
.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.')
|
|
1512
|
+
.option('--group <names|ids>', 'Comma-separated user group names or IDs to scope this policy to')
|
|
1513
|
+
.option('--disabled', 'Create the policy in disabled state')
|
|
1514
|
+
.option('--json', 'Output raw JSON of the created policy')
|
|
1515
|
+
.addHelpText('after', `
|
|
1516
|
+
Examples:
|
|
1517
|
+
Match a specific tool on a server:
|
|
1518
|
+
$ unbound policy tool create-mcp --name "Block Linear writes" \\
|
|
1519
|
+
--mcp-server Linear --mcp-tool create_issue --action BLOCK \\
|
|
1520
|
+
--custom-message "Issue creation is blocked. Contact admin."
|
|
1521
|
+
|
|
1522
|
+
Match all destructive tools on a server:
|
|
1523
|
+
$ unbound policy tool create-mcp --name "Audit all destructive Slack" \\
|
|
1524
|
+
--mcp-server Slack --mcp-action-type destructive --action AUDIT
|
|
1525
|
+
|
|
1526
|
+
Discover valid MCP servers and their tools: unbound policy tool mcp-servers
|
|
1527
|
+
Learn more: ${DOCS_TOOL}
|
|
1528
|
+
`)
|
|
1529
|
+
.action(async (opts) => {
|
|
1530
|
+
try {
|
|
1531
|
+
requireLogin();
|
|
1532
|
+
const action = normalizeAction(opts.action, TOOL_ACTIONS, '--action');
|
|
1533
|
+
if ((action === 'BLOCK' || action === 'WARN') && !opts.customMessage) {
|
|
1534
|
+
throw new Error('--custom-message is required when --action is BLOCK or WARN.');
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
const hasTool = !!opts.mcpTool;
|
|
1538
|
+
const hasActionType = !!opts.mcpActionType;
|
|
1539
|
+
if (hasTool && hasActionType) {
|
|
1540
|
+
throw new Error('--mcp-tool and --mcp-action-type are mutually exclusive.');
|
|
1541
|
+
}
|
|
1542
|
+
if (!hasTool && !hasActionType) {
|
|
1543
|
+
throw new Error('Either --mcp-tool or --mcp-action-type is required.');
|
|
1544
|
+
}
|
|
1545
|
+
if (hasActionType) {
|
|
1546
|
+
const at = opts.mcpActionType.toLowerCase();
|
|
1547
|
+
if (!MCP_ACTION_TYPES.includes(at)) {
|
|
1548
|
+
throw new Error(`Invalid --mcp-action-type "${opts.mcpActionType}". Must be one of: ${MCP_ACTION_TYPES.join(', ')}`);
|
|
1549
|
+
}
|
|
1550
|
+
opts.mcpActionType = at;
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
const body = {
|
|
1554
|
+
name: opts.name,
|
|
1555
|
+
description: opts.description || '',
|
|
1556
|
+
policy_type: 'MCP_TOOL',
|
|
1557
|
+
mcp_server: opts.mcpServer,
|
|
1558
|
+
action,
|
|
1559
|
+
enabled: !opts.disabled,
|
|
1560
|
+
config: {},
|
|
1561
|
+
};
|
|
1562
|
+
if (hasTool) body.mcp_tool = opts.mcpTool;
|
|
1563
|
+
if (hasActionType) body.mcp_tool_action_type = opts.mcpActionType;
|
|
1564
|
+
if (opts.customMessage) body.custom_message = opts.customMessage;
|
|
1565
|
+
|
|
1566
|
+
if (opts.group) {
|
|
1567
|
+
const formData = await loadFormData();
|
|
1568
|
+
body.scope_user_group_ids = resolveByName(formData.user_groups || [], opts.group, { kind: 'user group' });
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
const data = await api.post('/api/v1/command-policies/', { body });
|
|
1572
|
+
if (opts.json) {
|
|
1573
|
+
output.json(data);
|
|
1574
|
+
return;
|
|
1575
|
+
}
|
|
1576
|
+
output.success(`MCP policy "${opts.name}" created.`);
|
|
1577
|
+
displayToolPolicy(unwrapToolPolicy(data));
|
|
1578
|
+
} catch (err) {
|
|
1579
|
+
output.error(err.message);
|
|
1580
|
+
process.exitCode = 1;
|
|
1581
|
+
}
|
|
1582
|
+
});
|
|
1583
|
+
|
|
1584
|
+
tool
|
|
1585
|
+
.command('update <id>')
|
|
1586
|
+
.description('Update an existing tool (command) policy. Only provided fields are changed.')
|
|
1587
|
+
.option('--name <name>', 'New name')
|
|
1588
|
+
.option('--description <text>', 'New description')
|
|
1589
|
+
.option('--action <action>', `New action: ${TOOL_ACTIONS.join(' | ')}`)
|
|
1590
|
+
.option('--custom-message <text>', 'New custom message')
|
|
1591
|
+
.option('--group <names|ids>', 'Replace scope')
|
|
1592
|
+
.option('--enabled', 'Enable the policy')
|
|
1593
|
+
.option('--disabled', 'Disable the policy')
|
|
1594
|
+
.option('--command-family <family>', '(TERMINAL only) new command family')
|
|
1595
|
+
.option('--field <key=pattern>', '(TERMINAL only) replace config field. Repeatable.', (val, prev = []) => [...prev, val])
|
|
1596
|
+
.option('--mcp-server <server>', '(MCP only) new MCP server')
|
|
1597
|
+
.option('--mcp-tool <tool>', '(MCP only) new MCP tool')
|
|
1598
|
+
.option('--mcp-action-type <type>', '(MCP only) new MCP action type')
|
|
1599
|
+
.option('--config <json>', 'Advanced: replace config with raw JSON')
|
|
1600
|
+
.option('--json', 'Output raw JSON')
|
|
1601
|
+
.action(async (id, opts) => {
|
|
1602
|
+
try {
|
|
1603
|
+
requireLogin();
|
|
1604
|
+
if (opts.enabled && opts.disabled) throw new Error('Cannot combine --enabled and --disabled.');
|
|
1605
|
+
if (opts.config && opts.field) throw new Error('Cannot combine --config with --field.');
|
|
1606
|
+
if (opts.mcpTool && opts.mcpActionType) throw new Error('--mcp-tool and --mcp-action-type are mutually exclusive.');
|
|
1607
|
+
|
|
1608
|
+
const body = {};
|
|
1609
|
+
if (opts.name !== undefined) body.name = opts.name;
|
|
1610
|
+
if (opts.description !== undefined) body.description = opts.description;
|
|
1611
|
+
if (opts.action) body.action = normalizeAction(opts.action, TOOL_ACTIONS, '--action');
|
|
1612
|
+
if (opts.customMessage !== undefined) body.custom_message = opts.customMessage;
|
|
1613
|
+
if (opts.enabled) body.enabled = true;
|
|
1614
|
+
if (opts.disabled) body.enabled = false;
|
|
1615
|
+
|
|
1616
|
+
if (opts.commandFamily) body.command_family = opts.commandFamily;
|
|
1617
|
+
if (opts.field) {
|
|
1618
|
+
const cfg = {};
|
|
1619
|
+
for (const spec of opts.field) {
|
|
1620
|
+
const [k, v] = parseFieldSpec(spec);
|
|
1621
|
+
cfg[k] = v;
|
|
1622
|
+
}
|
|
1623
|
+
body.config = cfg;
|
|
1624
|
+
} else if (opts.config) {
|
|
1625
|
+
body.config = parseJsonOrThrow(opts.config);
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
if (opts.mcpServer) body.mcp_server = opts.mcpServer;
|
|
1629
|
+
if (opts.mcpTool) body.mcp_tool = opts.mcpTool;
|
|
1630
|
+
if (opts.mcpActionType) {
|
|
1631
|
+
const at = opts.mcpActionType.toLowerCase();
|
|
1632
|
+
if (!MCP_ACTION_TYPES.includes(at)) {
|
|
1633
|
+
throw new Error(`Invalid --mcp-action-type "${opts.mcpActionType}". Must be one of: ${MCP_ACTION_TYPES.join(', ')}`);
|
|
1634
|
+
}
|
|
1635
|
+
body.mcp_tool_action_type = at;
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
if (opts.group !== undefined) {
|
|
1639
|
+
const formData = await loadFormData();
|
|
1640
|
+
body.scope_user_group_ids = resolveByName(formData.user_groups || [], opts.group, { kind: 'user group' });
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
if ((body.action === 'BLOCK' || body.action === 'WARN') && !body.custom_message && opts.customMessage === undefined) {
|
|
1644
|
+
output.warn('Action changed to BLOCK/WARN without --custom-message. The existing custom_message will remain; pass --custom-message to replace it.');
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
const data = await api.put(`/api/v1/command-policies/${id}/`, { body });
|
|
1648
|
+
if (opts.json) {
|
|
1649
|
+
output.json(data);
|
|
1650
|
+
return;
|
|
1651
|
+
}
|
|
1652
|
+
output.success(`Tool policy ${id} updated.`);
|
|
1653
|
+
displayToolPolicy(unwrapToolPolicy(data));
|
|
1654
|
+
} catch (err) {
|
|
1655
|
+
output.error(err.message);
|
|
1656
|
+
process.exitCode = 1;
|
|
1657
|
+
}
|
|
1658
|
+
});
|
|
1659
|
+
|
|
1660
|
+
tool
|
|
1661
|
+
.command('families')
|
|
1662
|
+
.description('List terminal command families and the fields they accept.')
|
|
1663
|
+
.option('--json', 'Output raw JSON')
|
|
1664
|
+
.action(async (opts) => {
|
|
1665
|
+
try {
|
|
1666
|
+
requireLogin();
|
|
1667
|
+
const data = await api.get('/api/v1/command-policies/metadata/families/');
|
|
1668
|
+
if (opts.json) {
|
|
1669
|
+
output.json(data);
|
|
1670
|
+
return;
|
|
1671
|
+
}
|
|
1672
|
+
const families = data.families || [];
|
|
1673
|
+
if (families.length === 0) {
|
|
1674
|
+
output.info('No command families available.');
|
|
1675
|
+
return;
|
|
1676
|
+
}
|
|
1677
|
+
for (const f of families) {
|
|
1678
|
+
console.log('');
|
|
1679
|
+
output.info(f.name);
|
|
1680
|
+
const fields = Array.isArray(f.fields) ? f.fields : Object.keys(f.fields || {});
|
|
1681
|
+
if (fields.length === 0) {
|
|
1682
|
+
console.log(' (no fields)');
|
|
1683
|
+
} else {
|
|
1684
|
+
for (const field of fields) {
|
|
1685
|
+
const desc = (f.field_descriptions && f.field_descriptions[field]) || '';
|
|
1686
|
+
console.log(` ${field}${desc ? ` — ${desc}` : ''}`);
|
|
1687
|
+
}
|
|
1688
|
+
}
|
|
1689
|
+
if (f.risk_level) console.log(` risk: ${f.risk_level}`);
|
|
1690
|
+
}
|
|
1691
|
+
} catch (err) {
|
|
1692
|
+
output.error(err.message);
|
|
1693
|
+
process.exitCode = 1;
|
|
1694
|
+
}
|
|
1695
|
+
});
|
|
1696
|
+
|
|
1697
|
+
tool
|
|
1698
|
+
.command('mcp-servers')
|
|
1699
|
+
.description('List known MCP servers and their available tools.')
|
|
1700
|
+
.option('--json', 'Output raw JSON')
|
|
1701
|
+
.action(async (opts) => {
|
|
1702
|
+
try {
|
|
1703
|
+
requireLogin();
|
|
1704
|
+
const data = await api.get('/api/v1/command-policies/metadata/mcp-servers/');
|
|
1705
|
+
if (opts.json) {
|
|
1706
|
+
output.json(data);
|
|
1707
|
+
return;
|
|
1708
|
+
}
|
|
1709
|
+
const servers = data.mcp_servers || [];
|
|
1710
|
+
if (servers.length === 0) {
|
|
1711
|
+
output.info('No MCP servers known.');
|
|
1712
|
+
return;
|
|
1713
|
+
}
|
|
1714
|
+
for (const s of servers) {
|
|
1715
|
+
console.log('');
|
|
1716
|
+
output.info(s.mcp_server);
|
|
1717
|
+
const tools = s.tool_calls || [];
|
|
1718
|
+
if (tools.length === 0) {
|
|
1719
|
+
console.log(' (no tools)');
|
|
1720
|
+
} else {
|
|
1721
|
+
for (const t of tools) console.log(` ${t}`);
|
|
1722
|
+
}
|
|
1723
|
+
if (s.tool_action_types && typeof s.tool_action_types === 'object' && Object.keys(s.tool_action_types).length > 0) {
|
|
1724
|
+
console.log(` action types: ${Object.keys(s.tool_action_types).join(', ')}`);
|
|
1725
|
+
}
|
|
1726
|
+
}
|
|
1727
|
+
} catch (err) {
|
|
1728
|
+
output.error(err.message);
|
|
1729
|
+
process.exitCode = 1;
|
|
1730
|
+
}
|
|
1731
|
+
});
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
// ============================================================================
|
|
1735
|
+
// Backward-compatible generic create/update
|
|
1736
|
+
// ============================================================================
|
|
1737
|
+
|
|
1738
|
+
function registerLegacy(policy) {
|
|
1739
|
+
// Keep the old `policy create --type X --config JSON` and `policy update <id> --config JSON`
|
|
1740
|
+
// working as an escape hatch for scripts and power users who already know the backend shape.
|
|
1741
|
+
|
|
1742
|
+
policy
|
|
1743
|
+
.command('create')
|
|
1744
|
+
.description('Create a Cost/Model/Security policy. Prefer `policy cost|model|security create` for type-specific flags.')
|
|
1745
|
+
.requiredOption('--name <name>', 'Policy name (required)')
|
|
1746
|
+
.requiredOption('--type <type>', 'Policy type: COST, MODEL, or SECURITY (required)')
|
|
1747
|
+
.requiredOption('--config <json>', 'Policy configuration as a JSON string')
|
|
1748
|
+
.option('--enabled', 'Enable the policy (default: true)', true)
|
|
1749
|
+
.option('--no-enabled', 'Create the policy in disabled state')
|
|
1750
|
+
.option('--scope-groups <ids>', 'Comma-separated user group IDs')
|
|
1751
|
+
.option('--scope-tools <types>', 'Comma-separated tool types')
|
|
1752
|
+
.option('--priority <n>', 'Priority (integer)', parseInt)
|
|
1753
|
+
.addHelpText('after', `
|
|
1754
|
+
Advanced / power-user escape hatch. For guided flag-based creation prefer:
|
|
1755
|
+
$ unbound policy cost create --help
|
|
1756
|
+
$ unbound policy model create --help
|
|
1757
|
+
$ unbound policy security create --help
|
|
1758
|
+
$ unbound policy tool create-terminal --help (TOOL policies go through this path)
|
|
1759
|
+
$ unbound policy tool create-mcp --help
|
|
1760
|
+
|
|
1761
|
+
Examples:
|
|
1762
|
+
$ unbound policy create --name "Cost Limit" --type COST \\
|
|
1763
|
+
--config '{"monthly_budget":1000}' --scope-groups 1,2
|
|
1764
|
+
|
|
1765
|
+
Learn more: ${DOCS_POLICIES}
|
|
1766
|
+
`)
|
|
1767
|
+
.action(async (opts) => {
|
|
1768
|
+
try {
|
|
1769
|
+
requireLogin();
|
|
1770
|
+
|
|
1771
|
+
const type = opts.type.toUpperCase();
|
|
1772
|
+
if (!POLICY_TYPES.includes(type)) {
|
|
1773
|
+
throw new Error(`Invalid --type "${opts.type}". Must be one of: ${POLICY_TYPES.join(', ')}. For tool policies, use \`unbound policy tool create-terminal\` or \`create-mcp\`.`);
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
const body = {
|
|
1777
|
+
name: opts.name,
|
|
1778
|
+
type,
|
|
1779
|
+
config: parseJsonOrThrow(opts.config),
|
|
1780
|
+
enabled: opts.enabled,
|
|
1781
|
+
};
|
|
1782
|
+
|
|
1783
|
+
if (opts.scopeGroups) {
|
|
1784
|
+
body.scope_user_group_ids = parseCommaSeparated(opts.scopeGroups).map(Number);
|
|
1785
|
+
}
|
|
1786
|
+
if (opts.scopeTools) {
|
|
1787
|
+
body.scope_tool_types = parseCommaSeparated(opts.scopeTools);
|
|
1788
|
+
}
|
|
1789
|
+
if (opts.priority != null) body.priority = opts.priority;
|
|
1790
|
+
|
|
1791
|
+
const data = await api.post('/api/v1/policies/', { body });
|
|
1792
|
+
output.success(`Policy "${opts.name}" created.`);
|
|
1793
|
+
displayPolicy(data.policy);
|
|
1794
|
+
} catch (err) {
|
|
1795
|
+
output.error(err.message);
|
|
1796
|
+
process.exitCode = 1;
|
|
1797
|
+
}
|
|
1798
|
+
});
|
|
1799
|
+
|
|
1800
|
+
policy
|
|
1801
|
+
.command('update <id>')
|
|
1802
|
+
.description('Update a Cost/Model/Security policy via raw config. Prefer `policy cost|model|security update`.')
|
|
1803
|
+
.option('--name <name>', 'New name')
|
|
1804
|
+
.option('--config <json>', 'Replace config with raw JSON')
|
|
1805
|
+
.option('--enabled', 'Enable')
|
|
1806
|
+
.option('--no-enabled', 'Disable')
|
|
1807
|
+
.option('--scope-groups <ids>', 'Comma-separated user group IDs')
|
|
1808
|
+
.option('--scope-tools <types>', 'Comma-separated tool types')
|
|
1809
|
+
.option('--priority <n>', 'Priority (integer)', parseInt)
|
|
1810
|
+
.addHelpText('after', `
|
|
1811
|
+
Advanced escape hatch. Prefer the type-specific update subcommands.
|
|
1812
|
+
|
|
1813
|
+
Examples:
|
|
1814
|
+
$ unbound policy update 5 --name "New Name"
|
|
1815
|
+
$ unbound policy update 5 --config '{"monthly_budget":2000}'
|
|
1816
|
+
$ unbound policy update 5 --no-enabled
|
|
1817
|
+
|
|
1818
|
+
Learn more: ${DOCS_POLICIES}
|
|
1819
|
+
`)
|
|
1820
|
+
.action(async (id, opts) => {
|
|
1821
|
+
try {
|
|
1822
|
+
requireLogin();
|
|
1823
|
+
|
|
1824
|
+
const body = {};
|
|
1825
|
+
if (opts.name !== undefined) body.name = opts.name;
|
|
1826
|
+
if (opts.enabled !== undefined) body.enabled = opts.enabled;
|
|
1827
|
+
if (opts.priority != null) body.priority = opts.priority;
|
|
1828
|
+
if (opts.config) body.config = parseJsonOrThrow(opts.config);
|
|
1829
|
+
if (opts.scopeGroups) body.scope_user_group_ids = parseCommaSeparated(opts.scopeGroups).map(Number);
|
|
1830
|
+
if (opts.scopeTools) body.scope_tool_types = parseCommaSeparated(opts.scopeTools);
|
|
1831
|
+
|
|
1832
|
+
const data = await api.put(`/api/v1/policies/${id}/`, { body });
|
|
1833
|
+
output.success(`Policy ${id} updated.`);
|
|
1834
|
+
displayPolicy(data.policy);
|
|
1835
|
+
} catch (err) {
|
|
1836
|
+
output.error(err.message);
|
|
1837
|
+
process.exitCode = 1;
|
|
1838
|
+
}
|
|
1839
|
+
});
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1842
|
+
// ============================================================================
|
|
1843
|
+
// register(program)
|
|
1844
|
+
// ============================================================================
|
|
1845
|
+
|
|
1846
|
+
function register(program) {
|
|
1847
|
+
const policy = program
|
|
1848
|
+
.command('policy')
|
|
1849
|
+
.description('Manage policies. Four types: cost, model, security, tool.')
|
|
1850
|
+
.addHelpText('after', `
|
|
1851
|
+
Unbound has four policy types. Each has its own subcommand for guided
|
|
1852
|
+
flag-based create/update. Tool policies live in a separate backend table
|
|
1853
|
+
and are reached via \`unbound policy tool\`.
|
|
1854
|
+
|
|
1855
|
+
cost Monthly budget limits per user group. ${DOCS_COST}
|
|
1856
|
+
model Control which AI models are available. ${DOCS_MODEL}
|
|
1857
|
+
security Guardrails (PII, secrets), routing rules. ${DOCS_SECURITY}
|
|
1858
|
+
tool Shell command and MCP tool controls. ${DOCS_TOOL}
|
|
1859
|
+
|
|
1860
|
+
Generic commands (Cost/Model/Security only):
|
|
1861
|
+
list List policies, optionally filtered by type
|
|
1862
|
+
get <id> Get policy details
|
|
1863
|
+
delete <id> Delete a policy
|
|
1864
|
+
form-data Show reference data (user groups, models, guardrails, etc.)
|
|
1865
|
+
effective View effective policies for a user or group
|
|
1866
|
+
|
|
1867
|
+
Examples:
|
|
1868
|
+
$ unbound policy form-data # see available names
|
|
1869
|
+
$ unbound policy cost create --name "Eng Budget" --monthly-budget 1000 --group engg
|
|
1870
|
+
$ unbound policy model create --name "No Opus" --all-models --excluded claude-3-opus
|
|
1871
|
+
$ unbound policy security create --name "Block PII" --sub-type guardrails --guardrail PII:BLOCK
|
|
1872
|
+
$ unbound policy tool create-terminal --name "Block rm -rf" --command-family filesystem \\
|
|
1873
|
+
--field command='rm -rf*' --action BLOCK --custom-message "Blocked by policy"
|
|
1874
|
+
$ unbound policy tool create-mcp --name "Audit Linear writes" \\
|
|
1875
|
+
--mcp-server Linear --mcp-action-type write --action AUDIT
|
|
1876
|
+
|
|
1877
|
+
Learn more: ${DOCS_POLICIES}
|
|
1878
|
+
`);
|
|
1879
|
+
|
|
1880
|
+
registerTopLevel(policy);
|
|
1881
|
+
registerCost(policy);
|
|
1882
|
+
registerModel(policy);
|
|
1883
|
+
registerSecurity(policy);
|
|
1884
|
+
registerTool(policy);
|
|
1885
|
+
registerLegacy(policy);
|
|
394
1886
|
}
|
|
395
1887
|
|
|
396
1888
|
module.exports = { register };
|