fullcourtdefense-cli 1.15.8 → 1.15.9

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.
@@ -135,7 +135,9 @@ function logFile() {
135
135
  /** Spawn `discover --upload --surface all --silent` and wait for it to finish. */
136
136
  function runDiscoverSweep(timeoutMs = 300_000) {
137
137
  return new Promise((resolve, reject) => {
138
- const child = (0, child_process_1.spawn)(process.execPath, [cliEntry(), 'discover', '--upload', '--surface', 'all', '--silent'], {
138
+ // --scan-root auto sweeps the default dev roots; admin-selected folders from
139
+ // the console are merged in by discover itself (cached runtime bundle).
140
+ const child = (0, child_process_1.spawn)(process.execPath, [cliEntry(), 'discover', '--upload', '--surface', 'all', '--silent', '--scan-root', 'auto'], {
139
141
  windowsHide: true,
140
142
  stdio: 'ignore',
141
143
  // Daemon/scheduled-task cwd is C:\WINDOWS\system32 — scan from the user's
@@ -45,6 +45,7 @@ const discoverProxy_1 = require("./discoverProxy");
45
45
  const discoverSchedule_1 = require("./discoverSchedule");
46
46
  const mcpGateway_1 = require("./mcpGateway");
47
47
  const discoverPaths_1 = require("./discoverPaths");
48
+ const runtimeConfig_1 = require("../runtimeConfig");
48
49
  const knownMcpServers_1 = require("./knownMcpServers");
49
50
  const discoverAgentFiles_1 = require("./discoverAgentFiles");
50
51
  const discoverBlastRadius_1 = require("./discoverBlastRadius");
@@ -821,9 +822,14 @@ async function discoverCommand(args, config) {
821
822
  // Computed once and shared across MCP, secrets, and agent-file scans so the
822
823
  // whole posture references every mapped folder. Without the flag it is empty
823
824
  // and behavior is exactly the known-locations scan.
824
- const scanRoots = (0, discoverPaths_1.resolveScanRoots)(args.scanRoot);
825
+ // Admin-chosen folders (picked in the console per machine, delivered via the
826
+ // runtime bundle) are always merged in, so every scheduled/daemon/manual scan
827
+ // covers them without extra flags.
828
+ const adminScanRoots = (0, runtimeConfig_1.getCachedExtraScanRoots)();
829
+ const scanRoots = [...new Set([...(0, discoverPaths_1.resolveScanRoots)(args.scanRoot), ...adminScanRoots])];
825
830
  const sweep = scanRoots.length > 0 ? (0, discoverPaths_1.scanRootsForProjectConfigs)(scanRoots) : { candidates: [], scannedDirs: 0 };
826
- const sweepProjectRoots = deriveProjectRoots(sweep.candidates);
831
+ // Admin roots are scanned for env/secrets even when no MCP config lives there.
832
+ const sweepProjectRoots = [...new Set([...deriveProjectRoots(sweep.candidates), ...adminScanRoots])];
827
833
  const knownConfigCandidates = candidateConfigPaths(cwd, args.extraPath);
828
834
  const secretConfigFiles = [...knownConfigCandidates, ...sweep.candidates]
829
835
  .map(candidate => path.resolve(candidate.path))
@@ -886,6 +892,22 @@ async function discoverCommand(args, config) {
886
892
  : undefined;
887
893
  const host = buildHostMetadata(args.userEmail, deep && found.some(s => s.probeMode === 'deep') ? 'deep' : 'config');
888
894
  const home = os.homedir();
895
+ // Effective roots the posture scan covered — proof of coverage for the console.
896
+ const defaultEnvRoots = [home, cwd, path.join(home, 'dev'), path.join(home, 'repos'), path.join(home, 'projects'), path.join(home, 'Documents'), path.join(home, 'code')]
897
+ .map(root => path.resolve(root));
898
+ const adminRootSet = new Set(adminScanRoots.map(root => path.resolve(root).toLowerCase()));
899
+ const scannedRoots = [
900
+ ...[...new Set(defaultEnvRoots)].filter(root => fs.existsSync(root)).map(root => ({ path: root, source: 'default' })),
901
+ ...adminScanRoots.map(root => path.resolve(root)).filter(root => fs.existsSync(root)).map(root => ({ path: root, source: 'admin' })),
902
+ ...(0, discoverPaths_1.resolveScanRoots)(args.scanRoot)
903
+ .map(root => path.resolve(root))
904
+ .filter(root => fs.existsSync(root) && !adminRootSet.has(root.toLowerCase()))
905
+ .map(root => ({ path: root, source: 'default' })),
906
+ ].filter((entry, index, list) => list.findIndex(other => other.path.toLowerCase() === entry.path.toLowerCase()) === index);
907
+ // Shallow folder tree for the console folder picker (names only, no contents).
908
+ const folderCatalog = surfaces.has('posture') || surfaces.has('secrets')
909
+ ? (0, discoverPaths_1.buildFolderCatalog)({ extraRoots: adminScanRoots })
910
+ : undefined;
889
911
  const uploadExtras = {
890
912
  secrets,
891
913
  agentFiles,
@@ -895,6 +917,8 @@ async function discoverCommand(args, config) {
895
917
  surfaces: [...surfaces],
896
918
  workingDirectory: cwd,
897
919
  mcpConfigPaths: scanned.map(s => ({ ...s, exists: fs.existsSync(s.path) })),
920
+ scannedRoots,
921
+ folderCatalog,
898
922
  included: [
899
923
  cwd === rawCwd
900
924
  ? `Current command folder: ${cwd}`
@@ -903,13 +927,14 @@ async function discoverCommand(args, config) {
903
927
  `User-level AI rules, skills, hooks, and instruction files under ${home}`,
904
928
  `Credential stores and shell history under ${home}`,
905
929
  `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')}`,
930
+ ...adminScanRoots.map(root => `Admin-selected folder: ${root} (and subfolders)`),
906
931
  ],
907
932
  excluded: [
908
933
  'Unrelated project folders outside the listed roots',
909
934
  'Large/generated dependency and build folders such as node_modules, .git, dist, build, vendor, caches, and temp folders',
910
935
  'Full disk contents, browser profiles, email/chat archives, binary files, and operating-system folders',
911
936
  ],
912
- note: 'Desktop posture is a targeted AI-development exposure scan, not a full-device forensic scan. Run the command from the project folder you want represented.',
937
+ 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.',
913
938
  },
914
939
  };
915
940
  async function maybeUpload() {
@@ -29,6 +29,25 @@ export declare function scanRootsForProjectConfigs(roots: string[], options?: {
29
29
  candidates: ConfigPathCandidate[];
30
30
  scannedDirs: number;
31
31
  };
32
+ export interface FolderCatalogEntry {
33
+ path: string;
34
+ /** Depth relative to its walk root (home or a dev root) — lets the UI indent. */
35
+ depth: number;
36
+ /** True when the folder has at least one visible subfolder (UI shows an expander). */
37
+ hasChildren: boolean;
38
+ }
39
+ /**
40
+ * Shallow, bounded folder tree of the machine for the console folder picker:
41
+ * the user home plus every default dev root, walked a few levels deep, names
42
+ * only. The admin expands this tree in the dashboard and picks folders to add
43
+ * to the posture scan — so the picker always reflects the machine's REAL
44
+ * structure without uploading anything sensitive (no files, no contents).
45
+ */
46
+ export declare function buildFolderCatalog(options?: {
47
+ maxDepth?: number;
48
+ maxEntries?: number;
49
+ extraRoots?: string[];
50
+ }): FolderCatalogEntry[];
32
51
  /** Resolve the --scan-root flag: comma-separated paths, or "auto" for common dev folders. */
33
52
  export declare function resolveScanRoots(flag: string | undefined): string[];
34
53
  /** Include config paths for apps that exist on disk even when the MCP file is not created yet. */
@@ -40,6 +40,7 @@ exports.claudeDesktopInstallTargets = claudeDesktopInstallTargets;
40
40
  exports.candidateConfigPaths = candidateConfigPaths;
41
41
  exports.defaultScanRoots = defaultScanRoots;
42
42
  exports.scanRootsForProjectConfigs = scanRootsForProjectConfigs;
43
+ exports.buildFolderCatalog = buildFolderCatalog;
43
44
  exports.resolveScanRoots = resolveScanRoots;
44
45
  exports.discoverScanTargets = discoverScanTargets;
45
46
  const fs = __importStar(require("fs"));
@@ -281,6 +282,79 @@ function scanRootsForProjectConfigs(roots, options = {}) {
281
282
  }
282
283
  return { candidates, scannedDirs };
283
284
  }
285
+ /** Non-dev home folders that only add noise to the console folder picker. */
286
+ const CATALOG_SKIP_HOME_DIRS = new Set([
287
+ 'appdata', 'application data', 'local settings', 'cookies', 'nethood', 'printhood',
288
+ 'recent', 'sendto', 'start menu', 'templates', 'searches', 'links', 'saved games',
289
+ '3d objects', 'contacts', 'favorites', 'music', 'videos', 'pictures', 'library',
290
+ 'movies', 'public',
291
+ ]);
292
+ /**
293
+ * Shallow, bounded folder tree of the machine for the console folder picker:
294
+ * the user home plus every default dev root, walked a few levels deep, names
295
+ * only. The admin expands this tree in the dashboard and picks folders to add
296
+ * to the posture scan — so the picker always reflects the machine's REAL
297
+ * structure without uploading anything sensitive (no files, no contents).
298
+ */
299
+ function buildFolderCatalog(options = {}) {
300
+ const maxDepth = options.maxDepth ?? 3;
301
+ const maxEntries = options.maxEntries ?? 600;
302
+ const home = os.homedir();
303
+ const entries = [];
304
+ const seen = new Set();
305
+ const listVisibleSubdirs = (dir, atHomeLevel) => {
306
+ let dirents;
307
+ try {
308
+ dirents = fs.readdirSync(dir, { withFileTypes: true });
309
+ }
310
+ catch {
311
+ return [];
312
+ }
313
+ return dirents
314
+ .filter(entry => {
315
+ if (!entry.isDirectory())
316
+ return false;
317
+ const name = entry.name.toLowerCase();
318
+ if (entry.name.startsWith('.') || entry.name.startsWith('$'))
319
+ return false;
320
+ if (SCAN_SKIP_DIRS.has(name))
321
+ return false;
322
+ if (atHomeLevel && CATALOG_SKIP_HOME_DIRS.has(name))
323
+ return false;
324
+ return true;
325
+ })
326
+ .map(entry => path.join(dir, entry.name));
327
+ };
328
+ const walk = (root) => {
329
+ const queue = [{ dir: root, depth: 0 }];
330
+ while (queue.length > 0 && entries.length < maxEntries) {
331
+ const { dir, depth } = queue.shift();
332
+ const key = path.resolve(dir).toLowerCase();
333
+ if (seen.has(key))
334
+ continue;
335
+ seen.add(key);
336
+ const children = listVisibleSubdirs(dir, key === home.toLowerCase());
337
+ entries.push({ path: path.resolve(dir), depth, hasChildren: children.length > 0 });
338
+ if (depth < maxDepth) {
339
+ for (const child of children)
340
+ queue.push({ dir: child, depth: depth + 1 });
341
+ }
342
+ }
343
+ };
344
+ walk(home);
345
+ for (const root of defaultScanRoots())
346
+ walk(root);
347
+ // Admin-chosen folders get catalogued too, so the picker tree keeps expanding
348
+ // beneath paths the admin already selected.
349
+ for (const root of options.extraRoots || []) {
350
+ try {
351
+ if (fs.statSync(root).isDirectory())
352
+ walk(root);
353
+ }
354
+ catch { /* folder removed since selection — skip */ }
355
+ }
356
+ return entries;
357
+ }
284
358
  /** Resolve the --scan-root flag: comma-separated paths, or "auto" for common dev folders. */
285
359
  function resolveScanRoots(flag) {
286
360
  if (!flag?.trim())
@@ -199,6 +199,28 @@ function scanCredentialStorePosture(filePath, content, category, out, seen) {
199
199
  detail: '.netrc contains machine login/password material used by CLI tools.',
200
200
  });
201
201
  }
202
+ if (normalized.endsWith('/.git-credentials') && /https?:\/\/[^:\s/]+:[^@\s]+@/i.test(content)) {
203
+ pushStoreFinding(out, seen, {
204
+ filePath,
205
+ category,
206
+ provider: 'git',
207
+ severity: 'high',
208
+ title: 'Git credentials stored in plaintext',
209
+ detail: '.git-credentials stores repository usernames/tokens unencrypted — anything on this machine (including AI agents) can read and reuse them.',
210
+ remediation: 'Switch to `git config --global credential.helper manager` (Windows/mac keychain) or `cache`, then delete ~/.git-credentials and rotate the exposed tokens.',
211
+ });
212
+ }
213
+ if (normalized.endsWith('/.gitconfig') && /helper\s*=\s*store\b/i.test(content)) {
214
+ pushStoreFinding(out, seen, {
215
+ filePath,
216
+ category,
217
+ provider: 'git',
218
+ severity: 'medium',
219
+ title: 'Git credential helper set to plaintext "store"',
220
+ detail: 'credential.helper=store writes every git password/token to ~/.git-credentials in plaintext.',
221
+ remediation: 'Use the OS keychain helper instead: `git config --global credential.helper manager` (Windows) / `osxkeychain` (mac) / `libsecret` (Linux).',
222
+ });
223
+ }
202
224
  if (normalized.endsWith('/.pypirc') && /\b(?:password|token)\s*=\s*\S+/i.test(content)) {
203
225
  pushStoreFinding(out, seen, {
204
226
  filePath,
@@ -317,6 +339,14 @@ function credentialStores(home) {
317
339
  const localAppData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
318
340
  return [
319
341
  { path: path.join(home, '.aws', 'credentials'), category: 'credential_store', title: 'AWS credentials file' },
342
+ { path: path.join(home, '.git-credentials'), category: 'credential_store', title: 'Git plaintext credentials' },
343
+ { path: path.join(home, '.gitconfig'), category: 'credential_store', title: 'Git config' },
344
+ { path: path.join(appData, 'Code', 'User', 'settings.json'), category: 'credential_store', title: 'VS Code user settings' },
345
+ { path: path.join(appData, 'Cursor', 'User', 'settings.json'), category: 'credential_store', title: 'Cursor user settings' },
346
+ { path: path.join(home, 'Library', 'Application Support', 'Code', 'User', 'settings.json'), category: 'credential_store', title: 'VS Code user settings (mac)' },
347
+ { path: path.join(home, 'Library', 'Application Support', 'Cursor', 'User', 'settings.json'), category: 'credential_store', title: 'Cursor user settings (mac)' },
348
+ { path: path.join(xdg, 'Code', 'User', 'settings.json'), category: 'credential_store', title: 'VS Code user settings (linux)' },
349
+ { path: path.join(xdg, 'Cursor', 'User', 'settings.json'), category: 'credential_store', title: 'Cursor user settings (linux)' },
320
350
  { path: path.join(home, '.docker', 'config.json'), category: 'credential_store', title: 'Docker registry auth' },
321
351
  { path: path.join(home, '.npmrc'), category: 'credential_store', title: 'npm auth token' },
322
352
  { path: path.join(home, '.pypirc'), category: 'credential_store', title: 'PyPI auth token' },
@@ -24,6 +24,8 @@ export interface RuntimeBundle {
24
24
  failClosed?: boolean;
25
25
  /** Admin kill-switch: while true, hooks and the gateway deny ALL actions. */
26
26
  suspended?: boolean;
27
+ /** Admin-chosen extra posture scan folders for this machine (from the console). */
28
+ extraScanRoots?: string[];
27
29
  /** One constrained, auditable action queued for the resident daemon. */
28
30
  machineAction?: {
29
31
  id: string;
@@ -55,3 +57,9 @@ export interface EffectiveBundle extends RuntimeBundle {
55
57
  * or a 'default' marker so the caller can apply its local fallback.
56
58
  */
57
59
  export declare function getRuntimeBundle(input: FetchBundleInput): Promise<EffectiveBundle>;
60
+ /**
61
+ * Admin-chosen extra posture scan folders from the most recent cached bundle
62
+ * (any shield). Read-only and offline — used by `discover` so scheduled/daemon
63
+ * scans include console-selected folders without needing credentials plumbed in.
64
+ */
65
+ export declare function getCachedExtraScanRoots(): string[];
@@ -34,6 +34,7 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.getRuntimeBundle = getRuntimeBundle;
37
+ exports.getCachedExtraScanRoots = getCachedExtraScanRoots;
37
38
  const fs = __importStar(require("fs"));
38
39
  const os = __importStar(require("os"));
39
40
  const path = __importStar(require("path"));
@@ -70,7 +71,7 @@ async function getRuntimeBundle(input) {
70
71
  const cached = cache[input.shieldId];
71
72
  const fresh = cached && Date.now() - cached.fetchedAt < ttl;
72
73
  if (cached && fresh && !input.force) {
73
- return { mode: cached.mode, version: cached.version, policyHash: cached.policyHash, policyCount: cached.policyCount, pollIntervalMs: cached.pollIntervalMs, failClosed: cached.failClosed, suspended: cached.suspended, machineAction: cached.machineAction, source: 'cache' };
74
+ return { mode: cached.mode, version: cached.version, policyHash: cached.policyHash, policyCount: cached.policyCount, pollIntervalMs: cached.pollIntervalMs, failClosed: cached.failClosed, suspended: cached.suspended, extraScanRoots: cached.extraScanRoots, machineAction: cached.machineAction, source: 'cache' };
74
75
  }
75
76
  try {
76
77
  const headers = { 'Content-Type': 'application/json' };
@@ -90,7 +91,7 @@ async function getRuntimeBundle(input) {
90
91
  if (resp.status === 304 && cached) {
91
92
  cache[input.shieldId] = { ...cached, fetchedAt: Date.now() };
92
93
  writeCacheFile(cache);
93
- return { mode: cached.mode, version: cached.version, policyHash: cached.policyHash, policyCount: cached.policyCount, pollIntervalMs: cached.pollIntervalMs, failClosed: cached.failClosed, suspended: cached.suspended, machineAction: cached.machineAction, source: 'cache' };
94
+ return { mode: cached.mode, version: cached.version, policyHash: cached.policyHash, policyCount: cached.policyCount, pollIntervalMs: cached.pollIntervalMs, failClosed: cached.failClosed, suspended: cached.suspended, extraScanRoots: cached.extraScanRoots, machineAction: cached.machineAction, source: 'cache' };
94
95
  }
95
96
  if (resp.ok) {
96
97
  const body = await resp.json().catch(() => ({}));
@@ -103,6 +104,9 @@ async function getRuntimeBundle(input) {
103
104
  pollIntervalMs: body.data.pollIntervalMs,
104
105
  failClosed: typeof body.data.failClosed === 'boolean' ? body.data.failClosed : undefined,
105
106
  suspended: body.data.suspended === true,
107
+ extraScanRoots: Array.isArray(body.data.extraScanRoots)
108
+ ? body.data.extraScanRoots.filter((root) => typeof root === 'string').slice(0, 20)
109
+ : undefined,
106
110
  machineAction: body.data.machineAction,
107
111
  fetchedAt: Date.now(),
108
112
  };
@@ -114,7 +118,23 @@ async function getRuntimeBundle(input) {
114
118
  }
115
119
  catch { /* fall through to cache / default */ }
116
120
  if (cached) {
117
- return { mode: cached.mode, version: cached.version, policyHash: cached.policyHash, policyCount: cached.policyCount, pollIntervalMs: cached.pollIntervalMs, failClosed: cached.failClosed, suspended: cached.suspended, machineAction: cached.machineAction, source: 'cache' };
121
+ return { mode: cached.mode, version: cached.version, policyHash: cached.policyHash, policyCount: cached.policyCount, pollIntervalMs: cached.pollIntervalMs, failClosed: cached.failClosed, suspended: cached.suspended, extraScanRoots: cached.extraScanRoots, machineAction: cached.machineAction, source: 'cache' };
118
122
  }
119
123
  return { mode: 'block', version: '', source: 'default' };
120
124
  }
125
+ /**
126
+ * Admin-chosen extra posture scan folders from the most recent cached bundle
127
+ * (any shield). Read-only and offline — used by `discover` so scheduled/daemon
128
+ * scans include console-selected folders without needing credentials plumbed in.
129
+ */
130
+ function getCachedExtraScanRoots() {
131
+ const cache = readCacheFile();
132
+ const roots = new Set();
133
+ for (const entry of Object.values(cache)) {
134
+ for (const root of entry.extraScanRoots || []) {
135
+ if (typeof root === 'string' && root.trim())
136
+ roots.add(root.trim());
137
+ }
138
+ }
139
+ return [...roots];
140
+ }
package/dist/version.json CHANGED
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "1.15.8"
2
+ "version": "1.15.9"
3
3
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullcourtdefense-cli",
3
- "version": "1.15.8",
3
+ "version": "1.15.9",
4
4
  "description": "Full Court Defense CLI — security scanning for AI agents from your terminal",
5
5
  "main": "dist/index.js",
6
6
  "bin": {