unbound-cli 1.16.20 → 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
@@ -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.20",
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 };
@@ -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);
@@ -0,0 +1,231 @@
1
+ /**
2
+ * Query building and row shaping for `unbound flags` and `unbound trace`.
3
+ * Pure functions — no network, no process state — so they are unit-testable.
4
+ */
5
+
6
+ const { parseList, resolveWindow } = require('./analytics-query');
7
+
8
+ // Outcome ids the backend's ?policy_actions= filter accepts on Terminal Runs.
9
+ const TERMINAL_ACTIONS = ['block', 'approval', 'warn', 'audit'];
10
+
11
+ // Actions a data security detection can carry (GatewayGuardrailAction.action).
12
+ const DATA_SECURITY_ACTIONS = ['block', 'redact', 'route', 'audit'];
13
+
14
+ // The backend's ?findings= values; `all` sends both.
15
+ const FINDINGS = {
16
+ real: ['real'],
17
+ 'false-positive': ['false_positive'],
18
+ all: ['real', 'false_positive'],
19
+ };
20
+
21
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
22
+
23
+ function resolveTraceId(value) {
24
+ const trace = String(value || '').trim().toLowerCase();
25
+ if (!UUID_RE.test(trace)) {
26
+ throw new Error(`Invalid trace ID "${value}". Expected a UUID such as 3f2a6c1e-8b7d-4e2f-9a1c-5d6e7f8a9b0c.`);
27
+ }
28
+ return trace;
29
+ }
30
+
31
+ function resolvePage(opts) {
32
+ if (opts.page === undefined) return 1;
33
+ const page = Number(opts.page);
34
+ if (!Number.isInteger(page) || page < 1) {
35
+ throw new Error(`Invalid --page "${opts.page}". Must be a positive integer.`);
36
+ }
37
+ return page;
38
+ }
39
+
40
+ function resolveChoices(flag, raw, allowed) {
41
+ return parseList(raw).map((value) => {
42
+ const match = allowed.find((a) => a === value.toLowerCase());
43
+ if (!match) throw new Error(`Invalid ${flag} "${value}". Must be one of: ${allowed.join(', ')}.`);
44
+ return match;
45
+ });
46
+ }
47
+
48
+ /** Query for /api/v1/analytics/terminal_commands/, narrowed to runs a policy flagged. */
49
+ function buildTerminalQuery(opts) {
50
+ const actions = resolveChoices('--action', opts.action, TERMINAL_ACTIONS);
51
+ const query = {
52
+ ...resolveWindow(opts),
53
+ page: resolvePage(opts),
54
+ // Without an outcome filter the endpoint returns every run, flagged or not.
55
+ policy_actions: actions.length ? actions : TERMINAL_ACTIONS,
56
+ };
57
+ const lists = {
58
+ policy_ids: parseList(opts.policy),
59
+ tool_types: parseList(opts.tool).map((t) => t.toUpperCase().replace(/-/g, '_')),
60
+ command_families: parseList(opts.family),
61
+ };
62
+ for (const [key, values] of Object.entries(lists)) {
63
+ if (values.length) query[key] = values;
64
+ }
65
+ if (opts.trace) query.search = resolveTraceId(opts.trace);
66
+ return query;
67
+ }
68
+
69
+ /** Query for /api/v1/analytics/guardrails/; guardrailIds are already resolved from names. */
70
+ function buildDataSecurityQuery(opts, guardrailIds = []) {
71
+ // A trace ID names one event, so it is shown whatever its finding unless --findings narrows it.
72
+ const findings = String(opts.findings || (opts.trace ? 'all' : 'real')).toLowerCase();
73
+ if (!FINDINGS[findings]) {
74
+ throw new Error(`Invalid --findings "${opts.findings}". Must be one of: ${Object.keys(FINDINGS).join(', ')}.`);
75
+ }
76
+ const query = {
77
+ ...resolveWindow(opts),
78
+ page: resolvePage(opts),
79
+ findings: FINDINGS[findings],
80
+ };
81
+ const actions = resolveChoices('--action', opts.action, DATA_SECURITY_ACTIONS);
82
+ if (actions.length) query.actions = actions;
83
+ if (guardrailIds.length) query.guardrails = guardrailIds;
84
+ if (opts.trace) query.search = resolveTraceId(opts.trace);
85
+ return query;
86
+ }
87
+
88
+ /** Map --guardrail names or IDs to IDs using /analytics/guardrails/filters/. */
89
+ function resolveGuardrailIds(raw, available) {
90
+ return parseList(raw).map((value) => {
91
+ const match = (available || []).find(
92
+ (g) => String(g.id) === value || String(g.name).toLowerCase() === value.toLowerCase());
93
+ if (!match) {
94
+ const names = (available || []).map((g) => g.name).join(', ') || 'none';
95
+ throw new Error(`Unknown --guardrail "${value}". Available: ${names}.`);
96
+ }
97
+ return String(match.id);
98
+ });
99
+ }
100
+
101
+ /** Hide a detected secret or PII value; the backend returns it in clear. */
102
+ function maskValue(value) {
103
+ const text = String(value ?? '');
104
+ if (!text) return '';
105
+ if (text.length < 10) return '****';
106
+ return `${text.slice(0, 2)}****${text.slice(-2)}`;
107
+ }
108
+
109
+ /** A data security log with every detected value masked and the raw prompt removed. */
110
+ function redactDataSecurityLog(log) {
111
+ const { prompt, sensitive_contents: contents, ...rest } = log;
112
+ return {
113
+ ...rest,
114
+ sensitive_contents: (contents || []).map(maskValue),
115
+ sensitive_content_details: (log.sensitive_content_details || []).map((d) => ({
116
+ ...d,
117
+ sensitive_content: maskValue(d.sensitive_content),
118
+ })),
119
+ };
120
+ }
121
+
122
+ /** ISO timestamp as `YYYY-MM-DD HH:MM` in UTC; anything unparseable passes through. */
123
+ function shortTime(value) {
124
+ const date = new Date(value);
125
+ return value && !Number.isNaN(date.getTime()) ? date.toISOString().slice(0, 16).replace('T', ' ') : (value || '');
126
+ }
127
+
128
+ // Free-text cells are cut before the table shares out the width, or a long
129
+ // command takes the room the ID and trace ID columns need.
130
+ function clipText(value, max, wide) {
131
+ const text = String(value ?? '');
132
+ return wide || text.length <= max ? text : `${text.slice(0, max - 1)}…`;
133
+ }
134
+
135
+ function policySummary(row) {
136
+ return (row.all_matched_policies || [])
137
+ .map((p) => (p.action ? `${p.name} (${p.action})` : p.name))
138
+ .join('; ');
139
+ }
140
+
141
+ function feedbackLabel(feedback) {
142
+ if (!feedback) return '';
143
+ if (feedback.feedback_type === 'reject') return 'False positive';
144
+ if (feedback.feedback_type === 'confirm') return 'Confirmed';
145
+ return feedback.feedback_type || '';
146
+ }
147
+
148
+ const TERMINAL_HEADERS = ['ID', 'Time (UTC)', 'User', 'Command', 'Policies', 'Outcome', 'Risk', 'Feedback', 'Trace ID'];
149
+
150
+ function terminalRows(rows, { wide = false } = {}) {
151
+ return (rows || []).map((r) => [
152
+ String(r.id),
153
+ shortTime(r.created_at),
154
+ r.email || r.user || '',
155
+ clipText(r.terminal_command || r.path || '', 40, wide),
156
+ clipText(policySummary(r), 30, wide),
157
+ r.policy_action || '',
158
+ r.risk_score ?? '',
159
+ feedbackLabel(r.user_feedback),
160
+ r.trace_id || '',
161
+ ]);
162
+ }
163
+
164
+ const DATA_SECURITY_HEADERS = ['Action ID', 'Time (UTC)', 'User', 'AI Tool', 'Guardrail', 'Action', 'Value', 'False Positive', 'Trace ID'];
165
+
166
+ /** One row per detection, because mark-fp takes a detection's action ID. */
167
+ function dataSecurityRows(logs, { reveal = false, wide = false } = {}) {
168
+ const rows = [];
169
+ for (const log of logs || []) {
170
+ const base = [shortTime(log.timestamp), log.user_email || log.user_name || '', log.application_name || ''];
171
+ const details = log.sensitive_content_details || [];
172
+ if (details.length === 0) {
173
+ // Detections with no stored value carry no action ID in the response.
174
+ rows.push(['-', ...base, (log.guardrails || []).join(', '), (log.guardrail_actions || []).join(', '),
175
+ '', log.has_false_positive ? 'yes' : '', log.trace_id || '']);
176
+ continue;
177
+ }
178
+ for (const d of details) {
179
+ rows.push([String(d.action_id), ...base, d.guardrail_name || d.entity_type || '', d.action || '',
180
+ reveal ? clipText(d.sensitive_content, 40, wide) : maskValue(d.sensitive_content),
181
+ d.is_false_positive ? 'yes' : '', log.trace_id || '']);
182
+ }
183
+ }
184
+ return rows;
185
+ }
186
+
187
+ // Prompt text on a log can hold the same secret a data security guardrail found.
188
+ const LOG_TEXT_FIELDS = ['userPrompt', 'systemPrompt', 'assistantPrompt', 'cleanedPrompt'];
189
+
190
+ /** A log with its prompt text removed. */
191
+ function redactLog(log) {
192
+ const copy = { ...log };
193
+ for (const field of LOG_TEXT_FIELDS) delete copy[field];
194
+ return copy;
195
+ }
196
+
197
+ const LOG_HEADERS = ['Log ID', 'Time (UTC)', 'User', 'AI Tool', 'Model', 'Status', 'User Prompt'];
198
+
199
+ function logRows(prompts, { reveal = false, wide = false } = {}) {
200
+ return (prompts || []).map((p) => [
201
+ p.urlKey || p.id || '',
202
+ shortTime(p.requestInitializedAt),
203
+ (p.user && (p.user.email || p.user.name)) || '',
204
+ p.toolType || '',
205
+ (p.aiModel && p.aiModel.name) || '',
206
+ p.statusCode ?? '',
207
+ reveal ? clipText(p.userPrompt || '', 60, wide) : '(hidden)',
208
+ ]);
209
+ }
210
+
211
+ module.exports = {
212
+ DATA_SECURITY_ACTIONS,
213
+ DATA_SECURITY_HEADERS,
214
+ FINDINGS,
215
+ LOG_HEADERS,
216
+ TERMINAL_ACTIONS,
217
+ TERMINAL_HEADERS,
218
+ buildDataSecurityQuery,
219
+ buildTerminalQuery,
220
+ dataSecurityRows,
221
+ feedbackLabel,
222
+ logRows,
223
+ maskValue,
224
+ redactDataSecurityLog,
225
+ redactLog,
226
+ resolveGuardrailIds,
227
+ resolvePage,
228
+ resolveTraceId,
229
+ shortTime,
230
+ terminalRows,
231
+ };
@@ -0,0 +1,236 @@
1
+ const { test } = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const fs = require('fs');
4
+ const http = require('http');
5
+ const os = require('os');
6
+ const path = require('path');
7
+ const { spawn } = require('child_process');
8
+
9
+ const {
10
+ buildDataSecurityQuery,
11
+ buildTerminalQuery,
12
+ dataSecurityRows,
13
+ maskValue,
14
+ redactDataSecurityLog,
15
+ resolveGuardrailIds,
16
+ resolveTraceId,
17
+ terminalRows,
18
+ } = require('../src/lib/flags');
19
+
20
+ const TRACE = '225c6413-26e4-4cd2-85a6-8c5ccfc3c5ef';
21
+
22
+ test('terminal query asks only for flagged runs when no --action is given', () => {
23
+ const q = buildTerminalQuery({});
24
+ assert.deepEqual(q.policy_actions, ['block', 'approval', 'warn', 'audit']);
25
+ assert.equal(q.page, 1);
26
+ assert.equal(q.time_window, 'THIRTY_DAYS');
27
+ });
28
+
29
+ test('terminal query narrows to the given outcomes, policies and trace', () => {
30
+ const q = buildTerminalQuery({ action: 'Block,warn', policy: '12,13', trace: TRACE.toUpperCase(), page: '2' });
31
+ assert.deepEqual(q.policy_actions, ['block', 'warn']);
32
+ assert.deepEqual(q.policy_ids, ['12', '13']);
33
+ assert.equal(q.search, TRACE);
34
+ assert.equal(q.page, 2);
35
+ });
36
+
37
+ test('terminal query rejects an unknown outcome, a bad page and a non-UUID trace', () => {
38
+ assert.throws(() => buildTerminalQuery({ action: 'nuke' }), /Invalid --action "nuke"/);
39
+ assert.throws(() => buildTerminalQuery({ page: '0' }), /Invalid --page/);
40
+ assert.throws(() => buildTerminalQuery({ trace: 'abc' }), /Invalid trace ID/);
41
+ });
42
+
43
+ test('data security query maps --findings to the backend values', () => {
44
+ assert.deepEqual(buildDataSecurityQuery({}).findings, ['real']);
45
+ assert.deepEqual(buildDataSecurityQuery({ findings: 'false-positive' }).findings, ['false_positive']);
46
+ assert.deepEqual(buildDataSecurityQuery({ findings: 'all' }).findings, ['real', 'false_positive']);
47
+ assert.throws(() => buildDataSecurityQuery({ findings: 'maybe' }), /Invalid --findings/);
48
+ const trace = '3f2a6c1e-8b7d-4e2f-9a1c-5d6e7f8a9b0c';
49
+ assert.deepEqual(buildDataSecurityQuery({ trace }).findings, ['real', 'false_positive']);
50
+ assert.deepEqual(buildDataSecurityQuery({ trace, findings: 'real' }).findings, ['real']);
51
+ });
52
+
53
+ test('guardrail names resolve to IDs case-insensitively, and an unknown name lists the choices', () => {
54
+ const available = [{ id: 4, name: 'PII' }, { id: 11, name: 'Secrets' }];
55
+ assert.deepEqual(resolveGuardrailIds('secrets,4', available), ['11', '4']);
56
+ assert.throws(() => resolveGuardrailIds('nope', available), /Available: PII, Secrets/);
57
+ });
58
+
59
+ test('maskValue keeps only the ends of a long value', () => {
60
+ assert.equal(maskValue('AKIAE2ETESTKEY0000001'), 'AK****01');
61
+ assert.equal(maskValue('short'), '****');
62
+ assert.equal(maskValue(''), '');
63
+ });
64
+
65
+ const LOG = {
66
+ id: 7,
67
+ trace_id: TRACE,
68
+ timestamp: '2026-09-24T19:17:53.790Z',
69
+ user_email: 'ada@example.com',
70
+ application_name: 'CLAUDE_CODE',
71
+ guardrails: ['Secrets'],
72
+ guardrail_actions: ['Block'],
73
+ sensitive_contents: ['AKIAE2ETESTKEY0000001'],
74
+ sensitive_content_details: [{
75
+ action_id: 1, action: 'Block', guardrail_name: 'Secrets', sensitive_content: 'AKIAE2ETESTKEY0000001', is_false_positive: false,
76
+ }],
77
+ prompt: { user_prompt: 'my key is AKIAE2ETESTKEY0000001' },
78
+ };
79
+
80
+ test('redacted data security JSON carries no detected value and no prompt', () => {
81
+ const text = JSON.stringify(redactDataSecurityLog(LOG));
82
+ assert.ok(!text.includes('AKIAE2ETESTKEY0000001'), text);
83
+ assert.ok(!('prompt' in redactDataSecurityLog(LOG)));
84
+ });
85
+
86
+ test('data security rows list one line per detection, masked unless revealed', () => {
87
+ const [row] = dataSecurityRows([LOG]);
88
+ assert.equal(row[0], '1');
89
+ assert.equal(row[1], '2026-09-24 19:17');
90
+ assert.equal(row[6], 'AK****01');
91
+ assert.equal(dataSecurityRows([LOG], { reveal: true })[0][6], 'AKIAE2ETESTKEY0000001');
92
+ });
93
+
94
+ test('terminal rows show policies with their action and the feedback as a label', () => {
95
+ const [row] = terminalRows([{
96
+ id: 554681,
97
+ created_at: '2026-09-24T19:17:53.790Z',
98
+ email: 'ada@example.com',
99
+ terminal_command: 'git status',
100
+ all_matched_policies: [{ name: 'Warn AWS', action: 'warn' }],
101
+ policy_action: 'warn',
102
+ risk_score: 5,
103
+ user_feedback: { feedback_type: 'reject' },
104
+ trace_id: TRACE,
105
+ }]);
106
+ assert.deepEqual(row, ['554681', '2026-09-24 19:17', 'ada@example.com', 'git status', 'Warn AWS (warn)',
107
+ 'warn', 5, 'False positive', TRACE]);
108
+ });
109
+
110
+ test('resolveTraceId lowercases and trims', () => {
111
+ assert.equal(resolveTraceId(` ${TRACE.toUpperCase()} `), TRACE);
112
+ });
113
+
114
+ // End to end through the real binary, against a fake backend that records requests.
115
+
116
+ function startServer(routes) {
117
+ const seen = [];
118
+ const server = http.createServer((req, res) => {
119
+ let body = '';
120
+ req.on('data', (c) => { body += c; });
121
+ req.on('end', () => {
122
+ const url = new URL(req.url, 'http://x');
123
+ seen.push({ method: req.method, path: url.pathname, query: url.searchParams, body: body ? JSON.parse(body) : null });
124
+ const [status, payload] = routes(req.method, url) || [404, { error: 'no route' }];
125
+ res.writeHead(status, { 'Content-Type': 'application/json' });
126
+ res.end(JSON.stringify(payload));
127
+ });
128
+ });
129
+ return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve({ server, seen, port: server.address().port })));
130
+ }
131
+
132
+ function runCli(port, args) {
133
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'flags-test-'));
134
+ fs.mkdirSync(path.join(home, '.unbound'));
135
+ fs.writeFileSync(path.join(home, '.unbound', 'config.json'),
136
+ JSON.stringify({ api_key: 'test-key', base_url: `http://127.0.0.1:${port}` }));
137
+ return new Promise((resolve) => {
138
+ const child = spawn('node', [path.join(__dirname, '..', 'src', 'index.js'), ...args], {
139
+ env: { ...process.env, HOME: home, UNBOUND_API_URL: '', UNBOUND_TELEMETRY: '0', UNBOUND_NO_UPDATE_CHECK: '1' },
140
+ });
141
+ let stdout = '';
142
+ let stderr = '';
143
+ child.stdout.on('data', (d) => { stdout += d; });
144
+ child.stderr.on('data', (d) => { stderr += d; });
145
+ child.on('close', (code) => {
146
+ fs.rmSync(home, { recursive: true, force: true });
147
+ resolve({ code, stdout, stderr });
148
+ });
149
+ });
150
+ }
151
+
152
+ test('flags terminal mark-fp posts a reject feedback for the run', async () => {
153
+ const { server, seen, port } = await startServer((method, url) =>
154
+ (method === 'POST' && url.pathname === '/api/v1/analytics/feedback/'
155
+ ? [201, { status: 'success', action: 'created', feedback_id: 3 }] : null));
156
+ try {
157
+ const r = await runCli(port, ['flags', 'terminal', 'mark-fp', '554681', '--risk-score', '8', '--comment', 'build script']);
158
+ assert.equal(r.code, 0, r.stderr);
159
+ const post = seen.find((s) => s.method === 'POST');
160
+ assert.deepEqual(post.body, {
161
+ prompt_analytics_id: 554681, analytics_type: 'terminal_command', feedback_type: 'reject', risk_score: 8, comment: 'build script',
162
+ });
163
+ } finally {
164
+ server.close();
165
+ }
166
+ });
167
+
168
+ test('flags data-security mark-fp posts the detection ID', async () => {
169
+ const { server, seen, port } = await startServer((method, url) =>
170
+ (method === 'POST' && url.pathname === '/api/v1/analytics/guardrails/mark_false_positive/'
171
+ ? [200, { message: 'ok' }] : null));
172
+ try {
173
+ const r = await runCli(port, ['flags', 'data-security', 'mark-fp', '5521']);
174
+ assert.equal(r.code, 0, r.stderr);
175
+ assert.deepEqual(seen.find((s) => s.method === 'POST').body, { guardrail_action_id: 5521 });
176
+ } finally {
177
+ server.close();
178
+ }
179
+ });
180
+
181
+ test('trace keeps only exact log matches, masks values, and exits 1 when a source fails', async () => {
182
+ const { server, port } = await startServer((method, url) => {
183
+ if (url.pathname === '/api/v1/logs/prompts/') {
184
+ return [200, { prompts: [
185
+ { id: '1', traceId: TRACE, userPrompt: 'my key is AKIAE2ETESTKEY0000001' },
186
+ { id: '2', traceId: null, userPrompt: `mentions ${TRACE}` },
187
+ ] }];
188
+ }
189
+ if (url.pathname === '/api/v1/analytics/terminal_commands/') return [403, { error: 'Manager or admin access required' }];
190
+ if (url.pathname === '/api/v1/analytics/guardrails/') {
191
+ // A trace lookup must ask for false positives too.
192
+ const all = url.searchParams.getAll('findings').join() === 'real,false_positive';
193
+ return all ? [200, { logs: { data: [LOG] } }] : [400, { error: 'findings must be all' }];
194
+ }
195
+ return null;
196
+ });
197
+ try {
198
+ const r = await runCli(port, ['trace', TRACE, '--json']);
199
+ assert.equal(r.code, 1);
200
+ const out = JSON.parse(r.stdout);
201
+ assert.deepEqual(out.logs.rows.map((p) => p.id), ['1']);
202
+ assert.equal(out.terminal_runs.error, 'Manager or admin access required');
203
+ assert.equal(out.data_security.rows.length, 1);
204
+ assert.ok(!r.stdout.includes('AKIAE2ETESTKEY0000001'), 'values must be masked without --reveal');
205
+ } finally {
206
+ server.close();
207
+ }
208
+ });
209
+
210
+ test('trace reads every page of a source and hides log prompts in the table', async () => {
211
+ const { server, seen, port } = await startServer((method, url) => {
212
+ const page = url.searchParams.get('page');
213
+ if (url.pathname === '/api/v1/logs/prompts/') {
214
+ return [200, { prompts: [{ id: '1', traceId: TRACE, userPrompt: 'my key is AKIAE2ETESTKEY0000001' }], total_pages: 1 }];
215
+ }
216
+ if (url.pathname === '/api/v1/analytics/terminal_commands/') {
217
+ // A row from another trace stands in for an older backend that ignores search.
218
+ return [200, { terminal_commands: [{ id: Number(page), trace_id: TRACE }, { id: 99, trace_id: 'other' }], has_more: page === '1' }];
219
+ }
220
+ if (url.pathname === '/api/v1/analytics/guardrails/') return [200, { logs: { data: [], total_pages: 0 } }];
221
+ return null;
222
+ });
223
+ try {
224
+ const json = await runCli(port, ['trace', TRACE, '--json']);
225
+ assert.equal(json.code, 0, json.stderr);
226
+ assert.deepEqual(JSON.parse(json.stdout).terminal_runs.rows.map((row) => row.id), [1, 2]);
227
+ assert.deepEqual(seen.filter((s) => s.path === '/api/v1/analytics/terminal_commands/').map((s) => s.query.get('page')),
228
+ ['1', '2']);
229
+ const table = await runCli(port, ['trace', TRACE]);
230
+ assert.ok(!table.stdout.includes('AKIAE2ETESTKEY0000001'), table.stdout);
231
+ const revealed = await runCli(port, ['trace', TRACE, '--reveal', '--wide']);
232
+ assert.ok(revealed.stdout.includes('AKIAE2ETESTKEY0000001'), revealed.stdout);
233
+ } finally {
234
+ server.close();
235
+ }
236
+ });