fullcourtdefense-cli 1.15.10 → 1.15.12

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.
@@ -41,7 +41,9 @@ const path = __importStar(require("path"));
41
41
  const child_process_1 = require("child_process");
42
42
  const config_1 = require("../config");
43
43
  const mcpGateway_1 = require("./mcpGateway");
44
+ const protectionRepair_1 = require("./protectionRepair");
44
45
  const runtimeConfig_1 = require("../runtimeConfig");
46
+ const discoverPaths_1 = require("./discoverPaths");
45
47
  const localSafetySnapshot_1 = require("../localSafetySnapshot");
46
48
  const telemetry_1 = require("../telemetry");
47
49
  const notify_1 = require("../notify");
@@ -53,6 +55,7 @@ const COLOR = {
53
55
  red: '\x1b[31m', yellow: '\x1b[33m', green: '\x1b[32m', cyan: '\x1b[36m', gray: '\x1b[90m',
54
56
  };
55
57
  const TASK_NAME = 'FullCourtDefense Daemon';
58
+ const WATCHDOG_TASK_NAME = 'FullCourtDefense Daemon Watchdog';
56
59
  const CRON_MARKER = '# FCD_DAEMON';
57
60
  const LAUNCHD_LABEL = 'ai.fullcourtdefense.daemon';
58
61
  const SYSTEMD_UNIT = 'fullcourtdefense-daemon.service';
@@ -262,6 +265,16 @@ async function runDaemon(args, config) {
262
265
  console.log(`${COLOR.yellow}Another FullCourtDefense daemon is already running (pid file: ${pidFile()}).${COLOR.reset}`);
263
266
  return;
264
267
  }
268
+ // The resident loop must never die silently from one bad tick (fs watcher
269
+ // callback, fetch, timer). Log and keep running — the loop is timer-driven,
270
+ // so surviving a failed tick is safe, and on Windows there is no supervisor
271
+ // that would restart a crashed process instantly.
272
+ process.on('uncaughtException', error => {
273
+ log(`Uncaught exception (daemon continues): ${error?.stack || String(error)}`);
274
+ });
275
+ process.on('unhandledRejection', reason => {
276
+ log(`Unhandled rejection (daemon continues): ${reason?.stack || String(reason)}`);
277
+ });
265
278
  const creds = (0, config_1.resolveCliCredentials)(config, {
266
279
  shieldId: args.shieldId,
267
280
  shieldKey: args.shieldKey,
@@ -272,6 +285,7 @@ async function runDaemon(args, config) {
272
285
  log(`Shield: ${creds.shieldId || '(none — protect-only mode, telemetry disabled)'} API: ${creds.apiUrl}`);
273
286
  // --- state ---------------------------------------------------------------
274
287
  const watchers = new Map(); // directory -> watcher
288
+ const rootWatchers = new Map(); // admin protection roots -> recursive watcher
275
289
  const watchedFiles = new Set(); // lowercased absolute file paths we react to
276
290
  let quietUntil = 0; // ignore events until this time (self-writes)
277
291
  let debounceTimer = null;
@@ -290,9 +304,10 @@ async function runDaemon(args, config) {
290
304
  quietUntil = Date.now() + SELF_WRITE_QUIET_MS;
291
305
  log(`Config drift detected: ${reasonPaths.join(', ')} — re-running protect-all.`);
292
306
  try {
293
- await (0, mcpGateway_1.protectAllCommand)({ ...args, dryRun: undefined }, config);
307
+ const repaired = await (0, protectionRepair_1.repairProtection)({ ...args, dryRun: undefined }, config);
294
308
  quietUntil = Date.now() + SELF_WRITE_QUIET_MS;
295
- log('Re-protection pass complete.');
309
+ log(`Re-protection pass complete (Cursor hooks ${repaired.cursorHooksHealthy ? 'healthy' : 'unhealthy'}, Claude hooks ${repaired.claudeHooksHealthy ? 'healthy' : 'unhealthy'}).`);
310
+ refreshWatchTargets();
296
311
  await uploadLogTail();
297
312
  if (!quiet) {
298
313
  (0, notify_1.notifyOs)({
@@ -323,6 +338,25 @@ async function runDaemon(args, config) {
323
338
  clearTimeout(debounceTimer);
324
339
  debounceTimer = setTimeout(() => { void reprotect([full]); }, DEBOUNCE_MS);
325
340
  };
341
+ const onAdminRootEvent = (root, filename) => {
342
+ if (stopped || Date.now() < quietUntil || !filename)
343
+ return;
344
+ const full = path.resolve(root, filename.toString());
345
+ const normalized = full.replace(/\\/g, '/').toLowerCase();
346
+ if (normalized.includes('.fcd-backup-'))
347
+ return;
348
+ if (!/(?:\/\.cursor\/mcp\.json|\/\.mcp\.json|\/mcp\.json|\/\.claude\/settings\.json|\/\.vscode\/mcp\.json|\/\.codex\/config\.toml|\/\.gemini\/settings\.json)$/.test(normalized))
349
+ return;
350
+ const { disabledScanRoots } = (0, runtimeConfig_1.getCachedScanRootOverrides)();
351
+ if ((0, discoverPaths_1.isExcludedPath)(full, disabledScanRoots))
352
+ return;
353
+ if (debounceTimer)
354
+ clearTimeout(debounceTimer);
355
+ debounceTimer = setTimeout(() => {
356
+ refreshWatchTargets();
357
+ void reprotect([full]);
358
+ }, DEBOUNCE_MS);
359
+ };
326
360
  /**
327
361
  * (Re)build the watch set from the current MCP client configs + hook files.
328
362
  * Watches parent directories (not files) so atomic replaces on Windows and
@@ -355,6 +389,27 @@ async function runDaemon(args, config) {
355
389
  }
356
390
  catch { /* directory may vanish; the rescan tick re-tries */ }
357
391
  }
392
+ const { extraScanRoots, disabledScanRoots } = (0, runtimeConfig_1.getCachedScanRootOverrides)();
393
+ const wantedRoots = new Set(extraScanRoots
394
+ .filter(root => !(0, discoverPaths_1.isExcludedPath)(root, disabledScanRoots) && fs.existsSync(root))
395
+ .map(discoverPaths_1.normalizedPath));
396
+ for (const [root, watcher] of rootWatchers) {
397
+ if (!wantedRoots.has((0, discoverPaths_1.normalizedPath)(root))) {
398
+ watcher.close();
399
+ rootWatchers.delete(root);
400
+ }
401
+ }
402
+ for (const root of extraScanRoots) {
403
+ const key = (0, discoverPaths_1.normalizedPath)(root);
404
+ if (!wantedRoots.has(key) || [...rootWatchers.keys()].some(existing => (0, discoverPaths_1.normalizedPath)(existing) === key))
405
+ continue;
406
+ try {
407
+ const watcher = fs.watch(path.resolve(root), { recursive: true }, (_event, filename) => onAdminRootEvent(root, filename ? String(filename) : null));
408
+ watcher.on('error', () => { watcher.close(); rootWatchers.delete(root); });
409
+ rootWatchers.set(path.resolve(root), watcher);
410
+ }
411
+ catch { /* unsupported/unreadable roots are retried on the rescan tick */ }
412
+ }
358
413
  return targets.length;
359
414
  };
360
415
  // Ship the REAL daemon log tail to the control plane so the dashboard's
@@ -461,7 +516,7 @@ async function runDaemon(args, config) {
461
516
  throw new Error('Machine is suspended; resume it before repairing protection.');
462
517
  log('Repair protection: running protect-all (IDE hooks + MCP gateways)…');
463
518
  await uploadLogTail();
464
- await (0, mcpGateway_1.protectAllCommand)({ ...args, dryRun: undefined }, config);
519
+ await (0, protectionRepair_1.repairProtection)({ ...args, dryRun: undefined }, config);
465
520
  log('Repair protection: verifying hooks, gateways and daemon integrity…');
466
521
  const verification = (0, integrity_1.getLocalIntegrityReport)({ requireDaemon: true });
467
522
  if (!verification.ok) {
@@ -613,6 +668,8 @@ async function runDaemon(args, config) {
613
668
  clearTimeout(debounceTimer);
614
669
  for (const watcher of watchers.values())
615
670
  watcher.close();
671
+ for (const watcher of rootWatchers.values())
672
+ watcher.close();
616
673
  releasePidLock();
617
674
  process.exit(0);
618
675
  };
@@ -696,12 +753,23 @@ function installWindows() {
696
753
  ], { stdio: 'ignore', windowsHide: true });
697
754
  ok = reg.status === 0;
698
755
  }
756
+ // Watchdog: logon triggers only START the daemon — nothing restarts it if it
757
+ // is killed or crashes mid-session. A time-based per-user task (no elevation
758
+ // needed, unlike ONLOGON) re-launches the daemon every 5 minutes;
759
+ // acquirePidLock() makes each tick a cheap no-op while the daemon is alive,
760
+ // and revives it within one tick when it is not. Best-effort: a missing
761
+ // watchdog never fails the install.
762
+ (0, child_process_1.spawnSync)('schtasks', [
763
+ '/Create', '/TN', WATCHDOG_TASK_NAME, '/TR', `wscript.exe "${vbs}"`,
764
+ '/SC', 'MINUTE', '/MO', '5', '/F',
765
+ ], { stdio: 'ignore', windowsHide: true });
699
766
  if (ok)
700
767
  startDaemonNowWindows(vbs, taskOk);
701
768
  return ok;
702
769
  }
703
770
  function uninstallWindows() {
704
771
  const task = (0, child_process_1.spawnSync)('schtasks', ['/Delete', '/TN', TASK_NAME, '/F'], { stdio: 'ignore', windowsHide: true });
772
+ (0, child_process_1.spawnSync)('schtasks', ['/Delete', '/TN', WATCHDOG_TASK_NAME, '/F'], { stdio: 'ignore', windowsHide: true });
705
773
  const reg = (0, child_process_1.spawnSync)('reg', ['delete', WINDOWS_RUN_KEY, '/v', WINDOWS_RUN_VALUE, '/f'], { stdio: 'ignore', windowsHide: true });
706
774
  const vbs = path.join(process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'), 'FullCourtDefense', 'daemon.vbs');
707
775
  try {
@@ -790,11 +858,16 @@ WantedBy=default.target
790
858
  return true;
791
859
  }
792
860
  catch { /* fall back to cron */ }
793
- // Fallback: cron @reboot for boxes without a systemd user session.
861
+ // Fallback: cron for boxes without a systemd user session. @reboot starts
862
+ // the daemon at boot; the 5-min line is the watchdog (pid lock makes each
863
+ // tick a no-op while the daemon is alive, and revives it after a kill/crash).
794
864
  const command = `"${process.execPath}" "${cliEntry()}" daemon >/dev/null 2>&1`;
795
865
  const list = (0, child_process_1.spawnSync)('crontab', ['-l'], { encoding: 'utf8' });
796
866
  const existing = (list.status === 0 ? (list.stdout || '') : '').split('\n').filter(l => l && !l.includes(CRON_MARKER));
797
- const write = (0, child_process_1.spawnSync)('crontab', ['-'], { input: [...existing, `@reboot ${command} ${CRON_MARKER}`].join('\n') + '\n', encoding: 'utf8' });
867
+ const write = (0, child_process_1.spawnSync)('crontab', ['-'], {
868
+ input: [...existing, `@reboot ${command} ${CRON_MARKER}`, `*/5 * * * * ${command} ${CRON_MARKER}`].join('\n') + '\n',
869
+ encoding: 'utf8',
870
+ });
798
871
  return write.status === 0;
799
872
  }
800
873
  function uninstallLinux() {
@@ -854,6 +927,10 @@ function statusCommand() {
854
927
  else {
855
928
  console.log(' not installed — run: fullcourtdefense daemon --install true');
856
929
  }
930
+ const watchdog = (0, child_process_1.spawnSync)('schtasks', ['/Query', '/TN', WATCHDOG_TASK_NAME], { stdio: 'ignore', windowsHide: true, timeout: 5_000 });
931
+ console.log(watchdog.status === 0
932
+ ? ` Watchdog task "${WATCHDOG_TASK_NAME}" installed — revives a killed daemon within 5 min`
933
+ : ` ${COLOR.yellow}watchdog not installed${COLOR.reset} — a killed daemon stays down until next logon; re-run: fullcourtdefense daemon --install true`);
857
934
  }
858
935
  else if (process.platform === 'darwin') {
859
936
  console.log(fs.existsSync(launchdPlistPath()) ? ` launchd agent installed (${launchdPlistPath()})` : ' not installed');
@@ -897,7 +974,7 @@ async function daemonCommand(args, config) {
897
974
  : installLinux();
898
975
  if (ok) {
899
976
  console.log(`${COLOR.green}${COLOR.bold}Daemon autostart installed.${COLOR.reset}`);
900
- console.log(`${COLOR.gray}Trigger:${COLOR.reset} ${process.platform === 'win32' ? `at logon (Scheduled Task "${TASK_NAME}")` : process.platform === 'darwin' ? 'launchd (RunAtLoad + KeepAlive)' : 'systemd user unit (or @reboot cron)'}`);
977
+ console.log(`${COLOR.gray}Trigger:${COLOR.reset} ${process.platform === 'win32' ? `at logon (Scheduled Task "${TASK_NAME}") + 5-min watchdog ("${WATCHDOG_TASK_NAME}") that revives a killed daemon` : process.platform === 'darwin' ? 'launchd (RunAtLoad + KeepAlive)' : 'systemd user unit (or @reboot + 5-min watchdog cron)'}`);
901
978
  console.log(`${COLOR.gray}Start now:${COLOR.reset} fullcourtdefense daemon`);
902
979
  console.log(`${COLOR.gray}Status:${COLOR.reset} fullcourtdefense daemon --status true`);
903
980
  console.log(`${COLOR.gray}Remove:${COLOR.reset} fullcourtdefense daemon --uninstall true`);
@@ -774,6 +774,7 @@ function printPostureReport(posture, silent) {
774
774
  }
775
775
  }
776
776
  async function discoverCommand(args, config) {
777
+ const scanStartedAt = new Date().toISOString();
777
778
  const surfaces = parseSurfaces(args);
778
779
  const silent = args.silent === 'true';
779
780
  const uploadRequested = args.upload === 'true';
@@ -827,14 +828,18 @@ async function discoverCommand(args, config) {
827
828
  // covers them without extra flags. Default folders the admin explicitly
828
829
  // removed in the console are dropped from the sweep.
829
830
  const { extraScanRoots: adminScanRoots, disabledScanRoots } = (0, runtimeConfig_1.getCachedScanRootOverrides)();
830
- const disabledRootSet = new Set(disabledScanRoots.map(root => path.resolve(root).toLowerCase()));
831
- const isDisabledRoot = (root) => disabledRootSet.has(path.resolve(root).toLowerCase());
832
- const scanRoots = [...new Set([...(0, discoverPaths_1.resolveScanRoots)(args.scanRoot), ...adminScanRoots])]
831
+ const normalizedDisabledRoots = (0, discoverPaths_1.uniqueNormalizedPaths)(disabledScanRoots);
832
+ const isDisabledRoot = (root) => (0, discoverPaths_1.isExcludedPath)(root, normalizedDisabledRoots);
833
+ const scanRoots = (0, discoverPaths_1.uniqueNormalizedPaths)([...(0, discoverPaths_1.resolveScanRoots)(args.scanRoot), ...adminScanRoots])
833
834
  .filter(root => !isDisabledRoot(root));
834
- const sweep = scanRoots.length > 0 ? (0, discoverPaths_1.scanRootsForProjectConfigs)(scanRoots) : { candidates: [], scannedDirs: 0 };
835
+ const sweep = scanRoots.length > 0
836
+ ? (0, discoverPaths_1.scanRootsForProjectConfigs)(scanRoots, { excludedRoots: normalizedDisabledRoots })
837
+ : { candidates: [], scannedDirs: 0, excludedDirs: 0, truncated: false, maxDepth: 4, maxDirs: 25000 };
835
838
  // Admin roots are scanned for env/secrets even when no MCP config lives there.
836
- const sweepProjectRoots = [...new Set([...deriveProjectRoots(sweep.candidates), ...adminScanRoots])];
837
- const knownConfigCandidates = candidateConfigPaths(cwd, args.extraPath);
839
+ const sweepProjectRoots = (0, discoverPaths_1.uniqueNormalizedPaths)([...deriveProjectRoots(sweep.candidates), ...adminScanRoots])
840
+ .filter(root => !isDisabledRoot(root));
841
+ const knownConfigCandidates = candidateConfigPaths(cwd, args.extraPath)
842
+ .filter(candidate => !isDisabledRoot(candidate.path));
838
843
  const secretConfigFiles = [...knownConfigCandidates, ...sweep.candidates]
839
844
  .map(candidate => path.resolve(candidate.path))
840
845
  .filter(file => fs.existsSync(file));
@@ -881,11 +886,12 @@ async function discoverCommand(args, config) {
881
886
  }
882
887
  }
883
888
  const secrets = surfaces.has('secrets') || surfaces.has('posture')
884
- ? (0, discoverSecrets_1.scanSecrets)({ cwd, extraRoots: sweepProjectRoots, disabledRoots: disabledScanRoots, configFiles: secretConfigFiles })
889
+ ? (0, discoverSecrets_1.scanSecrets)({ cwd, extraRoots: sweepProjectRoots, disabledRoots: normalizedDisabledRoots, configFiles: secretConfigFiles })
885
890
  : undefined;
886
- const agentFiles = surfaces.has('agent-files') || surfaces.has('posture')
887
- ? (0, discoverAgentFiles_1.scanAgentFiles)({ cwd, extraRoots: sweepProjectRoots })
891
+ const agentFileScan = surfaces.has('agent-files') || surfaces.has('posture')
892
+ ? (0, discoverAgentFiles_1.scanAgentFilesWithMetadata)({ cwd, extraRoots: sweepProjectRoots, disabledRoots: normalizedDisabledRoots })
888
893
  : undefined;
894
+ const agentFiles = agentFileScan?.findings;
889
895
  const posture = surfaces.has('posture')
890
896
  ? (0, discoverBlastRadius_1.computeBlastRadius)({
891
897
  mcpServers: found,
@@ -932,14 +938,43 @@ async function discoverCommand(args, config) {
932
938
  `User-level AI rules, skills, hooks, and instruction files under ${home}`,
933
939
  `Credential stores and shell history under ${home}`,
934
940
  `Environment files under ${cwd}, ${path.join(home, 'dev')}, ${path.join(home, 'repos')}, ${path.join(home, 'projects')}, ${path.join(home, 'Documents')}, and ${path.join(home, 'code')}`,
935
- ...adminScanRoots.map(root => `Admin-selected folder: ${root} (and subfolders)`),
941
+ ...adminScanRoots.map(root => `Admin-selected folder: ${root} (bounded descendant scan; exclusions and limits below)`),
936
942
  ],
937
943
  excluded: [
938
944
  'Unrelated project folders outside the listed roots',
939
945
  'Large/generated dependency and build folders such as node_modules, .git, dist, build, vendor, caches, and temp folders',
946
+ ...normalizedDisabledRoots.map(root => `Admin-excluded subtree: ${root}`),
940
947
  'Full disk contents, browser profiles, email/chat archives, binary files, and operating-system folders',
941
948
  ],
942
- note: 'Desktop posture is a targeted AI-development exposure scan, not a full-device forensic scan. Add folders from the machine page to expand coverage.',
949
+ note: `Desktop posture is a bounded AI-development exposure scan (project walk depth ${sweep.maxDepth}, up to ${sweep.maxDirs} folders), not a full-device forensic scan.`,
950
+ scannedAt: scanStartedAt,
951
+ limits: {
952
+ projectMaxDepth: sweep.maxDepth,
953
+ projectMaxDirectories: sweep.maxDirs,
954
+ secretMaxFileBytes: secrets?.limits?.maxFileBytes,
955
+ secretMaxCandidateFiles: secrets?.limits?.maxEnvFiles,
956
+ secretMaxFindings: secrets?.limits?.maxFindings,
957
+ agentFileMaxFiles: agentFileScan?.limits.maxFiles,
958
+ agentFileMaxBytes: agentFileScan?.limits.maxFileBytes,
959
+ agentFileMaxDepth: agentFileScan?.limits.maxDepth,
960
+ },
961
+ stats: {
962
+ projectDirectoriesScanned: sweep.scannedDirs,
963
+ projectConfigsFound: sweep.candidates.length,
964
+ projectRootsFound: sweepProjectRoots.length,
965
+ rootsScanned: scannedRoots.length,
966
+ secretCandidateFilesScanned: secrets?.scannedFiles ?? 0,
967
+ secretFindingsFound: secrets?.findings.length ?? 0,
968
+ secretFindingsUploaded: secrets?.findings.length ?? 0,
969
+ agentFilesScanned: agentFileScan?.stats.scannedFiles ?? 0,
970
+ excludedPathsSkipped: sweep.excludedDirs + (secrets?.stats?.excludedFiles ?? 0) + (agentFileScan?.stats.excludedPaths ?? 0),
971
+ },
972
+ truncated: {
973
+ projectDirectories: sweep.truncated,
974
+ secretCandidates: secrets?.truncated?.candidateFiles ?? false,
975
+ secretFindings: secrets?.truncated?.findings ?? false,
976
+ agentFiles: agentFileScan?.truncated ?? false,
977
+ },
943
978
  },
944
979
  };
945
980
  async function maybeUpload() {
@@ -967,6 +1002,7 @@ async function discoverCommand(args, config) {
967
1002
  secrets,
968
1003
  agentFiles,
969
1004
  posture,
1005
+ scanScope: uploadExtras.scanScope,
970
1006
  }, null, 2));
971
1007
  return;
972
1008
  }
@@ -10,7 +10,27 @@ export interface AgentFileFinding {
10
10
  riskTags: string[];
11
11
  excerpt?: string;
12
12
  }
13
+ export interface AgentFilesScanResult {
14
+ findings: AgentFileFinding[];
15
+ limits: {
16
+ maxFiles: number;
17
+ maxFileBytes: number;
18
+ maxDepth: number;
19
+ };
20
+ stats: {
21
+ scannedFiles: number;
22
+ excludedPaths: number;
23
+ };
24
+ truncated: boolean;
25
+ }
26
+ export declare function scanAgentFilesWithMetadata(options?: {
27
+ cwd?: string;
28
+ extraRoots?: string[];
29
+ disabledRoots?: string[];
30
+ }): AgentFilesScanResult;
31
+ /** Backward-compatible findings-only API used by existing callers/tests. */
13
32
  export declare function scanAgentFiles(options?: {
14
33
  cwd?: string;
15
34
  extraRoots?: string[];
35
+ disabledRoots?: string[];
16
36
  }): AgentFileFinding[];
@@ -33,11 +33,13 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.scanAgentFilesWithMetadata = scanAgentFilesWithMetadata;
36
37
  exports.scanAgentFiles = scanAgentFiles;
37
38
  const crypto = __importStar(require("crypto"));
38
39
  const fs = __importStar(require("fs"));
39
40
  const os = __importStar(require("os"));
40
41
  const path = __importStar(require("path"));
42
+ const discoverPaths_1 = require("./discoverPaths");
41
43
  const MAX_FILES = 150;
42
44
  const MAX_FILE_BYTES = 512 * 1024;
43
45
  const EXCERPT_LEN = 180;
@@ -131,9 +133,19 @@ function pushFinding(out, seen, input) {
131
133
  excerpt: excerpt || undefined,
132
134
  });
133
135
  }
134
- function walkDir(dir, out, seen, matcher, mapper, maxDepth = 4, depth = 0) {
135
- if (depth > maxDepth || out.length >= MAX_FILES)
136
+ function walkDir(dir, out, seen, matcher, mapper, maxDepth = 4, depth = 0, excludedRoots = [], stats) {
137
+ if (depth > maxDepth) {
138
+ if (stats)
139
+ stats.depthLimited = true;
136
140
  return;
141
+ }
142
+ if (out.length >= MAX_FILES)
143
+ return;
144
+ if ((0, discoverPaths_1.isExcludedPath)(dir, excludedRoots)) {
145
+ if (stats)
146
+ stats.excludedPaths += 1;
147
+ return;
148
+ }
137
149
  let entries;
138
150
  try {
139
151
  entries = fs.readdirSync(dir, { withFileTypes: true });
@@ -145,10 +157,15 @@ function walkDir(dir, out, seen, matcher, mapper, maxDepth = 4, depth = 0) {
145
157
  if (out.length >= MAX_FILES)
146
158
  return;
147
159
  const full = path.join(dir, entry.name);
160
+ if ((0, discoverPaths_1.isExcludedPath)(full, excludedRoots)) {
161
+ if (stats)
162
+ stats.excludedPaths += 1;
163
+ continue;
164
+ }
148
165
  if (entry.isDirectory()) {
149
166
  if (['node_modules', '.git', 'dist', 'build'].includes(entry.name))
150
167
  continue;
151
- walkDir(full, out, seen, matcher, mapper, maxDepth, depth + 1);
168
+ walkDir(full, out, seen, matcher, mapper, maxDepth, depth + 1, excludedRoots, stats);
152
169
  continue;
153
170
  }
154
171
  if (!entry.isFile() || !matcher(full, entry.name))
@@ -159,12 +176,22 @@ function walkDir(dir, out, seen, matcher, mapper, maxDepth = 4, depth = 0) {
159
176
  pushFinding(out, seen, { ...mapper(full), ...parsed, content: parsed.content });
160
177
  }
161
178
  }
162
- function scanNamedFiles(roots, names, kind, titleFor, out, seen) {
179
+ function scanNamedFiles(roots, names, kind, titleFor, out, seen, excludedRoots = [], stats) {
163
180
  for (const { root, client } of roots) {
181
+ if ((0, discoverPaths_1.isExcludedPath)(root, excludedRoots)) {
182
+ if (stats)
183
+ stats.excludedPaths += 1;
184
+ continue;
185
+ }
164
186
  if (!fs.existsSync(root))
165
187
  continue;
166
188
  for (const name of names) {
167
189
  const filePath = path.join(root, name);
190
+ if ((0, discoverPaths_1.isExcludedPath)(filePath, excludedRoots)) {
191
+ if (stats)
192
+ stats.excludedPaths += 1;
193
+ continue;
194
+ }
168
195
  if (!fs.existsSync(filePath))
169
196
  continue;
170
197
  const parsed = readAgentFile(filePath);
@@ -183,17 +210,22 @@ function scanNamedFiles(roots, names, kind, titleFor, out, seen) {
183
210
  }
184
211
  }
185
212
  /** Scan a single project root for agent instruction/rule/skill/hook files. */
186
- function scanProjectRootAgentFiles(root, clientLabel, out, seen) {
213
+ function scanProjectRootAgentFiles(root, clientLabel, out, seen, excludedRoots = [], stats) {
214
+ if ((0, discoverPaths_1.isExcludedPath)(root, excludedRoots)) {
215
+ if (stats)
216
+ stats.excludedPaths += 1;
217
+ return;
218
+ }
187
219
  scanNamedFiles([{ root, client: clientLabel }], [
188
220
  'AGENTS.md',
189
221
  'CLAUDE.md',
190
222
  'GEMINI.md',
191
223
  '.cursorrules',
192
224
  '.windsurfrules',
193
- ], 'instructions', name => `Agent instructions (${name})`, out, seen);
225
+ ], 'instructions', name => `Agent instructions (${name})`, out, seen, excludedRoots, stats);
194
226
  scanNamedFiles([{ root: path.join(root, '.github'), client: 'github' }], [
195
227
  'copilot-instructions.md',
196
- ], 'instructions', () => 'GitHub Copilot instructions', out, seen);
228
+ ], 'instructions', () => 'GitHub Copilot instructions', out, seen, excludedRoots, stats);
197
229
  for (const base of [
198
230
  { dir: path.join(root, '.cursor', 'rules'), client: `Cursor (${clientLabel})`, kind: 'rules' },
199
231
  { dir: path.join(root, '.agents', 'skills'), client: `Agents (${clientLabel})`, kind: 'skills' },
@@ -207,7 +239,7 @@ function scanProjectRootAgentFiles(root, clientLabel, out, seen) {
207
239
  title: path.basename(filePath),
208
240
  sizeBytes: 0,
209
241
  lineCount: 0,
210
- }));
242
+ }), 4, 0, excludedRoots, stats);
211
243
  }
212
244
  const hooksPath = path.join(root, '.cursor', 'hooks.json');
213
245
  if (fs.existsSync(hooksPath)) {
@@ -226,20 +258,24 @@ function scanProjectRootAgentFiles(root, clientLabel, out, seen) {
226
258
  }
227
259
  }
228
260
  }
229
- function scanAgentFiles(options = {}) {
261
+ function scanAgentFilesWithMetadata(options = {}) {
230
262
  const home = os.homedir();
231
263
  const cwd = options.cwd || process.cwd();
232
264
  const out = [];
233
265
  const seen = new Set();
266
+ const excludedRoots = (0, discoverPaths_1.uniqueNormalizedPaths)(options.disabledRoots ?? []);
267
+ // Count configured subtree exclusions even when pruning prevents the walker
268
+ // from ever enumerating their contents.
269
+ const stats = { excludedPaths: excludedRoots.length, depthLimited: false };
234
270
  // Current project + user-global locations.
235
- scanProjectRootAgentFiles(cwd, 'project', out, seen);
271
+ scanProjectRootAgentFiles(cwd, 'project', out, seen, excludedRoots, stats);
236
272
  scanNamedFiles([{ root: home, client: 'user' }], [
237
273
  'AGENTS.md',
238
274
  'CLAUDE.md',
239
275
  'GEMINI.md',
240
276
  '.cursorrules',
241
277
  '.windsurfrules',
242
- ], 'instructions', name => `Agent instructions (${name})`, out, seen);
278
+ ], 'instructions', name => `Agent instructions (${name})`, out, seen, excludedRoots, stats);
243
279
  for (const base of [
244
280
  { dir: path.join(home, '.cursor', 'rules'), client: 'Cursor', kind: 'rules' },
245
281
  { dir: path.join(home, '.agents', 'skills'), client: 'Agents', kind: 'skills' },
@@ -253,10 +289,10 @@ function scanAgentFiles(options = {}) {
253
289
  title: path.basename(filePath),
254
290
  sizeBytes: 0,
255
291
  lineCount: 0,
256
- }));
292
+ }), 4, 0, excludedRoots, stats);
257
293
  }
258
294
  const homeHooks = path.join(home, '.cursor', 'hooks.json');
259
- if (fs.existsSync(homeHooks)) {
295
+ if (!(0, discoverPaths_1.isExcludedPath)(homeHooks, excludedRoots) && fs.existsSync(homeHooks)) {
260
296
  const parsed = readAgentFile(homeHooks);
261
297
  if (parsed) {
262
298
  pushFinding(out, seen, {
@@ -274,13 +310,26 @@ function scanAgentFiles(options = {}) {
274
310
  // Additional project roots (e.g. from `discover --scan-root`) — reference agent
275
311
  // files in every mapped project, not just the current folder.
276
312
  const cwdKey = path.resolve(cwd).toLowerCase();
277
- for (const extra of options.extraRoots ?? []) {
313
+ for (const extra of (0, discoverPaths_1.uniqueNormalizedPaths)(options.extraRoots ?? [])) {
278
314
  if (path.resolve(extra).toLowerCase() === cwdKey)
279
315
  continue;
316
+ if ((0, discoverPaths_1.isExcludedPath)(extra, excludedRoots)) {
317
+ stats.excludedPaths += 1;
318
+ continue;
319
+ }
280
320
  if (!fs.existsSync(extra))
281
321
  continue;
282
- scanProjectRootAgentFiles(extra, 'scanned project', out, seen);
322
+ scanProjectRootAgentFiles(extra, 'scanned project', out, seen, excludedRoots, stats);
283
323
  }
284
324
  out.sort((a, b) => b.riskTags.length - a.riskTags.length || b.sizeBytes - a.sizeBytes);
285
- return out;
325
+ return {
326
+ findings: out,
327
+ limits: { maxFiles: MAX_FILES, maxFileBytes: MAX_FILE_BYTES, maxDepth: 4 },
328
+ stats: { scannedFiles: out.length, excludedPaths: stats.excludedPaths },
329
+ truncated: stats.depthLimited || out.length >= MAX_FILES,
330
+ };
331
+ }
332
+ /** Backward-compatible findings-only API used by existing callers/tests. */
333
+ function scanAgentFiles(options = {}) {
334
+ return scanAgentFilesWithMetadata(options).findings;
286
335
  }
@@ -7,6 +7,11 @@ export interface BlastRadiusCombo {
7
7
  severity: BlastSeverity;
8
8
  title: string;
9
9
  detail: string;
10
+ /** Exact entities that caused this combo; IDs are stable across rescans. */
11
+ evidenceRefs: Array<{
12
+ kind: 'mcp_server' | 'secret_finding' | 'agent_file' | 'client';
13
+ id: string;
14
+ }>;
10
15
  }
11
16
  export interface BlastRadiusReport {
12
17
  score: number;
@@ -16,6 +21,7 @@ export interface BlastRadiusReport {
16
21
  }
17
22
  interface McpServerLike {
18
23
  serverName: string;
24
+ configPath?: string;
19
25
  riskLevel: 'critical' | 'high' | 'medium' | 'low';
20
26
  riskTags: string[];
21
27
  proxyStatus: string;