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.
@@ -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,239 @@
1
+ // Detects whether an AI coding tool is actually present on this device, so
2
+ // `unbound onboard` can set a tool up only where it is in use. Pi is the only
3
+ // tool gated on detection today; every other tool has no signals here and is
4
+ // always treated as installed.
5
+ //
6
+ // A tool counts as installed when ANY of its signals is found: its CLI binary,
7
+ // or a file the tool writes for itself. Detection deliberately leans towards
8
+ // "installed": a false positive only installs an unused extension, while a
9
+ // false negative leaves a tool ungoverned. For the same reason detection
10
+ // throws, instead of answering "not installed", when it found nothing but
11
+ // could not inspect some place it needed to.
12
+ //
13
+ // The one thing a signal must never be is a path Unbound's own setup scripts
14
+ // create (~/.pi/agent/extensions/unbound/). That would make pi look installed
15
+ // on any device that was onboarded before, so the markers below are files only
16
+ // pi itself writes.
17
+
18
+ const fs = require('fs');
19
+ const os = require('os');
20
+ const path = require('path');
21
+ const childProcess = require('child_process');
22
+
23
+ // Where a per-user CLI lands, relative to a home. PATH alone is not enough:
24
+ // under sudo and under the scheduled run it is a minimal system PATH that
25
+ // lacks these. `.pi/agent/bin` is where pi's own installer puts its binary.
26
+ const HOME_BIN_DIRS = [
27
+ '.pi/agent/bin',
28
+ '.local/bin',
29
+ '.bun/bin',
30
+ '.npm-global/bin',
31
+ '.volta/bin',
32
+ '.yarn/bin',
33
+ 'AppData/Roaming/npm',
34
+ 'AppData/Local/Microsoft/WinGet/Links',
35
+ ];
36
+
37
+ const TOOL_SIGNALS = {
38
+ 'pi': {
39
+ bins: ['pi'],
40
+ markers: ['.pi/agent/auth.json', '.pi/agent/sessions'],
41
+ },
42
+ };
43
+
44
+ // Directories under the users root that are not accounts. Linux has no entry:
45
+ // a user named Shared or Guest there is a real one.
46
+ const SKIP_HOME_NAMES = {
47
+ darwin: ['Shared', 'Guest'],
48
+ win32: ['Public', 'Default', 'Default User', 'All Users'],
49
+ };
50
+
51
+ // "Not there" and "could not look" are different answers. Only the first means
52
+ // a signal is absent; anything else (no permission, an I/O error) is kept in
53
+ // `errors` so the caller does not report a tool it could not rule out as
54
+ // missing.
55
+ const ABSENT = new Set(['ENOENT', 'ENOTDIR']);
56
+
57
+ function stat(p, errors) {
58
+ try {
59
+ return fs.statSync(p);
60
+ } catch (err) {
61
+ if (!ABSENT.has(err.code)) errors.push(err);
62
+ return null;
63
+ }
64
+ }
65
+
66
+ function listDir(p, errors) {
67
+ try {
68
+ return fs.readdirSync(p);
69
+ } catch (err) {
70
+ if (!ABSENT.has(err.code)) errors.push(err);
71
+ return [];
72
+ }
73
+ }
74
+
75
+ function isLink(p) {
76
+ try {
77
+ return fs.lstatSync(p).isSymbolicLink();
78
+ } catch {
79
+ return false;
80
+ }
81
+ }
82
+
83
+ function pathDirs() {
84
+ return (process.env.PATH || '').split(path.delimiter).filter(Boolean);
85
+ }
86
+
87
+ function machineBinDirs(platform) {
88
+ if (platform === 'win32') return [];
89
+ return ['/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/snap/bin'];
90
+ }
91
+
92
+ // Dirs the OS package manager owns, where a bare tool name may be another
93
+ // program: Debian and Ubuntu ship a digits-of-pi calculator at /usr/bin/pi.
94
+ function osPackageBinDirs(platform) {
95
+ return platform === 'win32' ? [] : ['/usr/bin', '/bin'];
96
+ }
97
+
98
+ function realDir(dir) {
99
+ try {
100
+ return fs.realpathSync(dir);
101
+ } catch {
102
+ return path.resolve(dir);
103
+ }
104
+ }
105
+
106
+ const HOME_PREFIXES = { darwin: '/Users/', linux: '/home/' };
107
+ const ACCOUNT_LOOKUP_TIMEOUT_MS = 30000;
108
+
109
+ // Home directories from the account database. A listing of the users root
110
+ // alone misses a home that is not its direct child, such as /home/DOMAIN/alice.
111
+ // The MDM setup scripts are Python and find their users with pwd.getpwall(),
112
+ // so the same interpreter is asked the same question: detection then looks in
113
+ // every home a setup script would install into. Windows has no such database;
114
+ // its profile directories are the list.
115
+ //
116
+ // Two ways of getting no answer are told apart:
117
+ // - Python is missing, or getpwall() itself fails. A setup script fails the
118
+ // same way, so it could not install into those homes either. Nothing
119
+ // reachable went unseen, and the users root listing is the whole answer.
120
+ // - The lookup timed out or printed something unreadable. The database may be
121
+ // slow rather than down, and a setup script, which waits far longer, could
122
+ // still list those homes. That is kept in `errors` as a place that could
123
+ // not be inspected.
124
+ function accountHomes(platform, errors) {
125
+ const prefix = HOME_PREFIXES[platform];
126
+ if (!prefix) return [];
127
+ const script = 'import json, pwd; print(json.dumps([e.pw_dir for e in pwd.getpwall()]))';
128
+ for (const python of ['python3', 'python']) {
129
+ let out;
130
+ try {
131
+ out = childProcess.execFileSync(python, ['-c', script], {
132
+ encoding: 'utf8', timeout: ACCOUNT_LOOKUP_TIMEOUT_MS, stdio: ['ignore', 'pipe', 'ignore'],
133
+ });
134
+ } catch (err) {
135
+ if (err.code === 'ETIMEDOUT') {
136
+ errors.push(err);
137
+ return [];
138
+ }
139
+ continue;
140
+ }
141
+ try {
142
+ return JSON.parse(out).filter(home => typeof home === 'string' && home.startsWith(prefix));
143
+ } catch (err) {
144
+ errors.push(err);
145
+ return [];
146
+ }
147
+ }
148
+ return [];
149
+ }
150
+
151
+ // Every real user's home, for the sudo/MDM scope where the tools belong to
152
+ // users other than the one running the command: every home the account
153
+ // database names, and every directory under the users root for accounts the
154
+ // database does not enumerate.
155
+ function allUserHomes(platform, { errors = [], root, accounts } = {}) {
156
+ const usersRoot = root || (platform === 'win32'
157
+ ? path.join(process.env.SystemDrive || 'C:', path.sep, 'Users')
158
+ : platform === 'darwin' ? '/Users' : '/home');
159
+ const skip = new Set(SKIP_HOME_NAMES[platform] || []);
160
+ const candidates = [
161
+ ...(accounts || accountHomes(platform, errors)),
162
+ ...listDir(usersRoot, errors).filter(name => !name.startsWith('.')).map(name => path.join(usersRoot, name)),
163
+ ];
164
+ const homes = new Set([os.homedir()]);
165
+ for (const home of candidates) {
166
+ if (skip.has(path.basename(home))) continue;
167
+ const found = stat(home, errors);
168
+ if (found && found.isDirectory()) homes.add(home);
169
+ }
170
+ return [...homes];
171
+ }
172
+
173
+ function binDirsFor(ctx) {
174
+ const dirs = [...ctx.pathDirs, ...ctx.machineBinDirs];
175
+ for (const home of ctx.homes) {
176
+ for (const rel of HOME_BIN_DIRS) dirs.push(path.join(home, rel));
177
+ // nvm keeps one bin dir per installed Node version.
178
+ const nvm = path.join(home, '.nvm', 'versions', 'node');
179
+ for (const version of listDir(nvm, ctx.errors)) dirs.push(path.join(nvm, version, 'bin'));
180
+ }
181
+ return dirs;
182
+ }
183
+
184
+ function hasBinary(name, ctx) {
185
+ const names = ctx.platform === 'win32'
186
+ ? [`${name}.exe`, `${name}.cmd`, `${name}.ps1`, name]
187
+ : [name];
188
+ return binDirsFor(ctx).some((dir) => {
189
+ // In a dir the OS package manager owns, a regular file with the tool's
190
+ // name is the OS's own program. npm links a tool in, so there only a
191
+ // symlink counts.
192
+ const linksOnly = ctx.osPackageBinDirs.has(realDir(dir));
193
+ return names.some((n) => {
194
+ const p = path.join(dir, n);
195
+ if (!stat(p, ctx.errors)) return false;
196
+ return !linksOnly || isLink(p);
197
+ });
198
+ });
199
+ }
200
+
201
+ function isInstalled(toolName, ctx) {
202
+ const signals = TOOL_SIGNALS[toolName];
203
+ // An unknown tool has no way to be ruled out, so it is treated as installed.
204
+ if (!signals) return true;
205
+ if ((signals.bins || []).some(bin => hasBinary(bin, ctx))) return true;
206
+ if ((signals.markers || []).some(rel => ctx.homes.some(home => stat(path.join(home, rel), ctx.errors)))) return true;
207
+ return false;
208
+ }
209
+
210
+ /**
211
+ * Splits `toolNames` into those present on this device and those that are not.
212
+ * `allUsers` checks every user's home (sudo/MDM scope) instead of only the
213
+ * current user's. The remaining options exist so tests can point detection at
214
+ * a fixture tree instead of the real machine.
215
+ *
216
+ * Throws when a tool was not found but some place could not be inspected, so
217
+ * the caller can set the tool up instead of treating it as absent.
218
+ */
219
+ function detectInstalledTools(toolNames, { allUsers = false, platform = process.platform, ...overrides } = {}) {
220
+ const errors = [];
221
+ const ctx = {
222
+ platform,
223
+ errors,
224
+ homes: overrides.homes || (allUsers ? allUserHomes(platform, { errors }) : [os.homedir()]),
225
+ pathDirs: overrides.pathDirs || pathDirs(),
226
+ machineBinDirs: overrides.machineBinDirs || machineBinDirs(platform),
227
+ osPackageBinDirs: new Set((overrides.osPackageBinDirs || osPackageBinDirs(platform)).map(realDir)),
228
+ };
229
+
230
+ const detected = [];
231
+ const missing = [];
232
+ for (const name of toolNames) {
233
+ (isInstalled(name, ctx) ? detected : missing).push(name);
234
+ }
235
+ if (missing.length && errors.length) throw errors[0];
236
+ return { detected, missing };
237
+ }
238
+
239
+ module.exports = { detectInstalledTools, allUserHomes, accountHomes, TOOL_SIGNALS };