unbound-cli 0.3.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
- function displayEffectivePolicies(data, opts) {
34
- if (opts.json) {
35
- output.json(data);
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
- const policies = data.effective_policies || data.policies || [];
40
- output.table(policies, [
41
- { key: 'id', header: 'ID' },
42
- { key: 'name', header: 'Name' },
43
- { key: 'type', header: 'Type' },
44
- { key: 'enabled', header: 'Enabled' },
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
- function register(program) {
51
- const policy = program
52
- .command('policy')
53
- .description('Manage policies. Policies control security guardrails, model access, and cost limits applied to user groups and tools.');
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
- // policy list
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 all policies. Supports filtering by type, enabled status, and search term.')
59
- .option('--type <type>', 'Filter by policy type: SECURITY, MODEL, or COST')
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 --enabled --json
68
- $ unbound policy list --search "default"
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
- if (!config.isLoggedIn()) {
73
- output.error('Not logged in. Run `unbound login` first.');
74
- process.exitCode = 1;
75
- return;
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
- output.table(data.policies, [
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: 'created_at', header: 'Created', format: (v) => formatDate(v) },
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
- // policy get
404
+ // ---- get ----
106
405
  policy
107
406
  .command('get <id>')
108
- .description('Get detailed information about a specific policy by its ID.')
109
- .option('--json', 'Output raw JSON instead of formatted key-value pairs')
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
- if (!config.isLoggedIn()) {
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
- // policy delete
429
+ // ---- delete ----
258
430
  policy
259
431
  .command('delete <id>')
260
- .description('Delete a policy by its ID. Prompts for confirmation unless --yes is provided.')
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
- if (!config.isLoggedIn()) {
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
- // policy form-data
457
+ // ---- form-data (the bugfix) ----
292
458
  policy
293
459
  .command('form-data')
294
- .description('Retrieve reference data for creating policies. Includes available user groups, tool types, guardrails, and models.')
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
- if (!config.isLoggedIn()) {
304
- output.error('Not logged in. Run `unbound login` first.');
305
- process.exitCode = 1;
306
- return;
307
- }
474
+ requireLogin();
308
475
 
309
- const data = await api.get('/api/v1/policies/form_data/');
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
- // Display each section of the form data
317
- if (data.user_groups) {
318
- console.log('');
319
- output.info('User Groups');
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
- console.log('');
328
- output.info('Tool Types');
329
- for (const type of data.tool_types) {
330
- console.log(` ${type}`);
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
- if (data.guardrails) {
335
- console.log('');
336
- output.info('Guardrails');
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: 'type', header: 'Type' },
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
- if (data.models) {
345
- console.log('');
346
- output.info('Models');
347
- output.table(data.models, [
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: 'provider', header: 'Provider' },
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
- // policy effective
571
+ // ---- effective ----
360
572
  policy
361
573
  .command('effective <id>')
362
- .description('View effective policies for a user or group. Effective policies are the resolved set after inheritance and priority.')
363
- .option('--user', 'Resolve effective policies for a user ID (default)')
364
- .option('--group', 'Resolve effective policies for a group ID')
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 # Effective policies for user 42
369
- $ unbound policy effective 42 --user # Same as above (--user is default)
370
- $ unbound policy effective 5 --group # Effective policies for group 5
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
- if (!config.isLoggedIn()) {
376
- output.error('Not logged in. Run `unbound login` first.');
377
- process.exitCode = 1;
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
- let data;
382
- if (opts.group) {
383
- data = await api.get(`/api/v1/user_groups/${id}/effective_policies/`);
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
- data = await api.get(`/api/v1/users/${id}/effective_policies/`);
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
- displayEffectivePolicies(data, opts);
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 };