unbound-cli 1.13.0 → 1.15.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 +1 -1
- package/src/api.js +217 -0
- package/src/commands/analytics.js +344 -0
- package/src/commands/chat.js +140 -31
- package/src/commands/download.js +245 -0
- package/src/index.js +14 -0
- package/src/lib/analytics-query.js +216 -0
- package/src/lib/downloads.js +343 -0
- package/src/output.js +75 -3
- package/test/analytics-columns.test.js +68 -0
- package/test/analytics-export-job.test.js +141 -0
- package/test/analytics-log-table.test.js +77 -0
- package/test/analytics-paging-footer.test.js +34 -0
- package/test/analytics-query.test.js +142 -0
- package/test/chat-json-stdout.test.js +101 -12
- package/test/chat-stream.test.js +255 -0
- package/test/download-flag-collision.test.js +52 -0
- package/test/download-query.test.js +79 -0
- package/test/download-safety.test.js +201 -0
package/package.json
CHANGED
package/src/api.js
CHANGED
|
@@ -114,6 +114,221 @@ function request(method, path, { body, query, apiKey, baseUrl, timeoutMs } = {})
|
|
|
114
114
|
});
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
+
// One SSE frame -> {event, data}. Data lines are joined per the spec: the server
|
|
118
|
+
// sends single-line JSON, but a frame split across lines must not lose its tail.
|
|
119
|
+
function parseSseFrame(frame) {
|
|
120
|
+
let event = 'message';
|
|
121
|
+
const data = [];
|
|
122
|
+
for (const line of frame.split('\n')) {
|
|
123
|
+
if (line.startsWith('event:')) event = line.slice(6).trim();
|
|
124
|
+
else if (line.startsWith('data:')) data.push(line.slice(5).replace(/^ /, ''));
|
|
125
|
+
}
|
|
126
|
+
if (!data.length) return null;
|
|
127
|
+
return { event, data: JSON.parse(data.join('\n')) };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* POST that reads a text/event-stream reply, calling onEvent(name, data) per
|
|
132
|
+
* frame as it arrives.
|
|
133
|
+
*
|
|
134
|
+
* timeoutMs bounds SILENCE, not the whole response — Node's socket timeout
|
|
135
|
+
* resets on every byte. That is the property a long stream needs.
|
|
136
|
+
*
|
|
137
|
+
* Resolves null once the stream ends. A server that answers with JSON instead
|
|
138
|
+
* resolves with that parsed body, so callers work against either.
|
|
139
|
+
*/
|
|
140
|
+
function postStream(path, { body, timeoutMs, onEvent } = {}) {
|
|
141
|
+
const key = config.getApiKey();
|
|
142
|
+
if (!key) {
|
|
143
|
+
return Promise.reject(new Error('Not logged in. Run `unbound login` first.'));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const url = new URL(path, config.getBaseUrl());
|
|
147
|
+
const payload = JSON.stringify(body || {});
|
|
148
|
+
const headers = {
|
|
149
|
+
'Authorization': `Bearer ${key}`,
|
|
150
|
+
'User-Agent': USER_AGENT,
|
|
151
|
+
'Accept': 'text/event-stream',
|
|
152
|
+
'Content-Type': 'application/json',
|
|
153
|
+
'Content-Length': Buffer.byteLength(payload),
|
|
154
|
+
};
|
|
155
|
+
const transport = url.protocol === 'https:' ? https : http;
|
|
156
|
+
|
|
157
|
+
return new Promise((resolve, reject) => {
|
|
158
|
+
let settled = false;
|
|
159
|
+
const finish = (fn, value) => { if (!settled) { settled = true; fn(value); } };
|
|
160
|
+
|
|
161
|
+
const req = transport.request(url, { method: 'POST', headers }, (res) => {
|
|
162
|
+
const ok = res.statusCode >= 200 && res.statusCode < 300;
|
|
163
|
+
const isStream = String(res.headers['content-type'] || '').includes('text/event-stream');
|
|
164
|
+
|
|
165
|
+
if (!ok || !isStream) {
|
|
166
|
+
const chunks = [];
|
|
167
|
+
res.on('data', (chunk) => chunks.push(chunk));
|
|
168
|
+
res.on('end', () => {
|
|
169
|
+
const raw = Buffer.concat(chunks).toString();
|
|
170
|
+
let parsed;
|
|
171
|
+
try { parsed = JSON.parse(raw); } catch { parsed = raw; }
|
|
172
|
+
if (ok) finish(resolve, parsed);
|
|
173
|
+
else finish(reject, new ApiError(res.statusCode, parsed));
|
|
174
|
+
});
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
res.setEncoding('utf8');
|
|
179
|
+
let buffer = '';
|
|
180
|
+
let heldCr = false;
|
|
181
|
+
|
|
182
|
+
// SSE line endings may be CR, LF or CRLF, and only LF is searched for
|
|
183
|
+
// below — so normalise all three rather than stripping CR, which would
|
|
184
|
+
// collapse a CR-only stream into one unbroken line and dispatch nothing.
|
|
185
|
+
// A CRLF split across two chunks would read as two endings and cut a frame
|
|
186
|
+
// in half, so a trailing CR waits for the chunk after it.
|
|
187
|
+
const feed = (chunk, last) => {
|
|
188
|
+
let text = (heldCr ? '\r' : '') + chunk;
|
|
189
|
+
heldCr = !last && text.endsWith('\r');
|
|
190
|
+
if (heldCr) text = text.slice(0, -1);
|
|
191
|
+
buffer += text.replace(/\r\n?/g, '\n');
|
|
192
|
+
|
|
193
|
+
let boundary = buffer.indexOf('\n\n');
|
|
194
|
+
while (boundary !== -1) {
|
|
195
|
+
const frame = buffer.slice(0, boundary);
|
|
196
|
+
buffer = buffer.slice(boundary + 2);
|
|
197
|
+
let parsed;
|
|
198
|
+
try {
|
|
199
|
+
parsed = parseSseFrame(frame);
|
|
200
|
+
} catch (err) {
|
|
201
|
+
req.destroy();
|
|
202
|
+
finish(reject, new Error(
|
|
203
|
+
`Unreadable reply from ${url.host}: ${String(err.message).slice(0, 200)}`));
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
if (parsed && onEvent) onEvent(parsed.event, parsed.data);
|
|
207
|
+
boundary = buffer.indexOf('\n\n');
|
|
208
|
+
}
|
|
209
|
+
return true;
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
res.on('data', (chunk) => feed(chunk, false));
|
|
213
|
+
res.on('end', () => {
|
|
214
|
+
// A held CR is the last byte of the stream, so nothing follows it to say
|
|
215
|
+
// whether it was CRLF. Release it, or a CR-terminated final frame is
|
|
216
|
+
// never dispatched and the whole turn reads as a dropped connection.
|
|
217
|
+
if (feed('', true)) finish(resolve, null);
|
|
218
|
+
});
|
|
219
|
+
// Headers are already out by now, so a proxy or peer giving up can only
|
|
220
|
+
// cut the socket — it cannot turn into a status code.
|
|
221
|
+
res.on('error', (err) => finish(reject, new Error(
|
|
222
|
+
`The connection to ${url.host} dropped before the reply finished (${err.code || err.message}).`)));
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
const effectiveTimeout = Number.isInteger(timeoutMs) && timeoutMs > 0 ? timeoutMs : DEFAULT_TIMEOUT_MS;
|
|
226
|
+
req.setTimeout(effectiveTimeout, () => {
|
|
227
|
+
req.destroy();
|
|
228
|
+
finish(reject, new Error(`${url.host} went quiet for ${Math.round(effectiveTimeout / 1000)}s. Please try again.`));
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
req.on('error', (err) => finish(reject, new Error(`Network error: ${err.message}`)));
|
|
232
|
+
req.write(payload);
|
|
233
|
+
req.end();
|
|
234
|
+
});
|
|
235
|
+
}
|
|
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
|
+
|
|
117
332
|
// Plain unauthenticated GET to an arbitrary URL. Returns the raw response body
|
|
118
333
|
// as a UTF-8 string. Callers that want JSON must do JSON.parse() themselves.
|
|
119
334
|
// Used by `unbound oacb` to fetch baseline JSONs and hook scripts from GitHub.
|
|
@@ -167,7 +382,9 @@ async function validateApiKey(apiKey, { baseUrl, timeoutMs = 3000 } = {}) {
|
|
|
167
382
|
module.exports = {
|
|
168
383
|
ApiError,
|
|
169
384
|
get: (path, opts) => request('GET', path, opts),
|
|
385
|
+
fetchToStream,
|
|
170
386
|
post: (path, opts) => request('POST', path, opts),
|
|
387
|
+
postStream,
|
|
171
388
|
put: (path, opts) => request('PUT', path, opts),
|
|
172
389
|
del: (path, opts) => request('DELETE', path, opts),
|
|
173
390
|
getRaw,
|
|
@@ -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 '[.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 };
|