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.
@@ -2,14 +2,26 @@ import { mkdir } from "node:fs/promises";
2
2
  import { homedir } from "node:os";
3
3
  import { basename, join } from "node:path";
4
4
  import { parse as parseYaml } from "yaml";
5
- import { facultStateDir, readFacultConfig } from "../paths";
5
+ import { loadManagedState } from "../manage";
6
+ import { isInlineMcpSecretValue } from "../mcp-config";
7
+ import {
8
+ facultContextRootDir,
9
+ facultRootDir,
10
+ facultStateDir,
11
+ readFacultConfig,
12
+ } from "../paths";
6
13
  import type { ScanResult } from "../scan";
7
14
  import { scan } from "../scan";
8
15
  import {
9
16
  extractCodexTomlMcpServerBlocks,
10
17
  sanitizeCodexTomlMcpText,
11
18
  } from "../util/codex-toml";
19
+ import { type GitPathExposure, getGitPathExposure } from "../util/git";
12
20
  import { parseJsonLenient } from "../util/json";
21
+ import {
22
+ applyAuditSuppressionsToStaticReport,
23
+ loadAuditSuppressions,
24
+ } from "./suppressions";
13
25
  import {
14
26
  type AuditFinding,
15
27
  type AuditItemResult,
@@ -440,14 +452,96 @@ function isSecretEnvKey(key: string): boolean {
440
452
  return SECRET_ENV_KEY_RE.test(key);
441
453
  }
442
454
 
455
+ function managedInlineSecretFinding(args: {
456
+ configPath: string;
457
+ envKey: string;
458
+ exposure: GitPathExposure;
459
+ serverName: string;
460
+ }): AuditFinding | null {
461
+ const location = `${args.configPath}:${args.serverName}:env:${args.envKey}`;
462
+ const evidence = `${args.envKey}=<redacted>`;
463
+
464
+ if (
465
+ args.exposure.state === "outside-repo" ||
466
+ args.exposure.state === "ignored"
467
+ ) {
468
+ return null;
469
+ }
470
+
471
+ if (args.exposure.state === "tracked") {
472
+ return {
473
+ severity: "critical",
474
+ ruleId: "mcp-env-inline-secret",
475
+ message:
476
+ "Managed MCP config includes an inline secret in a git-tracked file. Move the secret out of the repo or gitignore the rendered target.",
477
+ location,
478
+ evidence,
479
+ };
480
+ }
481
+
482
+ return {
483
+ severity: "high",
484
+ ruleId: "mcp-env-inline-secret",
485
+ message:
486
+ "Managed MCP config includes an inline secret in a repo-local file that is not gitignored. It may be committed accidentally.",
487
+ location,
488
+ evidence,
489
+ };
490
+ }
491
+
492
+ function canonicalInlineSecretFinding(args: {
493
+ configPath: string;
494
+ envKey: string;
495
+ exposure: GitPathExposure;
496
+ serverName: string;
497
+ }): AuditFinding {
498
+ const location = `${args.configPath}:${args.serverName}:env:${args.envKey}`;
499
+ const evidence = `${args.envKey}=<redacted>`;
500
+
501
+ if (args.exposure.state === "tracked") {
502
+ return {
503
+ severity: "critical",
504
+ ruleId: "mcp-env-inline-secret",
505
+ message:
506
+ "MCP server env includes an inline secret in a git-tracked file. Move it into local-only config or env indirection.",
507
+ location,
508
+ evidence,
509
+ };
510
+ }
511
+
512
+ if (args.exposure.state === "untracked") {
513
+ return {
514
+ severity: "high",
515
+ ruleId: "mcp-env-inline-secret",
516
+ message:
517
+ "MCP server env includes an inline secret in a repo-local file that is not gitignored. It may be committed accidentally.",
518
+ location,
519
+ evidence,
520
+ };
521
+ }
522
+
523
+ return {
524
+ severity: "high",
525
+ ruleId: "mcp-env-inline-secret",
526
+ message:
527
+ "MCP server env includes what looks like a secret value (consider using indirection instead of inlining).",
528
+ location,
529
+ evidence,
530
+ };
531
+ }
532
+
443
533
  function structuredMcpChecks({
444
534
  serverName,
445
535
  configPath,
446
536
  definition,
537
+ exposure,
538
+ isManagedOutput,
447
539
  }: {
448
540
  serverName: string;
449
541
  configPath: string;
450
542
  definition: unknown;
543
+ exposure: GitPathExposure;
544
+ isManagedOutput: boolean;
451
545
  }): AuditFinding[] {
452
546
  const findings: AuditFinding[] = [];
453
547
 
@@ -514,15 +608,23 @@ function structuredMcpChecks({
514
608
  const secretKeys = Object.keys(env).filter((k) => isSecretEnvKey(k));
515
609
  for (const k of secretKeys) {
516
610
  const v = env[k];
517
- if (typeof v === "string" && v.trim()) {
518
- findings.push({
519
- severity: "high",
520
- ruleId: "mcp-env-inline-secret",
521
- message:
522
- "MCP server env includes what looks like a secret value (consider using indirection instead of inlining).",
523
- location: `${configPath}:${serverName}:env:${k}`,
524
- evidence: `${k}=<redacted>`,
525
- });
611
+ if (isInlineMcpSecretValue(v)) {
612
+ const finding = isManagedOutput
613
+ ? managedInlineSecretFinding({
614
+ configPath,
615
+ envKey: k,
616
+ exposure,
617
+ serverName,
618
+ })
619
+ : canonicalInlineSecretFinding({
620
+ configPath,
621
+ envKey: k,
622
+ exposure,
623
+ serverName,
624
+ });
625
+ if (finding) {
626
+ findings.push(finding);
627
+ }
526
628
  }
527
629
  }
528
630
  }
@@ -713,6 +815,21 @@ export async function runStaticAudit(opts?: {
713
815
  ? { kind: "mcp", name: nameArg.slice("mcp:".length) }
714
816
  : { kind: "skill", name: nameArg }
715
817
  : null;
818
+ const managedRoots = uniqueSorted([
819
+ facultRootDir(home),
820
+ facultContextRootDir({ home, cwd: opts?.cwd }),
821
+ ]);
822
+ const managedStates = await Promise.all(
823
+ managedRoots.map((rootDir) =>
824
+ loadManagedState(home, rootDir).catch(() => null)
825
+ )
826
+ );
827
+ const managedMcpConfigPaths = new Set(
828
+ managedStates
829
+ .flatMap((state) => Object.values(state?.tools ?? {}))
830
+ .map((entry) => entry.mcpConfig)
831
+ .filter((path): path is string => typeof path === "string")
832
+ );
716
833
 
717
834
  const results: AuditItemResult[] = [];
718
835
 
@@ -860,6 +977,8 @@ export async function runStaticAudit(opts?: {
860
977
  for (const cfg of Array.from(uniqMcpConfigs.values()).sort((a, b) =>
861
978
  a.path.localeCompare(b.path)
862
979
  )) {
980
+ const exposure = await getGitPathExposure(cfg.path);
981
+ const isManagedOutput = managedMcpConfigPaths.has(cfg.path);
863
982
  const isToml = cfg.format === "toml" || cfg.path.endsWith(".toml");
864
983
 
865
984
  if (isToml) {
@@ -981,6 +1100,8 @@ export async function runStaticAudit(opts?: {
981
1100
  serverName,
982
1101
  configPath: cfg.path,
983
1102
  definition,
1103
+ exposure,
1104
+ isManagedOutput,
984
1105
  });
985
1106
 
986
1107
  const findings = [...ruleFindings, ...structured].sort((a, b) => {
@@ -1006,27 +1127,7 @@ export async function runStaticAudit(opts?: {
1006
1127
  }
1007
1128
 
1008
1129
  const minSeverity = opts?.minSeverity ?? undefined;
1009
- const bySeverity: Record<Severity, number> = {
1010
- low: 0,
1011
- medium: 0,
1012
- high: 0,
1013
- critical: 0,
1014
- };
1015
- let totalFindings = 0;
1016
- let flaggedItems = 0;
1017
-
1018
- for (const r of results) {
1019
- const all = r.findings;
1020
- totalFindings += all.length;
1021
- if (!r.passed) {
1022
- flaggedItems += 1;
1023
- }
1024
- for (const f of all) {
1025
- bySeverity[f.severity] += 1;
1026
- }
1027
- }
1028
-
1029
- const report: StaticAuditReport = {
1130
+ let report: StaticAuditReport = {
1030
1131
  timestamp: new Date().toISOString(),
1031
1132
  mode: "static",
1032
1133
  minSeverity,
@@ -1034,12 +1135,28 @@ export async function runStaticAudit(opts?: {
1034
1135
  results,
1035
1136
  summary: {
1036
1137
  totalItems: results.length,
1037
- totalFindings,
1038
- bySeverity,
1039
- flaggedItems,
1138
+ totalFindings: results.reduce(
1139
+ (sum, result) => sum + result.findings.length,
1140
+ 0
1141
+ ),
1142
+ bySeverity: results.reduce<Record<Severity, number>>(
1143
+ (acc, result) => {
1144
+ for (const finding of result.findings) {
1145
+ acc[finding.severity] += 1;
1146
+ }
1147
+ return acc;
1148
+ },
1149
+ { low: 0, medium: 0, high: 0, critical: 0 }
1150
+ ),
1151
+ flaggedItems: results.filter((result) => !result.passed).length,
1040
1152
  },
1041
1153
  };
1042
1154
 
1155
+ report = applyAuditSuppressionsToStaticReport(
1156
+ report,
1157
+ await loadAuditSuppressions(home)
1158
+ );
1159
+
1043
1160
  const auditDir = join(facultStateDir(home), "audit");
1044
1161
  await ensureDir(auditDir);
1045
1162
  await Bun.write(
@@ -0,0 +1,21 @@
1
+ import type { AuditFinding } from "./types";
2
+ import { SEVERITY_ORDER } from "./types";
3
+
4
+ export type StoredAuditStatus = "pending" | "passed" | "flagged";
5
+
6
+ export function computeStoredAuditStatus(
7
+ findings: Pick<AuditFinding, "severity" | "ruleId">[]
8
+ ): StoredAuditStatus {
9
+ if (findings.some((finding) => finding.ruleId === "agent-error")) {
10
+ return "pending";
11
+ }
12
+ const worst = findings.reduce(
13
+ (max, finding) => Math.max(max, SEVERITY_ORDER[finding.severity]),
14
+ -1
15
+ );
16
+ return worst >= SEVERITY_ORDER.high ? "flagged" : "passed";
17
+ }
18
+
19
+ export function isStoredAuditStatusPassed(status: StoredAuditStatus): boolean {
20
+ return status === "passed";
21
+ }
@@ -0,0 +1,266 @@
1
+ import { mkdir } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { facultStateDir } from "../paths";
5
+ import type { AgentAuditReport } from "./agent";
6
+ import { computeStoredAuditStatus, isStoredAuditStatusPassed } from "./status";
7
+ import type {
8
+ AuditFinding,
9
+ AuditItemResult,
10
+ Severity,
11
+ StaticAuditReport,
12
+ } from "./types";
13
+
14
+ const RULE_ID_PREFIX_RE = /^(static|agent):/;
15
+
16
+ export interface AuditSuppressionEntry {
17
+ key: string;
18
+ createdAt: string;
19
+ type: AuditItemResult["type"];
20
+ item: string;
21
+ path: string;
22
+ finding: {
23
+ severity: Severity;
24
+ ruleId: string;
25
+ message: string;
26
+ location?: string;
27
+ };
28
+ note?: string;
29
+ }
30
+
31
+ export interface AuditSuppressionStore {
32
+ version: 1;
33
+ updatedAt: string;
34
+ entries: AuditSuppressionEntry[];
35
+ }
36
+
37
+ function normalizeRuleId(ruleId: string): string {
38
+ return ruleId.replace(RULE_ID_PREFIX_RE, "");
39
+ }
40
+
41
+ function normalizedFindingSignature(args: {
42
+ type: AuditItemResult["type"];
43
+ item: string;
44
+ path: string;
45
+ finding: Pick<AuditFinding, "severity" | "ruleId" | "message" | "location">;
46
+ }): string {
47
+ return JSON.stringify({
48
+ type: args.type,
49
+ item: args.item,
50
+ path: args.path,
51
+ severity: args.finding.severity,
52
+ ruleId: normalizeRuleId(args.finding.ruleId),
53
+ message: args.finding.message,
54
+ location: args.finding.location ?? "",
55
+ });
56
+ }
57
+
58
+ function suppressionsPath(homeDir: string): string {
59
+ return join(facultStateDir(homeDir), "audit", "suppressions.json");
60
+ }
61
+
62
+ export function createAuditSuppressionEntry(args: {
63
+ result: AuditItemResult;
64
+ finding: AuditFinding;
65
+ createdAt?: string;
66
+ note?: string;
67
+ }): AuditSuppressionEntry {
68
+ const createdAt = args.createdAt ?? new Date().toISOString();
69
+ return {
70
+ key: normalizedFindingSignature({
71
+ type: args.result.type,
72
+ item: args.result.item,
73
+ path: args.result.path,
74
+ finding: args.finding,
75
+ }),
76
+ createdAt,
77
+ type: args.result.type,
78
+ item: args.result.item,
79
+ path: args.result.path,
80
+ finding: {
81
+ severity: args.finding.severity,
82
+ ruleId: args.finding.ruleId,
83
+ message: args.finding.message,
84
+ location: args.finding.location,
85
+ },
86
+ note: args.note?.trim() ? args.note.trim() : undefined,
87
+ };
88
+ }
89
+
90
+ async function loadAuditSuppressionStore(
91
+ homeDir: string
92
+ ): Promise<AuditSuppressionStore> {
93
+ const path = suppressionsPath(homeDir);
94
+ const file = Bun.file(path);
95
+ if (!(await file.exists())) {
96
+ return {
97
+ version: 1,
98
+ updatedAt: new Date(0).toISOString(),
99
+ entries: [],
100
+ };
101
+ }
102
+ try {
103
+ const parsed = (await file.json()) as Partial<AuditSuppressionStore>;
104
+ return {
105
+ version: 1,
106
+ updatedAt:
107
+ typeof parsed.updatedAt === "string"
108
+ ? parsed.updatedAt
109
+ : new Date(0).toISOString(),
110
+ entries: Array.isArray(parsed.entries)
111
+ ? parsed.entries.filter(
112
+ (entry): entry is AuditSuppressionEntry =>
113
+ !!entry &&
114
+ typeof entry === "object" &&
115
+ typeof (entry as AuditSuppressionEntry).key === "string"
116
+ )
117
+ : [],
118
+ };
119
+ } catch {
120
+ return {
121
+ version: 1,
122
+ updatedAt: new Date(0).toISOString(),
123
+ entries: [],
124
+ };
125
+ }
126
+ }
127
+
128
+ async function writeAuditSuppressionStore(
129
+ homeDir: string,
130
+ store: AuditSuppressionStore
131
+ ) {
132
+ const path = suppressionsPath(homeDir);
133
+ await mkdir(join(facultStateDir(homeDir), "audit"), { recursive: true });
134
+ await Bun.write(path, `${JSON.stringify(store, null, 2)}\n`);
135
+ }
136
+
137
+ export async function loadAuditSuppressions(
138
+ homeDir = homedir()
139
+ ): Promise<AuditSuppressionEntry[]> {
140
+ return (await loadAuditSuppressionStore(homeDir)).entries;
141
+ }
142
+
143
+ export async function recordAuditSuppressions(args: {
144
+ selected: { result: AuditItemResult; finding: AuditFinding }[];
145
+ homeDir?: string;
146
+ note?: string;
147
+ }): Promise<{ added: number; total: number }> {
148
+ const homeDir = args.homeDir ?? homedir();
149
+ const store = await loadAuditSuppressionStore(homeDir);
150
+ const next = new Map(store.entries.map((entry) => [entry.key, entry]));
151
+ const createdAt = new Date().toISOString();
152
+
153
+ for (const selection of args.selected) {
154
+ const entry = createAuditSuppressionEntry({
155
+ result: selection.result,
156
+ finding: selection.finding,
157
+ createdAt,
158
+ note: args.note,
159
+ });
160
+ next.set(entry.key, entry);
161
+ }
162
+
163
+ const entries = [...next.values()].sort((a, b) => a.key.localeCompare(b.key));
164
+ await writeAuditSuppressionStore(homeDir, {
165
+ version: 1,
166
+ updatedAt: createdAt,
167
+ entries,
168
+ });
169
+
170
+ return {
171
+ added: Math.max(0, entries.length - store.entries.length),
172
+ total: entries.length,
173
+ };
174
+ }
175
+
176
+ export function applyAuditSuppressionsToResults(args: {
177
+ results: AuditItemResult[];
178
+ suppressions: AuditSuppressionEntry[];
179
+ }): AuditItemResult[] {
180
+ if (args.suppressions.length === 0) {
181
+ return args.results;
182
+ }
183
+ const suppressedKeys = new Set(args.suppressions.map((entry) => entry.key));
184
+ return args.results.map((result) => {
185
+ const findings = result.findings.filter(
186
+ (finding) =>
187
+ !suppressedKeys.has(
188
+ normalizedFindingSignature({
189
+ type: result.type,
190
+ item: result.item,
191
+ path: result.path,
192
+ finding,
193
+ })
194
+ )
195
+ );
196
+ const status = computeStoredAuditStatus(findings);
197
+ return {
198
+ ...result,
199
+ passed: isStoredAuditStatusPassed(status),
200
+ findings,
201
+ };
202
+ });
203
+ }
204
+
205
+ function summarizeResults(results: AuditItemResult[]): {
206
+ totalItems: number;
207
+ totalFindings: number;
208
+ bySeverity: Record<Severity, number>;
209
+ flaggedItems: number;
210
+ } {
211
+ const bySeverity: Record<Severity, number> = {
212
+ low: 0,
213
+ medium: 0,
214
+ high: 0,
215
+ critical: 0,
216
+ };
217
+ let totalFindings = 0;
218
+ let flaggedItems = 0;
219
+
220
+ for (const result of results) {
221
+ totalFindings += result.findings.length;
222
+ if (!result.passed && result.findings.length > 0) {
223
+ flaggedItems += 1;
224
+ }
225
+ for (const finding of result.findings) {
226
+ bySeverity[finding.severity] += 1;
227
+ }
228
+ }
229
+
230
+ return {
231
+ totalItems: results.length,
232
+ totalFindings,
233
+ bySeverity,
234
+ flaggedItems,
235
+ };
236
+ }
237
+
238
+ export function applyAuditSuppressionsToStaticReport(
239
+ report: StaticAuditReport,
240
+ suppressions: AuditSuppressionEntry[]
241
+ ): StaticAuditReport {
242
+ const results = applyAuditSuppressionsToResults({
243
+ results: report.results,
244
+ suppressions,
245
+ });
246
+ return {
247
+ ...report,
248
+ results,
249
+ summary: summarizeResults(results),
250
+ };
251
+ }
252
+
253
+ export function applyAuditSuppressionsToAgentReport(
254
+ report: AgentAuditReport,
255
+ suppressions: AuditSuppressionEntry[]
256
+ ): AgentAuditReport {
257
+ const results = applyAuditSuppressionsToResults({
258
+ results: report.results,
259
+ suppressions,
260
+ });
261
+ return {
262
+ ...report,
263
+ results,
264
+ summary: summarizeResults(results),
265
+ };
266
+ }