fullcourtdefense-cli 1.15.11 → 1.16.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.
@@ -41,13 +41,16 @@ 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");
48
50
  const integrity_1 = require("../integrity");
49
51
  const machineIdentity_1 = require("../machineIdentity");
50
52
  const discoveryMarker_1 = require("../discoveryMarker");
53
+ const selfUpdate_1 = require("../selfUpdate");
51
54
  const COLOR = {
52
55
  reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m',
53
56
  red: '\x1b[31m', yellow: '\x1b[33m', green: '\x1b[32m', cyan: '\x1b[36m', gray: '\x1b[90m',
@@ -283,6 +286,7 @@ async function runDaemon(args, config) {
283
286
  log(`Shield: ${creds.shieldId || '(none — protect-only mode, telemetry disabled)'} API: ${creds.apiUrl}`);
284
287
  // --- state ---------------------------------------------------------------
285
288
  const watchers = new Map(); // directory -> watcher
289
+ const rootWatchers = new Map(); // admin protection roots -> recursive watcher
286
290
  const watchedFiles = new Set(); // lowercased absolute file paths we react to
287
291
  let quietUntil = 0; // ignore events until this time (self-writes)
288
292
  let debounceTimer = null;
@@ -290,6 +294,8 @@ async function runDaemon(args, config) {
290
294
  let suspended = false;
291
295
  let stopped = false;
292
296
  const executingActionIds = new Set();
297
+ /** Latest org auto-update policy seen on a bundle poll. */
298
+ let autoUpdatePolicy;
293
299
  const reprotect = async (reasonPaths) => {
294
300
  if (reprotecting || stopped)
295
301
  return;
@@ -301,9 +307,10 @@ async function runDaemon(args, config) {
301
307
  quietUntil = Date.now() + SELF_WRITE_QUIET_MS;
302
308
  log(`Config drift detected: ${reasonPaths.join(', ')} — re-running protect-all.`);
303
309
  try {
304
- await (0, mcpGateway_1.protectAllCommand)({ ...args, dryRun: undefined }, config);
310
+ const repaired = await (0, protectionRepair_1.repairProtection)({ ...args, dryRun: undefined }, config);
305
311
  quietUntil = Date.now() + SELF_WRITE_QUIET_MS;
306
- log('Re-protection pass complete.');
312
+ log(`Re-protection pass complete (Cursor hooks ${repaired.cursorHooksHealthy ? 'healthy' : 'unhealthy'}, Claude hooks ${repaired.claudeHooksHealthy ? 'healthy' : 'unhealthy'}).`);
313
+ refreshWatchTargets();
307
314
  await uploadLogTail();
308
315
  if (!quiet) {
309
316
  (0, notify_1.notifyOs)({
@@ -334,6 +341,25 @@ async function runDaemon(args, config) {
334
341
  clearTimeout(debounceTimer);
335
342
  debounceTimer = setTimeout(() => { void reprotect([full]); }, DEBOUNCE_MS);
336
343
  };
344
+ const onAdminRootEvent = (root, filename) => {
345
+ if (stopped || Date.now() < quietUntil || !filename)
346
+ return;
347
+ const full = path.resolve(root, filename.toString());
348
+ const normalized = full.replace(/\\/g, '/').toLowerCase();
349
+ if (normalized.includes('.fcd-backup-'))
350
+ return;
351
+ if (!/(?:\/\.cursor\/mcp\.json|\/\.mcp\.json|\/mcp\.json|\/\.claude\/settings\.json|\/\.vscode\/mcp\.json|\/\.codex\/config\.toml|\/\.gemini\/settings\.json)$/.test(normalized))
352
+ return;
353
+ const { disabledScanRoots } = (0, runtimeConfig_1.getCachedScanRootOverrides)();
354
+ if ((0, discoverPaths_1.isExcludedPath)(full, disabledScanRoots))
355
+ return;
356
+ if (debounceTimer)
357
+ clearTimeout(debounceTimer);
358
+ debounceTimer = setTimeout(() => {
359
+ refreshWatchTargets();
360
+ void reprotect([full]);
361
+ }, DEBOUNCE_MS);
362
+ };
337
363
  /**
338
364
  * (Re)build the watch set from the current MCP client configs + hook files.
339
365
  * Watches parent directories (not files) so atomic replaces on Windows and
@@ -366,6 +392,27 @@ async function runDaemon(args, config) {
366
392
  }
367
393
  catch { /* directory may vanish; the rescan tick re-tries */ }
368
394
  }
395
+ const { extraScanRoots, disabledScanRoots } = (0, runtimeConfig_1.getCachedScanRootOverrides)();
396
+ const wantedRoots = new Set(extraScanRoots
397
+ .filter(root => !(0, discoverPaths_1.isExcludedPath)(root, disabledScanRoots) && fs.existsSync(root))
398
+ .map(discoverPaths_1.normalizedPath));
399
+ for (const [root, watcher] of rootWatchers) {
400
+ if (!wantedRoots.has((0, discoverPaths_1.normalizedPath)(root))) {
401
+ watcher.close();
402
+ rootWatchers.delete(root);
403
+ }
404
+ }
405
+ for (const root of extraScanRoots) {
406
+ const key = (0, discoverPaths_1.normalizedPath)(root);
407
+ if (!wantedRoots.has(key) || [...rootWatchers.keys()].some(existing => (0, discoverPaths_1.normalizedPath)(existing) === key))
408
+ continue;
409
+ try {
410
+ const watcher = fs.watch(path.resolve(root), { recursive: true }, (_event, filename) => onAdminRootEvent(root, filename ? String(filename) : null));
411
+ watcher.on('error', () => { watcher.close(); rootWatchers.delete(root); });
412
+ rootWatchers.set(path.resolve(root), watcher);
413
+ }
414
+ catch { /* unsupported/unreadable roots are retried on the rescan tick */ }
415
+ }
369
416
  return targets.length;
370
417
  };
371
418
  // Ship the REAL daemon log tail to the control plane so the dashboard's
@@ -472,7 +519,7 @@ async function runDaemon(args, config) {
472
519
  throw new Error('Machine is suspended; resume it before repairing protection.');
473
520
  log('Repair protection: running protect-all (IDE hooks + MCP gateways)…');
474
521
  await uploadLogTail();
475
- await (0, mcpGateway_1.protectAllCommand)({ ...args, dryRun: undefined }, config);
522
+ await (0, protectionRepair_1.repairProtection)({ ...args, dryRun: undefined }, config);
476
523
  log('Repair protection: verifying hooks, gateways and daemon integrity…');
477
524
  const verification = (0, integrity_1.getLocalIntegrityReport)({ requireDaemon: true });
478
525
  if (!verification.ok) {
@@ -481,6 +528,24 @@ async function runDaemon(args, config) {
481
528
  log(`Repair protection: verified (${verification.protectedMcpConfigs}/${verification.discoveredMcpConfigs} MCP configs wrapped).`);
482
529
  resultSummary = 'AgentGuard hooks, gateways, and protection configuration were repaired and verified.';
483
530
  }
531
+ else if (action.type === 'upgrade_cli') {
532
+ // Explicit version in the action reason wins; otherwise the org
533
+ // auto-update target from the bundle (pinned or latest release).
534
+ const target = action.reason.match(/\d+\.\d+\.\d+/)?.[0] || autoUpdatePolicy?.targetVersion;
535
+ if (!target)
536
+ throw new Error('No target version available — the control plane could not resolve the latest release.');
537
+ log(`Upgrade CLI: admin requested an upgrade to ${target}.`);
538
+ const outcome = (0, selfUpdate_1.maybeSelfUpdate)({ currentVersion: cliVersion(), targetVersion: target, enabled: true, log });
539
+ if (!outcome) {
540
+ resultSummary = `CLI ${cliVersion() || 'unknown'} is already at ${target} (or an upgrade is in progress).`;
541
+ }
542
+ else if (outcome.started) {
543
+ resultSummary = outcome.detail;
544
+ }
545
+ else {
546
+ throw new Error(outcome.detail);
547
+ }
548
+ }
484
549
  else if (action.type === 'discovery_scan') {
485
550
  log('Discovery scan: starting full surface sweep (MCP + secrets + agent files + posture)…');
486
551
  await uploadLogTail();
@@ -541,6 +606,27 @@ async function runDaemon(args, config) {
541
606
  if (bundle.machineAction) {
542
607
  void executeMachineAction(bundle.machineAction);
543
608
  }
609
+ // Silent auto-update: upgrade toward the org's target version. Skipped
610
+ // while a remote action runs, while suspended, and rate-limited so a
611
+ // pending upgrade isn't re-kicked every poll.
612
+ autoUpdatePolicy = bundle.autoUpdate;
613
+ // Mirror the org policy for the elevated MSI updater task (it runs as
614
+ // SYSTEM and cannot read the shield-key-authenticated bundle itself).
615
+ if (bundle.autoUpdate) {
616
+ try {
617
+ fs.writeFileSync(path.join(daemonDir(), 'update-policy.json'), JSON.stringify({ ...bundle.autoUpdate, updatedAt: new Date().toISOString() }), 'utf8');
618
+ }
619
+ catch { /* mirror is best-effort; the updater defaults to enabled */ }
620
+ }
621
+ if (!suspended && bundle.autoUpdate?.enabled) {
622
+ (0, selfUpdate_1.maybeSelfUpdate)({
623
+ currentVersion: cliVersion(),
624
+ targetVersion: bundle.autoUpdate.targetVersion,
625
+ enabled: true,
626
+ busy: executingActionIds.size > 0,
627
+ log,
628
+ });
629
+ }
544
630
  }
545
631
  catch { /* offline — cached stance applies */ }
546
632
  };
@@ -624,6 +710,8 @@ async function runDaemon(args, config) {
624
710
  clearTimeout(debounceTimer);
625
711
  for (const watcher of watchers.values())
626
712
  watcher.close();
713
+ for (const watcher of rootWatchers.values())
714
+ watcher.close();
627
715
  releasePidLock();
628
716
  process.exit(0);
629
717
  };
@@ -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;
@@ -29,12 +29,25 @@ function computeBlastRadius(input) {
29
29
  const shellHistorySecrets = criticalSecrets.filter(f => f.category === 'shell_history');
30
30
  const packagePublisherSecrets = criticalSecrets.filter(f => ['npm', 'pypi', 'github'].includes(f.provider || ''));
31
31
  const riskyAgentFiles = agentFiles.filter(f => f.riskTags.length > 0);
32
+ const mcpRefs = (servers) => servers.map(server => ({
33
+ kind: 'mcp_server',
34
+ // Config path disambiguates identically named servers in different repos.
35
+ id: JSON.stringify([server.serverName, server.configPath || '']),
36
+ }));
37
+ const secretRefs = (findings) => findings.map(finding => ({ kind: 'secret_finding', id: finding.id }));
38
+ const agentRefs = (findings) => findings.map(finding => ({ kind: 'agent_file', id: finding.id }));
39
+ const clientRefs = (clients) => clients.map(client => ({
40
+ kind: 'client',
41
+ id: `${client.clientKey}:${client.configPath}`,
42
+ }));
43
+ const tagged = (tag) => directServers.filter(server => server.riskTags.includes(tag));
32
44
  if (hasTag(directServers, 'shell/exec', true)) {
33
45
  combos.push({
34
46
  id: 'unproxied_shell',
35
47
  severity: 'critical',
36
48
  title: 'Direct shell/exec MCP',
37
49
  detail: 'At least one shell or command-runner MCP is not routed through Full Court Defense gateway.',
50
+ evidenceRefs: mcpRefs(tagged('shell/exec')),
38
51
  });
39
52
  }
40
53
  if (hasTag(directServers, 'database', true)) {
@@ -43,6 +56,7 @@ function computeBlastRadius(input) {
43
56
  severity: 'critical',
44
57
  title: 'Direct database MCP',
45
58
  detail: 'Database MCP tools are reachable without gateway policy enforcement.',
59
+ evidenceRefs: mcpRefs(tagged('database')),
46
60
  });
47
61
  }
48
62
  if (hasTag(directServers, 'shell/exec', true) && hasTag(directServers, 'filesystem', true)) {
@@ -51,6 +65,7 @@ function computeBlastRadius(input) {
51
65
  severity: 'critical',
52
66
  title: 'Shell + filesystem combo (direct)',
53
67
  detail: 'Agent can execute commands and read/write files without gateway — high blast radius.',
68
+ evidenceRefs: mcpRefs(directServers.filter(s => s.riskTags.includes('shell/exec') || s.riskTags.includes('filesystem'))),
54
69
  });
55
70
  }
56
71
  if (hasTag(mcpServers, 'shell/exec', true) && criticalSecrets.length > 0) {
@@ -59,6 +74,7 @@ function computeBlastRadius(input) {
59
74
  severity: 'critical',
60
75
  title: 'Shell MCP + secrets on disk',
61
76
  detail: `${criticalSecrets.length} high/critical secret(s) on this machine while shell/exec MCP is enabled.`,
77
+ evidenceRefs: [...mcpRefs(tagged('shell/exec')), ...secretRefs(criticalSecrets)],
62
78
  });
63
79
  }
64
80
  if (directServers.length > 0 && credentialStoreSecrets.length > 0) {
@@ -67,6 +83,7 @@ function computeBlastRadius(input) {
67
83
  severity: 'critical',
68
84
  title: 'Direct MCP + credential stores',
69
85
  detail: `${credentialStoreSecrets.length} credential-store secret(s) are readable while direct MCP tools are configured.`,
86
+ evidenceRefs: [...mcpRefs(directServers), ...secretRefs(credentialStoreSecrets)],
70
87
  });
71
88
  }
72
89
  if (shellHistorySecrets.length > 0) {
@@ -75,6 +92,7 @@ function computeBlastRadius(input) {
75
92
  severity: hasTag(directServers, 'shell/exec', true) ? 'critical' : 'high',
76
93
  title: 'Secrets in shell history',
77
94
  detail: `${shellHistorySecrets.length} token or credential value(s) were found in recent shell history.`,
95
+ evidenceRefs: secretRefs(shellHistorySecrets),
78
96
  });
79
97
  }
80
98
  if (hasTag(directServers, 'web/network', true) && criticalSecrets.length > 0) {
@@ -83,6 +101,7 @@ function computeBlastRadius(input) {
83
101
  severity: 'high',
84
102
  title: 'Browser/network MCP + local secrets',
85
103
  detail: 'Web or browser automation MCP combined with API keys in .env or credential stores.',
104
+ evidenceRefs: [...mcpRefs(tagged('web/network')), ...secretRefs(criticalSecrets)],
86
105
  });
87
106
  }
88
107
  const clientsWithMcp = clientCoverage.filter(c => c.mcpServerCount > 0);
@@ -93,6 +112,7 @@ function computeBlastRadius(input) {
93
112
  severity: 'high',
94
113
  title: 'No gateway on any AI client',
95
114
  detail: `${clientsWithMcp.length} AI client(s) have MCP configured but no FCD gateway or runtime hooks.`,
115
+ evidenceRefs: clientRefs(missingGateway),
96
116
  });
97
117
  }
98
118
  if (countDirect(mcpServers, 'critical') >= 2) {
@@ -101,6 +121,7 @@ function computeBlastRadius(input) {
101
121
  severity: 'high',
102
122
  title: 'Multiple unproxied critical MCPs',
103
123
  detail: `${countDirect(mcpServers, 'critical')} critical MCP server(s) run direct.`,
124
+ evidenceRefs: mcpRefs(directServers.filter(server => server.riskLevel === 'critical')),
104
125
  });
105
126
  }
106
127
  const unencryptedSsh = secrets.findings.find(f => f.category === 'ssh_key' && f.severity === 'high');
@@ -110,6 +131,7 @@ function computeBlastRadius(input) {
110
131
  severity: 'high',
111
132
  title: 'Unencrypted SSH private key',
112
133
  detail: unencryptedSsh.filePath,
134
+ evidenceRefs: secretRefs([unencryptedSsh]),
113
135
  });
114
136
  }
115
137
  if (riskyAgentFiles.some(f => f.riskTags.includes('allows_shell') || f.riskTags.includes('broad_filesystem'))) {
@@ -118,6 +140,7 @@ function computeBlastRadius(input) {
118
140
  severity: 'medium',
119
141
  title: 'Permissive agent rules/skills',
120
142
  detail: 'Cursor rules or skills mention shell, filesystem, or weak guardrails — review what the agent is allowed to do.',
143
+ evidenceRefs: agentRefs(riskyAgentFiles.filter(f => f.riskTags.includes('allows_shell') || f.riskTags.includes('broad_filesystem'))),
121
144
  });
122
145
  }
123
146
  if (riskyAgentFiles.some(f => f.riskTags.includes('allows_shell')
@@ -129,6 +152,8 @@ function computeBlastRadius(input) {
129
152
  severity: 'high',
130
153
  title: 'Agent can reach local credentials',
131
154
  detail: `${criticalSecrets.length} high-risk credential(s) are on this machine, and agent instructions allow broad actions such as shell, filesystem, auto-approval, or network sends. If the agent is hijacked, those credentials could be read or used.`,
155
+ evidenceRefs: [...secretRefs(criticalSecrets), ...agentRefs(riskyAgentFiles.filter(f => f.riskTags.includes('allows_shell') || f.riskTags.includes('weak_guardrails')
156
+ || f.riskTags.includes('auto_approval') || f.riskTags.includes('network_exfil')))],
132
157
  });
133
158
  }
134
159
  if (packagePublisherSecrets.length > 0 && riskyAgentFiles.some(f => f.riskTags.includes('destructive_actions')
@@ -139,6 +164,7 @@ function computeBlastRadius(input) {
139
164
  severity: 'high',
140
165
  title: 'Publishing token exposed to agent actions',
141
166
  detail: `${packagePublisherSecrets.length} package/repository token(s) are on this machine while agent rules allow command execution, auto-approval, or publishing actions. A hijacked agent could publish packages or push code with those credentials.`,
167
+ evidenceRefs: [...secretRefs(packagePublisherSecrets), ...agentRefs(riskyAgentFiles.filter(f => f.riskTags.includes('destructive_actions') || f.riskTags.includes('allows_shell') || f.riskTags.includes('auto_approval')))],
142
168
  });
143
169
  }
144
170
  if (agentFiles.length >= 5 && !clientCoverage.some(c => c.cursorHooksInstalled)) {
@@ -147,20 +173,16 @@ function computeBlastRadius(input) {
147
173
  severity: 'medium',
148
174
  title: 'Many instruction files, no runtime hooks',
149
175
  detail: `${agentFiles.length} agent instruction/skill files found but runtime hooks are not installed.`,
176
+ evidenceRefs: [...agentRefs(agentFiles), ...clientRefs(clientCoverage.filter(c => !c.cursorHooksInstalled))],
150
177
  });
151
178
  }
179
+ // Score underlying exposure categories once. Combos explain interactions but
180
+ // do not repeatedly penalize the same MCP/secret/agent-file evidence.
152
181
  let score = 100;
153
- for (const combo of combos) {
154
- if (combo.severity === 'critical')
155
- score -= 22;
156
- else if (combo.severity === 'high')
157
- score -= 12;
158
- else
159
- score -= 6;
160
- }
161
- score -= Math.min(30, criticalSecrets.length * 8);
162
- score -= Math.min(15, countDirect(mcpServers, 'high') * 4);
163
- score -= Math.min(10, riskyAgentFiles.length * 2);
182
+ score -= Math.min(35, criticalSecrets.reduce((sum, finding) => sum + (finding.severity === 'critical' ? 10 : 5), 0));
183
+ score -= Math.min(30, directServers.reduce((sum, server) => sum + (server.riskLevel === 'critical' ? 10 : server.riskLevel === 'high' ? 6 : 3), 0));
184
+ score -= Math.min(15, riskyAgentFiles.length * 3);
185
+ score -= Math.min(15, missingGateway.length * 5);
164
186
  score = Math.max(0, Math.min(100, score));
165
187
  if (criticalSecrets.length)
166
188
  drivers.push(`${criticalSecrets.length} exposed secret(s)`);