unbound-cli 1.18.0 → 1.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -54,6 +54,9 @@ Only your API key is needed. Run with `sudo` to let the discovery step scan all
54
54
  | `unbound login` | Sign in via browser |
55
55
  | `unbound login --api-key <key>` | Sign in with an API key (non-interactive) |
56
56
  | `unbound login --domain <domain>` | Sign in via a custom domain |
57
+ | `unbound login --role <name>` | Browser sign-in with a token scoped to a console role (`admin`, `analytics-viewer`, `manager`, or a custom role) |
58
+ | `unbound login --permissions <list>` | Browser sign-in with a token scoped per area, e.g. `analytics=view,logs=view` |
59
+ | `unbound login --expires-in-days <n>` | Token lifetime for browser sign-in: 7, 30, 60 or 90 (default 90) |
57
60
  | `unbound logout` | Remove the CLI key from the OS keychain |
58
61
  | `unbound status` | Show CLI status, role, and connected tools |
59
62
  | `unbound doctor` | Diagnose per-tool health (config, hook, env) and API key |
@@ -212,6 +215,66 @@ unbound policy tool delete <id>
212
215
 
213
216
  Before creating any policy, run `unbound policy form-data` to see available user group names, model names, guardrail names, and existing command policies. The CLI accepts names (e.g. `engg`, `claude-3-opus`, `PII`) and resolves them to backend IDs automatically. You can also pass numeric IDs directly.
214
217
 
218
+ ### Policy packs as code (Admin only)
219
+
220
+ Keep policies in versioned YAML files and sync them to your org with `unbound pack`. One file is one **pack**: a named, versioned bundle of one or more policies of kind `tool`, `security`, `model` or `cost`. Every policy in a pack is marked on the server as managed by code, with the pack name and the policy `id` as its identity; its `name` is free text (it defaults to the id). The pack commands match, update and prune only by that identity, so policies made in the console are never read for diffing and never written, whatever they are called.
221
+
222
+ The console shows a **Managed by Code** badge on those policies and only lets people switch them on or off; every other change goes through the pack file. A policy switched off in the console shows up in `pack diff` as `enabled: false -> true (off on the server, on in the pack)`, and `pack apply` turns it back on (set `enabled: false` in the file to keep it off). `unbound pack adopt` brings a console policy into a pack without renaming it. Needs a backend that stores the code identity (ai-gateway-data 0448 or later).
223
+
224
+ Docs: https://docs.getunbound.ai/policies/policy-as-code
225
+
226
+ ```yaml
227
+ # policies/k8s-baseline.yaml
228
+ apiVersion: unbound.security/v1
229
+ kind: PolicyPack
230
+ metadata:
231
+ name: k8s-baseline # stored on every policy below as its pack identity
232
+ version: 1.0.0
233
+ defaults:
234
+ scope:
235
+ userGroups: [Engineering]
236
+ policies:
237
+ - id: block-secret-reads
238
+ kind: tool
239
+ type: TERMINAL_COMMAND
240
+ action: BLOCK
241
+ commandFamily: custom
242
+ customMessage: Reading cluster secrets is blocked by the k8s-baseline pack.
243
+ config:
244
+ command: "regex:kubectl\\s+(get|describe)\\s+secrets?"
245
+ - id: github-writes-need-approval
246
+ kind: tool
247
+ type: MCP_TOOL
248
+ action: REQUIRE_SLACK_APPROVAL
249
+ mcp:
250
+ server: github
251
+ actionType: write
252
+ - id: pii-guard
253
+ kind: security
254
+ guardrails:
255
+ - name: PII
256
+ action: BLOCK
257
+ - id: monthly-cap
258
+ kind: cost
259
+ config:
260
+ monthly_budget: 5000
261
+ ```
262
+
263
+ ```bash
264
+ unbound pack validate -f policies/ # schema check, no server contact
265
+ unbound pack diff -f policies/k8s-baseline.yaml # what apply would change; exit 1 on drift
266
+ unbound pack apply -f policies/k8s-baseline.yaml --dry-run
267
+ unbound pack apply -f policies/k8s-baseline.yaml
268
+ unbound pack apply -f policies/ --prune # also delete managed policies missing from the files
269
+ unbound pack sync policies/ --yes # folder is the source of truth (apply + prune)
270
+ unbound pack export k8s-baseline -o policies/k8s-baseline.yaml
271
+ unbound pack export --all -o policies/ # one file per pack found in the org
272
+ unbound pack adopt k8s-baseline --from "Block kubectl secrets" -o policies/k8s-baseline.yaml # mark a console policy as managed by the pack; its name stays
273
+ unbound pack delete k8s-baseline --yes # remove every policy the pack manages
274
+ ```
275
+
276
+ User groups, guardrails, models, labels and MCP servers are written by name and resolved at apply time; a name that does not resolve fails before anything is written. `config` is passed to the server as-is. Updates send only the fields that differ, and re-running the same file is safe. `diff` and `--dry-run` never write. A removed pack file does not delete its policies (use `pack delete`), because the server keeps no record of applied packs yet.
277
+
215
278
  ### Users
216
279
 
217
280
  | Command | Description |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unbound-cli",
3
- "version": "1.18.0",
3
+ "version": "1.20.0",
4
4
  "description": "CLI tool for Unbound - AI Gateway management",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -23,7 +23,8 @@
23
23
  "@iarna/toml": "^2.2.5",
24
24
  "@sentry/node": "^10.59.0",
25
25
  "commander": "^12.1.0",
26
- "open": "^10.1.0"
26
+ "open": "^10.1.0",
27
+ "yaml": "^2.9.1"
27
28
  },
28
29
  "engines": {
29
30
  "node": ">=18.0.0"
package/src/auth-pages.js CHANGED
@@ -169,12 +169,13 @@ function successPage({ email, orgName } = {}) {
169
169
  }
170
170
 
171
171
  /** Failure page for a callback that arrived without an api_key. */
172
- function failurePage() {
172
+ function failurePage(reason) {
173
+ const detail = reason ? `<p>${escapeHtml(reason)}</p>` : '';
173
174
  return renderPage({
174
175
  title: 'Authentication Failed | Unbound',
175
176
  icon: CROSS_ICON,
176
177
  heading: 'Authentication Failed',
177
- body: '<p>You can close this tab and try again from the terminal.</p>',
178
+ body: `${detail}<p>You can close this tab and try again from the terminal.</p>`,
178
179
  });
179
180
  }
180
181
 
package/src/auth.js CHANGED
@@ -3,6 +3,7 @@
3
3
  * Used by both `login` and `setup` commands.
4
4
  */
5
5
  const http = require('http');
6
+ const os = require('os');
6
7
  const { URL } = require('url');
7
8
  const authPages = require('./auth-pages');
8
9
  const config = require('./config');
@@ -32,7 +33,7 @@ function warnIfNotStored(stored) {
32
33
  * @param {string} frontendUrl - The frontend URL to open
33
34
  * @returns {Promise<{email: string, orgName: string}>}
34
35
  */
35
- async function loginWithBrowser(frontendUrl, { open: openFn } = {}) {
36
+ async function loginWithBrowser(frontendUrl, { open: openFn, scope = {} } = {}) {
36
37
  const server = http.createServer();
37
38
  let callbackResolve;
38
39
  let callbackReject;
@@ -102,10 +103,14 @@ async function loginWithBrowser(frontendUrl, { open: openFn } = {}) {
102
103
  const apiKey = url.searchParams.get('api_key');
103
104
  const email = url.searchParams.get('email');
104
105
  const orgName = url.searchParams.get('org');
106
+ // The callback is reachable by any local page, so its text is stripped of control characters before display.
107
+ const reason = (url.searchParams.get('error') || '').replace(/[\u0000-\u001f\u007f-\u009f]/g, '').slice(0, 200);
108
+ // `scope` echoes what the dashboard minted; an older dashboard sends none.
109
+ const grantedScope = url.searchParams.get('scope');
105
110
 
106
111
  if (!apiKey) {
107
- respondAndShutdown(res, 400, authPages.failurePage());
108
- callbackReject(new Error('No API key received in callback'));
112
+ respondAndShutdown(res, 400, authPages.failurePage(reason));
113
+ callbackReject(new Error(reason || 'No API key received in callback'));
109
114
  return;
110
115
  }
111
116
 
@@ -116,7 +121,7 @@ async function loginWithBrowser(frontendUrl, { open: openFn } = {}) {
116
121
  config.writeConfig(cfg);
117
122
 
118
123
  respondAndShutdown(res, 200, authPages.successPage({ email, orgName }));
119
- callbackResolve({ email, orgName });
124
+ callbackResolve(grantedScope ? { email, orgName, scope: grantedScope } : { email, orgName });
120
125
  });
121
126
 
122
127
  await new Promise((resolve, reject) => {
@@ -126,7 +131,9 @@ async function loginWithBrowser(frontendUrl, { open: openFn } = {}) {
126
131
 
127
132
  const port = server.address().port;
128
133
  const callbackUrl = `http://localhost:${port}/callback`;
129
- const authUrl = `${frontendUrl}/automations/api-key-callback?callback_url=${encodeURIComponent(callbackUrl)}&app_type=cli`;
134
+ // `machine` names the login token after this host, so a re-login replaces that token instead of adding one.
135
+ const params = new URLSearchParams({ callback_url: callbackUrl, app_type: 'cli', machine: os.hostname(), ...scope });
136
+ const authUrl = `${frontendUrl}/automations/api-key-callback?${params.toString()}`;
130
137
 
131
138
  const open = openFn || (await import('open')).default;
132
139
 
@@ -4,6 +4,7 @@ const output = require('../output');
4
4
  const api = require('../api');
5
5
  const { loginWithBrowser, loginWithApiKey, fetchDeviceKey } = require('../auth');
6
6
  const { getDeviceSerial } = require('../device-serial');
7
+ const { parseLoginScope, isScoped, describeToken } = require('../login-scope');
7
8
 
8
9
  function register(program) {
9
10
  program
@@ -15,6 +16,9 @@ function register(program) {
15
16
  .option('--backend-url <url>', 'Tenant backend REST API URL (e.g. https://backend.acme.com). Persisted to config.')
16
17
  .addOption(new Option('--base-url <url>', 'Hidden alias of --backend-url, kept for back-compat.').hideHelp())
17
18
  .option('--domain <domain>', 'Use a custom domain for login (e.g. custom.example.com)')
19
+ .option('--role <name>', 'Browser login only: scope the token to a console role (admin, analytics-viewer, manager, or a custom role name)')
20
+ .option('--permissions <list>', 'Browser login only: scope the token per area, e.g. analytics=view,logs=view (unlisted areas get none)')
21
+ .option('--expires-in-days <days>', 'Browser login only: token lifetime, one of 7, 30, 60, 90 (default 90)')
18
22
  .addHelpText('after', `
19
23
  Authentication methods:
20
24
  Browser login (default):
@@ -35,9 +39,17 @@ Options:
35
39
  --domain sets a custom domain for organizations with self-hosted frontends.
36
40
  Bare hostnames (e.g. "api.acme.com") are auto-prefixed with https://.
37
41
 
42
+ Scoped browser login (--role / --permissions / --expires-in-days):
43
+ By default the login token has your full access for 90 days. Scope it to a
44
+ console role or to specific areas; the dashboard still caps it to your own
45
+ access. Re-running login on this machine replaces its previous token.
46
+
38
47
  Examples:
39
48
  $ unbound login # Login via default gateway
40
49
  $ unbound login --api-key sk-abc123 # Non-interactive login
50
+ $ unbound login --role analytics-viewer # View-only token
51
+ $ unbound login --role "Policy Auditor" --expires-in-days 30
52
+ $ unbound login --permissions analytics=view,logs=view
41
53
  $ unbound login --api-key sk-abc123 \\
42
54
  --gateway-url https://api.acme.com \\
43
55
  --frontend-url https://gateway.acme.com \\
@@ -60,8 +72,12 @@ Examples:
60
72
  // wrong tenant.
61
73
  const explicitBaseUrl = written.base_url;
62
74
  const explicitFrontendUrl = written.frontend_url;
75
+ const scope = parseLoginScope(opts);
63
76
 
64
77
  if (opts.apiKey) {
78
+ if (isScoped(scope)) {
79
+ output.warn('--role, --permissions and --expires-in-days apply to browser login only; the given key is used as is.');
80
+ }
65
81
  try {
66
82
  await loginWithApiKey(opts.apiKey, { baseUrl: explicitBaseUrl });
67
83
  } catch {
@@ -77,14 +93,20 @@ Examples:
77
93
  } else {
78
94
  frontendUrl = explicitFrontendUrl || config.getFrontendUrl();
79
95
  }
80
- await loginWithBrowser(frontendUrl);
96
+ const result = await loginWithBrowser(frontendUrl, { scope });
97
+ if (isScoped(scope) && !result.scope) {
98
+ output.warn('Dashboard ignored your login flags. Stored a default 90-day full-access token. '
99
+ + 'Revoke it under Settings › API Tokens if unwanted.');
100
+ }
81
101
  // Validate the just-stored key against the explicit backend if one
82
102
  // was just persisted; otherwise fall back to the env-var-aware
83
103
  // getter. api.get reads the stored key from config internally.
84
104
  const deviceSerial = await getDeviceSerial();
85
- await api.get('/api/v1/users/privileges/', {
105
+ const privileges = await api.get('/api/v1/users/privileges/', {
86
106
  baseUrl: explicitBaseUrl, query: { device_serial: deviceSerial },
87
107
  });
108
+ const token = describeToken(privileges);
109
+ if (token) output.info(`Token: ${token}`);
88
110
  // Replace whatever config.json held (an old CLI login, another org's key) with this login's device key.
89
111
  try {
90
112
  config.setDeviceKey(await fetchDeviceKey(config.getApiKey(), { baseUrl: explicitBaseUrl }));