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.
- package/dist/commands/daemon.js +91 -3
- 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/installAll.js +2 -1
- 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 +84 -7
- package/dist/commands/local-scan.js +761 -761
- package/dist/commands/mcpGateway.d.ts +7 -0
- package/dist/commands/mcpGateway.js +143 -25
- package/dist/commands/onboard.js +58 -0
- 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.d.ts +6 -1
- package/dist/runtimeConfig.js +9 -3
- package/dist/selfUpdate.d.ts +41 -0
- package/dist/selfUpdate.js +180 -0
- package/dist/telemetry.js +22 -0
- package/dist/version.json +1 -1
- package/package.json +63 -62
package/dist/commands/daemon.js
CHANGED
|
@@ -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,
|
|
310
|
+
const repaired = await (0, protectionRepair_1.repairProtection)({ ...args, dryRun: undefined }, config);
|
|
305
311
|
quietUntil = Date.now() + SELF_WRITE_QUIET_MS;
|
|
306
|
-
log(
|
|
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,
|
|
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
|
|
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;
|
|
@@ -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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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)`);
|