unbound-cli 1.14.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 +96 -0
- package/src/commands/analytics.js +344 -0
- 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/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
|
@@ -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 '[.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 };
|