fullcourtdefense-cli 1.15.7 → 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,9 +135,14 @@ 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',
143
+ // Daemon/scheduled-task cwd is C:\WINDOWS\system32 — scan from the user's
144
+ // home so the posture scope reports a meaningful folder, not an OS dir.
145
+ cwd: os.homedir(),
141
146
  });
142
147
  const timer = setTimeout(() => {
143
148
  child.kill();
@@ -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");
@@ -123,6 +124,15 @@ function buildHostMetadata(userEmail, probeMode = 'config') {
123
124
  function candidateConfigPaths(cwd, extra) {
124
125
  return (0, discoverPaths_1.discoverScanTargets)(cwd, extra);
125
126
  }
127
+ /** True when a path is an operating-system folder (daemon/scheduled-task cwd), never a real project. */
128
+ function isOsSystemFolder(dir) {
129
+ const normalized = path.resolve(dir).toLowerCase();
130
+ if (process.platform === 'win32') {
131
+ const windir = (process.env.SystemRoot || 'C:\\Windows').toLowerCase();
132
+ return normalized === windir || normalized.startsWith(`${windir}${path.sep}`) || /^[a-z]:\\$/.test(normalized);
133
+ }
134
+ return normalized === '/' || ['/usr', '/bin', '/sbin', '/etc', '/var'].some(root => normalized === root || normalized.startsWith(`${root}/`));
135
+ }
126
136
  /** Dot-dirs that hold a client's project config; the real project root is their parent. */
127
137
  const CONFIG_DOT_DIRS = new Set(['.cursor', '.claude', '.codex', '.vscode', '.gemini', '.kiro']);
128
138
  /** Map discovered project config file paths back to their owning project folders. */
@@ -797,7 +807,12 @@ async function discoverCommand(args, config) {
797
807
  }
798
808
  return;
799
809
  }
800
- const cwd = process.cwd();
810
+ // Scheduled tasks and the daemon run with cwd=C:\WINDOWS\system32 (or "/" on
811
+ // POSIX). Scanning "the current project folder" from an OS directory is
812
+ // meaningless and confusing in the dashboard scan-scope report — fall back to
813
+ // the user's home, which is where all the AI-dev roots we scan live anyway.
814
+ const rawCwd = process.cwd();
815
+ const cwd = isOsSystemFolder(rawCwd) ? os.homedir() : rawCwd;
801
816
  const runMcp = surfaces.has('mcp');
802
817
  const scanned = [];
803
818
  let found = [];
@@ -807,9 +822,14 @@ async function discoverCommand(args, config) {
807
822
  // Computed once and shared across MCP, secrets, and agent-file scans so the
808
823
  // whole posture references every mapped folder. Without the flag it is empty
809
824
  // and behavior is exactly the known-locations scan.
810
- 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])];
811
830
  const sweep = scanRoots.length > 0 ? (0, discoverPaths_1.scanRootsForProjectConfigs)(scanRoots) : { candidates: [], scannedDirs: 0 };
812
- 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])];
813
833
  const knownConfigCandidates = candidateConfigPaths(cwd, args.extraPath);
814
834
  const secretConfigFiles = [...knownConfigCandidates, ...sweep.candidates]
815
835
  .map(candidate => path.resolve(candidate.path))
@@ -872,6 +892,22 @@ async function discoverCommand(args, config) {
872
892
  : undefined;
873
893
  const host = buildHostMetadata(args.userEmail, deep && found.some(s => s.probeMode === 'deep') ? 'deep' : 'config');
874
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;
875
911
  const uploadExtras = {
876
912
  secrets,
877
913
  agentFiles,
@@ -881,19 +917,24 @@ async function discoverCommand(args, config) {
881
917
  surfaces: [...surfaces],
882
918
  workingDirectory: cwd,
883
919
  mcpConfigPaths: scanned.map(s => ({ ...s, exists: fs.existsSync(s.path) })),
920
+ scannedRoots,
921
+ folderCatalog,
884
922
  included: [
885
- `Current command folder: ${cwd}`,
923
+ cwd === rawCwd
924
+ ? `Current command folder: ${cwd}`
925
+ : `User home folder: ${cwd} (scan started by the background daemon/scheduler)`,
886
926
  'Known MCP/AI client config files for Cursor, Claude, Codex, Gemini, Windsurf, and VS Code',
887
927
  `User-level AI rules, skills, hooks, and instruction files under ${home}`,
888
928
  `Credential stores and shell history under ${home}`,
889
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)`),
890
931
  ],
891
932
  excluded: [
892
933
  'Unrelated project folders outside the listed roots',
893
934
  'Large/generated dependency and build folders such as node_modules, .git, dist, build, vendor, caches, and temp folders',
894
935
  'Full disk contents, browser profiles, email/chat archives, binary files, and operating-system folders',
895
936
  ],
896
- 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.',
897
938
  },
898
939
  };
899
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.7"
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.7",
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": {