pkg-gate 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/tui.js ADDED
@@ -0,0 +1,501 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+ import readline from 'node:readline';
4
+ import { colors } from './utils.js';
5
+
6
+ /**
7
+ * Returns current terminal width clamped to readable bounds.
8
+ *
9
+ * @param {number} [fallback=76]
10
+ * @returns {number}
11
+ */
12
+ export function getTerminalWidth(fallback = 76) {
13
+ const cols = process.stdout?.columns;
14
+ if (!cols || typeof cols !== 'number' || cols <= 0) {
15
+ return fallback;
16
+ }
17
+ // Clamp between 42 (compact mobile/split-pane) and 92 (clean readable card width)
18
+ return Math.max(42, Math.min(cols - 2, 92));
19
+ }
20
+
21
+ /**
22
+ * Strips ANSI escape codes from a string.
23
+ */
24
+ export function stripAnsi(str) {
25
+ return String(str).replace(/\x1b\[[0-9;]*m/g, '');
26
+ }
27
+
28
+ /**
29
+ * Calculates visual display length of a string ignoring ANSI color codes.
30
+ */
31
+ export function visualLength(str) {
32
+ return stripAnsi(str).length;
33
+ }
34
+
35
+ /**
36
+ * Truncates a string containing ANSI codes without leaving dangling escape sequences.
37
+ */
38
+ export function truncateAnsi(str, maxLen) {
39
+ if (visualLength(str) <= maxLen) return str;
40
+ let len = 0;
41
+ let out = '';
42
+ const regex = /(\x1b\[[0-9;]*m)|([\s\S])/g;
43
+ let match;
44
+ while ((match = regex.exec(str)) !== null) {
45
+ if (match[1]) {
46
+ out += match[1];
47
+ } else if (match[2]) {
48
+ if (len >= maxLen - 1) {
49
+ out += '…\x1b[0m';
50
+ return out;
51
+ }
52
+ out += match[2];
53
+ len++;
54
+ }
55
+ }
56
+ return out;
57
+ }
58
+
59
+ /**
60
+ * Wraps text into lines not exceeding maxWidth.
61
+ */
62
+ export function wrapText(text, maxWidth) {
63
+ if (!text || text.length <= maxWidth) {
64
+ return [text || ''];
65
+ }
66
+ const words = text.split(/\s+/);
67
+ const lines = [];
68
+ let current = '';
69
+
70
+ for (const word of words) {
71
+ const test = current ? `${current} ${word}` : word;
72
+ if (test.length <= maxWidth) {
73
+ current = test;
74
+ } else {
75
+ if (current) lines.push(current);
76
+ if (word.length > maxWidth) {
77
+ let rem = word;
78
+ while (rem.length > maxWidth) {
79
+ lines.push(rem.slice(0, maxWidth));
80
+ rem = rem.slice(maxWidth);
81
+ }
82
+ current = rem;
83
+ } else {
84
+ current = word;
85
+ }
86
+ }
87
+ }
88
+ if (current) {
89
+ lines.push(current);
90
+ }
91
+ return lines;
92
+ }
93
+
94
+ /**
95
+ * Renders a visual ASCII progress meter.
96
+ *
97
+ * @param {number} ratio - Between 0.0 and 1.0
98
+ * @param {number} [width=16] - Width in characters
99
+ * @returns {string}
100
+ */
101
+ export function progressBar(ratio, width = 16) {
102
+ const clamped = Math.max(0, Math.min(1, ratio));
103
+ const filledCount = Math.round(clamped * width);
104
+ const emptyCount = width - filledCount;
105
+ return '█'.repeat(filledCount) + '░'.repeat(emptyCount);
106
+ }
107
+
108
+ /**
109
+ * Wraps a single line intended for drawBox to ensure it never exceeds maxWidth.
110
+ */
111
+ function wrapBoxLine(line, maxWidth) {
112
+ const vLen = visualLength(line);
113
+ if (vLen <= maxWidth) {
114
+ return [line];
115
+ }
116
+
117
+ // If it's a border divider like '─'.repeat(...)
118
+ if (stripAnsi(line).startsWith('─')) {
119
+ return ['─'.repeat(maxWidth)];
120
+ }
121
+
122
+ // Word wrap
123
+ const words = line.split(' ');
124
+ const result = [];
125
+ let current = '';
126
+
127
+ for (const word of words) {
128
+ const test = current ? `${current} ${word}` : word;
129
+ if (visualLength(test) <= maxWidth) {
130
+ current = test;
131
+ } else {
132
+ if (current) {
133
+ result.push(current);
134
+ }
135
+ if (visualLength(word) > maxWidth) {
136
+ result.push(truncateAnsi(word, maxWidth));
137
+ current = '';
138
+ } else {
139
+ current = ' ' + word;
140
+ }
141
+ }
142
+ }
143
+ if (current) {
144
+ result.push(current);
145
+ }
146
+ return result.length > 0 ? result : [truncateAnsi(line, maxWidth)];
147
+ }
148
+
149
+ /**
150
+ * Creates a rounded card box responsive to specified or terminal width.
151
+ */
152
+ export function drawBox(lines, options = {}) {
153
+ const width = options.width || getTerminalWidth();
154
+ const color = options.color || ((s) => s);
155
+ const innerWidth = Math.max(10, width - 4);
156
+
157
+ const topChar = '╭' + '─'.repeat(Math.max(0, width - 2)) + '╮';
158
+ const botChar = '╰' + '─'.repeat(Math.max(0, width - 2)) + '╯';
159
+
160
+ const formattedLines = [];
161
+
162
+ for (const rawLine of lines) {
163
+ if (rawLine === '') {
164
+ formattedLines.push(`│ ${' '.repeat(innerWidth)} │`);
165
+ continue;
166
+ }
167
+
168
+ const sublines = wrapBoxLine(rawLine, innerWidth);
169
+ for (const line of sublines) {
170
+ const vLen = visualLength(line);
171
+ const padding = Math.max(0, innerWidth - vLen);
172
+ formattedLines.push(`│ ${line}${' '.repeat(padding)} │`);
173
+ }
174
+ }
175
+
176
+ return [color(topChar), ...formattedLines, color(botChar)].join('\n');
177
+ }
178
+
179
+ /**
180
+ * Renders the rich TUI presentation for a pkg-gate evaluation report.
181
+ * Responsively scales cards, meters, and text to match the terminal width.
182
+ *
183
+ * @param {object} report - The GateReport object
184
+ * @param {object} [options]
185
+ * @param {number} [options.width] - Optional width override
186
+ * @returns {string}
187
+ */
188
+ export function renderTUI(report, options = {}) {
189
+ const { name, version, action, score, confidence, findings, reasons, latencyMs } = report;
190
+ const width = options.width || getTerminalWidth();
191
+ const innerWidth = Math.max(10, width - 4);
192
+ const out = [];
193
+
194
+ // 1. Header Banner
195
+ const isBlock = action === 'block';
196
+ const isWarn = action === 'warn';
197
+
198
+ const bannerColor = isBlock ? colors.red : isWarn ? colors.yellow : colors.green;
199
+ const bannerIcon = isBlock ? '✖' : isWarn ? '▲' : '✔';
200
+
201
+ let bannerTitle;
202
+ if (isBlock) {
203
+ bannerTitle = innerWidth < 45 ? 'BLOCK · CRITICAL THREAT' : 'BLOCK · CRITICAL THREAT DETECTED';
204
+ } else if (isWarn) {
205
+ bannerTitle = innerWidth < 45 ? 'WARN · HUMAN REVIEW' : 'WARN · HUMAN REVIEW REQUIRED';
206
+ } else {
207
+ bannerTitle = innerWidth < 45 ? 'ALLOW · SAFE' : 'ALLOW · SAFE TO INSTALL';
208
+ }
209
+
210
+ const headerBox = [
211
+ bannerColor(colors.bold(`${bannerIcon} ${bannerTitle}`)),
212
+ ];
213
+
214
+ // In narrow terminals, split package details across two lines
215
+ if (innerWidth < 58) {
216
+ headerBox.push(`${colors.bold(name)}@${version}`);
217
+ const scoreStr = `Threat: ${colors.bold(score.toFixed(2))}/3.00`;
218
+ const confStr = `Conf: ${colors.bold(`${(confidence * 100).toFixed(0)}%`)}`;
219
+ const latStr = latencyMs ? ` ${colors.dim('│')} ${latencyMs}ms` : '';
220
+ headerBox.push(`${scoreStr} ${colors.dim('│')} ${confStr}${latStr}`);
221
+ } else if (innerWidth < 84) {
222
+ const latStr = latencyMs ? ` ${colors.dim('│')} ${latencyMs}ms` : '';
223
+ headerBox.push(
224
+ `${colors.bold(name)}@${version} ${colors.dim('│')} Threat: ${colors.bold(score.toFixed(2))}/3.00 ${colors.dim('│')} Conf: ${colors.bold(`${(confidence * 100).toFixed(0)}%`)}${latStr}`
225
+ );
226
+ } else {
227
+ const latStr = latencyMs ? ` ${colors.dim('│')} Latency: ${latencyMs}ms` : '';
228
+ headerBox.push(
229
+ `${colors.bold(name)}@${version} ${colors.dim('│')} Threat Score: ${colors.bold(score.toFixed(2))}/3.00 ${colors.dim('│')} Confidence: ${colors.bold(`${(confidence * 100).toFixed(0)}%`)}${latStr}`
230
+ );
231
+ }
232
+
233
+ out.push(drawBox(headerBox, { color: bannerColor, width }));
234
+ out.push('');
235
+
236
+ // 2. Clean package (fast-path)
237
+ if (findings.length === 0) {
238
+ const cleanLines = [
239
+ colors.green('✔ No lifecycle hooks found in package.json'),
240
+ colors.dim('Package has no preinstall, install, or postinstall scripts.'),
241
+ innerWidth >= 60
242
+ ? colors.dim('Execution fast-path: 0ms runtime overhead, 0 tokens consumed.')
243
+ : colors.dim('Execution fast-path: 0ms runtime overhead.'),
244
+ ];
245
+ out.push(drawBox(cleanLines, { color: colors.green, width }));
246
+ out.push('');
247
+ return out.join('\n');
248
+ }
249
+
250
+ // 3. Render Structured Breakdown per lifecycle script
251
+ for (const item of findings) {
252
+ const { hook, command, answers, model } = item;
253
+ const scriptBoxLines = [];
254
+
255
+ // Hook header
256
+ const hookTag = colors.bold(colors.cyan(`[HOOK: ${hook.toUpperCase()}]`));
257
+ const normalizedModel = (model || 'jev-latest').replace(' (offline simulator)', innerWidth < 68 ? ' (sim)' : ' (offline simulator)');
258
+ const modelTag = colors.dim(`model: ${normalizedModel}`);
259
+ if (innerWidth < 50) {
260
+ scriptBoxLines.push(hookTag);
261
+ scriptBoxLines.push(` ${modelTag}`);
262
+ } else {
263
+ scriptBoxLines.push(`${hookTag} ${modelTag}`);
264
+ }
265
+
266
+ // Command (truncated dynamically to fit inner width)
267
+ const maxCmdLen = Math.max(10, innerWidth - 4);
268
+ const cmdDisplay = command.length > maxCmdLen ? command.slice(0, Math.max(0, maxCmdLen - 3)) + '...' : command;
269
+ scriptBoxLines.push(`${colors.dim('$')} ${colors.bold(cmdDisplay)}`);
270
+
271
+ // Divider line matching exact innerWidth
272
+ scriptBoxLines.push('─'.repeat(innerWidth));
273
+
274
+ // Responsive progress bar width
275
+ const barWidth = Math.max(6, Math.min(16, Math.floor(innerWidth * 0.22)));
276
+
277
+ // Primitive 1: Intent (Choice)
278
+ if (answers.script_intent) {
279
+ const choice = answers.script_intent.choice;
280
+ const intentConf = (answers.script_intent.confidence * 100).toFixed(0);
281
+ const isDangerousIntent = choice === 'credential_access' || choice === 'obfuscated_exec';
282
+ const intentColor = isDangerousIntent ? colors.red : choice === 'system_recon' ? colors.yellow : colors.green;
283
+
284
+ scriptBoxLines.push(
285
+ `${colors.bold('TypeSafe Choice ')} ${colors.dim('·')} ${colors.cyan('script_intent')}`
286
+ );
287
+ scriptBoxLines.push(
288
+ ` Outcome : ${intentColor(colors.bold(choice))} ${colors.dim(`(conf: ${intentConf}%)`)}`
289
+ );
290
+
291
+ const probs = answers.script_intent.probabilities || {};
292
+ const sortedProbs = Object.entries(probs)
293
+ .sort((a, b) => b[1] - a[1])
294
+ .slice(0, 3);
295
+
296
+ const maxLabelLen = Math.max(10, Math.min(17, innerWidth - barWidth - 16));
297
+
298
+ for (const [label, p] of sortedProbs) {
299
+ const bar = progressBar(p, barWidth);
300
+ const pStr = `${(p * 100).toFixed(0).padStart(3)}%`;
301
+ const itemColor = label === choice ? intentColor : colors.dim;
302
+ const displayLabel = label.length > maxLabelLen ? label.slice(0, maxLabelLen - 2) + '..' : label;
303
+ scriptBoxLines.push(
304
+ ` ${itemColor(displayLabel.padEnd(maxLabelLen))} [${intentColor(bar)}] ${pStr}`
305
+ );
306
+ }
307
+ scriptBoxLines.push('');
308
+ }
309
+
310
+ // Primitive 2: Threat Severity (Score)
311
+ if (answers.threat_severity) {
312
+ const sevScore = answers.threat_severity.score;
313
+ const sevConf = (answers.threat_severity.confidence * 100).toFixed(0);
314
+ const scoreRatio = sevScore / 3.0;
315
+ const scoreBar = progressBar(scoreRatio, barWidth);
316
+ const sevColor = sevScore >= 2.0 ? colors.red : sevScore >= 1.2 ? colors.yellow : colors.green;
317
+
318
+ const currentLevelDesc =
319
+ answers.threat_severity.legend?.[Math.round(sevScore)] ||
320
+ (sevScore >= 2.0 ? 'Critical Threat' : sevScore >= 1.0 ? 'Suspicious' : 'Safe / Routine');
321
+
322
+ scriptBoxLines.push(
323
+ `${colors.bold('TypeSafe Score ')} ${colors.dim('·')} ${colors.cyan('threat_severity (0–3)')}`
324
+ );
325
+
326
+ if (innerWidth < 64) {
327
+ scriptBoxLines.push(
328
+ ` Calibrated : ${sevColor(colors.bold(sevScore.toFixed(2)))}/3.00 [${sevColor(scoreBar)}] (${sevConf}%)`
329
+ );
330
+ } else {
331
+ scriptBoxLines.push(
332
+ ` Calibrated : ${sevColor(colors.bold(sevScore.toFixed(2)))} / 3.00 [${sevColor(scoreBar)}] ${colors.dim(`(conf: ${sevConf}%)`)}`
333
+ );
334
+ }
335
+
336
+ const maxDescWidth = Math.max(10, innerWidth - 18);
337
+ const descDisplay = currentLevelDesc.length > maxDescWidth
338
+ ? currentLevelDesc.slice(0, Math.max(0, maxDescWidth - 3)) + '...'
339
+ : currentLevelDesc;
340
+ scriptBoxLines.push(
341
+ ` Rubric Level : ${sevColor(descDisplay)}`
342
+ );
343
+ scriptBoxLines.push('');
344
+ }
345
+
346
+ // Primitives 3 & 4: Yes/No Judgments (Nouls)
347
+ if (answers.accesses_secrets || answers.remote_execution) {
348
+ scriptBoxLines.push(
349
+ `${colors.bold('TypeSafe Nouls ')} ${colors.dim('·')} ${colors.cyan('calibrated yes/no probabilities')}`
350
+ );
351
+
352
+ const isCompact = innerWidth < 54;
353
+
354
+ if (answers.accesses_secrets) {
355
+ const p = answers.accesses_secrets.noul;
356
+ const pStr = `${(p * 100).toFixed(0).padStart(3)}%`;
357
+ const bar = progressBar(p, barWidth);
358
+ const pColor = p >= 0.6 ? colors.red : p >= 0.3 ? colors.yellow : colors.green;
359
+ const status = p >= 0.6
360
+ ? colors.red(isCompact ? 'RISK' : 'CRITICAL RISK')
361
+ : p >= 0.3
362
+ ? colors.yellow(isCompact ? 'WARN' : 'SUSPICIOUS')
363
+ : colors.green('CLEAN');
364
+
365
+ const label = isCompact ? 'secrets' : 'accesses_secrets';
366
+ const labelPadded = label.padEnd(isCompact ? 11 : 16);
367
+ scriptBoxLines.push(
368
+ ` ${labelPadded} [${pColor(bar)}] ${pStr} → ${status}`
369
+ );
370
+ }
371
+
372
+ if (answers.remote_execution) {
373
+ const p = answers.remote_execution.noul;
374
+ const pStr = `${(p * 100).toFixed(0).padStart(3)}%`;
375
+ const bar = progressBar(p, barWidth);
376
+ const pColor = p >= 0.6 ? colors.red : p >= 0.3 ? colors.yellow : colors.green;
377
+ const status = p >= 0.6
378
+ ? colors.red(isCompact ? 'RISK' : 'CRITICAL RISK')
379
+ : p >= 0.3
380
+ ? colors.yellow(isCompact ? 'WARN' : 'SUSPICIOUS')
381
+ : colors.green('CLEAN');
382
+
383
+ const label = isCompact ? 'remote_exec' : 'remote_execution';
384
+ const labelPadded = label.padEnd(isCompact ? 11 : 16);
385
+ scriptBoxLines.push(
386
+ ` ${labelPadded} [${pColor(bar)}] ${pStr} → ${status}`
387
+ );
388
+ }
389
+ }
390
+
391
+ out.push(drawBox(scriptBoxLines, { width }));
392
+ out.push('');
393
+ }
394
+
395
+ // 4. Policy Reasons Section
396
+ if (reasons && reasons.length > 0) {
397
+ const reasonsLines = [
398
+ `${colors.bold('Deterministic Policy Decision:')}`,
399
+ ];
400
+
401
+ const maxReasonWidth = Math.max(10, innerWidth - 6);
402
+ for (const r of reasons) {
403
+ const wrapped = wrapText(r, maxReasonWidth);
404
+ for (let i = 0; i < wrapped.length; i++) {
405
+ if (i === 0) {
406
+ reasonsLines.push(` • ${colors.dim(wrapped[i])}`);
407
+ } else {
408
+ reasonsLines.push(` ${colors.dim(wrapped[i])}`);
409
+ }
410
+ }
411
+ }
412
+
413
+ out.push(drawBox(reasonsLines, { width }));
414
+ out.push('');
415
+ }
416
+
417
+ return out.join('\n');
418
+ }
419
+
420
+ /**
421
+ * Interactive prompt when action is 'warn'.
422
+ */
423
+ export async function promptUserConfirmation(question = 'Proceed with installation despite warning? [y/N] ') {
424
+ if (!process.stdin.isTTY) {
425
+ return false;
426
+ }
427
+
428
+ const rl = readline.createInterface({
429
+ input: process.stdin,
430
+ output: process.stdout,
431
+ });
432
+
433
+ return new Promise((resolve) => {
434
+ rl.question(colors.yellow(`? ${question}`), (answer) => {
435
+ rl.close();
436
+ const normalized = answer.trim().toLowerCase();
437
+ resolve(normalized === 'y' || normalized === 'yes');
438
+ });
439
+ });
440
+ }
441
+
442
+ /**
443
+ * Interactively prompts the user for a package name or path when none is passed.
444
+ *
445
+ * @param {string} [helpText]
446
+ * @returns {Promise<string | null>}
447
+ */
448
+ export async function promptForTarget(helpText = '') {
449
+ if (!process.stdin.isTTY) {
450
+ return null;
451
+ }
452
+
453
+ const localPkgPath = resolve(process.cwd(), 'package.json');
454
+ const hasLocalPkg = existsSync(localPkgPath);
455
+
456
+ const rl = readline.createInterface({
457
+ input: process.stdin,
458
+ output: process.stdout,
459
+ });
460
+
461
+ const promptMsg = hasLocalPkg
462
+ ? `${colors.cyan('?')} Scan local ${colors.bold('./package.json')}? [${colors.bold('Y')}/n] (or enter package name / path): `
463
+ : `${colors.cyan('?')} Enter package name or path to package.json (${colors.dim('or press Enter for help')}): `;
464
+
465
+ return new Promise((res) => {
466
+ rl.question(promptMsg, (answer) => {
467
+ rl.close();
468
+ const input = answer.trim();
469
+
470
+ if (!input) {
471
+ if (hasLocalPkg) {
472
+ return res('./package.json');
473
+ }
474
+ return res(null);
475
+ }
476
+
477
+ if (input.toLowerCase() === 'exit' || input.toLowerCase() === 'quit') {
478
+ process.exit(0);
479
+ }
480
+
481
+ if (hasLocalPkg && (input.toLowerCase() === 'y' || input.toLowerCase() === 'yes')) {
482
+ return res('./package.json');
483
+ }
484
+
485
+ if (hasLocalPkg && (input.toLowerCase() === 'n' || input.toLowerCase() === 'no')) {
486
+ return res(null);
487
+ }
488
+
489
+ if (input === '-h' || input === '--help') {
490
+ return res('--help');
491
+ }
492
+
493
+ if (input === '-v' || input === '--version') {
494
+ return res('--version');
495
+ }
496
+
497
+ res(input);
498
+ });
499
+ });
500
+ }
501
+
package/src/utils.js ADDED
@@ -0,0 +1,79 @@
1
+ const useColor = !process.env.NO_COLOR && process.stdout?.isTTY !== false;
2
+
3
+ export const colors = {
4
+ red: (s) => (useColor ? `\x1b[31m${s}\x1b[0m` : s),
5
+ green: (s) => (useColor ? `\x1b[32m${s}\x1b[0m` : s),
6
+ yellow: (s) => (useColor ? `\x1b[33m${s}\x1b[0m` : s),
7
+ cyan: (s) => (useColor ? `\x1b[36m${s}\x1b[0m` : s),
8
+ dim: (s) => (useColor ? `\x1b[2m${s}\x1b[0m` : s),
9
+ bold: (s) => (useColor ? `\x1b[1m${s}\x1b[0m` : s),
10
+ };
11
+
12
+ export const LIFECYCLE_HOOKS = [
13
+ 'preinstall',
14
+ 'install',
15
+ 'postinstall',
16
+ 'prepublish',
17
+ 'prepare',
18
+ 'preuninstall',
19
+ 'postuninstall',
20
+ ];
21
+
22
+ /**
23
+ * Extracts lifecycle scripts from a manifest.
24
+ *
25
+ * @param {Record<string, string>} scripts
26
+ * @returns {Array<{ hook: string, command: string }>}
27
+ */
28
+ export function extractLifecycleScripts(scripts = {}) {
29
+ const result = [];
30
+ for (const hook of LIFECYCLE_HOOKS) {
31
+ if (typeof scripts[hook] === 'string' && scripts[hook].trim() !== '') {
32
+ result.push({ hook, command: scripts[hook].trim() });
33
+ }
34
+ }
35
+ return result;
36
+ }
37
+
38
+ /**
39
+ * Formats a terminal inspection string for a gate report.
40
+ */
41
+ export function formatReport(report) {
42
+ const { name, version, action, score, confidence, findings, reasons } = report;
43
+
44
+ const badge =
45
+ action === 'allow'
46
+ ? colors.green('✔ ALLOW')
47
+ : action === 'block'
48
+ ? colors.red('✖ BLOCK')
49
+ : colors.yellow('▲ WARN');
50
+
51
+ const lines = [];
52
+ lines.push(`${badge} ${colors.bold(name)}@${version} ${colors.dim(`(risk score: ${score.toFixed(2)}, confidence: ${(confidence * 100).toFixed(0)}%)`)}`);
53
+
54
+ if (findings.length === 0) {
55
+ lines.push(` ${colors.dim('No lifecycle scripts detected (safe to install)')}`);
56
+ } else {
57
+ for (const item of findings) {
58
+ const { hook, command, answers } = item;
59
+ const intent = answers.script_intent?.choice || 'unknown';
60
+ const sev = answers.threat_severity?.score?.toFixed(2) || '0.00';
61
+ const secretsProb = answers.accesses_secrets ? `${(answers.accesses_secrets.noul * 100).toFixed(0)}%` : '0%';
62
+ const remoteProb = answers.remote_execution ? `${(answers.remote_execution.noul * 100).toFixed(0)}%` : '0%';
63
+
64
+ lines.push(` ${colors.cyan(`[${hook}]`)} ${colors.dim(command)}`);
65
+ lines.push(
66
+ ` intent: ${colors.bold(intent)} | severity: ${sev} | secrets: ${secretsProb} | remote-exec: ${remoteProb}`
67
+ );
68
+ }
69
+ }
70
+
71
+ if (reasons && reasons.length > 0) {
72
+ lines.push(` ${colors.dim('Reasons:')}`);
73
+ for (const reason of reasons) {
74
+ lines.push(` • ${reason}`);
75
+ }
76
+ }
77
+
78
+ return lines.join('\n');
79
+ }