facult 2.5.2 → 2.7.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/audit/tui.ts CHANGED
@@ -1,9 +1,13 @@
1
- import { homedir } from "node:os";
1
+ import { mkdtemp } from "node:fs/promises";
2
+ import { homedir, tmpdir } from "node:os";
2
3
  import { join } from "node:path";
3
4
  import {
5
+ cancel,
4
6
  confirm,
7
+ group,
5
8
  intro,
6
9
  isCancel,
10
+ log,
7
11
  multiselect,
8
12
  note,
9
13
  outro,
@@ -15,7 +19,14 @@ import { buildIndex } from "../index-builder";
15
19
  import { facultRootDir, facultStateDir, readFacultConfig } from "../paths";
16
20
  import { type QuarantineMode, quarantineItems } from "../quarantine";
17
21
  import { type AgentAuditReport, runAgentAudit } from "./agent";
22
+ import { fixInlineMcpSecrets, removeFixedInlineSecretFindings } from "./fix";
18
23
  import { runStaticAudit } from "./static";
24
+ import {
25
+ applyAuditSuppressionsToAgentReport,
26
+ applyAuditSuppressionsToStaticReport,
27
+ loadAuditSuppressions,
28
+ recordAuditSuppressions,
29
+ } from "./suppressions";
19
30
  import {
20
31
  type AuditFinding,
21
32
  type AuditItemResult,
@@ -23,6 +34,10 @@ import {
23
34
  type Severity,
24
35
  type StaticAuditReport,
25
36
  } from "./types";
37
+ import { updateIndexFromAuditReport } from "./update-index";
38
+
39
+ type InteractiveReviewerTool = "codex" | "claude";
40
+ const AUDIT_RULE_PREFIX_RE = /^(static|agent):/;
26
41
 
27
42
  function parseFromFlags(argv: string[]): string[] {
28
43
  const from: string[] = [];
@@ -196,6 +211,170 @@ function viewFindingDetails(r: AuditItemResult) {
196
211
  note(lines.join("\n"), "Findings");
197
212
  }
198
213
 
214
+ function availableInteractiveReviewerTools(): InteractiveReviewerTool[] {
215
+ return [
216
+ ...(Bun.which("codex") ? (["codex"] as const) : []),
217
+ ...(Bun.which("claude") ? (["claude"] as const) : []),
218
+ ];
219
+ }
220
+
221
+ function findingsSummary(findings: AuditFinding[]): string {
222
+ return findings
223
+ .map((finding) => {
224
+ const location = finding.location ? ` @ ${finding.location}` : "";
225
+ return `- [${finding.severity}] ${finding.ruleId}${location}: ${finding.message}`;
226
+ })
227
+ .join("\n");
228
+ }
229
+
230
+ function buildReviewerPrompt(args: {
231
+ items: AuditItemResult[];
232
+ reviewMode: "static" | "agent" | "combined";
233
+ cwd: string;
234
+ }): string {
235
+ const itemBlocks = args.items.map((item, index) =>
236
+ [
237
+ `${index + 1}. ${item.type}:${item.item}`,
238
+ `Path: ${item.path}`,
239
+ `Passed: ${item.passed ? "yes" : "no"}`,
240
+ item.sourceId ? `Source: ${item.sourceId}` : "",
241
+ item.notes ? `Notes: ${item.notes}` : "",
242
+ "Findings:",
243
+ findingsSummary(item.findings),
244
+ ]
245
+ .filter(Boolean)
246
+ .join("\n")
247
+ );
248
+
249
+ return [
250
+ "Review these audit findings and help reconcile them.",
251
+ "These findings came from `fclt audit`.",
252
+ `Current repo: ${args.cwd}`,
253
+ `Audit view: ${args.reviewMode}`,
254
+ "",
255
+ "What to do:",
256
+ "- Inspect the listed files directly.",
257
+ "- Validate whether each finding is real, stale, or acceptable.",
258
+ "- Group related issues when the same fix addresses multiple findings.",
259
+ "- Propose the safest order to handle them.",
260
+ "- If a fix is straightforward, suggest or implement it in this session.",
261
+ "- Prefer fixing the canonical `.ai` source once when the same MCP issue appears in multiple tool configs.",
262
+ "- If an MCP secret needs remediation, use the `fclt audit fix ...` flow before suggesting manual edits.",
263
+ "",
264
+ "Useful `fclt` commands in this repo:",
265
+ "- `fclt show mcp:<name>` to inspect the canonical MCP entry.",
266
+ "- `fclt audit fix <item>` to move inline MCP secrets into the local canonical overlay.",
267
+ "- `fclt audit safe ...` to suppress a reviewed false positive.",
268
+ "- `fclt manage <tool>` or `fclt sync [tool]` when a managed tool config needs to be re-rendered.",
269
+ "",
270
+ "Selected findings:",
271
+ ...itemBlocks,
272
+ ].join("\n");
273
+ }
274
+
275
+ async function launchInteractiveReviewer(args: {
276
+ tool: InteractiveReviewerTool;
277
+ prompt: string;
278
+ cwd: string;
279
+ }): Promise<number> {
280
+ const promptDir = await mkdtemp(join(tmpdir(), "facult-audit-review-"));
281
+ const promptPath = join(promptDir, "prompt.md");
282
+ await Bun.write(promptPath, `${args.prompt}\n`);
283
+ const promptText = await Bun.file(promptPath).text();
284
+
285
+ const cmd =
286
+ args.tool === "codex"
287
+ ? ["codex", "--no-alt-screen", "-C", args.cwd, promptText]
288
+ : ["claude", promptText];
289
+
290
+ const proc = Bun.spawn({
291
+ cmd,
292
+ cwd: args.cwd,
293
+ stdin: "inherit",
294
+ stdout: "inherit",
295
+ stderr: "inherit",
296
+ });
297
+
298
+ return await proc.exited;
299
+ }
300
+
301
+ function viewMultipleFindingDetails(items: AuditItemResult[]) {
302
+ const blocks = items.map((item) => {
303
+ const lines: string[] = [];
304
+ lines.push(`${item.type}:${item.item}`);
305
+ lines.push(`Path: ${item.path}`);
306
+ if (item.sourceId) {
307
+ lines.push(`Source: ${item.sourceId}`);
308
+ }
309
+ lines.push(
310
+ ...item.findings.map((finding) => {
311
+ const location = finding.location ? ` @ ${finding.location}` : "";
312
+ return `- [${finding.severity}] ${finding.ruleId}${location}: ${finding.message}`;
313
+ })
314
+ );
315
+ return lines.join("\n");
316
+ });
317
+
318
+ note(blocks.join("\n\n"), "Selected findings");
319
+ }
320
+
321
+ function inlineSecretSelectionLabel(selection: {
322
+ result: AuditItemResult;
323
+ finding: AuditFinding;
324
+ }): string {
325
+ const location = selection.finding.location
326
+ ? ` @ ${selection.finding.location}`
327
+ : "";
328
+ return `[${selection.finding.severity.toUpperCase()}] ${selection.result.item}${location}`;
329
+ }
330
+
331
+ function labelForFindingSelection(args: {
332
+ result: AuditItemResult;
333
+ finding: AuditFinding;
334
+ }): string {
335
+ const location = args.finding.location ? ` @ ${args.finding.location}` : "";
336
+ return `[${args.finding.severity.toUpperCase()}] ${args.result.type}:${args.result.item} — ${args.finding.ruleId}${location}`;
337
+ }
338
+
339
+ function hintForFindingSelection(args: {
340
+ result: AuditItemResult;
341
+ finding: AuditFinding;
342
+ }): string {
343
+ return `${args.result.path} — ${args.finding.message}`;
344
+ }
345
+
346
+ function sortReviewQueue(results: AuditItemResult[]): AuditItemResult[] {
347
+ return results
348
+ .filter((result) => result.findings.length > 0)
349
+ .sort((a, b) => {
350
+ const sa = SEVERITY_ORDER[maxSeverity(a.findings) ?? "low"];
351
+ const sb = SEVERITY_ORDER[maxSeverity(b.findings) ?? "low"];
352
+ return (
353
+ sb - sa || a.type.localeCompare(b.type) || a.item.localeCompare(b.item)
354
+ );
355
+ });
356
+ }
357
+
358
+ function summarizeRoots(args: {
359
+ includeConfigFrom: boolean;
360
+ from: string[];
361
+ cfgRoots: string[];
362
+ }): string {
363
+ const parts: string[] = [];
364
+ if (args.includeConfigFrom && args.cfgRoots.length > 0) {
365
+ parts.push("configured scanFrom roots");
366
+ }
367
+ if (args.from.length > 0) {
368
+ parts.push(args.from.join(", "));
369
+ }
370
+ if (parts.length === 0) {
371
+ return "tool defaults only";
372
+ }
373
+ return parts.join(" + ");
374
+ }
375
+
376
+ const AUDIT_TUI_CANCELLED = "audit-tui-cancelled";
377
+
199
378
  function printHelp() {
200
379
  console.log(`fclt audit tui — interactive security audit + quarantine
201
380
 
@@ -226,9 +405,8 @@ export async function auditTuiCommand(argv: string[]) {
226
405
  intro("fclt audit");
227
406
 
228
407
  if (cfgRoots.length) {
229
- note(
230
- `Configured scanFrom roots:\n- ${cfgRoots.join("\n- ")}`,
231
- "~/.ai/.facult/config.json"
408
+ log.info(
409
+ `Loaded ${cfgRoots.length} configured scan root${cfgRoots.length === 1 ? "" : "s"} from ~/.ai/.facult/config.json.`
232
410
  );
233
411
  }
234
412
 
@@ -237,158 +415,227 @@ export async function auditTuiCommand(argv: string[]) {
237
415
  ...(Bun.which("codex") ? ["codex" as const] : []),
238
416
  ];
239
417
 
240
- const mode = await select({
241
- message: "What should we run?",
242
- options: [
243
- {
244
- value: "static",
245
- label: "Static audit (fast)",
246
- hint: "regex + structured checks",
247
- },
248
- ...(availableAgentTools.length
249
- ? [
250
- {
251
- value: "both",
252
- label: "Static + agent audit",
253
- hint: "best coverage",
254
- },
255
- {
256
- value: "agent",
257
- label: "Agent audit (slower)",
258
- hint: "LLM review",
259
- },
260
- ]
261
- : []),
262
- ],
263
- });
264
- if (isCancel(mode)) {
265
- outro("Cancelled.");
266
- return;
267
- }
418
+ let setup:
419
+ | {
420
+ mode?: unknown;
421
+ scope?: unknown;
422
+ roots?: unknown;
423
+ includeGitHooks?: unknown;
424
+ minSeverity?: unknown;
425
+ agentTool?: unknown;
426
+ maxItems?: unknown;
427
+ }
428
+ | undefined;
268
429
 
269
- const scope = await select({
270
- message: "Audit scope",
271
- options: [
430
+ try {
431
+ setup = await group(
272
432
  {
273
- value: "defaults",
274
- label: "Defaults only",
275
- hint: "tool defaults; fastest",
433
+ mode: () =>
434
+ select({
435
+ message: "What kind of audit do you want to run?",
436
+ options: [
437
+ {
438
+ value: "static",
439
+ label: "Static only",
440
+ hint: "fast regex and structured checks",
441
+ },
442
+ ...(availableAgentTools.length
443
+ ? [
444
+ {
445
+ value: "both",
446
+ label: "Static + agent",
447
+ hint: "best coverage",
448
+ },
449
+ {
450
+ value: "agent",
451
+ label: "Agent only",
452
+ hint: "slower LLM review",
453
+ },
454
+ ]
455
+ : []),
456
+ ],
457
+ }),
458
+ scope: () =>
459
+ select({
460
+ message: "Where should the audit look?",
461
+ options: [
462
+ {
463
+ value: "defaults",
464
+ label: "Defaults only",
465
+ hint: "fastest",
466
+ },
467
+ {
468
+ value: "home",
469
+ label: "Home directory (~)",
470
+ hint: "broad local discovery",
471
+ },
472
+ {
473
+ value: "custom",
474
+ label: "Custom roots",
475
+ hint: "enter a comma-separated list",
476
+ },
477
+ ...(cfgRoots.length
478
+ ? [
479
+ {
480
+ value: "config",
481
+ label: "Configured scanFrom roots",
482
+ hint: "from ~/.ai/.facult/config.json",
483
+ },
484
+ ]
485
+ : []),
486
+ ],
487
+ }),
488
+ roots: ({ results }) =>
489
+ results.scope === "custom"
490
+ ? text({
491
+ message: "Roots to scan",
492
+ placeholder: parsedFrom.length
493
+ ? parsedFrom.join(", ")
494
+ : "~, ~/dev",
495
+ })
496
+ : undefined,
497
+ includeGitHooks: () =>
498
+ confirm({
499
+ message: "Include git hooks (.husky and .git/hooks)?",
500
+ initialValue: false,
501
+ active: "Include",
502
+ inactive: "Skip",
503
+ }),
504
+ minSeverity: ({ results }) =>
505
+ results.mode === "static" || results.mode === "both"
506
+ ? select({
507
+ message: "Minimum severity to review",
508
+ options: [
509
+ { value: "high", label: "high", hint: "recommended" },
510
+ {
511
+ value: "critical",
512
+ label: "critical",
513
+ hint: "critical only",
514
+ },
515
+ {
516
+ value: "medium",
517
+ label: "medium",
518
+ hint: "medium and above",
519
+ },
520
+ { value: "low", label: "low", hint: "show everything" },
521
+ ],
522
+ initialValue: "high",
523
+ })
524
+ : undefined,
525
+ agentTool: ({ results }) => {
526
+ if (results.mode !== "agent" && results.mode !== "both") {
527
+ return undefined;
528
+ }
529
+ if (availableAgentTools.length === 0) {
530
+ return undefined;
531
+ }
532
+ if (availableAgentTools.length === 1) {
533
+ return Promise.resolve(availableAgentTools[0]);
534
+ }
535
+ return select({
536
+ message: "Which agent tool should review the items?",
537
+ options: availableAgentTools.map((tool) => ({
538
+ value: tool,
539
+ label: tool,
540
+ })),
541
+ });
542
+ },
543
+ maxItems: ({ results }) =>
544
+ results.mode === "agent" || results.mode === "both"
545
+ ? text({
546
+ message: "Max items to send to the agent",
547
+ placeholder: "50 (or all)",
548
+ defaultValue: "50",
549
+ })
550
+ : undefined,
276
551
  },
277
552
  {
278
- value: "home",
279
- label: "Home directory (~)",
280
- hint: "broad project discovery",
281
- },
282
- { value: "custom", label: "Custom roots", hint: "comma-separated list" },
283
- ...(cfgRoots.length
284
- ? [
285
- {
286
- value: "config",
287
- label: "Use configured scanFrom",
288
- hint: "from ~/.ai/.facult/config.json",
289
- },
290
- ]
291
- : []),
292
- ],
293
- });
294
- if (isCancel(scope)) {
295
- outro("Cancelled.");
296
- return;
553
+ onCancel: () => {
554
+ cancel("Cancelled.");
555
+ throw new Error(AUDIT_TUI_CANCELLED);
556
+ },
557
+ }
558
+ );
559
+ } catch (err) {
560
+ if (err instanceof Error && err.message === AUDIT_TUI_CANCELLED) {
561
+ return;
562
+ }
563
+ throw err;
297
564
  }
298
565
 
566
+ const mode = setup?.mode as "static" | "agent" | "both";
567
+ const scope = setup?.scope as "defaults" | "home" | "custom" | "config";
568
+ const includeGitHooks = setup?.includeGitHooks === true;
569
+
299
570
  let includeConfigFrom = !noConfigFrom;
300
571
  let from: string[] = [];
301
572
  if (scope === "defaults") {
302
573
  includeConfigFrom = false;
303
- from = [];
304
574
  } else if (scope === "home") {
305
575
  from = parsedFrom.length ? parsedFrom : ["~"];
306
576
  } else if (scope === "config") {
307
577
  from = parsedFrom;
308
578
  } else {
309
- const raw = await text({
310
- message: "Roots to scan (comma-separated)",
311
- placeholder: parsedFrom.length ? parsedFrom.join(", ") : "~, ~/dev",
312
- });
313
- if (isCancel(raw)) {
314
- outro("Cancelled.");
315
- return;
316
- }
317
- from = String(raw)
579
+ from = String(setup?.roots ?? "")
318
580
  .split(",")
319
- .map((s) => s.trim())
581
+ .map((value) => value.trim())
320
582
  .filter(Boolean);
321
583
  }
322
584
 
323
- const includeGitHooks = await confirm({
324
- message: "Include git hooks (husky + .git/hooks)?",
325
- initialValue: false,
326
- });
327
- if (isCancel(includeGitHooks)) {
328
- outro("Cancelled.");
329
- return;
330
- }
331
-
332
585
  let minSeverity: Severity = "high";
333
- if (mode === "static" || mode === "both") {
334
- const sev = await select({
335
- message: "Minimum severity to show",
336
- options: [
337
- { value: "critical", label: "critical", hint: "only critical" },
338
- { value: "high", label: "high", hint: "high + critical" },
339
- { value: "medium", label: "medium", hint: "medium + above" },
340
- { value: "low", label: "low", hint: "everything" },
341
- ],
342
- });
343
- if (isCancel(sev)) {
344
- outro("Cancelled.");
345
- return;
346
- }
347
- minSeverity = sev as Severity;
586
+ if (
587
+ (setup?.minSeverity === "critical" ||
588
+ setup?.minSeverity === "high" ||
589
+ setup?.minSeverity === "medium" ||
590
+ setup?.minSeverity === "low") &&
591
+ (mode === "static" || mode === "both")
592
+ ) {
593
+ minSeverity = setup.minSeverity;
348
594
  }
349
595
 
350
596
  let agentTool: "claude" | "codex" | null = null;
597
+ if (setup?.agentTool === "claude" || setup?.agentTool === "codex") {
598
+ agentTool = setup.agentTool;
599
+ }
600
+
351
601
  let maxItems = 50;
352
602
  if (mode === "agent" || mode === "both") {
353
603
  if (availableAgentTools.length === 0) {
354
- note('No agent tool found. Install "claude" or "codex".', "Agent audit");
355
- } else if (availableAgentTools.length === 1) {
356
- agentTool = availableAgentTools[0] ?? null;
357
- } else {
358
- const chosen = await select({
359
- message: "Agent tool",
360
- options: availableAgentTools.map((t) => ({
361
- value: t,
362
- label: t,
363
- })),
364
- });
365
- if (isCancel(chosen)) {
366
- outro("Cancelled.");
367
- return;
368
- }
369
- agentTool = chosen as "claude" | "codex";
370
- }
371
-
372
- const rawMax = await text({
373
- message: "Max items to send to the agent",
374
- placeholder: "50 (or 'all')",
375
- defaultValue: String(maxItems),
376
- });
377
- if (isCancel(rawMax)) {
378
- outro("Cancelled.");
379
- return;
604
+ log.warn(
605
+ 'No agent tool found. Install "claude" or "codex" to run an agent audit.'
606
+ );
380
607
  }
381
- const raw = String(rawMax).trim().toLowerCase();
608
+ const raw = String(setup?.maxItems ?? "")
609
+ .trim()
610
+ .toLowerCase();
382
611
  if (raw === "all" || raw === "0") {
383
612
  maxItems = 0;
384
613
  } else {
385
- const n = Number(raw);
386
- if (Number.isFinite(n) && n > 0) {
387
- maxItems = Math.floor(n);
614
+ const parsed = Number(raw);
615
+ if (Number.isFinite(parsed) && parsed > 0) {
616
+ maxItems = Math.floor(parsed);
388
617
  }
389
618
  }
390
619
  }
391
620
 
621
+ note(
622
+ [
623
+ `Mode: ${mode}`,
624
+ `Roots: ${summarizeRoots({ includeConfigFrom, from, cfgRoots })}`,
625
+ `Git hooks: ${includeGitHooks ? "included" : "skipped"}`,
626
+ ...(mode === "static" || mode === "both"
627
+ ? [`Minimum severity: ${minSeverity}`]
628
+ : []),
629
+ ...(mode === "agent" || mode === "both"
630
+ ? [
631
+ `Agent tool: ${agentTool ?? "not available"}`,
632
+ `Agent max items: ${maxItems === 0 ? "all" : String(maxItems)}`,
633
+ ]
634
+ : []),
635
+ ].join("\n"),
636
+ "Plan"
637
+ );
638
+
392
639
  const reports: { static?: StaticAuditReport; agent?: AgentAuditReport } = {};
393
640
 
394
641
  if (mode === "static" || mode === "both") {
@@ -404,6 +651,9 @@ export async function auditTuiCommand(argv: string[]) {
404
651
  from,
405
652
  });
406
653
  sp.stop("Static audit complete.");
654
+ if (reports.static) {
655
+ log.success(`Static summary: ${summarizeReportStatic(reports.static)}`);
656
+ }
407
657
  } catch (err) {
408
658
  sp.stop("Static audit failed.");
409
659
  outro(err instanceof Error ? err.message : String(err));
@@ -436,6 +686,9 @@ export async function auditTuiCommand(argv: string[]) {
436
686
  },
437
687
  });
438
688
  sp.stop("Agent audit complete.");
689
+ if (reports.agent) {
690
+ log.success(`Agent summary: ${summarizeReportAgent(reports.agent)}`);
691
+ }
439
692
  } catch (err) {
440
693
  sp.stop("Agent audit failed.");
441
694
  outro(err instanceof Error ? err.message : String(err));
@@ -444,30 +697,16 @@ export async function auditTuiCommand(argv: string[]) {
444
697
  }
445
698
  }
446
699
 
447
- const summaries: string[] = [];
448
700
  if (reports.static) {
449
- summaries.push(`Static: ${summarizeReportStatic(reports.static)}`);
450
- summaries.push(
451
- `Wrote ${join(facultStateDir(homedir()), "audit", "static-latest.json")}`
701
+ log.info(
702
+ `Static report saved to ${join(facultStateDir(homedir()), "audit", "static-latest.json")}`
452
703
  );
453
704
  }
454
705
  if (reports.agent) {
455
- summaries.push(`Agent: ${summarizeReportAgent(reports.agent)}`);
456
- summaries.push(
457
- `Wrote ${join(facultStateDir(homedir()), "audit", "agent-latest.json")}`
706
+ log.info(
707
+ `Agent report saved to ${join(facultStateDir(homedir()), "audit", "agent-latest.json")}`
458
708
  );
459
709
  }
460
- if (summaries.length) {
461
- note(summaries.join("\n"), "Summary");
462
- }
463
-
464
- const combined = uniqueByKey(
465
- mergeStaticAndAgentResults({
466
- static: reports.static?.results ?? [],
467
- agent: reports.agent?.results ?? [],
468
- }),
469
- keyForResult
470
- );
471
710
 
472
711
  let review: "static" | "agent" | "combined" = reports.agent
473
712
  ? "agent"
@@ -491,42 +730,77 @@ export async function auditTuiCommand(argv: string[]) {
491
730
  }
492
731
  }
493
732
 
494
- const results =
495
- review === "combined"
496
- ? combined
497
- : review === "agent"
498
- ? (reports.agent?.results ?? [])
499
- : (reports.static?.results ?? []);
733
+ const suppressions = await loadAuditSuppressions(homedir());
734
+ let staticReport = reports.static
735
+ ? applyAuditSuppressionsToStaticReport(reports.static, suppressions)
736
+ : undefined;
737
+ let agentReport = reports.agent
738
+ ? applyAuditSuppressionsToAgentReport(reports.agent, suppressions)
739
+ : undefined;
500
740
 
501
- const withFindings = results
502
- .filter((r) => r.findings.length > 0)
503
- .sort((a, b) => {
504
- const sa = SEVERITY_ORDER[maxSeverity(a.findings) ?? "low"];
505
- const sb = SEVERITY_ORDER[maxSeverity(b.findings) ?? "low"];
506
- return (
507
- sb - sa || a.type.localeCompare(b.type) || a.item.localeCompare(b.item)
508
- );
509
- });
741
+ let results: AuditItemResult[] = [];
742
+ let withFindings: AuditItemResult[] = [];
743
+ const refreshReviewState = () => {
744
+ const combined = uniqueByKey(
745
+ mergeStaticAndAgentResults({
746
+ static: staticReport?.results ?? [],
747
+ agent: agentReport?.results ?? [],
748
+ }),
749
+ keyForResult
750
+ );
751
+ results =
752
+ review === "combined"
753
+ ? combined
754
+ : review === "agent"
755
+ ? (agentReport?.results ?? [])
756
+ : (staticReport?.results ?? []);
757
+ withFindings = sortReviewQueue(results);
758
+ };
759
+ refreshReviewState();
510
760
 
511
761
  if (withFindings.length === 0) {
512
- outro("No findings.");
762
+ outro("No findings above the selected threshold.");
513
763
  return;
514
764
  }
515
765
 
516
766
  const failCount = withFindings.filter((r) => !r.passed).length;
517
767
  const warnCount = withFindings.length - failCount;
518
- note(`fail=${failCount} warn=${warnCount}`, "Review");
768
+ log.warn(`Review queue: ${failCount} fail, ${warnCount} warn.`);
519
769
 
520
770
  while (true) {
521
771
  const action = await select({
522
- message: "Next action",
772
+ message: "What do you want to do next?",
523
773
  options: [
524
774
  {
525
775
  value: "quarantine",
526
776
  label: "Quarantine items",
527
777
  hint: "move/copy to ~/.ai/.facult/quarantine",
528
778
  },
529
- { value: "view", label: "View item details", hint: "inspect findings" },
779
+ {
780
+ value: "view",
781
+ label: "Inspect one item",
782
+ hint: "open finding details",
783
+ },
784
+ {
785
+ value: "view-many",
786
+ label: "Inspect several items",
787
+ hint: "review multiple findings together",
788
+ },
789
+ {
790
+ value: "review-ai",
791
+ label: "Review with AI",
792
+ hint: "hand off selected findings to Codex or Claude",
793
+ },
794
+ {
795
+ value: "fix-inline-secrets",
796
+ label: "Fix inline MCP secrets",
797
+ hint: "move secrets into local canonical overlay and re-sync",
798
+ },
799
+ {
800
+ value: "mark-safe",
801
+ label: "Mark findings safe",
802
+ hint: "suppress reviewed false positives in future audits",
803
+ },
530
804
  { value: "exit", label: "Exit", hint: "leave files unchanged" },
531
805
  ],
532
806
  });
@@ -538,7 +812,7 @@ export async function auditTuiCommand(argv: string[]) {
538
812
  if (action === "view") {
539
813
  const viewList = withFindings.slice(0, 200);
540
814
  const chosen = await select({
541
- message: "Pick an item to view",
815
+ message: "Pick an item to inspect",
542
816
  options: viewList.map((r, idx) => ({
543
817
  value: String(idx),
544
818
  label: labelForResult(r),
@@ -556,6 +830,343 @@ export async function auditTuiCommand(argv: string[]) {
556
830
  continue;
557
831
  }
558
832
 
833
+ if (action === "view-many") {
834
+ const viewList = withFindings.slice(0, 100);
835
+ const picked = await multiselect({
836
+ message: "Select findings to inspect together",
837
+ options: viewList.map((r, idx) => ({
838
+ value: String(idx),
839
+ label: labelForResult(r),
840
+ hint: hintForResult(r),
841
+ })),
842
+ required: true,
843
+ });
844
+ if (isCancel(picked)) {
845
+ continue;
846
+ }
847
+ const selected = (picked as string[])
848
+ .map((value) => viewList[Number(value)])
849
+ .filter(Boolean) as AuditItemResult[];
850
+ if (selected.length === 0) {
851
+ continue;
852
+ }
853
+ viewMultipleFindingDetails(selected);
854
+ continue;
855
+ }
856
+
857
+ if (action === "review-ai") {
858
+ const tools = availableInteractiveReviewerTools();
859
+ if (tools.length === 0) {
860
+ log.warn('No interactive reviewer found. Install "codex" or "claude".');
861
+ continue;
862
+ }
863
+
864
+ const reviewList = withFindings.slice(0, 100);
865
+ const picked = await multiselect({
866
+ message: "Select findings to review with AI",
867
+ options: reviewList.map((r, idx) => ({
868
+ value: String(idx),
869
+ label: labelForResult(r),
870
+ hint: hintForResult(r),
871
+ })),
872
+ initialValues: reviewList
873
+ .slice(0, Math.min(reviewList.length, 8))
874
+ .map((_, idx) => String(idx)),
875
+ required: true,
876
+ });
877
+ if (isCancel(picked)) {
878
+ continue;
879
+ }
880
+ const selected = (picked as string[])
881
+ .map((value) => reviewList[Number(value)])
882
+ .filter(Boolean) as AuditItemResult[];
883
+ if (selected.length === 0) {
884
+ continue;
885
+ }
886
+
887
+ const tool =
888
+ tools.length === 1
889
+ ? tools[0]
890
+ : ((await select({
891
+ message: "Which reviewer should take this handoff?",
892
+ options: tools.map((candidate) => ({
893
+ value: candidate,
894
+ label: candidate,
895
+ hint:
896
+ candidate === "codex"
897
+ ? "interactive code-focused review"
898
+ : "interactive Claude review",
899
+ })),
900
+ })) as InteractiveReviewerTool | symbol);
901
+ if (isCancel(tool) || !tool) {
902
+ continue;
903
+ }
904
+
905
+ const prompt = buildReviewerPrompt({
906
+ items: selected,
907
+ reviewMode: review,
908
+ cwd: process.cwd(),
909
+ });
910
+
911
+ const ok = await confirm({
912
+ message: `Start an interactive ${tool} session with ${selected.length} selected finding${selected.length === 1 ? "" : "s"}?`,
913
+ initialValue: true,
914
+ active: "Start session",
915
+ inactive: "Cancel",
916
+ });
917
+ if (isCancel(ok) || ok !== true) {
918
+ continue;
919
+ }
920
+
921
+ log.step(`Launching ${tool} with the selected audit context...`);
922
+ const exitCode = await launchInteractiveReviewer({
923
+ tool,
924
+ prompt,
925
+ cwd: process.cwd(),
926
+ });
927
+ if (exitCode === 0) {
928
+ outro(
929
+ `Returned from ${tool}. Re-run audit when you want a fresh review queue.`
930
+ );
931
+ } else {
932
+ outro(`${tool} exited with code ${exitCode}.`);
933
+ }
934
+ return;
935
+ }
936
+
937
+ if (action === "fix-inline-secrets") {
938
+ const candidates = withFindings
939
+ .flatMap((result) =>
940
+ result.findings.map((finding) => ({
941
+ result,
942
+ finding,
943
+ }))
944
+ )
945
+ .filter(
946
+ (selection) =>
947
+ selection.result.type === "mcp" &&
948
+ selection.finding.ruleId.replace(AUDIT_RULE_PREFIX_RE, "") ===
949
+ "mcp-env-inline-secret"
950
+ )
951
+ .slice(0, 300);
952
+ if (candidates.length === 0) {
953
+ log.info("No fixable inline MCP secret findings in the current queue.");
954
+ continue;
955
+ }
956
+
957
+ const picked = await multiselect({
958
+ message: "Select inline MCP secret findings to fix",
959
+ options: candidates.map((candidate, idx) => ({
960
+ value: String(idx),
961
+ label: inlineSecretSelectionLabel(candidate),
962
+ hint: hintForFindingSelection(candidate),
963
+ })),
964
+ required: true,
965
+ });
966
+ if (isCancel(picked)) {
967
+ continue;
968
+ }
969
+
970
+ const selected = (picked as string[])
971
+ .map((value) => candidates[Number(value)])
972
+ .filter(Boolean) as {
973
+ result: AuditItemResult;
974
+ finding: AuditFinding;
975
+ }[];
976
+ if (selected.length === 0) {
977
+ continue;
978
+ }
979
+
980
+ const ok = await confirm({
981
+ message: `Fix ${selected.length} selected inline MCP secret finding${selected.length === 1 ? "" : "s"}?`,
982
+ initialValue: true,
983
+ active: "Fix now",
984
+ inactive: "Cancel",
985
+ });
986
+ if (isCancel(ok) || ok !== true) {
987
+ continue;
988
+ }
989
+
990
+ const fixResult = await fixInlineMcpSecrets({
991
+ findings: selected,
992
+ homeDir: homedir(),
993
+ });
994
+ if (fixResult.fixed === 0) {
995
+ log.warn("No selected findings could be fixed automatically.");
996
+ for (const skipped of fixResult.skipped.slice(0, 6)) {
997
+ log.info(`${skipped.label}: ${skipped.reason}`);
998
+ }
999
+ continue;
1000
+ }
1001
+
1002
+ staticReport = staticReport
1003
+ ? applyAuditSuppressionsToStaticReport(
1004
+ {
1005
+ ...staticReport,
1006
+ results: removeFixedInlineSecretFindings({
1007
+ results: staticReport.results,
1008
+ fixed: fixResult.fixedSelections,
1009
+ }),
1010
+ },
1011
+ []
1012
+ )
1013
+ : undefined;
1014
+ agentReport = agentReport
1015
+ ? applyAuditSuppressionsToAgentReport(
1016
+ {
1017
+ ...agentReport,
1018
+ results: removeFixedInlineSecretFindings({
1019
+ results: agentReport.results,
1020
+ fixed: fixResult.fixedSelections,
1021
+ }),
1022
+ },
1023
+ []
1024
+ )
1025
+ : undefined;
1026
+ refreshReviewState();
1027
+
1028
+ if (staticReport) {
1029
+ await Bun.write(
1030
+ join(facultStateDir(homedir()), "audit", "static-latest.json"),
1031
+ `${JSON.stringify(staticReport, null, 2)}\n`
1032
+ );
1033
+ }
1034
+ if (agentReport) {
1035
+ await Bun.write(
1036
+ join(facultStateDir(homedir()), "audit", "agent-latest.json"),
1037
+ `${JSON.stringify(agentReport, null, 2)}\n`
1038
+ );
1039
+ }
1040
+
1041
+ await updateIndexFromAuditReport({
1042
+ homeDir: homedir(),
1043
+ timestamp: new Date().toISOString(),
1044
+ results: uniqueByKey(
1045
+ mergeStaticAndAgentResults({
1046
+ static: staticReport?.results ?? [],
1047
+ agent: agentReport?.results ?? [],
1048
+ }),
1049
+ keyForResult
1050
+ ),
1051
+ });
1052
+
1053
+ log.success(
1054
+ `Fixed ${fixResult.fixed} inline MCP secret finding${fixResult.fixed === 1 ? "" : "s"}.`
1055
+ );
1056
+ if (fixResult.trackedPath && fixResult.localPath) {
1057
+ log.info(`Tracked MCP config: ${fixResult.trackedPath}`);
1058
+ log.info(`Local MCP overlay: ${fixResult.localPath}`);
1059
+ }
1060
+ if (fixResult.syncedTools.length > 0) {
1061
+ log.info(
1062
+ `Re-synced managed tools: ${fixResult.syncedTools.join(", ")}`
1063
+ );
1064
+ }
1065
+ if (fixResult.riskyManagedOutputs.length > 0) {
1066
+ for (const output of fixResult.riskyManagedOutputs) {
1067
+ log.warn(
1068
+ `${output.path} is ${output.state === "tracked" ? "git-tracked" : "repo-local and not gitignored"}.`
1069
+ );
1070
+ }
1071
+ }
1072
+ if (fixResult.skipped.length > 0) {
1073
+ log.warn(
1074
+ `Skipped ${fixResult.skipped.length} finding${fixResult.skipped.length === 1 ? "" : "s"} that still need manual review.`
1075
+ );
1076
+ }
1077
+ continue;
1078
+ }
1079
+
1080
+ if (action === "mark-safe") {
1081
+ const candidates = withFindings
1082
+ .flatMap((result) =>
1083
+ result.findings.map((finding) => ({
1084
+ result,
1085
+ finding,
1086
+ }))
1087
+ )
1088
+ .slice(0, 300);
1089
+ const picked = await multiselect({
1090
+ message: "Select findings to mark safe",
1091
+ options: candidates.map((candidate, idx) => ({
1092
+ value: String(idx),
1093
+ label: labelForFindingSelection(candidate),
1094
+ hint: hintForFindingSelection(candidate),
1095
+ })),
1096
+ required: true,
1097
+ });
1098
+ if (isCancel(picked)) {
1099
+ continue;
1100
+ }
1101
+
1102
+ const selected = (picked as string[])
1103
+ .map((value) => candidates[Number(value)])
1104
+ .filter(Boolean) as {
1105
+ result: AuditItemResult;
1106
+ finding: AuditFinding;
1107
+ }[];
1108
+ if (selected.length === 0) {
1109
+ continue;
1110
+ }
1111
+
1112
+ const why = await text({
1113
+ message: "Why is this safe?",
1114
+ placeholder: "optional note for future reviews",
1115
+ });
1116
+ if (isCancel(why)) {
1117
+ continue;
1118
+ }
1119
+
1120
+ const ok = await confirm({
1121
+ message: `Suppress ${selected.length} selected finding${selected.length === 1 ? "" : "s"} in future audits?`,
1122
+ initialValue: true,
1123
+ active: "Mark safe",
1124
+ inactive: "Cancel",
1125
+ });
1126
+ if (isCancel(ok) || ok !== true) {
1127
+ continue;
1128
+ }
1129
+
1130
+ const saved = await recordAuditSuppressions({
1131
+ homeDir: homedir(),
1132
+ selected,
1133
+ note: String(why ?? ""),
1134
+ });
1135
+ const nextSuppressions = await loadAuditSuppressions(homedir());
1136
+ staticReport = staticReport
1137
+ ? applyAuditSuppressionsToStaticReport(staticReport, nextSuppressions)
1138
+ : undefined;
1139
+ agentReport = agentReport
1140
+ ? applyAuditSuppressionsToAgentReport(agentReport, nextSuppressions)
1141
+ : undefined;
1142
+ refreshReviewState();
1143
+
1144
+ await updateIndexFromAuditReport({
1145
+ homeDir: homedir(),
1146
+ timestamp: new Date().toISOString(),
1147
+ results: uniqueByKey(
1148
+ mergeStaticAndAgentResults({
1149
+ static: staticReport?.results ?? [],
1150
+ agent: agentReport?.results ?? [],
1151
+ }),
1152
+ keyForResult
1153
+ ),
1154
+ });
1155
+
1156
+ log.success(
1157
+ `Marked ${selected.length} finding${selected.length === 1 ? "" : "s"} safe. Saved ${saved.added} new suppression${saved.added === 1 ? "" : "s"}.`
1158
+ );
1159
+ if (withFindings.length === 0) {
1160
+ outro("All reviewed findings are now suppressed.");
1161
+ return;
1162
+ }
1163
+
1164
+ const failCount = withFindings.filter((result) => !result.passed).length;
1165
+ const warnCount = withFindings.length - failCount;
1166
+ log.info(`Updated review queue: ${failCount} fail, ${warnCount} warn.`);
1167
+ continue;
1168
+ }
1169
+
559
1170
  // quarantine
560
1171
  const quarantineList = withFindings.slice(0, 500);
561
1172
  const picked = await multiselect({
@@ -579,19 +1190,19 @@ export async function auditTuiCommand(argv: string[]) {
579
1190
  .map((i) => quarantineList[i])
580
1191
  .filter(Boolean) as AuditItemResult[];
581
1192
  if (selected.length === 0) {
582
- note("No items selected.", "Quarantine");
1193
+ log.info("Quarantine: no items selected.");
583
1194
  continue;
584
1195
  }
585
1196
 
586
1197
  const modeChoice = await select({
587
- message: "Quarantine mode",
1198
+ message: "How should quarantine behave?",
588
1199
  options: [
589
1200
  {
590
1201
  value: "move",
591
- label: "Move (quarantine)",
1202
+ label: "Move",
592
1203
  hint: "removes from original location",
593
1204
  },
594
- { value: "copy", label: "Copy (snapshot)", hint: "non-destructive" },
1205
+ { value: "copy", label: "Copy", hint: "non-destructive snapshot" },
595
1206
  ],
596
1207
  });
597
1208
  if (isCancel(modeChoice)) {
@@ -636,12 +1247,14 @@ export async function auditTuiCommand(argv: string[]) {
636
1247
  .join("\n");
637
1248
  note(
638
1249
  `${preview}${plan.manifest.entries.length > 12 ? `\n... (${plan.manifest.entries.length - 12} more)` : ""}`,
639
- "Planned quarantine"
1250
+ "Quarantine plan"
640
1251
  );
641
1252
 
642
1253
  const ok = await confirm({
643
1254
  message: "Proceed with quarantine?",
644
1255
  initialValue: false,
1256
+ active: "Proceed",
1257
+ inactive: "Cancel",
645
1258
  });
646
1259
  if (isCancel(ok) || ok === false) {
647
1260
  continue;
@@ -657,13 +1270,8 @@ export async function auditTuiCommand(argv: string[]) {
657
1270
  destDir,
658
1271
  });
659
1272
  sp.stop("Quarantine complete.");
660
- note(
661
- `Quarantine directory:\n${res.quarantineDir}\n\nManifest:\n${join(
662
- res.quarantineDir,
663
- "manifest.json"
664
- )}`,
665
- "Quarantine"
666
- );
1273
+ log.success(`Quarantine directory: ${res.quarantineDir}`);
1274
+ log.info(`Manifest: ${join(res.quarantineDir, "manifest.json")}`);
667
1275
 
668
1276
  // If we quarantined canonical-store paths, offer to rebuild the index so list/show stay accurate.
669
1277
  if (qMode === "move") {
@@ -683,10 +1291,12 @@ export async function auditTuiCommand(argv: string[]) {
683
1291
  try {
684
1292
  const { outputPath } = await buildIndex({ force: false });
685
1293
  isp.stop("Index rebuilt.");
686
- note(`Index written to:\n${outputPath}`, "Index");
1294
+ log.success(`Index rebuilt: ${outputPath}`);
687
1295
  } catch (e: unknown) {
688
1296
  isp.stop("Index rebuild failed.");
689
- note(e instanceof Error ? e.message : String(e), "Index");
1297
+ log.error(
1298
+ `Index rebuild failed: ${e instanceof Error ? e.message : String(e)}`
1299
+ );
690
1300
  }
691
1301
  }
692
1302
  }