facult 1.0.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.
Files changed (50) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +383 -0
  3. package/bin/facult.cjs +302 -0
  4. package/package.json +78 -0
  5. package/src/adapters/claude-cli.ts +18 -0
  6. package/src/adapters/claude-desktop.ts +15 -0
  7. package/src/adapters/clawdbot.ts +18 -0
  8. package/src/adapters/codex.ts +19 -0
  9. package/src/adapters/cursor.ts +18 -0
  10. package/src/adapters/index.ts +69 -0
  11. package/src/adapters/mcp.ts +270 -0
  12. package/src/adapters/reference.ts +9 -0
  13. package/src/adapters/skills.ts +47 -0
  14. package/src/adapters/types.ts +42 -0
  15. package/src/adapters/version.ts +18 -0
  16. package/src/audit/agent.ts +1071 -0
  17. package/src/audit/index.ts +74 -0
  18. package/src/audit/static.ts +1130 -0
  19. package/src/audit/tui.ts +704 -0
  20. package/src/audit/types.ts +68 -0
  21. package/src/audit/update-index.ts +115 -0
  22. package/src/conflicts.ts +135 -0
  23. package/src/consolidate-conflict-action.ts +57 -0
  24. package/src/consolidate.ts +1637 -0
  25. package/src/enable-disable.ts +349 -0
  26. package/src/index-builder.ts +562 -0
  27. package/src/index.ts +589 -0
  28. package/src/manage.ts +894 -0
  29. package/src/migrate.ts +272 -0
  30. package/src/paths.ts +238 -0
  31. package/src/quarantine.ts +217 -0
  32. package/src/query.ts +186 -0
  33. package/src/remote-manifest-integrity.ts +367 -0
  34. package/src/remote-providers.ts +905 -0
  35. package/src/remote-source-policy.ts +237 -0
  36. package/src/remote-sources.ts +162 -0
  37. package/src/remote-types.ts +136 -0
  38. package/src/remote.ts +1970 -0
  39. package/src/scan.ts +2427 -0
  40. package/src/schema.ts +39 -0
  41. package/src/self-update.ts +408 -0
  42. package/src/snippets-cli.ts +293 -0
  43. package/src/snippets.ts +706 -0
  44. package/src/source-trust.ts +203 -0
  45. package/src/trust-list.ts +232 -0
  46. package/src/trust.ts +170 -0
  47. package/src/tui.ts +118 -0
  48. package/src/util/codex-toml.ts +126 -0
  49. package/src/util/json.ts +32 -0
  50. package/src/util/skills.ts +55 -0
@@ -0,0 +1,1071 @@
1
+ import { mkdir, mkdtemp } from "node:fs/promises";
2
+ import { homedir, tmpdir } from "node:os";
3
+ import { basename, join, sep } from "node:path";
4
+ import { facultRootDir, readFacultConfig } from "../paths";
5
+ import type { AssetFile, ScanResult } from "../scan";
6
+ import { scan } from "../scan";
7
+ import {
8
+ extractCodexTomlMcpServerBlocks,
9
+ sanitizeCodexTomlMcpText,
10
+ } from "../util/codex-toml";
11
+ import { parseJsonLenient } from "../util/json";
12
+ import {
13
+ type AuditFinding,
14
+ type AuditItemResult,
15
+ parseSeverity,
16
+ SEVERITY_ORDER,
17
+ type Severity,
18
+ } from "./types";
19
+ import { updateIndexFromAuditReport } from "./update-index";
20
+
21
+ type AgentTool = "claude" | "codex";
22
+
23
+ export interface AgentAuditReport {
24
+ timestamp: string;
25
+ mode: "agent";
26
+ agent: {
27
+ tool: AgentTool;
28
+ model?: string;
29
+ };
30
+ scope: {
31
+ from: string[];
32
+ maxItems: number;
33
+ requested?: string | null;
34
+ };
35
+ results: AuditItemResult[];
36
+ summary: {
37
+ totalItems: number;
38
+ totalFindings: number;
39
+ bySeverity: Record<Severity, number>;
40
+ flaggedItems: number;
41
+ };
42
+ }
43
+
44
+ function isPlainObject(v: unknown): v is Record<string, unknown> {
45
+ return !!v && typeof v === "object" && !Array.isArray(v);
46
+ }
47
+
48
+ const SECRETY_STRING_RE =
49
+ /\b(sk-[A-Za-z0-9]{10,}|ghp_[A-Za-z0-9]{10,}|github_pat_[A-Za-z0-9_]{10,})\b/g;
50
+ const SECRET_KEY_RE = /(TOKEN|KEY|SECRET|PASSWORD|PASS|BEARER)/i;
51
+
52
+ function redactPossibleSecrets(value: string): string {
53
+ return value.replace(SECRETY_STRING_RE, "<redacted>");
54
+ }
55
+
56
+ function sanitizeEnvAssignments(text: string): string {
57
+ // Redact common KEY=... patterns for secret-ish keys.
58
+ return text.replace(
59
+ /^([A-Z0-9_]*(TOKEN|KEY|SECRET|PASSWORD|PASS|BEARER)[A-Z0-9_]*)\s*=\s*.*$/gim,
60
+ "$1=<redacted>"
61
+ );
62
+ }
63
+
64
+ function requestedNameFromArgv(argv: string[]): string | null {
65
+ for (let i = 0; i < argv.length; i += 1) {
66
+ const arg = argv[i];
67
+ if (!arg) {
68
+ continue;
69
+ }
70
+ if (arg === "--with" || arg === "--from" || arg === "--max-items") {
71
+ i += 1;
72
+ continue;
73
+ }
74
+ if (
75
+ arg.startsWith("--with=") ||
76
+ arg.startsWith("--from=") ||
77
+ arg.startsWith("--max-items=")
78
+ ) {
79
+ continue;
80
+ }
81
+ if (arg === "--json") {
82
+ continue;
83
+ }
84
+ if (arg.startsWith("-")) {
85
+ continue;
86
+ }
87
+ return arg;
88
+ }
89
+ return null;
90
+ }
91
+
92
+ function parseFromFlags(argv: string[]): string[] {
93
+ const from: string[] = [];
94
+ for (let i = 0; i < argv.length; i += 1) {
95
+ const arg = argv[i];
96
+ if (!arg) {
97
+ continue;
98
+ }
99
+ if (arg === "--from") {
100
+ const next = argv[i + 1];
101
+ if (!next) {
102
+ throw new Error("--from requires a path");
103
+ }
104
+ from.push(next);
105
+ i += 1;
106
+ continue;
107
+ }
108
+ if (arg.startsWith("--from=")) {
109
+ const value = arg.slice("--from=".length);
110
+ if (!value) {
111
+ throw new Error("--from requires a path");
112
+ }
113
+ from.push(value);
114
+ }
115
+ }
116
+ return from;
117
+ }
118
+
119
+ function parseWithFlag(argv: string[]): AgentTool | null {
120
+ for (let i = 0; i < argv.length; i += 1) {
121
+ const arg = argv[i];
122
+ if (!arg) {
123
+ continue;
124
+ }
125
+ if (arg === "--with") {
126
+ const next = argv[i + 1];
127
+ if (!next) {
128
+ throw new Error("--with requires claude|codex");
129
+ }
130
+ const v = next.trim().toLowerCase();
131
+ if (v === "claude" || v === "codex") {
132
+ return v;
133
+ }
134
+ throw new Error(`Unknown agent tool: ${next}`);
135
+ }
136
+ if (arg.startsWith("--with=")) {
137
+ const raw = arg.slice("--with=".length).trim().toLowerCase();
138
+ if (raw === "claude" || raw === "codex") {
139
+ return raw;
140
+ }
141
+ throw new Error(`Unknown agent tool: ${raw}`);
142
+ }
143
+ }
144
+ return null;
145
+ }
146
+
147
+ function parseMaxItemsFlag(argv: string[]): number | null {
148
+ for (let i = 0; i < argv.length; i += 1) {
149
+ const arg = argv[i];
150
+ if (!arg) {
151
+ continue;
152
+ }
153
+ if (arg === "--max-items") {
154
+ const next = argv[i + 1];
155
+ if (!next) {
156
+ throw new Error("--max-items requires a number");
157
+ }
158
+ const raw = next.trim().toLowerCase();
159
+ if (raw === "all" || raw === "0") {
160
+ return 0;
161
+ }
162
+ const n = Number(next);
163
+ if (!Number.isFinite(n) || n <= 0) {
164
+ throw new Error(`Invalid --max-items value: ${next}`);
165
+ }
166
+ return Math.floor(n);
167
+ }
168
+ if (arg.startsWith("--max-items=")) {
169
+ const raw = arg.slice("--max-items=".length);
170
+ const trimmed = raw.trim().toLowerCase();
171
+ if (trimmed === "all" || trimmed === "0") {
172
+ return 0;
173
+ }
174
+ const n = Number(raw);
175
+ if (!Number.isFinite(n) || n <= 0) {
176
+ throw new Error(`Invalid --max-items value: ${raw}`);
177
+ }
178
+ return Math.floor(n);
179
+ }
180
+ }
181
+ return null;
182
+ }
183
+
184
+ function extractMcpServersObject(
185
+ parsed: unknown
186
+ ): Record<string, unknown> | null {
187
+ if (!isPlainObject(parsed)) {
188
+ return null;
189
+ }
190
+ const obj = parsed as Record<string, unknown>;
191
+ if (isPlainObject(obj.mcpServers)) {
192
+ return obj.mcpServers as Record<string, unknown>;
193
+ }
194
+ for (const [k, v] of Object.entries(obj)) {
195
+ if (k.endsWith(".mcpServers") && isPlainObject(v)) {
196
+ return v as Record<string, unknown>;
197
+ }
198
+ }
199
+ if (isPlainObject(obj["mcp.servers"])) {
200
+ return obj["mcp.servers"] as Record<string, unknown>;
201
+ }
202
+ if (isPlainObject(obj.servers)) {
203
+ return obj.servers as Record<string, unknown>;
204
+ }
205
+ if (isPlainObject(obj.mcp)) {
206
+ const mcp = obj.mcp as Record<string, unknown>;
207
+ if (isPlainObject(mcp.servers)) {
208
+ return mcp.servers as Record<string, unknown>;
209
+ }
210
+ }
211
+ return null;
212
+ }
213
+
214
+ function mcpSafeText(definition: unknown): string {
215
+ if (!isPlainObject(definition)) {
216
+ return redactPossibleSecrets(String(definition));
217
+ }
218
+
219
+ const obj = definition as Record<string, unknown>;
220
+ const out: Record<string, unknown> = {};
221
+
222
+ if (typeof obj.transport === "string") {
223
+ out.transport = obj.transport;
224
+ }
225
+ if (typeof obj.command === "string") {
226
+ out.command = redactPossibleSecrets(obj.command);
227
+ }
228
+ if (Array.isArray(obj.args)) {
229
+ out.args = obj.args.map((v) => redactPossibleSecrets(String(v)));
230
+ }
231
+ if (typeof obj.url === "string") {
232
+ out.url = redactPossibleSecrets(obj.url);
233
+ }
234
+ if (isPlainObject(obj.env)) {
235
+ out.envKeys = Object.keys(obj.env as Record<string, unknown>).sort();
236
+ }
237
+ if (isPlainObject(obj.vendorExtensions)) {
238
+ out.vendorKeys = Object.keys(
239
+ obj.vendorExtensions as Record<string, unknown>
240
+ ).sort();
241
+ }
242
+
243
+ return JSON.stringify(out, null, 2);
244
+ }
245
+
246
+ function sanitizeJsonSecrets(value: unknown): unknown {
247
+ if (typeof value === "string") {
248
+ return redactPossibleSecrets(value);
249
+ }
250
+ if (Array.isArray(value)) {
251
+ return value.slice(0, 500).map(sanitizeJsonSecrets);
252
+ }
253
+ if (!isPlainObject(value)) {
254
+ return value;
255
+ }
256
+ const out: Record<string, unknown> = {};
257
+ for (const [k, v] of Object.entries(value)) {
258
+ if (SECRET_KEY_RE.test(k)) {
259
+ out[k] = "<redacted>";
260
+ } else {
261
+ out[k] = sanitizeJsonSecrets(v);
262
+ }
263
+ }
264
+ return out;
265
+ }
266
+
267
+ function computeAuditStatus(findings: AuditFinding[]): "passed" | "flagged" {
268
+ const bad = findings.some(
269
+ (f) => f.severity === "high" || f.severity === "critical"
270
+ );
271
+ return bad ? "flagged" : "passed";
272
+ }
273
+
274
+ function selectPreferredSkillInstance(
275
+ items: { name: string; path: string; sourceId: string }[],
276
+ canonicalRoot: string
277
+ ): { name: string; path: string; sourceId: string }[] {
278
+ const byName = new Map<
279
+ string,
280
+ { name: string; path: string; sourceId: string }
281
+ >();
282
+
283
+ const score = (p: string): number => {
284
+ if (p.startsWith(canonicalRoot)) {
285
+ return 100;
286
+ }
287
+ return 0;
288
+ };
289
+
290
+ for (const it of items) {
291
+ const prev = byName.get(it.name);
292
+ if (!prev) {
293
+ byName.set(it.name, it);
294
+ continue;
295
+ }
296
+ const sp = score(prev.path);
297
+ const si = score(it.path);
298
+ if (si > sp) {
299
+ byName.set(it.name, it);
300
+ continue;
301
+ }
302
+ if (si === sp && it.path.length < prev.path.length) {
303
+ byName.set(it.name, it);
304
+ }
305
+ }
306
+
307
+ return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
308
+ }
309
+
310
+ async function readSkillBundle(skillDir: string): Promise<string> {
311
+ const skillMd = join(skillDir, "SKILL.md");
312
+ const file = Bun.file(skillMd);
313
+ if (!(await file.exists())) {
314
+ return "";
315
+ }
316
+
317
+ let text = await file.text();
318
+ text = sanitizeEnvAssignments(redactPossibleSecrets(text));
319
+
320
+ // Optionally include small supporting scripts/references; keep bounded.
321
+ const included: { rel: string; content: string }[] = [];
322
+ const glob = new Bun.Glob("**/*");
323
+ for await (const rel of glob.scan({ cwd: skillDir, onlyFiles: true })) {
324
+ const parts = rel.split(sep);
325
+ if (parts.includes("node_modules") || parts.includes(".git")) {
326
+ continue;
327
+ }
328
+ // Always include SKILL.md only once.
329
+ if (rel === "SKILL.md") {
330
+ continue;
331
+ }
332
+ // Prefer common subdirs. Skip other files to avoid sending huge bundles.
333
+ const root = parts[0] ?? "";
334
+ if (root !== "scripts" && root !== "references" && root !== "assets") {
335
+ continue;
336
+ }
337
+ const abs = join(skillDir, rel);
338
+ const st = await Bun.file(abs)
339
+ .stat()
340
+ .catch(() => null);
341
+ if (!st?.isFile()) {
342
+ continue;
343
+ }
344
+ if (st.size > 50_000) {
345
+ continue;
346
+ }
347
+ const raw = await Bun.file(abs)
348
+ .text()
349
+ .catch(() => "");
350
+ if (!raw) {
351
+ continue;
352
+ }
353
+ included.push({
354
+ rel,
355
+ content: sanitizeEnvAssignments(redactPossibleSecrets(raw)),
356
+ });
357
+ if (included.length >= 12) {
358
+ break;
359
+ }
360
+ }
361
+
362
+ let bundle = `SKILL.md:\n${text}\n`;
363
+ for (const f of included) {
364
+ bundle += `\nFILE: ${f.rel}\n${f.content}\n`;
365
+ }
366
+
367
+ // Keep bundle size bounded.
368
+ const MAX_CHARS = 200_000;
369
+ if (bundle.length > MAX_CHARS) {
370
+ bundle = bundle.slice(0, MAX_CHARS);
371
+ }
372
+
373
+ return bundle;
374
+ }
375
+
376
+ async function readAssetBundle(asset: AssetFile): Promise<string> {
377
+ const file = Bun.file(asset.path);
378
+ if (!(await file.exists())) {
379
+ return "";
380
+ }
381
+ let text = await file.text();
382
+ if (text.length > 200_000) {
383
+ text = text.slice(0, 200_000);
384
+ }
385
+ if (asset.format === "json") {
386
+ try {
387
+ const parsed = parseJsonLenient(text);
388
+ text = JSON.stringify(sanitizeJsonSecrets(parsed), null, 2);
389
+ } catch {
390
+ // keep raw
391
+ }
392
+ }
393
+ text = sanitizeEnvAssignments(redactPossibleSecrets(text));
394
+ return text;
395
+ }
396
+
397
+ type PerItemOutput = {
398
+ passed: boolean;
399
+ findings: {
400
+ severity: Severity;
401
+ category: string;
402
+ message: string;
403
+ recommendation?: string;
404
+ location?: string;
405
+ }[];
406
+ notes?: string;
407
+ };
408
+
409
+ const PER_ITEM_SCHEMA = {
410
+ type: "object",
411
+ additionalProperties: false,
412
+ properties: {
413
+ passed: { type: "boolean" },
414
+ findings: {
415
+ type: "array",
416
+ items: {
417
+ type: "object",
418
+ additionalProperties: false,
419
+ properties: {
420
+ severity: {
421
+ type: "string",
422
+ enum: ["low", "medium", "high", "critical"],
423
+ },
424
+ category: { type: "string" },
425
+ message: { type: "string" },
426
+ recommendation: { type: "string" },
427
+ location: { type: "string" },
428
+ },
429
+ // Codex output-schema validation is strict: required must include all keys in properties.
430
+ // Use empty strings for fields that don't apply.
431
+ required: [
432
+ "severity",
433
+ "category",
434
+ "message",
435
+ "recommendation",
436
+ "location",
437
+ ],
438
+ },
439
+ },
440
+ notes: { type: "string" },
441
+ },
442
+ // Codex output-schema validation is strict: required must include all keys in properties.
443
+ // Use empty string for notes if there is nothing to add.
444
+ required: ["passed", "findings", "notes"],
445
+ } as const;
446
+
447
+ async function runClaude(
448
+ prompt: string
449
+ ): Promise<{ output: PerItemOutput; model?: string }> {
450
+ const proc = Bun.spawn({
451
+ cmd: [
452
+ "claude",
453
+ "-p",
454
+ "--output-format",
455
+ "json",
456
+ "--json-schema",
457
+ JSON.stringify(PER_ITEM_SCHEMA),
458
+ "--tools",
459
+ "",
460
+ ],
461
+ stdin: new Blob([prompt]),
462
+ stdout: "pipe",
463
+ stderr: "pipe",
464
+ });
465
+
466
+ const stdout = await new Response(proc.stdout).text();
467
+ const stderr = await new Response(proc.stderr).text();
468
+ const code = await proc.exited;
469
+ if (code !== 0) {
470
+ throw new Error(`claude exited with code ${code}: ${stderr || stdout}`);
471
+ }
472
+
473
+ const parsed = JSON.parse(stdout) as any;
474
+ const structured = parsed?.structured_output as unknown;
475
+ if (!structured || typeof structured !== "object") {
476
+ throw new Error("claude did not return structured_output");
477
+ }
478
+ return {
479
+ output: structured as PerItemOutput,
480
+ model: Object.keys(parsed?.modelUsage ?? {})[0],
481
+ };
482
+ }
483
+
484
+ async function runCodex(
485
+ prompt: string
486
+ ): Promise<{ output: PerItemOutput; model?: string }> {
487
+ const dir = await mkdtemp(join(tmpdir(), "facult-agent-audit-"));
488
+ const schemaPath = join(dir, "schema.json");
489
+ const outPath = join(dir, "last-message.txt");
490
+ await Bun.write(schemaPath, `${JSON.stringify(PER_ITEM_SCHEMA, null, 2)}\n`);
491
+
492
+ const proc = Bun.spawn({
493
+ cmd: [
494
+ "codex",
495
+ "exec",
496
+ "--skip-git-repo-check",
497
+ "--sandbox",
498
+ "read-only",
499
+ "--output-schema",
500
+ schemaPath,
501
+ "--output-last-message",
502
+ outPath,
503
+ "-",
504
+ ],
505
+ stdin: new Blob([prompt]),
506
+ stdout: "pipe",
507
+ stderr: "pipe",
508
+ });
509
+
510
+ const stdout = await new Response(proc.stdout).text();
511
+ const stderr = await new Response(proc.stderr).text();
512
+ const code = await proc.exited;
513
+ if (code !== 0) {
514
+ throw new Error(`codex exited with code ${code}: ${stderr || stdout}`);
515
+ }
516
+
517
+ const raw = await Bun.file(outPath).text();
518
+ const trimmed = raw.trim();
519
+ const jsonStart = trimmed.indexOf("{");
520
+ const jsonEnd = trimmed.lastIndexOf("}");
521
+ if (jsonStart < 0 || jsonEnd < 0 || jsonEnd <= jsonStart) {
522
+ throw new Error("codex output did not contain JSON object");
523
+ }
524
+ const parsed = JSON.parse(
525
+ trimmed.slice(jsonStart, jsonEnd + 1)
526
+ ) as PerItemOutput;
527
+ return { output: parsed };
528
+ }
529
+
530
+ function promptForItem(args: {
531
+ kind: "skill" | "mcp" | "asset";
532
+ name: string;
533
+ path: string;
534
+ content: string;
535
+ }): string {
536
+ return `You are auditing local coding-assistant configuration assets for security risks.
537
+
538
+ Goals:
539
+ - Find security issues (credential access, data exfiltration, destructive commands, privilege escalation, persistence).
540
+ - Flag risky patterns and suggest concrete mitigations.
541
+ - Be conservative: if it's unclear, mark as medium and explain.
542
+ - Do not invent file contents; only analyze what is provided.
543
+ - Do not output secrets; if you see something that looks like a secret, refer to it as "<redacted>".
544
+
545
+ Return ONLY JSON that matches the provided schema.
546
+ Schema notes:
547
+ - All fields are required.
548
+ - If a field does not apply, use an empty string (e.g. recommendation/location/notes).
549
+
550
+ Item:
551
+ type: ${args.kind}
552
+ name: ${args.name}
553
+ path: ${args.path}
554
+
555
+ Content (sanitized):
556
+ ${args.content}
557
+ `;
558
+ }
559
+
560
+ export async function runAgentAudit(opts?: {
561
+ argv?: string[];
562
+ homeDir?: string;
563
+ cwd?: string;
564
+ from?: string[];
565
+ includeConfigFrom?: boolean;
566
+ includeGitHooks?: boolean;
567
+ requested?: string | null;
568
+ withTool?: AgentTool;
569
+ maxItems?: number;
570
+ // Test hook: inject a runner by tool name.
571
+ runner?: (
572
+ tool: AgentTool,
573
+ prompt: string
574
+ ) => Promise<{ output: PerItemOutput; model?: string }>;
575
+ onProgress?: (p: {
576
+ phase: "start" | "done";
577
+ current: number;
578
+ total: number;
579
+ type: "skill" | "mcp" | "asset";
580
+ item: string;
581
+ path: string;
582
+ }) => void;
583
+ }): Promise<AgentAuditReport> {
584
+ const argv = opts?.argv ?? [];
585
+ const home = opts?.homeDir ?? homedir();
586
+ const cwd = opts?.cwd ?? process.cwd();
587
+
588
+ const includeConfigFrom =
589
+ opts?.includeConfigFrom ?? !argv.includes("--no-config-from");
590
+ let from = opts?.from ?? parseFromFlags(argv);
591
+ if (includeConfigFrom && from.length === 0) {
592
+ const cfg = readFacultConfig(home);
593
+ if (!(cfg?.scanFrom && cfg.scanFrom.length > 0)) {
594
+ from = ["~"];
595
+ }
596
+ }
597
+ const requested = opts?.requested ?? requestedNameFromArgv(argv);
598
+ const tool =
599
+ opts?.withTool ??
600
+ parseWithFlag(argv) ??
601
+ (Bun.which("claude") ? "claude" : Bun.which("codex") ? "codex" : null);
602
+ if (!tool) {
603
+ throw new Error(
604
+ 'No agent tool found. Install "claude" or "codex", or pass --with.'
605
+ );
606
+ }
607
+
608
+ const maxItems = opts?.maxItems ?? parseMaxItemsFlag(argv) ?? 50;
609
+
610
+ const scanRes: ScanResult = await scan(argv, {
611
+ homeDir: home,
612
+ cwd,
613
+ includeConfigFrom,
614
+ includeGitHooks:
615
+ opts?.includeGitHooks ?? argv.includes("--include-git-hooks"),
616
+ from,
617
+ });
618
+ const canonicalRoot = facultRootDir(home);
619
+
620
+ // Collect skill instances and prefer canonical copies when available.
621
+ const skillInstances: { name: string; path: string; sourceId: string }[] = [];
622
+ for (const src of scanRes.sources) {
623
+ for (const dir of src.skills.entries) {
624
+ skillInstances.push({ name: basename(dir), path: dir, sourceId: src.id });
625
+ }
626
+ }
627
+ const skills = selectPreferredSkillInstance(skillInstances, canonicalRoot);
628
+
629
+ // Collect MCP servers from all configs (best-effort), but prefer canonical store definitions.
630
+ const mcpByName = new Map<
631
+ string,
632
+ { name: string; sourceId: string; path: string; definition: unknown }
633
+ >();
634
+ const mcpScore = (configPath: string): number => {
635
+ if (configPath.startsWith(join(canonicalRoot, "mcp"))) {
636
+ return 100;
637
+ }
638
+ if (configPath.startsWith(canonicalRoot)) {
639
+ return 90;
640
+ }
641
+ return 0;
642
+ };
643
+
644
+ for (const src of scanRes.sources) {
645
+ for (const cfg of src.mcp.configs) {
646
+ if (cfg.format === "toml" || cfg.path.endsWith(".toml")) {
647
+ let txt: string;
648
+ try {
649
+ txt = await Bun.file(cfg.path).text();
650
+ } catch {
651
+ continue;
652
+ }
653
+ const blocks = extractCodexTomlMcpServerBlocks(txt);
654
+ for (const [name, blockText] of Object.entries(blocks)) {
655
+ const definition = sanitizeCodexTomlMcpText(blockText);
656
+ const existing = mcpByName.get(name);
657
+ if (!existing) {
658
+ mcpByName.set(name, {
659
+ name,
660
+ sourceId: src.id,
661
+ path: cfg.path,
662
+ definition,
663
+ });
664
+ continue;
665
+ }
666
+ const curScore = mcpScore(existing.path);
667
+ const nextScore = mcpScore(cfg.path);
668
+ if (nextScore > curScore) {
669
+ mcpByName.set(name, {
670
+ name,
671
+ sourceId: src.id,
672
+ path: cfg.path,
673
+ definition,
674
+ });
675
+ }
676
+ }
677
+ continue;
678
+ }
679
+
680
+ if (cfg.format !== "json") {
681
+ continue;
682
+ }
683
+ let parsed: unknown;
684
+ try {
685
+ const txt = await Bun.file(cfg.path).text();
686
+ parsed = parseJsonLenient(txt);
687
+ } catch {
688
+ continue;
689
+ }
690
+ const serversObj = extractMcpServersObject(parsed);
691
+ if (!serversObj) {
692
+ continue;
693
+ }
694
+ for (const [name, definition] of Object.entries(serversObj)) {
695
+ const existing = mcpByName.get(name);
696
+ if (!existing) {
697
+ mcpByName.set(name, {
698
+ name,
699
+ sourceId: src.id,
700
+ path: cfg.path,
701
+ definition,
702
+ });
703
+ continue;
704
+ }
705
+ const curScore = mcpScore(existing.path);
706
+ const nextScore = mcpScore(cfg.path);
707
+ if (nextScore > curScore) {
708
+ mcpByName.set(name, {
709
+ name,
710
+ sourceId: src.id,
711
+ path: cfg.path,
712
+ definition,
713
+ });
714
+ }
715
+ }
716
+ }
717
+ }
718
+
719
+ const assets: {
720
+ item: string;
721
+ path: string;
722
+ sourceId: string;
723
+ file: AssetFile;
724
+ }[] = [];
725
+ for (const src of scanRes.sources) {
726
+ for (const f of src.assets.files) {
727
+ // Avoid noisy default sample hooks when scanning many repos.
728
+ if (f.kind === "git-hook" && f.path.endsWith(".sample")) {
729
+ continue;
730
+ }
731
+ assets.push({
732
+ item: `${f.kind}:${basename(f.path)}`,
733
+ path: f.path,
734
+ sourceId: src.id,
735
+ file: f,
736
+ });
737
+ }
738
+ }
739
+
740
+ const requestedParsed: { kind: "skill" | "mcp"; name: string } | null =
741
+ requested
742
+ ? requested.startsWith("mcp:")
743
+ ? { kind: "mcp", name: requested.slice("mcp:".length) }
744
+ : { kind: "skill", name: requested }
745
+ : null;
746
+
747
+ const items: {
748
+ type: "skill" | "mcp" | "asset";
749
+ item: string;
750
+ path: string;
751
+ sourceId: string;
752
+ content: string;
753
+ }[] = [];
754
+
755
+ if (!requestedParsed || requestedParsed.kind === "skill") {
756
+ for (const s of skills) {
757
+ if (requestedParsed && requestedParsed.name !== s.name) {
758
+ continue;
759
+ }
760
+ const content = await readSkillBundle(s.path);
761
+ if (!content) {
762
+ continue;
763
+ }
764
+ items.push({
765
+ type: "skill",
766
+ item: s.name,
767
+ path: s.path,
768
+ sourceId: s.sourceId,
769
+ content,
770
+ });
771
+ }
772
+ }
773
+
774
+ if (!requestedParsed || requestedParsed.kind === "mcp") {
775
+ for (const [name, entry] of [...mcpByName.entries()].sort(([a], [b]) =>
776
+ a.localeCompare(b)
777
+ )) {
778
+ if (requestedParsed && requestedParsed.name !== name) {
779
+ continue;
780
+ }
781
+ const content = mcpSafeText(entry.definition);
782
+ items.push({
783
+ type: "mcp",
784
+ item: name,
785
+ path: entry.path,
786
+ sourceId: entry.sourceId,
787
+ content,
788
+ });
789
+ }
790
+ }
791
+
792
+ if (!requestedParsed) {
793
+ for (const a of assets.sort(
794
+ (x, y) => x.item.localeCompare(y.item) || x.path.localeCompare(y.path)
795
+ )) {
796
+ const content = await readAssetBundle(a.file);
797
+ if (!content) {
798
+ continue;
799
+ }
800
+ items.push({
801
+ type: "asset",
802
+ item: a.item,
803
+ path: a.path,
804
+ sourceId: a.sourceId,
805
+ content,
806
+ });
807
+ }
808
+ }
809
+
810
+ const limit = maxItems === 0 ? items.length : maxItems;
811
+ const limited = items.slice(0, limit);
812
+ const runner =
813
+ opts?.runner ??
814
+ (async (t: AgentTool, prompt: string) => {
815
+ if (t === "claude") {
816
+ return await runClaude(prompt);
817
+ }
818
+ return await runCodex(prompt);
819
+ });
820
+
821
+ const results: AuditItemResult[] = [];
822
+ let model: string | undefined;
823
+
824
+ for (let i = 0; i < limited.length; i += 1) {
825
+ const it = limited[i]!;
826
+ opts?.onProgress?.({
827
+ phase: "start",
828
+ current: i + 1,
829
+ total: limited.length,
830
+ type: it.type,
831
+ item: it.item,
832
+ path: it.path,
833
+ });
834
+
835
+ const prompt = promptForItem({
836
+ kind: it.type,
837
+ name: it.type === "mcp" ? `mcp:${it.item}` : it.item,
838
+ path: it.path,
839
+ content: it.content,
840
+ });
841
+
842
+ let out: PerItemOutput | null = null;
843
+ try {
844
+ const res = await runner(tool, prompt);
845
+ out = res.output;
846
+ model = model ?? res.model;
847
+ } catch (e: unknown) {
848
+ const msg = e instanceof Error ? e.message : String(e);
849
+ results.push({
850
+ item: it.item,
851
+ type: it.type,
852
+ sourceId: it.sourceId,
853
+ path: it.path,
854
+ passed: false,
855
+ findings: [
856
+ {
857
+ severity: "medium",
858
+ ruleId: "agent-error",
859
+ message: "Agent audit failed; review manually.",
860
+ location: it.path,
861
+ evidence: redactPossibleSecrets(msg),
862
+ },
863
+ ],
864
+ });
865
+ opts?.onProgress?.({
866
+ phase: "done",
867
+ current: i + 1,
868
+ total: limited.length,
869
+ type: it.type,
870
+ item: it.item,
871
+ path: it.path,
872
+ });
873
+ continue;
874
+ }
875
+
876
+ const findings: AuditFinding[] = [];
877
+ for (const f of out.findings ?? []) {
878
+ const sev = parseSeverity(f.severity);
879
+ if (!sev) {
880
+ continue;
881
+ }
882
+ const loc =
883
+ typeof f.location === "string" && f.location.trim()
884
+ ? f.location.trim()
885
+ : undefined;
886
+ const rec =
887
+ typeof f.recommendation === "string" && f.recommendation.trim()
888
+ ? f.recommendation.trim()
889
+ : undefined;
890
+ findings.push({
891
+ severity: sev,
892
+ ruleId: f.category || "agent",
893
+ message: f.message,
894
+ location: loc,
895
+ evidence: rec,
896
+ });
897
+ }
898
+
899
+ const status = computeAuditStatus(findings);
900
+ results.push({
901
+ item: it.item,
902
+ type: it.type,
903
+ sourceId: it.sourceId,
904
+ path: it.path,
905
+ passed: status === "passed",
906
+ findings,
907
+ notes:
908
+ typeof out.notes === "string"
909
+ ? (() => {
910
+ const txt = out.notes.trim();
911
+ if (!txt) {
912
+ return undefined;
913
+ }
914
+ return sanitizeEnvAssignments(redactPossibleSecrets(txt));
915
+ })()
916
+ : undefined,
917
+ });
918
+
919
+ opts?.onProgress?.({
920
+ phase: "done",
921
+ current: i + 1,
922
+ total: limited.length,
923
+ type: it.type,
924
+ item: it.item,
925
+ path: it.path,
926
+ });
927
+ }
928
+
929
+ // Summary
930
+ const bySeverity: Record<Severity, number> = {
931
+ low: 0,
932
+ medium: 0,
933
+ high: 0,
934
+ critical: 0,
935
+ };
936
+ let totalFindings = 0;
937
+ let flaggedItems = 0;
938
+
939
+ for (const r of results) {
940
+ totalFindings += r.findings.length;
941
+ if (!r.passed && r.findings.length > 0) {
942
+ flaggedItems += 1;
943
+ }
944
+ for (const f of r.findings) {
945
+ bySeverity[f.severity] += 1;
946
+ }
947
+ }
948
+
949
+ const report: AgentAuditReport = {
950
+ timestamp: new Date().toISOString(),
951
+ mode: "agent",
952
+ agent: { tool, model },
953
+ scope: {
954
+ from,
955
+ maxItems,
956
+ requested,
957
+ },
958
+ results: results.sort((a, b) => {
959
+ const aBad = a.findings.reduce(
960
+ (m, f) => Math.max(m, SEVERITY_ORDER[f.severity]),
961
+ -1
962
+ );
963
+ const bBad = b.findings.reduce(
964
+ (m, f) => Math.max(m, SEVERITY_ORDER[f.severity]),
965
+ -1
966
+ );
967
+ return (
968
+ bBad - aBad ||
969
+ a.type.localeCompare(b.type) ||
970
+ a.item.localeCompare(b.item)
971
+ );
972
+ }),
973
+ summary: {
974
+ totalItems: results.length,
975
+ totalFindings,
976
+ bySeverity,
977
+ flaggedItems,
978
+ },
979
+ };
980
+
981
+ const auditDir = join(home, ".facult", "audit");
982
+ await mkdir(auditDir, { recursive: true });
983
+ await Bun.write(
984
+ join(auditDir, "agent-latest.json"),
985
+ `${JSON.stringify(report, null, 2)}\n`
986
+ );
987
+
988
+ return report;
989
+ }
990
+
991
+ function printHuman(report: AgentAuditReport) {
992
+ console.log("Agent Security Audit");
993
+ console.log("====================");
994
+ console.log("");
995
+ console.log(
996
+ `Agent: ${report.agent.tool}${report.agent.model ? ` (${report.agent.model})` : ""}`
997
+ );
998
+ console.log(
999
+ `Max items: ${report.scope.maxItems === 0 ? "all" : report.scope.maxItems}`
1000
+ );
1001
+ if (report.scope.from.length) {
1002
+ console.log(`From: ${report.scope.from.join(", ")}`);
1003
+ }
1004
+ if (report.scope.requested) {
1005
+ console.log(`Requested: ${report.scope.requested}`);
1006
+ }
1007
+ console.log("");
1008
+
1009
+ const failures = report.results.filter(
1010
+ (r) => !r.passed && r.findings.length > 0
1011
+ );
1012
+ const passes = report.results.filter(
1013
+ (r) => r.passed || r.findings.length === 0
1014
+ );
1015
+
1016
+ for (const r of [...failures, ...passes]) {
1017
+ const status = r.findings.length === 0 ? "OK" : r.passed ? "WARN" : "FAIL";
1018
+ const label =
1019
+ r.type === "mcp"
1020
+ ? `mcp:${r.item}`
1021
+ : r.type === "asset"
1022
+ ? `asset:${r.item}`
1023
+ : r.item;
1024
+ const count = r.findings.length;
1025
+ console.log(
1026
+ `${status} ${label} (${count} finding${count === 1 ? "" : "s"})`
1027
+ );
1028
+ for (const f of r.findings) {
1029
+ const loc = f.location ? ` @ ${f.location}` : "";
1030
+ console.log(` [${f.severity.toUpperCase()}] ${f.ruleId}${loc}`);
1031
+ console.log(` ${f.message}`);
1032
+ }
1033
+ }
1034
+
1035
+ console.log("");
1036
+ console.log(
1037
+ `Summary: ${report.summary.totalFindings} findings across ${report.summary.totalItems} items (flagged: ${report.summary.flaggedItems}).`
1038
+ );
1039
+ console.log(
1040
+ `By severity: critical=${report.summary.bySeverity.critical}, high=${report.summary.bySeverity.high}, medium=${report.summary.bySeverity.medium}, low=${report.summary.bySeverity.low}`
1041
+ );
1042
+ console.log(
1043
+ `Wrote ${join(homedir(), ".facult", "audit", "agent-latest.json")}`
1044
+ );
1045
+ }
1046
+
1047
+ export async function agentAuditCommand(argv: string[]) {
1048
+ const json = argv.includes("--json");
1049
+
1050
+ let report: AgentAuditReport;
1051
+ try {
1052
+ report = await runAgentAudit({ argv });
1053
+ } catch (err) {
1054
+ console.error(err instanceof Error ? err.message : String(err));
1055
+ process.exitCode = 1;
1056
+ return;
1057
+ }
1058
+
1059
+ // Best-effort: update index.json auditStatus/lastAuditAt for canonical items.
1060
+ await updateIndexFromAuditReport({
1061
+ timestamp: report.timestamp,
1062
+ results: report.results,
1063
+ });
1064
+
1065
+ if (json) {
1066
+ console.log(JSON.stringify(report, null, 2));
1067
+ return;
1068
+ }
1069
+
1070
+ printHuman(report);
1071
+ }