facult 2.6.0 → 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.
@@ -0,0 +1,875 @@
1
+ import { mkdir } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { basename, dirname, join } from "node:path";
4
+ import { loadManagedState, syncManagedTools } from "../manage";
5
+ import {
6
+ extractServersObject,
7
+ isInlineMcpSecretValue,
8
+ loadCanonicalMcpState,
9
+ stringifyCanonicalMcpServers,
10
+ } from "../mcp-config";
11
+ import { facultContextRootDir, facultStateDir } from "../paths";
12
+ import { getGitPathExposure } from "../util/git";
13
+ import { parseJsonLenient } from "../util/json";
14
+ import type { AgentAuditReport } from "./agent";
15
+ import { computeStoredAuditStatus, isStoredAuditStatusPassed } from "./status";
16
+ import {
17
+ applyAuditSuppressionsToAgentReport,
18
+ applyAuditSuppressionsToStaticReport,
19
+ } from "./suppressions";
20
+ import type { AuditFinding, AuditItemResult, StaticAuditReport } from "./types";
21
+ import { updateIndexFromAuditReport } from "./update-index";
22
+
23
+ type AuditFixSource = "static" | "agent" | "combined";
24
+ const RULE_ID_PREFIX_RE = /^(static|agent):/;
25
+ const INLINE_SECRET_RULE_ID = "mcp-env-inline-secret";
26
+ const ARG_VALUE_SPLIT_RE = /=(.*)/s;
27
+
28
+ interface AuditFixArgs {
29
+ all: boolean;
30
+ dryRun: boolean;
31
+ itemSelectors: string[];
32
+ json: boolean;
33
+ paths: string[];
34
+ source?: AuditFixSource;
35
+ yes: boolean;
36
+ }
37
+
38
+ interface FindingSelection {
39
+ result: AuditItemResult;
40
+ finding: AuditFinding;
41
+ }
42
+
43
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
44
+ return !!value && typeof value === "object" && !Array.isArray(value);
45
+ }
46
+
47
+ function normalizeRuleId(ruleId: string): string {
48
+ return ruleId.replace(RULE_ID_PREFIX_RE, "");
49
+ }
50
+
51
+ function parseSource(value: string): AuditFixSource {
52
+ const normalized = value.trim().toLowerCase();
53
+ if (
54
+ normalized === "static" ||
55
+ normalized === "agent" ||
56
+ normalized === "combined"
57
+ ) {
58
+ return normalized;
59
+ }
60
+ throw new Error(`Unknown audit fix source: ${value}`);
61
+ }
62
+
63
+ function parseAuditFixArgs(argv: string[]): AuditFixArgs {
64
+ const args: AuditFixArgs = {
65
+ all: false,
66
+ dryRun: false,
67
+ itemSelectors: [],
68
+ json: false,
69
+ paths: [],
70
+ yes: false,
71
+ };
72
+
73
+ for (let i = 0; i < argv.length; i += 1) {
74
+ const arg = argv[i];
75
+ if (!arg) {
76
+ continue;
77
+ }
78
+
79
+ if (arg === "--all") {
80
+ args.all = true;
81
+ continue;
82
+ }
83
+ if (arg === "--dry-run") {
84
+ args.dryRun = true;
85
+ continue;
86
+ }
87
+ if (arg === "--json") {
88
+ args.json = true;
89
+ continue;
90
+ }
91
+ if (arg === "--yes" || arg === "-y") {
92
+ args.yes = true;
93
+ continue;
94
+ }
95
+
96
+ if (arg === "--source" || arg === "--item" || arg === "--path") {
97
+ const next = argv[i + 1];
98
+ if (!next) {
99
+ throw new Error(`${arg} requires a value`);
100
+ }
101
+ if (arg === "--source") {
102
+ args.source = parseSource(next);
103
+ } else if (arg === "--item") {
104
+ args.itemSelectors.push(next);
105
+ } else {
106
+ args.paths.push(next);
107
+ }
108
+ i += 1;
109
+ continue;
110
+ }
111
+
112
+ if (
113
+ arg.startsWith("--source=") ||
114
+ arg.startsWith("--item=") ||
115
+ arg.startsWith("--path=")
116
+ ) {
117
+ const [flag, rawValue] = arg.split(ARG_VALUE_SPLIT_RE, 2);
118
+ const value = rawValue ?? "";
119
+ if (!value) {
120
+ throw new Error(`${flag} requires a value`);
121
+ }
122
+ if (flag === "--source") {
123
+ args.source = parseSource(value);
124
+ } else if (flag === "--item") {
125
+ args.itemSelectors.push(value);
126
+ } else {
127
+ args.paths.push(value);
128
+ }
129
+ continue;
130
+ }
131
+
132
+ if (arg.startsWith("-")) {
133
+ throw new Error(`Unknown argument: ${arg}`);
134
+ }
135
+
136
+ args.itemSelectors.push(arg);
137
+ }
138
+
139
+ if (!args.all && args.itemSelectors.length === 0 && args.paths.length === 0) {
140
+ throw new Error("Specify what to fix with --item, --path, or use --all.");
141
+ }
142
+
143
+ return args;
144
+ }
145
+
146
+ function parseInlineSecretLocation(location: string): {
147
+ configPath: string;
148
+ serverName: string;
149
+ envKey: string;
150
+ } | null {
151
+ const envMarker = location.lastIndexOf(":env:");
152
+ if (envMarker <= 0) {
153
+ return null;
154
+ }
155
+ const envKey = location.slice(envMarker + ":env:".length).trim();
156
+ const left = location.slice(0, envMarker);
157
+ const serverMarker = left.lastIndexOf(":");
158
+ if (serverMarker <= 0 || !envKey) {
159
+ return null;
160
+ }
161
+ const configPath = left.slice(0, serverMarker);
162
+ const serverName = left.slice(serverMarker + 1).trim();
163
+ if (!(configPath && serverName)) {
164
+ return null;
165
+ }
166
+ return { configPath, serverName, envKey };
167
+ }
168
+
169
+ function cloneRecord(value: Record<string, unknown>): Record<string, unknown> {
170
+ return JSON.parse(JSON.stringify(value)) as Record<string, unknown>;
171
+ }
172
+
173
+ function ensureServerRecord(
174
+ servers: Record<string, unknown>,
175
+ serverName: string
176
+ ): Record<string, unknown> {
177
+ const current = servers[serverName];
178
+ if (isPlainObject(current)) {
179
+ return current;
180
+ }
181
+ const next: Record<string, unknown> = {};
182
+ servers[serverName] = next;
183
+ return next;
184
+ }
185
+
186
+ function readSecretFromServer(
187
+ server: Record<string, unknown> | null,
188
+ envKey: string
189
+ ): string | null {
190
+ if (!server) {
191
+ return null;
192
+ }
193
+ const env = server.env;
194
+ if (!isPlainObject(env)) {
195
+ return null;
196
+ }
197
+ const value = env[envKey];
198
+ return isInlineMcpSecretValue(value) ? value : null;
199
+ }
200
+
201
+ function scrubTrackedServerEnv(
202
+ server: Record<string, unknown>,
203
+ envKey: string
204
+ ) {
205
+ const env = server.env;
206
+ if (!isPlainObject(env)) {
207
+ return;
208
+ }
209
+ delete env[envKey];
210
+ if (Object.keys(env).length === 0) {
211
+ server.env = undefined;
212
+ }
213
+ }
214
+
215
+ function setLocalServerEnv(args: {
216
+ localServers: Record<string, unknown>;
217
+ serverName: string;
218
+ envKey: string;
219
+ secretValue: string;
220
+ }) {
221
+ const server = ensureServerRecord(args.localServers, args.serverName);
222
+ const env = isPlainObject(server.env)
223
+ ? (server.env as Record<string, unknown>)
224
+ : {};
225
+ env[args.envKey] = args.secretValue;
226
+ server.env = env;
227
+ }
228
+
229
+ function findingKey(args: {
230
+ result: AuditItemResult;
231
+ finding: AuditFinding;
232
+ }): string {
233
+ const parsed = args.finding.location
234
+ ? parseInlineSecretLocation(args.finding.location)
235
+ : null;
236
+ return [
237
+ args.result.type,
238
+ args.result.item,
239
+ parsed?.serverName ?? "",
240
+ parsed?.envKey ?? "",
241
+ normalizeRuleId(args.finding.ruleId),
242
+ ].join("\0");
243
+ }
244
+
245
+ function keyForResult(result: AuditItemResult): string {
246
+ return `${result.type}\0${result.item}\0${result.path}`;
247
+ }
248
+
249
+ function prefixRuleId(
250
+ finding: AuditFinding,
251
+ prefix: "static" | "agent"
252
+ ): AuditFinding {
253
+ return finding.ruleId.startsWith(`${prefix}:`)
254
+ ? finding
255
+ : { ...finding, ruleId: `${prefix}:${finding.ruleId}` };
256
+ }
257
+
258
+ function uniqueByKey<T>(items: T[], key: (value: T) => string): T[] {
259
+ const seen = new Set<string>();
260
+ const out: T[] = [];
261
+ for (const item of items) {
262
+ const itemKey = key(item);
263
+ if (seen.has(itemKey)) {
264
+ continue;
265
+ }
266
+ seen.add(itemKey);
267
+ out.push(item);
268
+ }
269
+ return out;
270
+ }
271
+
272
+ function mergeStaticAndAgentResults(args: {
273
+ static: AuditItemResult[];
274
+ agent: AuditItemResult[];
275
+ }): AuditItemResult[] {
276
+ const byKey = new Map<
277
+ string,
278
+ { static?: AuditItemResult; agent?: AuditItemResult }
279
+ >();
280
+
281
+ for (const result of args.static) {
282
+ const key = keyForResult(result);
283
+ const previous = byKey.get(key) ?? {};
284
+ byKey.set(key, { ...previous, static: result });
285
+ }
286
+ for (const result of args.agent) {
287
+ const key = keyForResult(result);
288
+ const previous = byKey.get(key) ?? {};
289
+ byKey.set(key, { ...previous, agent: result });
290
+ }
291
+
292
+ const out: AuditItemResult[] = [];
293
+ for (const key of [...byKey.keys()].sort()) {
294
+ const entry = byKey.get(key);
295
+ if (!entry) {
296
+ continue;
297
+ }
298
+ if (entry.static && entry.agent) {
299
+ out.push({
300
+ ...entry.agent,
301
+ passed: entry.static.passed && entry.agent.passed,
302
+ findings: [
303
+ ...entry.agent.findings.map((finding) =>
304
+ prefixRuleId(finding, "agent")
305
+ ),
306
+ ...entry.static.findings.map((finding) =>
307
+ prefixRuleId(finding, "static")
308
+ ),
309
+ ],
310
+ });
311
+ continue;
312
+ }
313
+ out.push(entry.agent ?? entry.static!);
314
+ }
315
+ return out;
316
+ }
317
+
318
+ function matchesItemSelector(
319
+ result: AuditItemResult,
320
+ selector: string
321
+ ): boolean {
322
+ const normalized = selector.trim();
323
+ if (!normalized) {
324
+ return false;
325
+ }
326
+ const labels = [
327
+ result.item,
328
+ `${result.type}:${result.item}`,
329
+ result.type === "mcp" ? `mcp:${result.item}` : null,
330
+ basename(result.path),
331
+ ].filter(Boolean) as string[];
332
+ return labels.some(
333
+ (label) => label.toLowerCase() === normalized.toLowerCase()
334
+ );
335
+ }
336
+
337
+ function matchesPath(result: AuditItemResult, candidate: string): boolean {
338
+ const normalized = candidate.trim().toLowerCase();
339
+ if (!normalized) {
340
+ return false;
341
+ }
342
+ const path = result.path.toLowerCase();
343
+ return path === normalized || path.endsWith(`/${normalized}`);
344
+ }
345
+
346
+ function matchesSelection(args: {
347
+ result: AuditItemResult;
348
+ filters: AuditFixArgs;
349
+ }): boolean {
350
+ if (
351
+ args.filters.itemSelectors.length > 0 &&
352
+ !args.filters.itemSelectors.some((selector) =>
353
+ matchesItemSelector(args.result, selector)
354
+ )
355
+ ) {
356
+ return false;
357
+ }
358
+
359
+ if (
360
+ args.filters.paths.length > 0 &&
361
+ !args.filters.paths.some((candidate) => matchesPath(args.result, candidate))
362
+ ) {
363
+ return false;
364
+ }
365
+
366
+ return true;
367
+ }
368
+
369
+ async function loadLatestStaticReport(
370
+ homeDir: string
371
+ ): Promise<StaticAuditReport | null> {
372
+ const path = join(facultStateDir(homeDir), "audit", "static-latest.json");
373
+ const file = Bun.file(path);
374
+ if (!(await file.exists())) {
375
+ return null;
376
+ }
377
+ return (await file.json()) as StaticAuditReport;
378
+ }
379
+
380
+ async function loadLatestAgentReport(
381
+ homeDir: string
382
+ ): Promise<AgentAuditReport | null> {
383
+ const path = join(facultStateDir(homeDir), "audit", "agent-latest.json");
384
+ const file = Bun.file(path);
385
+ if (!(await file.exists())) {
386
+ return null;
387
+ }
388
+ return (await file.json()) as AgentAuditReport;
389
+ }
390
+
391
+ function inferSource(args: {
392
+ requested?: AuditFixSource;
393
+ staticReport: StaticAuditReport | null;
394
+ agentReport: AgentAuditReport | null;
395
+ }): AuditFixSource {
396
+ if (args.requested) {
397
+ return args.requested;
398
+ }
399
+ if (args.staticReport && args.agentReport) {
400
+ return "combined";
401
+ }
402
+ if (args.agentReport) {
403
+ return "agent";
404
+ }
405
+ return "static";
406
+ }
407
+
408
+ function rewriteStaticReportResults(
409
+ report: StaticAuditReport,
410
+ fixedSelections: FindingSelection[]
411
+ ): StaticAuditReport {
412
+ return applyAuditSuppressionsToStaticReport(
413
+ {
414
+ ...report,
415
+ results: removeFixedInlineSecretFindings({
416
+ results: report.results,
417
+ fixed: fixedSelections,
418
+ }),
419
+ },
420
+ []
421
+ );
422
+ }
423
+
424
+ function rewriteAgentReportResults(
425
+ report: AgentAuditReport,
426
+ fixedSelections: FindingSelection[]
427
+ ): AgentAuditReport {
428
+ return applyAuditSuppressionsToAgentReport(
429
+ {
430
+ ...report,
431
+ results: removeFixedInlineSecretFindings({
432
+ results: report.results,
433
+ fixed: fixedSelections,
434
+ }),
435
+ },
436
+ []
437
+ );
438
+ }
439
+
440
+ async function rewriteLatestReports(args: {
441
+ homeDir: string;
442
+ staticReport: StaticAuditReport | null;
443
+ agentReport: AgentAuditReport | null;
444
+ }) {
445
+ const auditDir = join(facultStateDir(args.homeDir), "audit");
446
+ await mkdir(auditDir, { recursive: true });
447
+ if (args.staticReport) {
448
+ await Bun.write(
449
+ join(auditDir, "static-latest.json"),
450
+ `${JSON.stringify(args.staticReport, null, 2)}\n`
451
+ );
452
+ }
453
+ if (args.agentReport) {
454
+ await Bun.write(
455
+ join(auditDir, "agent-latest.json"),
456
+ `${JSON.stringify(args.agentReport, null, 2)}\n`
457
+ );
458
+ }
459
+ }
460
+
461
+ function selectFixableFindings(args: {
462
+ results: AuditItemResult[];
463
+ filters: AuditFixArgs;
464
+ }): FindingSelection[] {
465
+ return uniqueByKey(
466
+ args.results.flatMap((result) =>
467
+ result.findings
468
+ .filter(
469
+ (finding) =>
470
+ normalizeRuleId(finding.ruleId) === INLINE_SECRET_RULE_ID &&
471
+ result.type === "mcp" &&
472
+ matchesSelection({ result, filters: args.filters })
473
+ )
474
+ .map((finding) => ({ result, finding }))
475
+ ),
476
+ (selection) => findingKey(selection)
477
+ );
478
+ }
479
+
480
+ export async function fixInlineMcpSecrets(args: {
481
+ findings: FindingSelection[];
482
+ homeDir?: string;
483
+ rootDir?: string;
484
+ }): Promise<{
485
+ fixed: number;
486
+ fixedSelections: FindingSelection[];
487
+ localPath: string | null;
488
+ riskyManagedOutputs: { path: string; state: "tracked" | "untracked" }[];
489
+ skipped: { label: string; reason: string }[];
490
+ syncedTools: string[];
491
+ trackedPath: string | null;
492
+ }> {
493
+ const homeDir = args.homeDir ?? homedir();
494
+ const rootDir =
495
+ args.rootDir ?? facultContextRootDir({ home: homeDir, cwd: process.cwd() });
496
+ const selected = args.findings.filter(
497
+ ({ result, finding }) =>
498
+ result.type === "mcp" &&
499
+ normalizeRuleId(finding.ruleId) === INLINE_SECRET_RULE_ID &&
500
+ typeof finding.location === "string"
501
+ );
502
+ if (selected.length === 0) {
503
+ return {
504
+ fixed: 0,
505
+ fixedSelections: [],
506
+ localPath: null,
507
+ riskyManagedOutputs: [],
508
+ skipped: [],
509
+ syncedTools: [],
510
+ trackedPath: null,
511
+ };
512
+ }
513
+
514
+ const managedState = await loadManagedState(homeDir, rootDir);
515
+ const managedToolsByPath = new Map<string, string>();
516
+ for (const [tool, entry] of Object.entries(managedState.tools)) {
517
+ if (entry.mcpConfig) {
518
+ managedToolsByPath.set(entry.mcpConfig, tool);
519
+ }
520
+ }
521
+
522
+ const canonical = await loadCanonicalMcpState(rootDir, {
523
+ includeLocal: true,
524
+ });
525
+ const trackedServers = cloneRecord(canonical.trackedServers);
526
+ const localServers = cloneRecord(canonical.localServers);
527
+ const touchedTools = new Set<string>();
528
+ const fixedSelections: FindingSelection[] = [];
529
+ const skipped: { label: string; reason: string }[] = [];
530
+
531
+ for (const selection of selected) {
532
+ const parsed = selection.finding.location
533
+ ? parseInlineSecretLocation(selection.finding.location)
534
+ : null;
535
+ const label = `${selection.result.item}:${selection.finding.location ?? selection.result.path}`;
536
+ if (!parsed) {
537
+ skipped.push({ label, reason: "could-not-parse-location" });
538
+ continue;
539
+ }
540
+
541
+ const trackedServer = isPlainObject(trackedServers[parsed.serverName])
542
+ ? (trackedServers[parsed.serverName] as Record<string, unknown>)
543
+ : null;
544
+ const localServer = isPlainObject(localServers[parsed.serverName])
545
+ ? (localServers[parsed.serverName] as Record<string, unknown>)
546
+ : null;
547
+
548
+ let secretValue =
549
+ readSecretFromServer(trackedServer, parsed.envKey) ??
550
+ readSecretFromServer(localServer, parsed.envKey);
551
+
552
+ if (!secretValue) {
553
+ const selectedPathRaw = await Bun.file(selection.result.path)
554
+ .text()
555
+ .catch(() => null);
556
+ if (selectedPathRaw) {
557
+ try {
558
+ const parsedConfig = parseJsonLenient(selectedPathRaw);
559
+ const servers = extractServersObject(parsedConfig);
560
+ const selectedServer = servers?.[parsed.serverName];
561
+ secretValue = isPlainObject(selectedServer)
562
+ ? readSecretFromServer(selectedServer, parsed.envKey)
563
+ : null;
564
+ } catch {
565
+ secretValue = null;
566
+ }
567
+ }
568
+ }
569
+
570
+ if (!secretValue) {
571
+ skipped.push({ label, reason: "no-inline-secret-value-found" });
572
+ continue;
573
+ }
574
+
575
+ if (!trackedServer) {
576
+ skipped.push({ label, reason: "server-not-found-in-canonical-store" });
577
+ continue;
578
+ }
579
+
580
+ scrubTrackedServerEnv(trackedServer, parsed.envKey);
581
+ setLocalServerEnv({
582
+ localServers,
583
+ serverName: parsed.serverName,
584
+ envKey: parsed.envKey,
585
+ secretValue,
586
+ });
587
+
588
+ const managedTool = managedToolsByPath.get(selection.result.path);
589
+ if (managedTool) {
590
+ touchedTools.add(managedTool);
591
+ }
592
+ fixedSelections.push(selection);
593
+ }
594
+
595
+ if (fixedSelections.length === 0) {
596
+ return {
597
+ fixed: 0,
598
+ fixedSelections: [],
599
+ localPath: null,
600
+ riskyManagedOutputs: [],
601
+ skipped,
602
+ syncedTools: [],
603
+ trackedPath: null,
604
+ };
605
+ }
606
+
607
+ await mkdir(dirname(canonical.trackedPath), { recursive: true });
608
+ await Bun.write(
609
+ canonical.trackedPath,
610
+ stringifyCanonicalMcpServers(trackedServers)
611
+ );
612
+ await Bun.write(
613
+ canonical.localPath,
614
+ stringifyCanonicalMcpServers(localServers)
615
+ );
616
+
617
+ if (Object.keys(managedState.tools).length > 0) {
618
+ await syncManagedTools({ homeDir, rootDir });
619
+ }
620
+
621
+ const riskyManagedOutputs = (
622
+ await Promise.all(
623
+ [...touchedTools]
624
+ .map((tool) => managedState.tools[tool]?.mcpConfig)
625
+ .filter((path): path is string => typeof path === "string")
626
+ .map(async (pathValue) => {
627
+ const exposure = await getGitPathExposure(pathValue);
628
+ if (
629
+ exposure.insideRepo &&
630
+ (exposure.state === "tracked" || exposure.state === "untracked")
631
+ ) {
632
+ return {
633
+ path: pathValue,
634
+ state: exposure.state,
635
+ };
636
+ }
637
+ return null;
638
+ })
639
+ )
640
+ ).filter(Boolean) as { path: string; state: "tracked" | "untracked" }[];
641
+
642
+ return {
643
+ fixed: uniqueByKey(fixedSelections, (selection) => findingKey(selection))
644
+ .length,
645
+ fixedSelections,
646
+ localPath: canonical.localPath,
647
+ riskyManagedOutputs,
648
+ skipped,
649
+ syncedTools: [...touchedTools].sort(),
650
+ trackedPath: canonical.trackedPath,
651
+ };
652
+ }
653
+
654
+ export function removeFixedInlineSecretFindings(args: {
655
+ results: AuditItemResult[];
656
+ fixed: FindingSelection[];
657
+ }): AuditItemResult[] {
658
+ const fixedKeys = new Set(
659
+ args.fixed.map((selection) => findingKey(selection))
660
+ );
661
+ if (fixedKeys.size === 0) {
662
+ return args.results;
663
+ }
664
+
665
+ return args.results.map((result) => {
666
+ const findings = result.findings.filter((finding) => {
667
+ if (normalizeRuleId(finding.ruleId) !== INLINE_SECRET_RULE_ID) {
668
+ return true;
669
+ }
670
+ const parsed = finding.location
671
+ ? parseInlineSecretLocation(finding.location)
672
+ : null;
673
+ if (!parsed) {
674
+ return true;
675
+ }
676
+ return !fixedKeys.has(
677
+ [
678
+ result.type,
679
+ result.item,
680
+ parsed.serverName,
681
+ parsed.envKey,
682
+ INLINE_SECRET_RULE_ID,
683
+ ].join("\0")
684
+ );
685
+ });
686
+ const status = computeStoredAuditStatus(findings);
687
+ return {
688
+ ...result,
689
+ findings,
690
+ passed: isStoredAuditStatusPassed(status),
691
+ };
692
+ });
693
+ }
694
+
695
+ export async function runAuditFix(args: {
696
+ argv: string[];
697
+ homeDir?: string;
698
+ cwd?: string;
699
+ }): Promise<{
700
+ fixed: number;
701
+ localPath: string | null;
702
+ matched: number;
703
+ riskyManagedOutputs: { path: string; state: "tracked" | "untracked" }[];
704
+ skipped: { label: string; reason: string }[];
705
+ source: AuditFixSource;
706
+ syncedTools: string[];
707
+ trackedPath: string | null;
708
+ }> {
709
+ const parsed = parseAuditFixArgs(args.argv);
710
+ const homeDir = args.homeDir ?? homedir();
711
+ const cwd = args.cwd ?? process.cwd();
712
+ const rootDir = facultContextRootDir({ home: homeDir, cwd });
713
+
714
+ const staticReport = await loadLatestStaticReport(homeDir);
715
+ const agentReport = await loadLatestAgentReport(homeDir);
716
+ if (!(staticReport || agentReport)) {
717
+ throw new Error(
718
+ "No latest audit reports found. Run `fclt audit` first, then fix the flagged secrets."
719
+ );
720
+ }
721
+
722
+ const source = inferSource({
723
+ requested: parsed.source,
724
+ staticReport,
725
+ agentReport,
726
+ });
727
+ const reportResults =
728
+ source === "static"
729
+ ? (staticReport?.results ?? [])
730
+ : source === "agent"
731
+ ? (agentReport?.results ?? [])
732
+ : mergeStaticAndAgentResults({
733
+ static: staticReport?.results ?? [],
734
+ agent: agentReport?.results ?? [],
735
+ });
736
+
737
+ const selections = selectFixableFindings({
738
+ results: reportResults,
739
+ filters: parsed,
740
+ });
741
+ if (selections.length === 0) {
742
+ throw new Error(
743
+ "No inline MCP secret findings matched the requested filters."
744
+ );
745
+ }
746
+
747
+ if (parsed.dryRun) {
748
+ return {
749
+ fixed: 0,
750
+ localPath: null,
751
+ matched: selections.length,
752
+ riskyManagedOutputs: [],
753
+ skipped: [],
754
+ source,
755
+ syncedTools: [],
756
+ trackedPath: null,
757
+ };
758
+ }
759
+
760
+ const fixed = await fixInlineMcpSecrets({
761
+ findings: selections,
762
+ homeDir,
763
+ rootDir,
764
+ });
765
+
766
+ const nextStaticReport =
767
+ staticReport && fixed.fixedSelections.length > 0
768
+ ? rewriteStaticReportResults(staticReport, fixed.fixedSelections)
769
+ : staticReport;
770
+ const nextAgentReport =
771
+ agentReport && fixed.fixedSelections.length > 0
772
+ ? rewriteAgentReportResults(agentReport, fixed.fixedSelections)
773
+ : agentReport;
774
+
775
+ await rewriteLatestReports({
776
+ homeDir,
777
+ staticReport: nextStaticReport,
778
+ agentReport: nextAgentReport,
779
+ });
780
+
781
+ await updateIndexFromAuditReport({
782
+ homeDir,
783
+ timestamp: new Date().toISOString(),
784
+ results: uniqueByKey(
785
+ mergeStaticAndAgentResults({
786
+ static: nextStaticReport?.results ?? [],
787
+ agent: nextAgentReport?.results ?? [],
788
+ }),
789
+ keyForResult
790
+ ),
791
+ });
792
+
793
+ return {
794
+ fixed: fixed.fixed,
795
+ localPath: fixed.localPath,
796
+ matched: selections.length,
797
+ riskyManagedOutputs: fixed.riskyManagedOutputs,
798
+ skipped: fixed.skipped,
799
+ source,
800
+ syncedTools: fixed.syncedTools,
801
+ trackedPath: fixed.trackedPath,
802
+ };
803
+ }
804
+
805
+ function printHelp() {
806
+ console.log(`fclt audit fix — remediate fixable audit findings
807
+
808
+ Usage:
809
+ fclt audit fix <item>
810
+ fclt audit fix --item <item> [--path <path>] [--source <static|agent|combined>]
811
+ fclt audit fix --all [--source <static|agent|combined>] [--yes]
812
+ fclt audit fix --dry-run ...
813
+
814
+ Notes:
815
+ - Currently fixes inline MCP secrets by moving them into a local canonical overlay.
816
+ - Tracked canonical MCP config is scrubbed and managed tool MCP configs are re-synced.
817
+ - Managed tool copies continue to work, but the canonical secret now lives in *.local.json.
818
+ `);
819
+ }
820
+
821
+ export async function auditFixCommand(
822
+ argv: string[],
823
+ opts?: { cwd?: string; homeDir?: string }
824
+ ) {
825
+ if (argv.includes("--help") || argv.includes("-h") || argv[0] === "help") {
826
+ printHelp();
827
+ return;
828
+ }
829
+
830
+ try {
831
+ const result = await runAuditFix({
832
+ argv,
833
+ cwd: opts?.cwd,
834
+ homeDir: opts?.homeDir,
835
+ });
836
+
837
+ if (argv.includes("--json")) {
838
+ console.log(JSON.stringify(result, null, 2));
839
+ return;
840
+ }
841
+
842
+ if (argv.includes("--dry-run")) {
843
+ console.log(
844
+ `Matched ${result.matched} inline MCP secret finding${result.matched === 1 ? "" : "s"} in the ${result.source} audit view.`
845
+ );
846
+ return;
847
+ }
848
+
849
+ console.log(
850
+ `Fixed ${result.fixed} inline MCP secret finding${result.fixed === 1 ? "" : "s"} in the ${result.source} audit view.`
851
+ );
852
+ if (result.trackedPath && result.localPath) {
853
+ console.log(`Tracked canonical MCP config: ${result.trackedPath}`);
854
+ console.log(`Local MCP overlay: ${result.localPath}`);
855
+ }
856
+ if (result.syncedTools.length > 0) {
857
+ console.log(`Re-synced managed tools: ${result.syncedTools.join(", ")}`);
858
+ }
859
+ if (result.riskyManagedOutputs.length > 0) {
860
+ for (const output of result.riskyManagedOutputs) {
861
+ console.warn(
862
+ `Warning: ${output.path} is ${output.state === "tracked" ? "git-tracked" : "repo-local and not gitignored"}.`
863
+ );
864
+ }
865
+ }
866
+ if (result.skipped.length > 0) {
867
+ console.log(
868
+ `Skipped ${result.skipped.length} finding${result.skipped.length === 1 ? "" : "s"} that could not be fixed automatically.`
869
+ );
870
+ }
871
+ } catch (error) {
872
+ console.error(error instanceof Error ? error.message : String(error));
873
+ process.exitCode = 1;
874
+ }
875
+ }