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 +23 -1
- package/package.json +1 -1
- package/src/commands/flags.js +253 -0
- package/src/commands/onboard.js +68 -3
- package/src/commands/setup.js +8 -6
- package/src/commands/trace.js +158 -0
- package/src/index.js +11 -0
- package/src/lib/flags.js +231 -0
- package/src/toolDetect.js +239 -0
- package/test/flags.test.js +236 -0
- package/test/onboard-failure-exit.test.js +3 -0
- package/test/onboard-scope.test.js +3 -0
- package/test/tool-detect.test.js +438 -0
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
|
@@ -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 };
|
package/src/commands/onboard.js
CHANGED
|
@@ -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(
|
|
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(
|
|
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
|
}));
|
package/src/commands/setup.js
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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);
|