unbound-cli 1.14.0 → 1.15.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unbound-cli",
3
- "version": "1.14.0",
3
+ "version": "1.15.1",
4
4
  "description": "CLI tool for Unbound - AI Gateway management",
5
5
  "main": "src/index.js",
6
6
  "bin": {
package/src/api.js CHANGED
@@ -234,6 +234,101 @@ function postStream(path, { body, timeoutMs, onEvent } = {}) {
234
234
  });
235
235
  }
236
236
 
237
+ /**
238
+ * Stream an absolute URL to a destination, sending no credentials.
239
+ *
240
+ * For presigned S3 downloads: the signature is in the URL, and an Authorization
241
+ * header would both break it and hand our API key to a third party. Redirects
242
+ * are refused, so a tampered response cannot steer the client at another host.
243
+ *
244
+ * `onHeaders` fires before the first byte is written so the caller can pick the
245
+ * destination. Resolves { headers, bytes }.
246
+ */
247
+ function fetchToStream(absoluteUrl, { timeoutMs, onHeaders, transport } = {}) {
248
+ let url;
249
+ try {
250
+ url = new URL(absoluteUrl);
251
+ } catch {
252
+ return Promise.reject(new Error('The server returned an unusable download URL.'));
253
+ }
254
+ if (url.protocol !== 'https:' && url.protocol !== 'http:') {
255
+ return Promise.reject(new Error(`Refusing to download from a ${url.protocol} URL.`));
256
+ }
257
+ const client = transport || (url.protocol === 'https:' ? https : http);
258
+
259
+ return new Promise((resolve, reject) => {
260
+ // The caller deletes the partial file when this rejects, and on Windows
261
+ // that fails while a write handle is open. A socket reset arrives on the
262
+ // request, not the response, so closing it in one place covers every path.
263
+ let destination;
264
+ const fail = (err) => {
265
+ if (destination && destination !== process.stdout) destination.destroy();
266
+ reject(err);
267
+ };
268
+
269
+ const req = client.request(url, { method: 'GET', headers: { 'User-Agent': USER_AGENT } }, (res) => {
270
+ const status = res.statusCode;
271
+
272
+ // Redirects are not followed. A presigned URL points straight at the
273
+ // signing endpoint, so a Location header means either a misconfigured
274
+ // bucket region or a response steering us at a host nobody vetted.
275
+ if (status >= 300 && status < 400) {
276
+ res.resume();
277
+ fail(new Error(
278
+ `The download server redirected to somewhere unexpected (HTTP ${status}). Nothing was downloaded.`));
279
+ return;
280
+ }
281
+
282
+ if (status < 200 || status >= 300) {
283
+ const chunks = [];
284
+ res.on('data', (chunk) => chunks.push(chunk));
285
+ res.on('end', () => {
286
+ // S3 answers with XML, so the body is unhelpful to a user. An expired
287
+ // signature is the one case worth naming, since re-running fixes it.
288
+ const message = status === 403
289
+ ? 'This download link has expired. Run the export again.'
290
+ : `The download failed (HTTP ${status}).`;
291
+ fail(new ApiError(status, { error: message }));
292
+ });
293
+ return;
294
+ }
295
+
296
+ try {
297
+ destination = onHeaders(res.headers);
298
+ } catch (err) {
299
+ res.destroy();
300
+ fail(err);
301
+ return;
302
+ }
303
+
304
+ let bytes = 0;
305
+ res.on('data', (chunk) => { bytes += chunk.length; });
306
+ // A stream that dies mid-transfer must surface as an error, never as a
307
+ // silently short file.
308
+ res.on('error', (err) => fail(new Error(
309
+ `The download from ${url.host} was cut short (${err.code || err.message}). The file is incomplete.`)));
310
+ destination.on('error', (err) => { res.destroy(); reject(err); });
311
+
312
+ res.pipe(destination, { end: destination !== process.stdout });
313
+ const done = destination === process.stdout ? res : destination;
314
+ done.on(destination === process.stdout ? 'end' : 'finish', () => {
315
+ resolve({ headers: res.headers, bytes });
316
+ });
317
+ });
318
+
319
+ // Bounds silence, not the whole transfer: Node's socket timeout resets on
320
+ // every byte, which is what a multi-megabyte download needs.
321
+ const effectiveTimeout = Number.isInteger(timeoutMs) && timeoutMs > 0 ? timeoutMs : DEFAULT_TIMEOUT_MS;
322
+ req.setTimeout(effectiveTimeout, () => {
323
+ req.destroy();
324
+ fail(new Error(`The download from ${url.host} stalled for ${Math.round(effectiveTimeout / 1000)}s. Please try again.`));
325
+ });
326
+
327
+ req.on('error', (err) => fail(new Error(`Network error: ${err.message}`)));
328
+ req.end();
329
+ });
330
+ }
331
+
237
332
  // Plain unauthenticated GET to an arbitrary URL. Returns the raw response body
238
333
  // as a UTF-8 string. Callers that want JSON must do JSON.parse() themselves.
239
334
  // Used by `unbound oacb` to fetch baseline JSONs and hook scripts from GitHub.
@@ -287,6 +382,7 @@ async function validateApiKey(apiKey, { baseUrl, timeoutMs = 3000 } = {}) {
287
382
  module.exports = {
288
383
  ApiError,
289
384
  get: (path, opts) => request('GET', path, opts),
385
+ fetchToStream,
290
386
  post: (path, opts) => request('POST', path, opts),
291
387
  postStream,
292
388
  put: (path, opts) => request('PUT', path, opts),
@@ -0,0 +1,344 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ const api = require('../api');
5
+ const config = require('../config');
6
+ const output = require('../output');
7
+ const downloads = require('../lib/downloads');
8
+ const {
9
+ DEFAULT_LIMIT,
10
+ DEFAULT_TIME_WINDOW,
11
+ MAX_LIMIT,
12
+ MAX_TABLE_LIMIT,
13
+ TIME_WINDOWS,
14
+ buildQuery,
15
+ buildQueryString,
16
+ resolveFormat,
17
+ } = require('../lib/analytics-query');
18
+
19
+ const EXPORT_PATH = '/api/v1/analytics/export';
20
+ const { MAX_EXPORT_ROWS, RETENTION_HOURS } = downloads;
21
+
22
+ // Squeezing every column into one terminal makes all of them unreadable, so the
23
+ // table view shows a curated subset. --format json/csv and --wide return all of
24
+ // them; these names are matched against the server's `columns` array.
25
+ const DATASETS = {
26
+ terminal: {
27
+ key: 'terminal-runs',
28
+ title: 'Terminal Runs',
29
+ summary: 'Terminal commands agents ran, with risk score and matched tool policies.',
30
+ tableColumns: ['Timestamp', 'User', 'Command Family', 'Command', 'Risk Score', 'Initiator', 'Matched Policies'],
31
+ },
32
+ 'mcp-actions': {
33
+ key: 'mcp-actions',
34
+ title: 'MCP Actions',
35
+ summary: 'Individual MCP tool calls, with server, tool, targets and risk score.',
36
+ tableColumns: ['Timestamp', 'User', 'MCP Server', 'MCP Tool', 'Risk Score', 'Initiator', 'Matched Policies'],
37
+ },
38
+ unsanctioned: {
39
+ key: 'unsanctioned',
40
+ title: 'Unsanctioned Usage',
41
+ summary: 'MCP calls to servers your org has not sanctioned (blocked or warned).',
42
+ tableColumns: ['Timestamp', 'User', 'MCP Server', 'MCP Tool', 'Status'],
43
+ },
44
+ 'mcp-servers': {
45
+ key: 'mcp-servers',
46
+ title: 'MCP Servers',
47
+ summary: 'One row per MCP server: action count and average risk. Use it to find --server values.',
48
+ tableColumns: ['MCP Server', 'Actions', 'Average Risk Score'],
49
+ },
50
+ };
51
+
52
+ /**
53
+ * Narrow a response to a dataset's table columns, preserving the server's order.
54
+ * Unknown names are ignored so an older CLI still renders a newer response.
55
+ */
56
+ function selectTableColumns(dataset, columns, rows) {
57
+ const wanted = new Set(dataset.tableColumns || []);
58
+ const indices = columns.map((name, i) => (wanted.has(name) ? i : -1)).filter((i) => i >= 0);
59
+ if (indices.length === 0) return { columns, rows };
60
+ return {
61
+ columns: indices.map((i) => columns[i]),
62
+ rows: rows.map((row) => indices.map((i) => row[i])),
63
+ hidden: columns.length - indices.length,
64
+ };
65
+ }
66
+
67
+ const AGENT_HELP = `
68
+ AGENTS / SCRIPTING
69
+ --format json is a stable contract. The envelope is:
70
+
71
+ {
72
+ "dataset": "terminal-runs",
73
+ "columns": ["Timestamp", "User", ...], // header order
74
+ "rows": [["2026-08-05T09:55:44Z", ...], ...], // aligned to columns
75
+ "total_count": 1431, // rows matching the filters, ignoring --limit
76
+ "returned_count": 20,
77
+ "offset": 0,
78
+ "next_offset": 20, // pass as --offset for the next page
79
+ "has_more": true
80
+ }
81
+
82
+ Every cell is a string. Page with --offset next_offset until has_more is false.
83
+ Do NOT compute the next offset as offset + returned_count: the server counts
84
+ source rows, and drops some while serializing, so the two differ.
85
+
86
+ Diagnostics go to stderr; stdout carries only the payload, so it is safe to pipe.
87
+ Exit code is 0 on success and 1 on any error.
88
+
89
+ $ unbound analytics tool-use terminal --format json --limit 100 \\
90
+ | jq -r '.rows[] | @tsv'
91
+ $ unbound analytics tool-use terminal --format json \\
92
+ | jq -r '(.columns, .rows[]) | @csv'
93
+ `;
94
+
95
+ const FILTER_HELP = `
96
+ FILTERS (all accept comma-separated values)
97
+ --time-window <w> ${TIME_WINDOWS.join(' | ')}
98
+ Default ${DEFAULT_TIME_WINDOW}.
99
+ --start, --end Custom range as YYYY-MM-DD (max 90 days). Not with --time-window.
100
+ --tool <types> AI tool, e.g. CLAUDE_CODE,CURSOR,CODEX
101
+ --user <ids> User IDs (see \`unbound users list\`)
102
+ --risk <levels> Low, Medium, High
103
+ --policy <ids> Tool policy IDs (see \`unbound policy tool list\`)
104
+
105
+ OUTPUT
106
+ --format <f> table (default) | json | csv
107
+ --json Shorthand for --format json
108
+ -o, --output <file> Write to a file instead of stdout
109
+ --limit <n> Rows to show (default ${DEFAULT_LIMIT}; max ${MAX_TABLE_LIMIT} table, ${MAX_LIMIT} json)
110
+ --offset <n> Skip n rows — page through with --limit
111
+ --wide Do not clip long values to the terminal width
112
+
113
+ csv contains EVERY matching row, not just --limit. It is prepared in the
114
+ background and can take a few minutes; the command waits and then saves it.
115
+ A selection over ${MAX_EXPORT_ROWS.toLocaleString()} rows is refused up front — narrow the window or filter.
116
+ With --format csv and no -o: writes a named .csv in the current directory when
117
+ run in a terminal, or streams to stdout when piped.
118
+ If you interrupt the wait, fetch the file within ${RETENTION_HOURS} hours with
119
+ \`unbound download get <job-id>\`.
120
+ \`unbound download\` exports Logs, Tasks and Users the same way.
121
+ The table view hides the widest columns; --wide or json/csv return all of them.
122
+ `;
123
+
124
+ function requireLogin() {
125
+ if (!config.isLoggedIn()) {
126
+ throw new Error('Not logged in. Run `unbound login` first.');
127
+ }
128
+ }
129
+
130
+ function writeOut(body, destination) {
131
+ if (!destination) {
132
+ process.stdout.write(body);
133
+ return null;
134
+ }
135
+ const resolved = path.resolve(destination);
136
+ fs.writeFileSync(resolved, body, 'utf8');
137
+ return resolved;
138
+ }
139
+
140
+ async function runCsv(dataset, opts) {
141
+ // The query string is passed whole: filters repeat keys (risk_levels,
142
+ // server, ...) and an object would collapse them to the last value.
143
+ const query = buildQuery(opts, { format: 'csv' });
144
+ const job = await downloads.startExportAt(
145
+ `${EXPORT_PATH}/${dataset.key}/?${buildQueryString(query)}`);
146
+ const finished = await downloads.waitForJob(job.id, dataset.title);
147
+ await downloads.downloadJob(finished, opts.output, dataset.title);
148
+ }
149
+
150
+ async function runJsonOrTable(dataset, opts, format) {
151
+ // -o is meaningful for json and csv, which produce a file. The table is laid
152
+ // out for a terminal, so accepting -o there would report a save that is not
153
+ // one of the formats anybody wants on disk.
154
+ if (format === 'table' && opts.output) {
155
+ throw new Error('-o needs --format json or --format csv. The table view prints to the terminal.');
156
+ }
157
+ const query = buildQuery(opts, { format });
158
+ const data = await api.get(`${EXPORT_PATH}/${dataset.key}/?${buildQueryString(query)}`);
159
+
160
+ if (format === 'json') {
161
+ const payload = JSON.stringify(data, null, 2);
162
+ const written = writeOut(`${payload}\n`, opts.output);
163
+ if (written) output.success(`Wrote ${dataset.title} JSON to ${written}`);
164
+ return;
165
+ }
166
+
167
+ const rows = data.rows || [];
168
+ if (rows.length === 0) {
169
+ const total = data.total_count || 0;
170
+ const offset = data.offset || 0;
171
+ // An empty page past the end is not an empty result set, and saying "no
172
+ // activity" hides the rows that are sitting at a lower offset.
173
+ output.info(
174
+ total > 0 && offset > 0
175
+ ? `No rows at --offset ${offset}. There are ${total.toLocaleString()} in total — lower the offset.`
176
+ : `No ${dataset.title} activity for this window and filter set.`
177
+ );
178
+ return;
179
+ }
180
+
181
+ const wide = Boolean(opts.wide);
182
+ const view = wide
183
+ ? { columns: data.columns || [], rows, hidden: 0 }
184
+ : selectTableColumns(dataset, data.columns || [], rows);
185
+
186
+ output.logTable(view.columns, view.rows, { wide });
187
+
188
+ const offset = data.offset || 0;
189
+ const total = data.total_count || rows.length;
190
+ // next_offset counts source rows read, which is not offset + rows.length
191
+ // whenever the server dropped rows while serializing.
192
+ const nextOffset = data.next_offset ?? offset + rows.length;
193
+ console.log('');
194
+ if (data.has_more ?? nextOffset < total) {
195
+ output.info(
196
+ `Showing ${offset + 1}-${offset + rows.length} of ${total.toLocaleString()}. ` +
197
+ `Page with --offset ${nextOffset}, or use --format csv to export every row.`
198
+ );
199
+ } else if (offset > 0) {
200
+ // Last page, not the only page: "all" here would read as the whole result
201
+ // set while a fraction of it is on screen.
202
+ output.info(`Showing ${offset + 1}-${offset + rows.length} of ${total.toLocaleString()}. End of results.`);
203
+ } else {
204
+ output.info(`Showing all ${total.toLocaleString()} row(s).`);
205
+ }
206
+ if (view.hidden) {
207
+ output.info(`${view.hidden} more column(s) hidden — use --wide, or --format json/csv for all of them.`);
208
+ }
209
+ }
210
+
211
+ function addSharedOptions(command, { server = false, family = false, initiator = false } = {}) {
212
+ command
213
+ .option('--time-window <window>', `Preset window (default ${DEFAULT_TIME_WINDOW})`)
214
+ .option('--start <date>', 'Custom range start, YYYY-MM-DD (use with --end)')
215
+ .option('--end <date>', 'Custom range end, YYYY-MM-DD (use with --start)')
216
+ .option('--tool <types>', 'Filter by AI tool type, comma-separated')
217
+ .option('--user <ids>', 'Filter by user ID, comma-separated')
218
+ .option('--risk <levels>', 'Filter by risk level: Low, Medium, High')
219
+ .option('--policy <ids>', 'Filter by tool policy ID, comma-separated');
220
+
221
+ if (family) command.option('--family <families>', 'Filter by command family, comma-separated');
222
+ if (server) command.option('--server <names>', 'Filter by MCP service or server name, comma-separated');
223
+ if (initiator) command.option('--initiator <who>', 'Filter by initiator: user, agent, unknown');
224
+
225
+ return command
226
+ // No commander default: resolveFormat needs to tell "unset" from an
227
+ // explicit --format table, or --json would look like a contradiction.
228
+ .option('--format <format>', 'Output format: table (default), json or csv')
229
+ .option('--json', 'Shorthand for --format json')
230
+ .option('-o, --output <file>', 'Write output to a file instead of stdout')
231
+ .option('--limit <n>', `Rows to display (default ${DEFAULT_LIMIT}; max ${MAX_TABLE_LIMIT} table, ${MAX_LIMIT} json)`)
232
+ .option('--offset <n>', 'Skip n rows before displaying')
233
+ .option('--wide', 'Print full values instead of clipping to terminal width');
234
+ }
235
+
236
+ function attach(parent, name, dataset, help, flags) {
237
+ const command = parent
238
+ .command(name)
239
+ .description(dataset.summary);
240
+ addSharedOptions(command, flags);
241
+ command.addHelpText('after', help);
242
+ command.action(async (opts) => {
243
+ let format = 'table';
244
+ try {
245
+ requireLogin();
246
+ format = resolveFormat(opts);
247
+ if (format === 'csv') {
248
+ await runCsv(dataset, opts);
249
+ } else {
250
+ await runJsonOrTable(dataset, opts, format);
251
+ }
252
+ } catch (err) {
253
+ output.error(format === 'csv'
254
+ ? downloads.describeError(err, `${dataset.title} export`)
255
+ : downloads.describeError(err, `Analytics > Tool Use`, { listing: true }));
256
+ process.exitCode = 1;
257
+ }
258
+ });
259
+ return command;
260
+ }
261
+
262
+ function register(program) {
263
+ const analytics = program
264
+ .command('analytics')
265
+ .description('Read Analytics data from the command line. Mirrors the Analytics section of the dashboard.');
266
+
267
+ const toolUse = analytics
268
+ .command('tool-use')
269
+ .description('Agentic activity: terminal commands and MCP tool calls (Analytics > Tool Use).')
270
+ .addHelpText('after', `
271
+ DATASETS
272
+ terminal ${DATASETS.terminal.summary}
273
+ mcp-actions ${DATASETS['mcp-actions'].summary}
274
+ unsanctioned ${DATASETS.unsanctioned.summary}
275
+ mcp-servers ${DATASETS['mcp-servers'].summary}
276
+
277
+ Examples:
278
+ $ unbound analytics tool-use terminal
279
+ $ unbound analytics tool-use mcp-servers
280
+ $ unbound analytics tool-use mcp-actions --server claude_ai_notion
281
+ $ unbound analytics tool-use unsanctioned --format csv -o blocked.csv
282
+
283
+ Admin role required — for listing as well as CSV export.
284
+ ${FILTER_HELP}${AGENT_HELP}`);
285
+
286
+ attach(toolUse, 'terminal', DATASETS.terminal, `
287
+ Columns:
288
+ Timestamp, User, Email, AI Tool, Command Family, All Command Families,
289
+ Command, User Prompt, Risk Score, Risk Reason, Initiator, Matched Policies
290
+
291
+ Examples:
292
+ $ unbound analytics tool-use terminal
293
+ $ unbound analytics tool-use terminal --risk High --time-window SEVEN_DAYS
294
+ $ unbound analytics tool-use terminal --family git_action,delete_file --wide
295
+ $ unbound analytics tool-use terminal --tool CLAUDE_CODE --initiator agent
296
+ $ unbound analytics tool-use terminal --start 2026-07-01 --end 2026-07-31 \\
297
+ --format csv -o july-terminal-runs.csv
298
+ $ unbound analytics tool-use terminal --limit 50 --offset 50
299
+ ${FILTER_HELP}${AGENT_HELP}`, { family: true, initiator: true });
300
+
301
+ attach(toolUse, 'mcp-actions', DATASETS['mcp-actions'], `
302
+ Columns:
303
+ Timestamp, User, Email, AI Tool, MCP Server, MCP Tool, Targets,
304
+ User Prompt, Risk Score, Risk Reason, Initiator, Matched Policies
305
+
306
+ --server matches the server name case-insensitively.
307
+ Run \`unbound analytics tool-use mcp-servers\` to see the available values.
308
+
309
+ Examples:
310
+ $ unbound analytics tool-use mcp-actions
311
+ $ unbound analytics tool-use mcp-actions --server claude_ai_notion
312
+ $ unbound analytics tool-use mcp-actions --server claude_ai_notion --risk High
313
+ $ unbound analytics tool-use mcp-actions --server claude_ai_slack,claude_ai_notion --format csv -o mcp.csv
314
+ ${FILTER_HELP}${AGENT_HELP}`, { server: true, initiator: true });
315
+
316
+ attach(toolUse, 'unsanctioned', DATASETS.unsanctioned, `
317
+ Columns:
318
+ Timestamp, User, Email, AI Tool, MCP Server, MCP Tool,
319
+ Status, Sanction Message, User Prompt
320
+
321
+ Status is Blocked or Warned, matching the Unsanctioned Usage lens in the dashboard.
322
+
323
+ Examples:
324
+ $ unbound analytics tool-use unsanctioned
325
+ $ unbound analytics tool-use unsanctioned --time-window THIRTY_DAYS
326
+ $ unbound analytics tool-use unsanctioned --format csv -o unsanctioned.csv
327
+ ${FILTER_HELP}${AGENT_HELP}`, { server: true });
328
+
329
+ attach(toolUse, 'mcp-servers', DATASETS['mcp-servers'], `
330
+ Columns:
331
+ MCP Server, Actions, Average Risk Score
332
+
333
+ Use this to find the value to pass to --server on \`mcp-actions\`.
334
+
335
+ Examples:
336
+ $ unbound analytics tool-use mcp-servers
337
+ $ unbound analytics tool-use mcp-servers --limit 50
338
+ $ unbound analytics tool-use mcp-servers --format csv -o mcp-servers.csv
339
+ ${FILTER_HELP}${AGENT_HELP}`, { server: true });
340
+
341
+ return analytics;
342
+ }
343
+
344
+ module.exports = { register, DATASETS, selectTableColumns };