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.
- package/dist/commands/daemon.js +83 -6
- package/dist/commands/discover.js +47 -11
- package/dist/commands/discoverAgentFiles.d.ts +20 -0
- package/dist/commands/discoverAgentFiles.js +65 -16
- package/dist/commands/discoverBlastRadius.d.ts +6 -0
- package/dist/commands/discoverBlastRadius.js +33 -11
- package/dist/commands/discoverPaths.d.ts +11 -0
- package/dist/commands/discoverPaths.js +47 -7
- package/dist/commands/discoverSecrets.d.ts +14 -0
- package/dist/commands/discoverSecrets.js +64 -16
- package/dist/commands/installClaudeHook.d.ts +10 -0
- package/dist/commands/installClaudeHook.js +74 -0
- package/dist/commands/installCursorHook.d.ts +13 -0
- package/dist/commands/installCursorHook.js +71 -0
- package/dist/commands/local-scan.js +761 -761
- package/dist/commands/mcpGateway.d.ts +7 -0
- package/dist/commands/mcpGateway.js +103 -8
- package/dist/commands/protectionRepair.d.ts +14 -0
- package/dist/commands/protectionRepair.js +24 -0
- package/dist/integrity.d.ts +1 -1
- package/dist/integrity.js +19 -14
- package/dist/runtimeConfig.js +3 -3
- package/dist/version.json +1 -1
- package/package.json +63 -62
package/dist/commands/daemon.js
CHANGED
|
@@ -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,
|
|
307
|
+
const repaired = await (0, protectionRepair_1.repairProtection)({ ...args, dryRun: undefined }, config);
|
|
294
308
|
quietUntil = Date.now() + SELF_WRITE_QUIET_MS;
|
|
295
|
-
log(
|
|
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,
|
|
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
|
|
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', ['-'], {
|
|
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
|
|
831
|
-
const isDisabledRoot = (root) =>
|
|
832
|
-
const scanRoots =
|
|
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
|
|
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 =
|
|
837
|
-
|
|
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:
|
|
889
|
+
? (0, discoverSecrets_1.scanSecrets)({ cwd, extraRoots: sweepProjectRoots, disabledRoots: normalizedDisabledRoots, configFiles: secretConfigFiles })
|
|
885
890
|
: undefined;
|
|
886
|
-
const
|
|
887
|
-
? (0, discoverAgentFiles_1.
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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;
|