unbound-cli 1.9.0 → 1.11.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unbound-cli",
3
- "version": "1.9.0",
3
+ "version": "1.11.0",
4
4
  "description": "CLI tool for Unbound - AI Gateway management",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -0,0 +1,178 @@
1
+ /**
2
+ * HTML pages served by the local auth-callback server (src/auth.js).
3
+ *
4
+ * Design follows the unbound-fe light-surface language (matching the auth
5
+ * login page): #fafafb scene with a soft purple glow, white card on a gray-200
6
+ * border, #3d3d3d headings, brand purple #7B56FB (logo) accents, Inter-first
7
+ * font stack. Fully self-contained —
8
+ * no external fonts, scripts, or images — so the page renders identically
9
+ * offline and never blocks on a network fetch from a localhost one-shot server.
10
+ *
11
+ * SECURITY: email/org arrive as query params on a loopback URL anyone local
12
+ * can hit, so every interpolated value MUST go through escapeHtml. Never
13
+ * interpolate the api_key into a page.
14
+ */
15
+
16
+ /** Minimal HTML entity escape for untrusted interpolated text. */
17
+ function escapeHtml(value) {
18
+ return String(value)
19
+ .replace(/&/g, '&')
20
+ .replace(/</g, '&lt;')
21
+ .replace(/>/g, '&gt;')
22
+ .replace(/"/g, '&quot;')
23
+ .replace(/'/g, '&#39;');
24
+ }
25
+
26
+ // Unbound shield mark (public/Unbound.svg in unbound-fe), inlined verbatim.
27
+ const LOGO_SVG =
28
+ '<svg width="28" height="27" viewBox="0 0 24 23" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">' +
29
+ '<path d="M5.12736 6.88525C6.33833 14.4266 11.9473 18.6338 12.0038 18.6757L9.577 22.0419C9.28788 21.8335 2.47374 16.8456 0.979492 7.53649L5.12639 6.88525H5.12736Z" fill="#7B56FB"/>' +
30
+ '<path d="M18.7655 6.81787L22.8988 7.24132C22.8637 7.59566 21.9496 15.9907 14.6361 21.9394L12.0059 18.6755C17.9322 13.8559 18.7577 6.88796 18.7655 6.81787Z" fill="#7B56FB"/>' +
31
+ '<path d="M12.1878 1.4375C14.6214 1.4375 17.3928 1.8113 20.282 2.91325L18.7654 6.81775C11.5638 4.07068 5.19258 6.85572 5.12833 6.88492L3.42188 3.10697C3.63993 3.00865 7.20178 1.4375 12.1878 1.4375Z" fill="#7B56FB"/>' +
32
+ '</svg>';
33
+
34
+ /**
35
+ * Shared page shell. `status` slots the icon block, heading, and body copy;
36
+ * `accent` tints the icon ring (brand purple on success, red on failure).
37
+ */
38
+ function renderPage({ title, icon, heading, body }) {
39
+ return `<!DOCTYPE html>
40
+ <html lang="en">
41
+ <head>
42
+ <meta charset="utf-8">
43
+ <meta name="viewport" content="width=device-width, initial-scale=1">
44
+ <title>${title}</title>
45
+ <style>
46
+ * { margin: 0; padding: 0; box-sizing: border-box; }
47
+ body {
48
+ font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
49
+ background: #fafafb;
50
+ min-height: 100vh;
51
+ display: flex;
52
+ align-items: center;
53
+ justify-content: center;
54
+ padding: 24px;
55
+ /* Soft brand glow behind the card, echoing the light auth/login scene. */
56
+ background-image: radial-gradient(600px 400px at 50% 30%, rgba(123, 86, 251, 0.07), transparent 70%);
57
+ }
58
+ .card {
59
+ background: #ffffff;
60
+ border: 1px solid #e5e7eb;
61
+ border-radius: 16px;
62
+ padding: 44px 40px 36px;
63
+ max-width: 400px;
64
+ width: 100%;
65
+ text-align: center;
66
+ box-shadow: 0 16px 48px rgba(23, 19, 43, 0.08);
67
+ animation: rise 0.45s ease-out both;
68
+ }
69
+ .brand {
70
+ display: inline-flex;
71
+ align-items: center;
72
+ gap: 8px;
73
+ margin-bottom: 28px;
74
+ }
75
+ .brand span {
76
+ color: #17132B;
77
+ font-size: 17px;
78
+ font-weight: 600;
79
+ letter-spacing: 0.02em;
80
+ }
81
+ .icon {
82
+ display: block;
83
+ width: 64px;
84
+ height: 64px;
85
+ margin: 0 auto 20px;
86
+ }
87
+ .icon circle {
88
+ stroke-dasharray: 166;
89
+ stroke-dashoffset: 166;
90
+ animation: draw 0.6s 0.15s ease-out forwards;
91
+ }
92
+ .icon .mark {
93
+ stroke-dasharray: 48;
94
+ stroke-dashoffset: 48;
95
+ animation: draw 0.35s 0.65s ease-out forwards;
96
+ }
97
+ h1 { color: #3d3d3d; font-size: 20px; font-weight: 600; margin-bottom: 10px; }
98
+ p { color: #6b7280; font-size: 14px; line-height: 1.6; }
99
+ .identity {
100
+ display: inline-flex;
101
+ flex-direction: column;
102
+ gap: 2px;
103
+ margin: 14px 0 6px;
104
+ padding: 9px 18px;
105
+ border: 1px solid #e5e7eb;
106
+ border-radius: 12px;
107
+ background: #fafafb;
108
+ font-size: 13px;
109
+ max-width: 100%;
110
+ overflow-wrap: anywhere;
111
+ }
112
+ .identity b { color: #3d3d3d; font-weight: 500; }
113
+ .identity i { color: #6b7280; font-style: normal; font-size: 12px; }
114
+ @keyframes rise { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
115
+ @keyframes draw { to { stroke-dashoffset: 0; } }
116
+ @media (prefers-reduced-motion: reduce) {
117
+ .card, .icon circle, .icon .mark { animation: none; stroke-dashoffset: 0; }
118
+ }
119
+ </style>
120
+ </head>
121
+ <body>
122
+ <main class="card">
123
+ <div class="brand">${LOGO_SVG}<span>unbound</span></div>
124
+ ${icon}
125
+ <h1>${heading}</h1>
126
+ ${body}
127
+ </main>
128
+ </body>
129
+ </html>
130
+ `;
131
+ }
132
+
133
+ // Stroke-drawn status icons: circle sweeps in first, then the mark.
134
+ const CHECK_ICON =
135
+ '<svg class="icon" viewBox="0 0 56 56" fill="none" aria-hidden="true">' +
136
+ '<circle cx="28" cy="28" r="26" stroke="#7B56FB" stroke-width="2.5"/>' +
137
+ '<path class="mark" d="M17 29l8 8 14-16" stroke="#7B56FB" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>' +
138
+ '</svg>';
139
+
140
+ const CROSS_ICON =
141
+ '<svg class="icon" viewBox="0 0 56 56" fill="none" aria-hidden="true">' +
142
+ '<circle cx="28" cy="28" r="26" stroke="#EF4444" stroke-width="2.5"/>' +
143
+ '<path class="mark" d="M20 20l16 16M36 20L20 36" stroke="#EF4444" stroke-width="3" stroke-linecap="round"/>' +
144
+ '</svg>';
145
+
146
+ /**
147
+ * Success page shown after the API key lands. `email`/`orgName` are untrusted
148
+ * query-param values — escaped here, and omitted entirely when absent.
149
+ */
150
+ function successPage({ email, orgName } = {}) {
151
+ // Stacked (email over org) rather than one truncating line — real tenant
152
+ // emails and org names routinely overflow a 400px card side by side.
153
+ const identityParts = [];
154
+ if (email) identityParts.push(`<b>${escapeHtml(email)}</b>`);
155
+ if (orgName) identityParts.push(`<i>${escapeHtml(orgName)}</i>`);
156
+ const identity = identityParts.length
157
+ ? `<div class="identity">${identityParts.join('')}</div>`
158
+ : '';
159
+
160
+ return renderPage({
161
+ title: 'Authentication Successful | Unbound',
162
+ icon: CHECK_ICON,
163
+ heading: 'Authentication Successful',
164
+ body: `${identity}<p>You can close this tab and return to the terminal.</p>`,
165
+ });
166
+ }
167
+
168
+ /** Failure page for a callback that arrived without an api_key. */
169
+ function failurePage() {
170
+ return renderPage({
171
+ title: 'Authentication Failed | Unbound',
172
+ icon: CROSS_ICON,
173
+ heading: 'Authentication Failed',
174
+ body: '<p>You can close this tab and try again from the terminal.</p>',
175
+ });
176
+ }
177
+
178
+ module.exports = { successPage, failurePage, escapeHtml };
package/src/auth.js CHANGED
@@ -4,6 +4,7 @@
4
4
  */
5
5
  const http = require('http');
6
6
  const { URL } = require('url');
7
+ const authPages = require('./auth-pages');
7
8
  const config = require('./config');
8
9
  const output = require('./output');
9
10
  const api = require('./api');
@@ -35,18 +36,38 @@ async function loginWithBrowser(frontendUrl, { open: openFn } = {}) {
35
36
  // / timed-out socket so the process exits immediately. closeAllConnections is
36
37
  // Node >=18.2 — guard for the package's >=18 engine floor.
37
38
  //
38
- // Calling this synchronously right after res.end() is safe ONLY because the
39
- // callback responses are a few hundred bytes of static HTML — they fit in the
40
- // socket send buffer, so the body reaches the kernel before closeAllConnections
41
- // destroys the socket. If these pages ever grow to multi-KB (inline assets,
42
- // styling), defer teardown to res.on('finish') to avoid truncating the response.
39
+ // The styled callback pages (auth-pages.js) are multi-KB, so calling this
40
+ // synchronously after res.end() could destroy the socket before the body
41
+ // flushes and truncate the page. Callback responses therefore defer teardown
42
+ // to res 'finish' (body flushed) / 'close' (client vanished mid-write) via
43
+ // respondAndShutdown; the guard keeps the second event and the timeout path
44
+ // from double-closing the server (server.close() on a closed server throws).
45
+ let tornDown = false;
43
46
  function shutdownServer() {
47
+ if (tornDown) return;
48
+ tornDown = true;
44
49
  server.close();
45
50
  if (typeof server.closeAllConnections === 'function') {
46
51
  server.closeAllConnections();
47
52
  }
48
53
  }
49
54
 
55
+ // Sends a terminal (success/failure) callback response and tears the server
56
+ // down once the body has actually left userspace. Resolution of the login
57
+ // promise stays with the caller — this only owns response + teardown.
58
+ function respondAndShutdown(res, statusCode, html) {
59
+ res.on('finish', shutdownServer);
60
+ res.on('close', shutdownServer);
61
+ res.writeHead(statusCode, { 'Content-Type': 'text/html; charset=utf-8', 'Connection': 'close' });
62
+ res.end(html);
63
+ }
64
+
65
+ // First /callback wins. Teardown is deferred until the response body
66
+ // flushes, which leaves a window where a second /callback could arrive and
67
+ // overwrite the just-persisted credentials after the login promise has
68
+ // settled — reject duplicates outright instead of processing them.
69
+ let callbackHandled = false;
70
+
50
71
  server.on('request', (req, res) => {
51
72
  const url = new URL(req.url, `http://localhost`);
52
73
 
@@ -56,15 +77,20 @@ async function loginWithBrowser(frontendUrl, { open: openFn } = {}) {
56
77
  return;
57
78
  }
58
79
 
80
+ if (callbackHandled) {
81
+ res.writeHead(409, { 'Connection': 'close' });
82
+ res.end('Callback already processed');
83
+ return;
84
+ }
85
+ callbackHandled = true;
86
+
59
87
  const apiKey = url.searchParams.get('api_key');
60
88
  const email = url.searchParams.get('email');
61
89
  const orgName = url.searchParams.get('org');
62
90
 
63
91
  if (!apiKey) {
64
- res.writeHead(400, { 'Content-Type': 'text/html', 'Connection': 'close' });
65
- res.end('<html><body><h2>Authentication failed</h2><p>No API key received. Please try again.</p></body></html>');
92
+ respondAndShutdown(res, 400, authPages.failurePage());
66
93
  callbackReject(new Error('No API key received in callback'));
67
- shutdownServer();
68
94
  return;
69
95
  }
70
96
 
@@ -74,11 +100,8 @@ async function loginWithBrowser(frontendUrl, { open: openFn } = {}) {
74
100
  if (orgName) cfg.org_name = orgName;
75
101
  config.writeConfig(cfg);
76
102
 
77
- res.writeHead(200, { 'Content-Type': 'text/html', 'Connection': 'close' });
78
- res.end('<html><body><h2>Authentication successful!</h2><p>You can close this tab and return to the terminal.</p></body></html>');
79
-
103
+ respondAndShutdown(res, 200, authPages.successPage({ email, orgName }));
80
104
  callbackResolve({ email, orgName });
81
- shutdownServer();
82
105
  });
83
106
 
84
107
  await new Promise((resolve, reject) => {
@@ -312,6 +312,34 @@ function parseFieldSpec(spec) {
312
312
  return [spec.slice(0, eq), spec.slice(eq + 1)];
313
313
  }
314
314
 
315
+ // The `custom` command family has no match-against fields — its single condition is
316
+ // the reserved `command` key, matched as ONE pattern against the whole raw command
317
+ // regardless of the classified family. The families metadata reports it with
318
+ // is_custom:true and no fields (mirrors the dashboard), so surface how to author it.
319
+ const CUSTOM_FAMILY_NAME = 'custom';
320
+ const CUSTOM_FAMILY_HINT =
321
+ "matches ONE pattern against the whole command (exact text, *wildcard*, or regex). " +
322
+ "Author it with --field command='<pattern>', e.g. --field command='make release-prod'.";
323
+
324
+ // Indented detail lines shown under a family name in `policy tool families`. Pure so
325
+ // the custom-family guidance is unit-testable. A custom family (is_custom) reports no
326
+ // fields by design, so explain its single whole-command pattern instead of "(no fields)".
327
+ function familyFieldLines(f) {
328
+ // Custom has no fields by design — return before touching f.fields so its shape is
329
+ // irrelevant for the custom branch.
330
+ if (f.is_custom) {
331
+ return [CUSTOM_FAMILY_HINT];
332
+ }
333
+ const fields = Array.isArray(f.fields) ? f.fields : Object.keys(f.fields || {});
334
+ if (fields.length === 0) {
335
+ return ['(no fields)'];
336
+ }
337
+ return fields.map((field) => {
338
+ const desc = (f.field_descriptions && f.field_descriptions[field]) || '';
339
+ return `${field}${desc ? ` — ${desc}` : ''}`;
340
+ });
341
+ }
342
+
315
343
  // "ANY" is a wildcard across all fields; it can't be ANDed with other conditions.
316
344
  // Mirrors the backend rule so the CLI fails fast before the API call. The check is
317
345
  // case-insensitive so `--field Any=*`/`--field any=*` are caught too.
@@ -1433,8 +1461,8 @@ Subcommands:
1433
1461
  .option('--prompt <text>', 'Natural-language description; routes through Unbound AI assist. Mutually exclusive with --command-family/--field/--config. Compatible with --name/--description/--action overrides.')
1434
1462
  .option('--no-ai', 'Opt out of the AI-assist guard and use raw classification flags. Mutually exclusive with --prompt.')
1435
1463
  .option('--name <name>', 'Policy name (required unless --prompt is used)')
1436
- .option('--command-family <family>', 'Command family (e.g. git, filesystem, network). See `policy tool families`.')
1437
- .option('--field <key=pattern>', 'Match field: <key>=<pattern>. Repeatable — multiple --field are ANDed (all must match), one per field, for a more specific policy. At least one required unless --config is used.', (val, prev = []) => [...prev, val])
1464
+ .option('--command-family <family>', 'Command family (e.g. git, filesystem, network), or `custom` to match one pattern against the whole command. See `policy tool families`.')
1465
+ .option('--field <key=pattern>', 'Match field: <key>=<pattern>. Repeatable — multiple --field are ANDed (all must match), one per field, for a more specific policy. At least one required unless --config is used. For the `custom` family the key is `command`.', (val, prev = []) => [...prev, val])
1438
1466
  .option('--action <action>', `Action to take: ${TOOL_ACTIONS.join(' | ')}`)
1439
1467
  .option('--description <text>', 'Human-readable description')
1440
1468
  .option('--custom-message <text>', 'Message shown to the user when the policy fires. Required when --action is BLOCK or WARN.')
@@ -1466,6 +1494,12 @@ Examples:
1466
1494
  --field operation=push --field branch=main --field remote='*origin*' \\
1467
1495
  --action BLOCK --custom-message "Pushing to main on origin is blocked."
1468
1496
 
1497
+ # 'custom' family: one pattern matched against the WHOLE command (exact / *wildcard* / regex),
1498
+ # regardless of the classified family. The field key is always 'command'.
1499
+ $ unbound policy tool create-terminal --no-ai --name "Block prod release" \\
1500
+ --command-family custom --field command='make release-prod' \\
1501
+ --action BLOCK --custom-message "Prod releases go through CI."
1502
+
1469
1503
  Discover valid command families and their fields: unbound policy tool families
1470
1504
  Learn more: ${DOCS_TOOL}
1471
1505
  `)
@@ -1558,12 +1592,18 @@ Learn more: ${DOCS_TOOL}
1558
1592
  throw new Error('Cannot combine --config with --field.');
1559
1593
  }
1560
1594
 
1595
+ const isCustomFamily = (opts.commandFamily || '').toLowerCase() === CUSTOM_FAMILY_NAME;
1596
+
1561
1597
  let configObj;
1562
1598
  if (opts.config) {
1563
1599
  configObj = parseJsonOrThrow(opts.config);
1564
1600
  } else {
1565
1601
  if (!opts.field || opts.field.length === 0) {
1566
- throw new Error('At least one --field <key>=<pattern> is required (or use --config).');
1602
+ throw new Error(
1603
+ isCustomFamily
1604
+ ? `The 'custom' family ${CUSTOM_FAMILY_HINT}`
1605
+ : 'At least one --field <key>=<pattern> is required (or use --config).'
1606
+ );
1567
1607
  }
1568
1608
  configObj = {};
1569
1609
  for (const spec of opts.field) {
@@ -1874,14 +1914,8 @@ Learn more: ${DOCS_TOOL}
1874
1914
  for (const f of families) {
1875
1915
  console.log('');
1876
1916
  output.info(f.name);
1877
- const fields = Array.isArray(f.fields) ? f.fields : Object.keys(f.fields || {});
1878
- if (fields.length === 0) {
1879
- console.log(' (no fields)');
1880
- } else {
1881
- for (const field of fields) {
1882
- const desc = (f.field_descriptions && f.field_descriptions[field]) || '';
1883
- console.log(` ${field}${desc ? ` — ${desc}` : ''}`);
1884
- }
1917
+ for (const line of familyFieldLines(f)) {
1918
+ console.log(` ${line}`);
1885
1919
  }
1886
1920
  if (f.risk_level) console.log(` risk: ${f.risk_level}`);
1887
1921
  }
@@ -2082,4 +2116,4 @@ Learn more: ${DOCS_POLICIES}
2082
2116
  registerLegacy(policy);
2083
2117
  }
2084
2118
 
2085
- module.exports = { register, assertAnyExclusive };
2119
+ module.exports = { register, assertAnyExclusive, familyFieldLines, CUSTOM_FAMILY_HINT };
@@ -134,6 +134,42 @@ test('loginWithBrowser: success path resolves, sets Connection: close, and stops
134
134
  });
135
135
  });
136
136
 
137
+ test('loginWithBrowser: duplicate /callback cannot overwrite the stored credentials', async () => {
138
+ await withStubbedConfig(async () => {
139
+ const agent = new http.Agent({ keepAlive: true });
140
+ const storedKeys = [];
141
+ config.setApiKey = (key) => { storedKeys.push(key); };
142
+
143
+ const fakeOpen = async (authUrl) => {
144
+ const callbackUrl = new URL(authUrl).searchParams.get('callback_url');
145
+ await getKeepAlive(agent, `${callbackUrl}?api_key=sk-first&email=a@b.co&org=Acme`);
146
+ // Second callback with a different key: teardown is deferred to the
147
+ // response flush, so the server may still be up — it must answer 409
148
+ // without persisting; once down, ECONNREFUSED is equally acceptable.
149
+ // Either way the first credentials must be the only ones stored.
150
+ try {
151
+ const dup = await getKeepAlive(agent, `${callbackUrl}?api_key=sk-attacker&email=x@y.z&org=Evil`);
152
+ assert.equal(dup.statusCode, 409, 'late duplicate must be rejected, not processed');
153
+ } catch (err) {
154
+ if (err.code !== 'ECONNREFUSED') throw err;
155
+ }
156
+ };
157
+
158
+ try {
159
+ const result = await withTimeout(
160
+ auth.loginWithBrowser('http://frontend.invalid', { open: fakeOpen }),
161
+ 10_000,
162
+ 'loginWithBrowser duplicate-callback path',
163
+ );
164
+
165
+ assert.deepEqual(result, { email: 'a@b.co', orgName: 'Acme' });
166
+ assert.deepEqual(storedKeys, ['sk-first'], 'only the first callback may persist credentials');
167
+ } finally {
168
+ agent.destroy();
169
+ }
170
+ });
171
+ });
172
+
137
173
  test('loginWithBrowser: 404 path also sets Connection: close', async () => {
138
174
  await withStubbedConfig(async () => {
139
175
  const agent = new http.Agent({ keepAlive: true });
@@ -3,7 +3,7 @@
3
3
  const { test } = require('node:test');
4
4
  const assert = require('node:assert');
5
5
 
6
- const { assertAnyExclusive } = require('../src/commands/policy');
6
+ const { assertAnyExclusive, familyFieldLines, CUSTOM_FAMILY_HINT } = require('../src/commands/policy');
7
7
 
8
8
  test('assertAnyExclusive: allows a single ANY condition', () => {
9
9
  assert.doesNotThrow(() => assertAnyExclusive({ ANY: '*' }));
@@ -26,3 +26,32 @@ test('assertAnyExclusive: tolerates empty/undefined config', () => {
26
26
  assert.doesNotThrow(() => assertAnyExclusive({}));
27
27
  assert.doesNotThrow(() => assertAnyExclusive(undefined));
28
28
  });
29
+
30
+ // `policy tool families` must teach an agent how to author a custom policy: the
31
+ // custom family reports is_custom + no fields, and must render the command-pattern
32
+ // hint (not "(no fields)", which reads as "unusable").
33
+ test('familyFieldLines: custom family shows the command-pattern hint, not "(no fields)"', () => {
34
+ const lines = familyFieldLines({ name: 'custom', is_custom: true, fields: [] });
35
+ assert.deepStrictEqual(lines, [CUSTOM_FAMILY_HINT]);
36
+ assert.match(lines[0], /--field command=/);
37
+ assert.match(lines[0], /exact text, \*wildcard\*, or regex/);
38
+ });
39
+
40
+ test('familyFieldLines: a normal family with fields lists field — description', () => {
41
+ const lines = familyFieldLines({
42
+ name: 'git_action',
43
+ fields: ['operation', 'branch'],
44
+ field_descriptions: { operation: 'push/pull/…', branch: 'branch name' },
45
+ });
46
+ assert.deepStrictEqual(lines, ['operation — push/pull/…', 'branch — branch name']);
47
+ });
48
+
49
+ test('familyFieldLines: a fieldless non-custom family still shows "(no fields)"', () => {
50
+ assert.deepStrictEqual(familyFieldLines({ name: 'misc', fields: [] }), ['(no fields)']);
51
+ });
52
+
53
+ test('familyFieldLines: custom returns the hint even with no/malformed fields property', () => {
54
+ // The is_custom guard fires before f.fields is read, so its shape is irrelevant.
55
+ assert.deepStrictEqual(familyFieldLines({ name: 'custom', is_custom: true }), [CUSTOM_FAMILY_HINT]);
56
+ assert.deepStrictEqual(familyFieldLines({ is_custom: true, fields: null }), [CUSTOM_FAMILY_HINT]);
57
+ });