facult 2.6.0 → 2.7.1
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 +145 -337
- package/package.json +1 -1
- package/src/adapters/codex.ts +1 -1
- package/src/audit/agent.ts +26 -24
- package/src/audit/fix.ts +875 -0
- package/src/audit/index.ts +51 -2
- package/src/audit/safe.ts +596 -0
- package/src/audit/static.ts +151 -34
- package/src/audit/status.ts +21 -0
- package/src/audit/suppressions.ts +266 -0
- package/src/audit/tui.ts +784 -174
- package/src/audit/update-index.ts +4 -17
- package/src/builtin.ts +7 -1
- package/src/cli-ui.ts +375 -0
- package/src/consolidate.ts +151 -55
- package/src/doctor.ts +327 -0
- package/src/global-docs.ts +43 -2
- package/src/index.ts +571 -292
- package/src/manage.ts +931 -88
- package/src/mcp-config.ts +132 -0
- package/src/project-sync.ts +288 -0
- package/src/remote.ts +387 -117
- package/src/trust.ts +119 -11
- package/src/util/git.ts +95 -0
package/src/audit/tui.ts
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
230
|
-
`
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
options: [
|
|
430
|
+
try {
|
|
431
|
+
setup = await group(
|
|
272
432
|
{
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
-
|
|
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((
|
|
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 (
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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(
|
|
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
|
|
386
|
-
if (Number.isFinite(
|
|
387
|
-
maxItems = Math.floor(
|
|
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
|
-
|
|
450
|
-
|
|
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
|
-
|
|
456
|
-
|
|
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
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
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
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
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
|
-
|
|
768
|
+
log.warn(`Review queue: ${failCount} fail, ${warnCount} warn.`);
|
|
519
769
|
|
|
520
770
|
while (true) {
|
|
521
771
|
const action = await select({
|
|
522
|
-
message: "
|
|
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
|
-
{
|
|
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
|
|
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
|
-
|
|
1193
|
+
log.info("Quarantine: no items selected.");
|
|
583
1194
|
continue;
|
|
584
1195
|
}
|
|
585
1196
|
|
|
586
1197
|
const modeChoice = await select({
|
|
587
|
-
message: "
|
|
1198
|
+
message: "How should quarantine behave?",
|
|
588
1199
|
options: [
|
|
589
1200
|
{
|
|
590
1201
|
value: "move",
|
|
591
|
-
label: "Move
|
|
1202
|
+
label: "Move",
|
|
592
1203
|
hint: "removes from original location",
|
|
593
1204
|
},
|
|
594
|
-
{ value: "copy", label: "Copy
|
|
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
|
-
"
|
|
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
|
-
|
|
661
|
-
|
|
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
|
-
|
|
1294
|
+
log.success(`Index rebuilt: ${outputPath}`);
|
|
687
1295
|
} catch (e: unknown) {
|
|
688
1296
|
isp.stop("Index rebuild failed.");
|
|
689
|
-
|
|
1297
|
+
log.error(
|
|
1298
|
+
`Index rebuild failed: ${e instanceof Error ? e.message : String(e)}`
|
|
1299
|
+
);
|
|
690
1300
|
}
|
|
691
1301
|
}
|
|
692
1302
|
}
|