unbound-cli 1.16.19 → 1.16.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -32,7 +32,7 @@ unbound setup cursor
32
32
 
33
33
  ## Onboarding (one command)
34
34
 
35
- For new users, the fastest path from install to a fully-configured device is the `onboard` command. It logs you in, installs the default tool bundle (Cursor, Claude Code hooks, Codex hooks), and runs device discovery — all in one step.
35
+ For new users, the fastest path from install to a fully-configured device is the `onboard` command. It logs you in, installs the default tool bundle (Cursor, Claude Code hooks, Codex hooks), and runs device discovery — all in one step. Pi Coding Agent is also set up, after the bundle, when pi is already installed on the device, and is skipped otherwise (`unbound setup pi` sets it up regardless). A failed Pi setup does not stop discovery.
36
36
 
37
37
  ```bash
38
38
  # End user
@@ -242,6 +242,28 @@ Alias: `unbound groups` works the same as `unbound user-groups`.
242
242
 
243
243
  Supported tool types: `CLAUDE_CODE`, `CURSOR`, `COPILOT`, `AUGMENT_CODE`, `ROO_CODE`, `CLINE`, `GEMINI_CLI`, `CODEX`, `KILO_CODE`, `CUSTOM_ACCESS`
244
244
 
245
+ ### Flags and False Positives
246
+
247
+ Listing needs Admin, Manager or Analytics Viewer. `mark-fp` needs Admin.
248
+
249
+ | Command | Description |
250
+ |---------|-------------|
251
+ | `unbound flags terminal` | Terminal runs that a tool policy flagged (block, approval, warn, audit) |
252
+ | `unbound flags terminal mark-fp <id> --risk-score <n>` | Reject the run's risk score as a false positive |
253
+ | `unbound flags data-security` | PII and secrets a guardrail found (values masked; `--reveal` shows them) |
254
+ | `unbound flags data-security mark-fp <action-id>` | Mark a detection as a false positive |
255
+ | `unbound trace <trace-id>` | The log, terminal runs and data security detections for one trace ID (prompts and values hidden; `--reveal` shows them) |
256
+
257
+ ```bash
258
+ unbound flags terminal --action block,warn --time-window SEVEN_DAYS
259
+ unbound flags terminal mark-fp 81234 --risk-score 8 --comment "Our build script"
260
+ unbound flags data-security --guardrail Secrets --findings all
261
+ unbound flags data-security mark-fp 5521
262
+ unbound trace 3f2a6c1e-8b7d-4e2f-9a1c-5d6e7f8a9b0c --json
263
+ ```
264
+
265
+ Terminal `mark-fp` records the same feedback as rejecting a risk score in the dashboard. It does not hide the run. Data security `mark-fp` moves the detection to `--findings false-positive`, and the gateway then skips that value for the organization.
266
+
245
267
  ## Configuration
246
268
 
247
269
  Config is stored in `~/.unbound/config.json`. For tenant deployments, point the CLI at your own hosts in one command (recommended for new installs):
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unbound-cli",
3
- "version": "1.16.19",
3
+ "version": "1.16.21",
4
4
  "description": "CLI tool for Unbound - AI Gateway management",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -0,0 +1,253 @@
1
+ const api = require('../api');
2
+ const config = require('../config');
3
+ const output = require('../output');
4
+ const { buildQueryString, TIME_WINDOWS, DEFAULT_TIME_WINDOW } = require('../lib/analytics-query');
5
+ const {
6
+ DATA_SECURITY_ACTIONS,
7
+ DATA_SECURITY_HEADERS,
8
+ FINDINGS,
9
+ TERMINAL_ACTIONS,
10
+ TERMINAL_HEADERS,
11
+ buildDataSecurityQuery,
12
+ buildTerminalQuery,
13
+ dataSecurityRows,
14
+ redactDataSecurityLog,
15
+ resolveGuardrailIds,
16
+ terminalRows,
17
+ } = require('../lib/flags');
18
+
19
+ const TERMINAL_PATH = '/api/v1/analytics/terminal_commands/';
20
+ const DATA_SECURITY_PATH = '/api/v1/analytics/guardrails/';
21
+ const GUARDRAIL_FILTERS_PATH = '/api/v1/analytics/guardrails/filters/';
22
+ const FEEDBACK_PATH = '/api/v1/analytics/feedback/';
23
+ const MARK_FP_PATH = '/api/v1/analytics/guardrails/mark_false_positive/';
24
+
25
+ const WINDOW_HELP = `
26
+ TIME RANGE
27
+ --time-window <w> ${TIME_WINDOWS.join(' | ')}
28
+ Default ${DEFAULT_TIME_WINDOW}.
29
+ --start, --end Custom range as YYYY-MM-DD (max 90 days). Not with --time-window.
30
+ `;
31
+
32
+ function requireLogin() {
33
+ if (!config.isLoggedIn()) {
34
+ throw new Error('Not logged in. Run `unbound login` first.');
35
+ }
36
+ }
37
+
38
+ async function run(fn) {
39
+ try {
40
+ requireLogin();
41
+ await fn();
42
+ } catch (err) {
43
+ output.error(err.message);
44
+ process.exitCode = 1;
45
+ }
46
+ }
47
+
48
+ function addWindowOptions(command) {
49
+ return command
50
+ .option('--time-window <window>', `Preset window (default ${DEFAULT_TIME_WINDOW})`)
51
+ .option('--start <date>', 'Custom range start, YYYY-MM-DD (use with --end)')
52
+ .option('--end <date>', 'Custom range end, YYYY-MM-DD (use with --start)');
53
+ }
54
+
55
+ function pageFooter(page, totalPages, total, noun, hasMore) {
56
+ console.log('');
57
+ if (total !== undefined) {
58
+ output.info(`Page ${page} of ${Math.max(totalPages || 1, 1)} (${Number(total).toLocaleString()} ${noun}).`);
59
+ }
60
+ if (hasMore) output.info(`Next page: --page ${page + 1}`);
61
+ }
62
+
63
+ function parsePositiveInt(flag, value) {
64
+ const n = Number(value);
65
+ if (!Number.isInteger(n) || n < 1) throw new Error(`Invalid ${flag} "${value}". Must be a positive integer.`);
66
+ return n;
67
+ }
68
+
69
+ async function listTerminal(opts) {
70
+ const query = buildTerminalQuery(opts);
71
+ const data = await api.get(`${TERMINAL_PATH}?${buildQueryString(query)}`);
72
+ if (opts.json) {
73
+ output.json(data);
74
+ return;
75
+ }
76
+ const rows = data.terminal_commands || [];
77
+ if (rows.length === 0) {
78
+ output.info('No flagged terminal runs for this window and filter set.');
79
+ return;
80
+ }
81
+ output.logTable(TERMINAL_HEADERS, terminalRows(rows, { wide: Boolean(opts.wide) }), { wide: Boolean(opts.wide) });
82
+ pageFooter(query.page, data.total_pages, data.total_count, 'flagged runs', data.has_more);
83
+ output.info('Mark one as a false positive: unbound flags terminal mark-fp <ID> --risk-score <Risk>');
84
+ }
85
+
86
+ async function markTerminalFalsePositive(id, opts) {
87
+ const riskScore = Number(opts.riskScore);
88
+ if (!Number.isInteger(riskScore) || riskScore < 1 || riskScore > 10) {
89
+ throw new Error(`Invalid --risk-score "${opts.riskScore}". Must be an integer from 1 to 10 (the Risk column).`);
90
+ }
91
+ const body = {
92
+ prompt_analytics_id: parsePositiveInt('ID', id),
93
+ analytics_type: 'terminal_command',
94
+ feedback_type: 'reject',
95
+ risk_score: riskScore,
96
+ };
97
+ if (opts.comment !== undefined) body.comment = opts.comment;
98
+ const data = await api.post(FEEDBACK_PATH, { body });
99
+ if (opts.json) {
100
+ output.json(data);
101
+ return;
102
+ }
103
+ output.success(`Terminal run ${id} marked as a false positive (feedback ${data.action || 'saved'}).`);
104
+ }
105
+
106
+ async function listDataSecurity(opts) {
107
+ let guardrailIds = [];
108
+ if (opts.guardrail) {
109
+ const filters = await api.get(GUARDRAIL_FILTERS_PATH);
110
+ guardrailIds = resolveGuardrailIds(opts.guardrail, filters.guardrails);
111
+ }
112
+ const query = buildDataSecurityQuery(opts, guardrailIds);
113
+ const data = await api.get(`${DATA_SECURITY_PATH}?${buildQueryString(query)}`);
114
+ const logs = (data.logs && data.logs.data) || [];
115
+ if (opts.json) {
116
+ const payload = opts.reveal ? data : { ...data, logs: { ...data.logs, data: logs.map(redactDataSecurityLog) } };
117
+ output.json(payload);
118
+ return;
119
+ }
120
+ if (logs.length === 0) {
121
+ output.info('No data security flags for this window and filter set.');
122
+ return;
123
+ }
124
+ output.logTable(DATA_SECURITY_HEADERS, dataSecurityRows(logs, { reveal: Boolean(opts.reveal), wide: Boolean(opts.wide) }),
125
+ { wide: Boolean(opts.wide) });
126
+ const totalPages = data.logs.total_pages;
127
+ pageFooter(query.page, totalPages, data.logs.total_items, 'flagged requests', query.page < (totalPages || 0));
128
+ if (!opts.reveal) output.info('Detected values are masked. Use --reveal to show them.');
129
+ output.info('Mark one as a false positive: unbound flags data-security mark-fp <Action ID>');
130
+ }
131
+
132
+ async function markDataSecurityFalsePositive(id, opts) {
133
+ const data = await api.post(MARK_FP_PATH, { body: { guardrail_action_id: parsePositiveInt('Action ID', id) } });
134
+ if (opts.json) {
135
+ output.json(data);
136
+ return;
137
+ }
138
+ output.success(`Data security detection ${id} marked as a false positive.`);
139
+ }
140
+
141
+ function register(program) {
142
+ const flags = program
143
+ .command('flags')
144
+ .description('Review what your policies flagged, and mark false positives.')
145
+ .addHelpText('after', `
146
+ KINDS
147
+ terminal Terminal commands that matched a tool policy (block, approval, warn, audit)
148
+ data-security Prompts where a data security guardrail found PII or a secret
149
+
150
+ Examples:
151
+ $ unbound flags terminal
152
+ $ unbound flags terminal --action block --time-window SEVEN_DAYS
153
+ $ unbound flags terminal mark-fp 81234 --risk-score 8 --comment "Expected cleanup script"
154
+ $ unbound flags data-security --guardrail Secrets
155
+ $ unbound flags data-security mark-fp 5521
156
+
157
+ Search every source for one trace ID with \`unbound trace <trace-id>\`.
158
+ `);
159
+
160
+ const terminal = flags
161
+ .command('terminal')
162
+ .description('List terminal runs that a tool policy flagged (Analytics > Tool Use > Terminal Runs).')
163
+ .option('--action <actions>', `Policy outcome, comma-separated: ${TERMINAL_ACTIONS.join(', ')} (default: all)`)
164
+ .option('--policy <ids>', 'Tool policy IDs, comma-separated (see `unbound policy tool list`)')
165
+ .option('--trace <trace-id>', 'Only the run with this gateway trace ID')
166
+ .option('--tool <types>', 'AI tool type, comma-separated, e.g. CLAUDE_CODE,CURSOR')
167
+ .option('--family <families>', 'Command family, comma-separated')
168
+ .option('--page <n>', 'Page number (10 runs per page)')
169
+ .option('--json', 'Output the raw JSON response')
170
+ .option('--wide', 'Print full values instead of clipping to terminal width');
171
+ addWindowOptions(terminal)
172
+ .addHelpText('after', `
173
+ Columns:
174
+ ID The run's ID. Pass it to \`mark-fp\`.
175
+ Outcome The strongest action across the matched policies.
176
+ Risk The risk score. \`mark-fp\` records it with the dispute.
177
+ Feedback Your earlier feedback on this run, if any.
178
+ ${WINDOW_HELP}
179
+ Examples:
180
+ $ unbound flags terminal
181
+ $ unbound flags terminal --action block,warn --policy 12
182
+ $ unbound flags terminal --trace 3f2a6c1e-8b7d-4e2f-9a1c-5d6e7f8a9b0c
183
+ $ unbound flags terminal --page 2 --json
184
+
185
+ Admin, Manager or Analytics Viewer.
186
+ `)
187
+ .action((opts) => run(() => listTerminal(opts)));
188
+
189
+ terminal
190
+ .command('mark-fp <id>')
191
+ .description('Mark a flagged terminal run as a false positive (reject its risk score).')
192
+ .requiredOption('--risk-score <n>', 'The Risk value shown for the run (1-10)')
193
+ .option('--comment <text>', 'Why this run is a false positive')
194
+ .option('--json', 'Output the raw JSON response')
195
+ .addHelpText('after', `
196
+ Records your feedback that the run was not risky. This is the same feedback as
197
+ rejecting a risk score in the dashboard. It does not hide the run or change the
198
+ policy. Running it again updates your earlier feedback.
199
+
200
+ Examples:
201
+ $ unbound flags terminal mark-fp 81234 --risk-score 8
202
+ $ unbound flags terminal mark-fp 81234 --risk-score 8 --comment "Our build script"
203
+
204
+ Admin only.
205
+ `)
206
+ .action((id, opts) => run(() => markTerminalFalsePositive(id, opts)));
207
+
208
+ const dataSecurity = flags
209
+ .command('data-security')
210
+ .description('List prompts where a data security guardrail found PII or a secret (Analytics > Data Security).')
211
+ .option('--guardrail <names>', 'Guardrail names or IDs, comma-separated, e.g. PII,Secrets')
212
+ .option('--action <actions>', `Action taken, comma-separated: ${DATA_SECURITY_ACTIONS.join(', ')}`)
213
+ .option('--findings <which>', `${Object.keys(FINDINGS).join(' | ')} (default real)`)
214
+ .option('--trace <trace-id>', 'Only the request with this gateway trace ID (default --findings all)')
215
+ .option('--reveal', 'Show detected values and prompts in clear text')
216
+ .option('--page <n>', 'Page number (10 requests per page)')
217
+ .option('--json', 'Output the JSON response (values masked unless --reveal)')
218
+ .option('--wide', 'Print full values instead of clipping to terminal width');
219
+ addWindowOptions(dataSecurity)
220
+ .addHelpText('after', `
221
+ Columns:
222
+ Action ID The detection's ID. Pass it to \`mark-fp\`.
223
+ Value The detected value, masked unless --reveal.
224
+ ${WINDOW_HELP}
225
+ Examples:
226
+ $ unbound flags data-security
227
+ $ unbound flags data-security --guardrail Secrets --action block
228
+ $ unbound flags data-security --findings false-positive
229
+ $ unbound flags data-security --trace 3f2a6c1e-8b7d-4e2f-9a1c-5d6e7f8a9b0c --reveal
230
+
231
+ Admin, Manager or Analytics Viewer.
232
+ `)
233
+ .action((opts) => run(() => listDataSecurity(opts)));
234
+
235
+ dataSecurity
236
+ .command('mark-fp <action-id>')
237
+ .description('Mark a data security detection as a false positive.')
238
+ .option('--json', 'Output the raw JSON response')
239
+ .addHelpText('after', `
240
+ The detection moves to --findings false-positive. The gateway then skips the
241
+ same value for everyone in your organization.
242
+
243
+ Examples:
244
+ $ unbound flags data-security mark-fp 5521
245
+
246
+ Admin only.
247
+ `)
248
+ .action((id, opts) => run(() => markDataSecurityFalsePositive(id, opts)));
249
+
250
+ return flags;
251
+ }
252
+
253
+ module.exports = { register };
@@ -3,8 +3,9 @@ const config = require('../config');
3
3
  const output = require('../output');
4
4
  const telemetry = require('../telemetry');
5
5
  const { ensureLoggedIn, fetchDeviceOwnerKey } = require('../auth');
6
- const { runSetupAllBundle, runMdmSetupAllBundle, hasRootPrivileges, elevated, scopeNotice, ALL_TOOLS, MDM_ALL_TOOLS } = require('./setup');
6
+ const { runSetupAllBundle, runMdmSetupAllBundle, hasRootPrivileges, elevated, scopeNotice, ALL_TOOLS, MDM_ALL_TOOLS, SETUP_TOOL_MAP, MDM_TOOLS } = require('./setup');
7
7
  const { runDiscoveryScan } = require('./discover');
8
+ const toolDetect = require('../toolDetect');
8
9
 
9
10
  /**
10
11
  * Builds the recovery-command suffix for partial-failure hints.
@@ -17,6 +18,34 @@ function domainHintSuffix(domain) {
17
18
  return ` --domain ${domain}`;
18
19
  }
19
20
 
21
+ /**
22
+ * Whether onboarding should also set up Pi: only when pi is present on the
23
+ * device. Pi is the one tool onboarding installs only where it is already in
24
+ * use. A check that could not be completed counts as detected, so a device is
25
+ * never left ungoverned because the check itself broke.
26
+ */
27
+ function shouldSetUpPi(label, { allUsers }) {
28
+ let detected;
29
+ try {
30
+ detected = toolDetect.detectInstalledTools(['pi'], { allUsers }).detected.includes('pi');
31
+ } catch (err) {
32
+ telemetry.captureError(err, { tags: { flow: 'onboard-detect' } });
33
+ output.warn(`Could not check whether ${label} is installed — setting it up anyway.`);
34
+ return true;
35
+ }
36
+ if (!detected) {
37
+ console.log(output.colors.dim(` ${label} not detected — skipped (run \`unbound setup pi\` to set it up regardless)`));
38
+ return false;
39
+ }
40
+ output.info(`${label} detected — setting it up after the bundle`);
41
+ return true;
42
+ }
43
+
44
+ /** The command that retries a failed Pi setup in this scope. */
45
+ function piRetryCommand(isMdm) {
46
+ return isMdm ? elevated('unbound setup --api-key <ADMIN_KEY> pi') : 'unbound setup pi';
47
+ }
48
+
20
49
  function register(program) {
21
50
  const onboard = program
22
51
  .command('onboard')
@@ -39,6 +68,8 @@ Runs the full onboarding flow, auto-detecting scope from your privileges:
39
68
  With sudo, installs the MDM tool bundle (${MDM_ALL_TOOLS.join(', ')}) and scans
40
69
  every user on the device. Without sudo, installs the user bundle
41
70
  (${ALL_TOOLS.join(', ')}) and scans just the current user.
71
+ Pi Coding Agent (pi) is also set up, after the bundle, only when pi is detected
72
+ on the device; use \`unbound setup pi\` to set it up regardless.
42
73
 
43
74
  1. Logs in / resolves the API key (or reuses a stored \`unbound login\` key
44
75
  when --api-key is omitted).
@@ -82,6 +113,7 @@ Examples:
82
113
  let discoverySucceeded = false;
83
114
  let discoveryDomain;
84
115
  let skippedTools;
116
+ let piFailed = false;
85
117
  let mdmScanKey; // device-owner key resolved from the serial (MDM scope only)
86
118
  try {
87
119
  // Persist URLs first, then login, then setup — order matters so the
@@ -120,6 +152,7 @@ Examples:
120
152
 
121
153
  console.log('');
122
154
  output.info('Step 1/2: Installing MDM tool bundle (all users)');
155
+ const setUpPi = shouldSetUpPi(MDM_TOOLS.pi.label, { allUsers: true });
123
156
  const { ok, skipped } = await runMdmSetupAllBundle(adminApiKey, {
124
157
  backendUrl, frontendUrl, gatewayUrl, backfill: !!opts.backfill,
125
158
  });
@@ -128,6 +161,14 @@ Examples:
128
161
  // non-zero exit explicit here so onboard never returns 0 on failure.
129
162
  if (!ok) { process.exitCode = 1; return; }
130
163
  setupSucceeded = true;
164
+ // Pi runs on its own so a failure there cannot cost the device its
165
+ // bundle, its discovery scan or its scheduled run.
166
+ if (setUpPi) {
167
+ const pi = await runMdmSetupAllBundle(adminApiKey, {
168
+ backendUrl, frontendUrl, gatewayUrl, tools: ['pi'],
169
+ });
170
+ piFailed = !pi.ok;
171
+ }
131
172
 
132
173
  console.log('');
133
174
  output.info('Step 2/2: Running device discovery');
@@ -145,6 +186,7 @@ Examples:
145
186
 
146
187
  console.log('');
147
188
  output.info('Step 1/2: Installing tool bundle');
189
+ const setUpPi = shouldSetUpPi(SETUP_TOOL_MAP.pi.label, { allUsers: false });
148
190
  const { ok, skipped } = await runSetupAllBundle(apiKey, {
149
191
  backendUrl, frontendUrl, gatewayUrl, backfill: !!opts.backfill,
150
192
  });
@@ -153,6 +195,14 @@ Examples:
153
195
  // non-zero exit explicit here so onboard never returns 0 on failure.
154
196
  if (!ok) { process.exitCode = 1; return; }
155
197
  setupSucceeded = true;
198
+ // Pi runs on its own so a failure there cannot cost the device its
199
+ // bundle, its discovery scan or its scheduled run.
200
+ if (setUpPi) {
201
+ const pi = await runSetupAllBundle(apiKey, {
202
+ backendUrl, frontendUrl, gatewayUrl, tools: ['pi'],
203
+ });
204
+ piFailed = !pi.ok;
205
+ }
156
206
 
157
207
  console.log('');
158
208
  output.info('Step 2/2: Running device discovery');
@@ -195,6 +245,12 @@ Examples:
195
245
  }
196
246
 
197
247
  console.log('');
248
+ if (piFailed) {
249
+ // Everything else is done, but a failed setup must NOT report onboarding success.
250
+ output.error(`Onboarding finished, but ${SETUP_TOOL_MAP.pi.label} setup failed (see above). Retry it with: ${piRetryCommand(isMdm)}`);
251
+ process.exitCode = 1;
252
+ return;
253
+ }
198
254
  output.success(skippedTools && skippedTools.length
199
255
  ? 'Onboarding complete — tools managed by MDM were skipped (see above)'
200
256
  : 'Onboarding complete');
@@ -209,20 +265,29 @@ Examples:
209
265
  // step failed. Retry just the cron: under sudo the scheduled job is a
210
266
  // discovery-only scan, so point at `discover --set-cron`; in user
211
267
  // scope it re-runs the full onboard (the bundle reinstall is intended).
212
- console.error(' Setup and discovery completed successfully — only scheduled-run setup failed.');
268
+ console.error(piFailed
269
+ ? ' The tool bundle and discovery completed — scheduled-run setup failed.'
270
+ : ' Setup and discovery completed successfully — only scheduled-run setup failed.');
213
271
  if (isMdm) {
214
272
  console.error(` Re-run with: ${elevated(`unbound onboard --api-key <ADMIN_KEY> --set-cron${suffix}`)}`);
215
273
  } else {
216
274
  console.error(` Re-run cron setup with: unbound onboard --api-key <KEY> --set-cron${suffix}`);
217
275
  }
218
276
  } else if (setupSucceeded && !discoverySucceeded) {
219
- console.error(' Tool setup completed successfully — only discovery failed.');
277
+ console.error(piFailed
278
+ ? ' The tool bundle is set up — discovery failed.'
279
+ : ' Tool setup completed successfully — only discovery failed.');
220
280
  if (isMdm) {
221
281
  console.error(` Re-run onboarding with: ${elevated(`unbound onboard --api-key <ADMIN_KEY>${suffix}`)}`);
222
282
  } else {
223
283
  console.error(` Re-run discovery only with: unbound discover --api-key <KEY>${suffix}`);
224
284
  }
225
285
  }
286
+ // A later failure must not bury an earlier one: the hints above retry
287
+ // discovery or the schedule, and neither of those retries Pi.
288
+ if (piFailed) {
289
+ console.error(` ${SETUP_TOOL_MAP.pi.label} setup also failed (see above). Retry it with: ${piRetryCommand(isMdm)}`);
290
+ }
226
291
  process.exitCode = 1;
227
292
  }
228
293
  }));
@@ -1289,12 +1289,13 @@ Examples:
1289
1289
  }
1290
1290
 
1291
1291
  /**
1292
- * Runs the user-level default bundle (Cursor, Claude Code hooks, Codex hooks, Copilot hooks) with spinners.
1292
+ * Runs the user-level default bundle (Cursor, Claude Code hooks, Codex hooks, Copilot hooks) with spinners,
1293
+ * or just `tools` when the caller has narrowed it.
1293
1294
  * Assumes the caller has already ensured the user is logged in.
1294
1295
  * Returns true on success, false on failure.
1295
1296
  */
1296
- async function runSetupAllBundle(apiKey, { backendUrl, frontendUrl, gatewayUrl, backfill = false } = {}) {
1297
- const resolvedTools = ALL_TOOLS.map(name => ({ name, ...SETUP_TOOL_MAP[name] }));
1297
+ async function runSetupAllBundle(apiKey, { backendUrl, frontendUrl, gatewayUrl, backfill = false, tools = ALL_TOOLS } = {}) {
1298
+ const resolvedTools = tools.map(name => ({ name, ...SETUP_TOOL_MAP[name] }));
1298
1299
  if (backfill) {
1299
1300
  for (const tool of resolvedTools) {
1300
1301
  if (!scriptSupportsBackfill(tool.script)) noteBackfillUnsupported(tool.label, tool.script);
@@ -1315,12 +1316,13 @@ async function runSetupAllBundle(apiKey, { backendUrl, frontendUrl, gatewayUrl,
1315
1316
  }
1316
1317
 
1317
1318
  /**
1318
- * Runs the MDM default bundle (Cursor, Claude Code hooks, Gemini CLI, Codex hooks, Copilot hooks) with spinners.
1319
+ * Runs the MDM default bundle (Cursor, Claude Code hooks, Gemini CLI, Codex hooks, Copilot hooks) with spinners,
1320
+ * or just `tools` when the caller has narrowed it.
1319
1321
  * Caller must ensure the process is running as root.
1320
1322
  * Returns true on success, false on failure.
1321
1323
  */
1322
- async function runMdmSetupAllBundle(adminApiKey, { backendUrl, frontendUrl, gatewayUrl, backfill = false } = {}) {
1323
- const resolvedTools = MDM_ALL_TOOLS.map(name => ({ name, ...MDM_TOOLS[name] }));
1324
+ async function runMdmSetupAllBundle(adminApiKey, { backendUrl, frontendUrl, gatewayUrl, backfill = false, tools = MDM_ALL_TOOLS } = {}) {
1325
+ const resolvedTools = tools.map(name => ({ name, ...MDM_TOOLS[name] }));
1324
1326
  if (backfill) {
1325
1327
  for (const tool of resolvedTools) {
1326
1328
  if (!scriptSupportsBackfill(tool.script)) noteBackfillUnsupported(tool.label, tool.script);
@@ -0,0 +1,158 @@
1
+ const api = require('../api');
2
+ const config = require('../config');
3
+ const output = require('../output');
4
+ const { buildQueryString } = require('../lib/analytics-query');
5
+ const {
6
+ DATA_SECURITY_HEADERS,
7
+ FINDINGS,
8
+ LOG_HEADERS,
9
+ TERMINAL_HEADERS,
10
+ dataSecurityRows,
11
+ logRows,
12
+ redactDataSecurityLog,
13
+ redactLog,
14
+ resolveTraceId,
15
+ terminalRows,
16
+ } = require('../lib/flags');
17
+
18
+ // The widest preset the analytics endpoints accept; Logs takes no window.
19
+ const TRACE_WINDOW = 'THREE_MONTHS';
20
+
21
+ // One trace rarely fills a page; past this cap the result is reported as incomplete.
22
+ const MAX_PAGES = 10;
23
+
24
+ const sameTrace = (value, trace) => String(value || '').toLowerCase() === trace;
25
+
26
+ const SOURCES = [
27
+ {
28
+ key: 'logs',
29
+ title: 'Logs',
30
+ path: (trace, page) => `/api/v1/logs/prompts/?${buildQueryString({ search: trace, page })}`,
31
+ // Logs search also matches the text of a prompt, so keep only the exact trace.
32
+ rows: (data, trace) => (data.prompts || []).filter((p) => sameTrace(p.traceId, trace)),
33
+ more: (data, page) => page < (data.total_pages || 0),
34
+ headers: LOG_HEADERS,
35
+ toTable: (rows, opts) => logRows(rows, { reveal: Boolean(opts.reveal), wide: Boolean(opts.wide) }),
36
+ redact: (rows) => rows.map(redactLog),
37
+ },
38
+ {
39
+ key: 'terminal_runs',
40
+ title: 'Terminal Runs',
41
+ path: (trace, page) => `/api/v1/analytics/terminal_commands/?${buildQueryString({ search: trace, time_window: TRACE_WINDOW, page })}`,
42
+ // The other sources match the trace exactly today; the filter guards an older backend that ignores search.
43
+ rows: (data, trace) => (data.terminal_commands || []).filter((r) => sameTrace(r.trace_id, trace)),
44
+ more: (data) => Boolean(data.has_more),
45
+ headers: TERMINAL_HEADERS,
46
+ toTable: (rows, opts) => terminalRows(rows, { wide: Boolean(opts.wide) }),
47
+ },
48
+ {
49
+ key: 'data_security',
50
+ title: 'Data Security',
51
+ path: (trace, page) => `/api/v1/analytics/guardrails/?${buildQueryString({ search: trace, time_window: TRACE_WINDOW, findings: FINDINGS.all, page })}`,
52
+ rows: (data, trace) => ((data.logs && data.logs.data) || []).filter((l) => sameTrace(l.trace_id, trace)),
53
+ more: (data, page) => page < ((data.logs && data.logs.total_pages) || 0),
54
+ headers: DATA_SECURITY_HEADERS,
55
+ toTable: (rows, opts) => dataSecurityRows(rows, { reveal: Boolean(opts.reveal), wide: Boolean(opts.wide) }),
56
+ redact: (rows) => rows.map(redactDataSecurityLog),
57
+ },
58
+ ];
59
+
60
+ async function fetchAll(source, trace) {
61
+ const rows = [];
62
+ for (let page = 1; page <= MAX_PAGES; page += 1) {
63
+ const data = await api.get(source.path(trace, page));
64
+ rows.push(...source.rows(data, trace));
65
+ if (!source.more(data, page)) return { source, rows, incomplete: false };
66
+ }
67
+ return { source, rows, incomplete: true };
68
+ }
69
+
70
+ async function lookup(trace) {
71
+ return Promise.all(SOURCES.map(async (source) => {
72
+ try {
73
+ return await fetchAll(source, trace);
74
+ } catch (err) {
75
+ return { source, error: err.message };
76
+ }
77
+ }));
78
+ }
79
+
80
+ function printJson(trace, results, opts) {
81
+ const payload = { trace_id: trace };
82
+ for (const { source, rows, error, incomplete } of results) {
83
+ if (error) {
84
+ payload[source.key] = { error };
85
+ } else {
86
+ payload[source.key] = { rows: source.redact && !opts.reveal ? source.redact(rows) : rows };
87
+ if (incomplete) payload[source.key].incomplete = true;
88
+ }
89
+ }
90
+ output.json(payload);
91
+ }
92
+
93
+ function printTables(trace, results, opts) {
94
+ for (const { source, rows, error, incomplete } of results) {
95
+ console.log('');
96
+ console.log(output.colors.bold(`${source.title}`));
97
+ if (error) {
98
+ output.warn(`${source.title} lookup failed: ${error}`);
99
+ } else if (rows.length === 0) {
100
+ output.info(`No ${source.title} entries for trace ${trace}.`);
101
+ } else {
102
+ output.logTable(source.headers, source.toTable(rows, opts), { wide: Boolean(opts.wide) });
103
+ }
104
+ if (incomplete) output.warn(`${source.title}: more than ${MAX_PAGES} pages matched. Only the first ${MAX_PAGES} are shown.`);
105
+ }
106
+ if (!opts.reveal) {
107
+ console.log('');
108
+ output.info('Prompts and detected values are hidden. Use --reveal to show them.');
109
+ }
110
+ }
111
+
112
+ async function runTrace(value, opts) {
113
+ try {
114
+ if (!config.isLoggedIn()) throw new Error('Not logged in. Run `unbound login` first.');
115
+ const trace = resolveTraceId(value);
116
+ const results = await lookup(trace);
117
+ if (opts.json) {
118
+ printJson(trace, results, opts);
119
+ } else {
120
+ printTables(trace, results, opts);
121
+ }
122
+ // A failed or cut-off source means the answer is incomplete, so scripts must not read it as complete.
123
+ if (results.some((r) => r.error || r.incomplete)) process.exitCode = 1;
124
+ } catch (err) {
125
+ output.error(err.message);
126
+ process.exitCode = 1;
127
+ }
128
+ }
129
+
130
+ function register(program) {
131
+ program
132
+ .command('trace <trace-id>')
133
+ .description('Find everything recorded for one gateway trace ID: the log, terminal runs and data security flags.')
134
+ .option('--json', 'Output one JSON object with a key per source')
135
+ .option('--reveal', 'Show prompts and detected data security values in clear text')
136
+ .option('--wide', 'Print full values instead of clipping to terminal width')
137
+ .addHelpText('after', `
138
+ The trace ID is the UUID shown to a user under a blocked or warned action.
139
+
140
+ Sources:
141
+ Logs The request and its prompt (any age)
142
+ Terminal Runs Tool calls on the request, flagged or not (last 3 months)
143
+ Data Security PII or secret detections on the request (last 3 months)
144
+
145
+ Prompts and detected values are hidden unless --reveal is given.
146
+
147
+ The exit code is 1 when a source cannot be read, for example because your
148
+ role cannot see it, or when a source has more than ${MAX_PAGES} pages. The
149
+ other sources still print.
150
+
151
+ Examples:
152
+ $ unbound trace 3f2a6c1e-8b7d-4e2f-9a1c-5d6e7f8a9b0c
153
+ $ unbound trace 3f2a6c1e-8b7d-4e2f-9a1c-5d6e7f8a9b0c --json | jq '.terminal_runs.rows[0]'
154
+ `)
155
+ .action(runTrace);
156
+ }
157
+
158
+ module.exports = { register, SOURCES };
package/src/index.js CHANGED
@@ -164,6 +164,15 @@ ANALYTICS (Admin/Analytics Viewer only, for listing as well as CSV export)
164
164
  csv exports every matching row, not just the page.
165
165
  See "unbound analytics tool-use --help" for the JSON contract used by agents.
166
166
 
167
+ FLAGS AND FALSE POSITIVES (Admin/Analytics Viewer/Manager; mark-fp is Admin only)
168
+ $ unbound flags terminal Terminal runs a tool policy flagged
169
+ $ unbound flags terminal mark-fp <id> --risk-score <n>
170
+ Mark a flagged run as a false positive
171
+ $ unbound flags data-security PII and secrets a guardrail found (values masked)
172
+ $ unbound flags data-security mark-fp <action-id>
173
+ Mark a detection as a false positive
174
+ $ unbound trace <trace-id> Log, terminal runs and data security for one trace ID
175
+
167
176
  CHAT (Admin/Analytics Viewer/Manager)
168
177
  $ unbound chat Interactive REPL (multi-turn, in-memory history)
169
178
  $ unbound chat -m "show cost by provider for the last 30 days"
@@ -206,6 +215,8 @@ require('./commands/users').register(program);
206
215
  require('./commands/user-groups').register(program);
207
216
  require('./commands/tools').register(program);
208
217
  require('./commands/analytics').register(program);
218
+ require('./commands/flags').register(program);
219
+ require('./commands/trace').register(program);
209
220
  require('./commands/download').register(program);
210
221
  require('./commands/setup').register(program);
211
222
  require('./commands/discover').register(program);