fullcourtdefense-cli 1.15.9 → 1.15.11

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.
@@ -53,6 +53,7 @@ const COLOR = {
53
53
  red: '\x1b[31m', yellow: '\x1b[33m', green: '\x1b[32m', cyan: '\x1b[36m', gray: '\x1b[90m',
54
54
  };
55
55
  const TASK_NAME = 'FullCourtDefense Daemon';
56
+ const WATCHDOG_TASK_NAME = 'FullCourtDefense Daemon Watchdog';
56
57
  const CRON_MARKER = '# FCD_DAEMON';
57
58
  const LAUNCHD_LABEL = 'ai.fullcourtdefense.daemon';
58
59
  const SYSTEMD_UNIT = 'fullcourtdefense-daemon.service';
@@ -262,6 +263,16 @@ async function runDaemon(args, config) {
262
263
  console.log(`${COLOR.yellow}Another FullCourtDefense daemon is already running (pid file: ${pidFile()}).${COLOR.reset}`);
263
264
  return;
264
265
  }
266
+ // The resident loop must never die silently from one bad tick (fs watcher
267
+ // callback, fetch, timer). Log and keep running — the loop is timer-driven,
268
+ // so surviving a failed tick is safe, and on Windows there is no supervisor
269
+ // that would restart a crashed process instantly.
270
+ process.on('uncaughtException', error => {
271
+ log(`Uncaught exception (daemon continues): ${error?.stack || String(error)}`);
272
+ });
273
+ process.on('unhandledRejection', reason => {
274
+ log(`Unhandled rejection (daemon continues): ${reason?.stack || String(reason)}`);
275
+ });
265
276
  const creds = (0, config_1.resolveCliCredentials)(config, {
266
277
  shieldId: args.shieldId,
267
278
  shieldKey: args.shieldKey,
@@ -696,12 +707,23 @@ function installWindows() {
696
707
  ], { stdio: 'ignore', windowsHide: true });
697
708
  ok = reg.status === 0;
698
709
  }
710
+ // Watchdog: logon triggers only START the daemon — nothing restarts it if it
711
+ // is killed or crashes mid-session. A time-based per-user task (no elevation
712
+ // needed, unlike ONLOGON) re-launches the daemon every 5 minutes;
713
+ // acquirePidLock() makes each tick a cheap no-op while the daemon is alive,
714
+ // and revives it within one tick when it is not. Best-effort: a missing
715
+ // watchdog never fails the install.
716
+ (0, child_process_1.spawnSync)('schtasks', [
717
+ '/Create', '/TN', WATCHDOG_TASK_NAME, '/TR', `wscript.exe "${vbs}"`,
718
+ '/SC', 'MINUTE', '/MO', '5', '/F',
719
+ ], { stdio: 'ignore', windowsHide: true });
699
720
  if (ok)
700
721
  startDaemonNowWindows(vbs, taskOk);
701
722
  return ok;
702
723
  }
703
724
  function uninstallWindows() {
704
725
  const task = (0, child_process_1.spawnSync)('schtasks', ['/Delete', '/TN', TASK_NAME, '/F'], { stdio: 'ignore', windowsHide: true });
726
+ (0, child_process_1.spawnSync)('schtasks', ['/Delete', '/TN', WATCHDOG_TASK_NAME, '/F'], { stdio: 'ignore', windowsHide: true });
705
727
  const reg = (0, child_process_1.spawnSync)('reg', ['delete', WINDOWS_RUN_KEY, '/v', WINDOWS_RUN_VALUE, '/f'], { stdio: 'ignore', windowsHide: true });
706
728
  const vbs = path.join(process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'), 'FullCourtDefense', 'daemon.vbs');
707
729
  try {
@@ -790,11 +812,16 @@ WantedBy=default.target
790
812
  return true;
791
813
  }
792
814
  catch { /* fall back to cron */ }
793
- // Fallback: cron @reboot for boxes without a systemd user session.
815
+ // Fallback: cron for boxes without a systemd user session. @reboot starts
816
+ // the daemon at boot; the 5-min line is the watchdog (pid lock makes each
817
+ // tick a no-op while the daemon is alive, and revives it after a kill/crash).
794
818
  const command = `"${process.execPath}" "${cliEntry()}" daemon >/dev/null 2>&1`;
795
819
  const list = (0, child_process_1.spawnSync)('crontab', ['-l'], { encoding: 'utf8' });
796
820
  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', ['-'], { input: [...existing, `@reboot ${command} ${CRON_MARKER}`].join('\n') + '\n', encoding: 'utf8' });
821
+ const write = (0, child_process_1.spawnSync)('crontab', ['-'], {
822
+ input: [...existing, `@reboot ${command} ${CRON_MARKER}`, `*/5 * * * * ${command} ${CRON_MARKER}`].join('\n') + '\n',
823
+ encoding: 'utf8',
824
+ });
798
825
  return write.status === 0;
799
826
  }
800
827
  function uninstallLinux() {
@@ -854,6 +881,10 @@ function statusCommand() {
854
881
  else {
855
882
  console.log(' not installed — run: fullcourtdefense daemon --install true');
856
883
  }
884
+ const watchdog = (0, child_process_1.spawnSync)('schtasks', ['/Query', '/TN', WATCHDOG_TASK_NAME], { stdio: 'ignore', windowsHide: true, timeout: 5_000 });
885
+ console.log(watchdog.status === 0
886
+ ? ` Watchdog task "${WATCHDOG_TASK_NAME}" installed — revives a killed daemon within 5 min`
887
+ : ` ${COLOR.yellow}watchdog not installed${COLOR.reset} — a killed daemon stays down until next logon; re-run: fullcourtdefense daemon --install true`);
857
888
  }
858
889
  else if (process.platform === 'darwin') {
859
890
  console.log(fs.existsSync(launchdPlistPath()) ? ` launchd agent installed (${launchdPlistPath()})` : ' not installed');
@@ -897,7 +928,7 @@ async function daemonCommand(args, config) {
897
928
  : installLinux();
898
929
  if (ok) {
899
930
  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)'}`);
931
+ 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
932
  console.log(`${COLOR.gray}Start now:${COLOR.reset} fullcourtdefense daemon`);
902
933
  console.log(`${COLOR.gray}Status:${COLOR.reset} fullcourtdefense daemon --status true`);
903
934
  console.log(`${COLOR.gray}Remove:${COLOR.reset} fullcourtdefense daemon --uninstall true`);
@@ -824,9 +824,13 @@ async function discoverCommand(args, config) {
824
824
  // and behavior is exactly the known-locations scan.
825
825
  // Admin-chosen folders (picked in the console per machine, delivered via the
826
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])];
827
+ // covers them without extra flags. Default folders the admin explicitly
828
+ // removed in the console are dropped from the sweep.
829
+ const { extraScanRoots: adminScanRoots, disabledScanRoots } = (0, runtimeConfig_1.getCachedScanRootOverrides)();
830
+ const disabledRootSet = new Set(disabledScanRoots.map(root => path.resolve(root).toLowerCase()));
831
+ const isDisabledRoot = (root) => disabledRootSet.has(path.resolve(root).toLowerCase());
832
+ const scanRoots = [...new Set([...(0, discoverPaths_1.resolveScanRoots)(args.scanRoot), ...adminScanRoots])]
833
+ .filter(root => !isDisabledRoot(root));
830
834
  const sweep = scanRoots.length > 0 ? (0, discoverPaths_1.scanRootsForProjectConfigs)(scanRoots) : { candidates: [], scannedDirs: 0 };
831
835
  // Admin roots are scanned for env/secrets even when no MCP config lives there.
832
836
  const sweepProjectRoots = [...new Set([...deriveProjectRoots(sweep.candidates), ...adminScanRoots])];
@@ -877,7 +881,7 @@ async function discoverCommand(args, config) {
877
881
  }
878
882
  }
879
883
  const secrets = surfaces.has('secrets') || surfaces.has('posture')
880
- ? (0, discoverSecrets_1.scanSecrets)({ cwd, extraRoots: sweepProjectRoots, configFiles: secretConfigFiles })
884
+ ? (0, discoverSecrets_1.scanSecrets)({ cwd, extraRoots: sweepProjectRoots, disabledRoots: disabledScanRoots, configFiles: secretConfigFiles })
881
885
  : undefined;
882
886
  const agentFiles = surfaces.has('agent-files') || surfaces.has('posture')
883
887
  ? (0, discoverAgentFiles_1.scanAgentFiles)({ cwd, extraRoots: sweepProjectRoots })
@@ -893,15 +897,16 @@ async function discoverCommand(args, config) {
893
897
  const host = buildHostMetadata(args.userEmail, deep && found.some(s => s.probeMode === 'deep') ? 'deep' : 'config');
894
898
  const home = os.homedir();
895
899
  // Effective roots the posture scan covered — proof of coverage for the console.
900
+ // Admin-removed defaults are excluded here too, so the console mirrors reality.
896
901
  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
902
  .map(root => path.resolve(root));
898
903
  const adminRootSet = new Set(adminScanRoots.map(root => path.resolve(root).toLowerCase()));
899
904
  const scannedRoots = [
900
- ...[...new Set(defaultEnvRoots)].filter(root => fs.existsSync(root)).map(root => ({ path: root, source: 'default' })),
905
+ ...[...new Set(defaultEnvRoots)].filter(root => fs.existsSync(root) && !isDisabledRoot(root)).map(root => ({ path: root, source: 'default' })),
901
906
  ...adminScanRoots.map(root => path.resolve(root)).filter(root => fs.existsSync(root)).map(root => ({ path: root, source: 'admin' })),
902
907
  ...(0, discoverPaths_1.resolveScanRoots)(args.scanRoot)
903
908
  .map(root => path.resolve(root))
904
- .filter(root => fs.existsSync(root) && !adminRootSet.has(root.toLowerCase()))
909
+ .filter(root => fs.existsSync(root) && !adminRootSet.has(root.toLowerCase()) && !isDisabledRoot(root))
905
910
  .map(root => ({ path: root, source: 'default' })),
906
911
  ].filter((entry, index, list) => list.findIndex(other => other.path.toLowerCase() === entry.path.toLowerCase()) === index);
907
912
  // Shallow folder tree for the console folder picker (names only, no contents).
@@ -22,5 +22,6 @@ export declare function scanSecrets(options?: {
22
22
  cwd?: string;
23
23
  maxEnvDepth?: number;
24
24
  extraRoots?: string[];
25
+ disabledRoots?: string[];
25
26
  configFiles?: string[];
26
27
  }): SecretsScanResult;
@@ -478,7 +478,8 @@ function scanSecrets(options = {}) {
478
478
  const home = os.homedir();
479
479
  const cwd = options.cwd || process.cwd();
480
480
  const maxDepth = options.maxEnvDepth ?? 4;
481
- const extraRoots = options.extraRoots ?? [];
481
+ const disabledRoots = new Set((options.disabledRoots ?? []).map(root => path.resolve(root).toLowerCase()));
482
+ const extraRoots = (options.extraRoots ?? []).filter(root => !disabledRoots.has(path.resolve(root).toLowerCase()));
482
483
  const findings = [];
483
484
  const seen = new Set();
484
485
  let scannedFiles = 0;
@@ -513,7 +514,7 @@ function scanSecrets(options = {}) {
513
514
  path.join(home, 'projects'),
514
515
  path.join(home, 'Documents'),
515
516
  path.join(home, 'code'),
516
- ].filter(root => fs.existsSync(root))));
517
+ ].filter(root => fs.existsSync(root) && !disabledRoots.has(path.resolve(root).toLowerCase()))));
517
518
  for (const envFile of collectEnvFiles(envRoots, maxDepth)) {
518
519
  if (findings.length >= MAX_FINDINGS)
519
520
  break;
@@ -26,6 +26,8 @@ export interface RuntimeBundle {
26
26
  suspended?: boolean;
27
27
  /** Admin-chosen extra posture scan folders for this machine (from the console). */
28
28
  extraScanRoots?: string[];
29
+ /** Default scan folders the admin explicitly removed from this machine's posture sweep. */
30
+ disabledScanRoots?: string[];
29
31
  /** One constrained, auditable action queued for the resident daemon. */
30
32
  machineAction?: {
31
33
  id: string;
@@ -58,8 +60,14 @@ export interface EffectiveBundle extends RuntimeBundle {
58
60
  */
59
61
  export declare function getRuntimeBundle(input: FetchBundleInput): Promise<EffectiveBundle>;
60
62
  /**
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.
63
+ * Admin scan-folder choices from the most recent cached bundle (any shield):
64
+ * extra folders to add and default folders the admin removed. Read-only and
65
+ * offline used by `discover` so scheduled/daemon scans honor the console
66
+ * selection without needing credentials plumbed in.
64
67
  */
68
+ export declare function getCachedScanRootOverrides(): {
69
+ extraScanRoots: string[];
70
+ disabledScanRoots: string[];
71
+ };
72
+ /** @deprecated Use getCachedScanRootOverrides(). */
65
73
  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.getCachedScanRootOverrides = getCachedScanRootOverrides;
37
38
  exports.getCachedExtraScanRoots = getCachedExtraScanRoots;
38
39
  const fs = __importStar(require("fs"));
39
40
  const os = __importStar(require("os"));
@@ -107,6 +108,9 @@ async function getRuntimeBundle(input) {
107
108
  extraScanRoots: Array.isArray(body.data.extraScanRoots)
108
109
  ? body.data.extraScanRoots.filter((root) => typeof root === 'string').slice(0, 20)
109
110
  : undefined,
111
+ disabledScanRoots: Array.isArray(body.data.disabledScanRoots)
112
+ ? body.data.disabledScanRoots.filter((root) => typeof root === 'string').slice(0, 20)
113
+ : undefined,
110
114
  machineAction: body.data.machineAction,
111
115
  fetchedAt: Date.now(),
112
116
  };
@@ -123,18 +127,28 @@ async function getRuntimeBundle(input) {
123
127
  return { mode: 'block', version: '', source: 'default' };
124
128
  }
125
129
  /**
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.
130
+ * Admin scan-folder choices from the most recent cached bundle (any shield):
131
+ * extra folders to add and default folders the admin removed. Read-only and
132
+ * offline used by `discover` so scheduled/daemon scans honor the console
133
+ * selection without needing credentials plumbed in.
129
134
  */
130
- function getCachedExtraScanRoots() {
135
+ function getCachedScanRootOverrides() {
131
136
  const cache = readCacheFile();
132
- const roots = new Set();
137
+ const extra = new Set();
138
+ const disabled = new Set();
133
139
  for (const entry of Object.values(cache)) {
134
140
  for (const root of entry.extraScanRoots || []) {
135
141
  if (typeof root === 'string' && root.trim())
136
- roots.add(root.trim());
142
+ extra.add(root.trim());
143
+ }
144
+ for (const root of entry.disabledScanRoots || []) {
145
+ if (typeof root === 'string' && root.trim())
146
+ disabled.add(root.trim());
137
147
  }
138
148
  }
139
- return [...roots];
149
+ return { extraScanRoots: [...extra], disabledScanRoots: [...disabled] };
150
+ }
151
+ /** @deprecated Use getCachedScanRootOverrides(). */
152
+ function getCachedExtraScanRoots() {
153
+ return getCachedScanRootOverrides().extraScanRoots;
140
154
  }
package/dist/version.json CHANGED
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "1.15.9"
2
+ "version": "1.15.11"
3
3
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullcourtdefense-cli",
3
- "version": "1.15.9",
3
+ "version": "1.15.11",
4
4
  "description": "Full Court Defense CLI — security scanning for AI agents from your terminal",
5
5
  "main": "dist/index.js",
6
6
  "bin": {