fullcourtdefense-cli 1.21.28 → 1.21.30

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.
@@ -32,7 +32,21 @@ export declare function discoverSweepCredentialEnv(credentials?: {
32
32
  shieldKey?: string;
33
33
  apiUrl?: string;
34
34
  }): Record<string, string>;
35
+ /** Snapshot of the resident daemon for out-of-process callers (watchdog/status). */
36
+ export interface DaemonRuntimeState {
37
+ alive: boolean;
38
+ pid?: number;
39
+ version?: string;
40
+ startedAt?: string;
41
+ }
42
+ export declare function daemonRuntimeState(): DaemonRuntimeState;
43
+ /** Relaunch the daemon outside our process tree (plain node — no shells). */
44
+ export declare function spawnDetachedDaemon(): boolean;
45
+ /** Pid-alive check shared with the watchdog (EPERM still means alive). */
46
+ export declare function pidIsAlive(pid: number): boolean;
35
47
  export declare function macosLaunchdPath(currentPath?: string, execPath?: string): string;
36
48
  /** Whether the daemon has been registered to start automatically. */
49
+ /** Is the 5-minute watchdog scheduled task registered? (Windows-only feature.) */
50
+ export declare function isWatchdogTaskInstalled(): boolean;
37
51
  export declare function isDaemonAutostartInstalled(): boolean;
38
52
  export declare function daemonCommand(args: DaemonArgs, config: BotGuardConfig): Promise<void>;
@@ -36,7 +36,11 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.daemonDiscoverSweepArgs = daemonDiscoverSweepArgs;
37
37
  exports.summarizeDiscoverStderr = summarizeDiscoverStderr;
38
38
  exports.discoverSweepCredentialEnv = discoverSweepCredentialEnv;
39
+ exports.daemonRuntimeState = daemonRuntimeState;
40
+ exports.spawnDetachedDaemon = spawnDetachedDaemon;
41
+ exports.pidIsAlive = pidIsAlive;
39
42
  exports.macosLaunchdPath = macosLaunchdPath;
43
+ exports.isWatchdogTaskInstalled = isWatchdogTaskInstalled;
40
44
  exports.isDaemonAutostartInstalled = isDaemonAutostartInstalled;
41
45
  exports.daemonCommand = daemonCommand;
42
46
  const fs = __importStar(require("fs"));
@@ -44,6 +48,8 @@ const os = __importStar(require("os"));
44
48
  const path = __importStar(require("path"));
45
49
  const child_process_1 = require("child_process");
46
50
  const config_1 = require("../config");
51
+ const daemonForensics_1 = require("../daemonForensics");
52
+ const securityAgents_1 = require("../securityAgents");
47
53
  const mcpGateway_1 = require("./mcpGateway");
48
54
  const protectionRepair_1 = require("./protectionRepair");
49
55
  const runtimeConfig_1 = require("../runtimeConfig");
@@ -351,6 +357,18 @@ async function runDaemon(args, config) {
351
357
  const quiet = args.quiet === 'true';
352
358
  log(`Daemon started (pid ${process.pid}, platform ${process.platform}).`);
353
359
  log(`Shield: ${creds.shieldId || '(none — protect-only mode, telemetry disabled)'} API: ${creds.apiUrl}`);
360
+ // A machine enrolled with a DPAPI key whose decrypt fails (blocked
361
+ // PowerShell) lands here: shieldId readable, key not. Say so LOUDLY — this
362
+ // state looks identical to "machine off" on the dashboard otherwise.
363
+ if (creds.shieldId && !creds.shieldKey) {
364
+ const ph = (0, config_1.getPowershellHealth)();
365
+ log(`Shield key UNAVAILABLE${ph && !ph.spawnOk ? ' — powershell.exe could not start (EDR/policy block?), so the DPAPI-protected key cannot be decrypted' : ''}. Telemetry/control-plane sync disabled; retrying periodically.`);
366
+ }
367
+ // Crash forensics: was the previous daemon killed without a clean shutdown?
368
+ const postmortem = (0, daemonForensics_1.recordDaemonStart)({ version: cliVersion(), isPidAlive });
369
+ if (postmortem) {
370
+ log(`Previous daemon (v${postmortem.version || '?'}) died UNCLEANLY — last alive ${postmortem.lastAliveAt || 'unknown'}, no shutdown recorded. Likely killed externally (EDR/AV, OOM, or forced termination). Post-mortem recorded; reporting upstream.`);
371
+ }
354
372
  // --- state ---------------------------------------------------------------
355
373
  const watchers = new Map(); // directory -> watcher
356
374
  const rootWatchers = new Map(); // admin protection roots -> recursive watcher
@@ -363,6 +381,34 @@ async function runDaemon(args, config) {
363
381
  const executingActionIds = new Set();
364
382
  /** Latest org auto-update policy seen on a bundle poll. */
365
383
  let autoUpdatePolicy;
384
+ /** Heartbeat ticks spent without full credentials (drives recovery cadence). */
385
+ let credRecoveryTicks = 0;
386
+ /**
387
+ * A daemon that starts without a usable shield key (DPAPI decrypt blocked,
388
+ * config written after start by `login`) must NOT stay credential-less
389
+ * forever — that silences telemetry AND auto-update, stranding the machine.
390
+ * Retry quickly right after start, then hourly (each retry may spawn
391
+ * PowerShell for DPAPI, which we keep rare on machines that block it).
392
+ */
393
+ const recoverCredentialsIfMissing = () => {
394
+ if (creds.shieldId && creds.shieldKey)
395
+ return;
396
+ credRecoveryTicks += 1;
397
+ if (credRecoveryTicks > 3 && credRecoveryTicks % 12 !== 0)
398
+ return;
399
+ try {
400
+ const fresh = (0, config_1.resolveCliCredentials)((0, config_1.loadConfig)(args.config), {
401
+ shieldId: args.shieldId,
402
+ shieldKey: args.shieldKey,
403
+ apiUrl: args.apiUrl,
404
+ });
405
+ if (fresh.shieldId && fresh.shieldKey) {
406
+ Object.assign(creds, fresh);
407
+ log('Credentials recovered — telemetry and control-plane sync restored.');
408
+ }
409
+ }
410
+ catch { /* next tick */ }
411
+ };
366
412
  const reprotect = async (reasonPaths) => {
367
413
  if (reprotecting || stopped)
368
414
  return;
@@ -812,10 +858,16 @@ async function runDaemon(args, config) {
812
858
  catch { /* offline — cached stance applies */ }
813
859
  };
814
860
  const heartbeat = async () => {
861
+ (0, daemonForensics_1.touchDaemonAlive)();
862
+ recoverCredentialsIfMissing();
815
863
  if (!creds.shieldId)
816
864
  return;
817
865
  try {
818
866
  const integrity = (0, integrity_1.getLocalIntegrityReport)({ requireDaemon: true });
867
+ // Report a previous daemon's unclean death exactly once.
868
+ const crash = (0, daemonForensics_1.readPostmortem)();
869
+ const unreportedCrash = crash && !crash.reportedAt ? crash : undefined;
870
+ const powershell = (0, config_1.getPowershellHealth)();
819
871
  const result = await (0, telemetry_1.flushSpool)({
820
872
  apiUrl: creds.apiUrl,
821
873
  shieldId: creds.shieldId,
@@ -827,7 +879,21 @@ async function runDaemon(args, config) {
827
879
  integrityReasons: integrity.reasons,
828
880
  integrityCheckedAt: integrity.checkedAt,
829
881
  desktopChatGuard: (0, desktopChatGuard_1.desktopChatGuardSupported)() && (0, desktopChatGuard_1.desktopChatGuardHealthy)(),
882
+ securityAgents: (0, securityAgents_1.getSecurityAgentsReport)()?.products,
883
+ powershellSpawnOk: powershell?.spawnOk,
884
+ powershellDecryptOk: powershell?.decryptOk,
885
+ lastCrash: unreportedCrash
886
+ ? {
887
+ version: unreportedCrash.version,
888
+ startedAt: unreportedCrash.startedAt,
889
+ lastAliveAt: unreportedCrash.lastAliveAt,
890
+ detectedAt: unreportedCrash.detectedAt,
891
+ detectedBy: unreportedCrash.detectedBy,
892
+ }
893
+ : undefined,
830
894
  });
895
+ if (result && unreportedCrash)
896
+ (0, daemonForensics_1.markPostmortemReported)();
831
897
  if (result && result.accepted > 0)
832
898
  log(`Heartbeat: flushed ${result.accepted} spooled event(s).`);
833
899
  if (!integrity.ok)
@@ -840,6 +906,9 @@ async function runDaemon(args, config) {
840
906
  await verifyPendingUpgrade();
841
907
  };
842
908
  // --- boot ---------------------------------------------------------------
909
+ // Self-heal autostart + watchdog tasks: machines installed by older versions
910
+ // (or where task creation failed once) must converge without a reinstall.
911
+ ensureWindowsAutostartHealthy(log);
843
912
  const watched = refreshWatchTargets();
844
913
  log(`Watching ${watched} config file(s) across ${watchers.size} director${watchers.size === 1 ? 'y' : 'ies'}.`);
845
914
  // First: if this boot IS the post-upgrade relaunch, confirm the pending
@@ -962,6 +1031,9 @@ async function runDaemon(args, config) {
962
1031
  watcher.close();
963
1032
  for (const watcher of rootWatchers.values())
964
1033
  watcher.close();
1034
+ // Forensics: this is an INTENTIONAL exit — without this stamp the next
1035
+ // boot would report it as an external kill.
1036
+ (0, daemonForensics_1.recordCleanExit)(signal);
965
1037
  releasePidLock();
966
1038
  process.exit(0);
967
1039
  };
@@ -975,18 +1047,29 @@ async function runDaemon(args, config) {
975
1047
  // Autostart install / uninstall / status
976
1048
  // ---------------------------------------------------------------------------
977
1049
  /** Hidden VBS launcher so the logon task doesn't flash a console window. */
978
- function ensureWindowsLauncher() {
1050
+ function writeHiddenLauncher(fileName, cliCommand) {
979
1051
  const base = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
980
1052
  const dir = path.join(base, 'FullCourtDefense');
981
1053
  fs.mkdirSync(dir, { recursive: true });
982
- const vbs = path.join(dir, 'daemon.vbs');
1054
+ const vbs = path.join(dir, fileName);
983
1055
  // Run node directly — routing through `cmd /c` breaks cmd's quote parsing
984
1056
  // when node/CLI live under a path with spaces (e.g. Program Files).
985
- const inner = `"${process.execPath}" "${cliEntry()}" daemon`;
1057
+ const inner = `"${process.execPath}" "${cliEntry()}" ${cliCommand}`;
986
1058
  const content = `CreateObject("WScript.Shell").Run "${inner.replace(/"/g, '""')}", 0, False\n`;
987
1059
  fs.writeFileSync(vbs, content, 'utf8');
988
1060
  return vbs;
989
1061
  }
1062
+ function ensureWindowsLauncher() {
1063
+ return writeHiddenLauncher('daemon.vbs', 'daemon');
1064
+ }
1065
+ /**
1066
+ * Watchdog launcher: runs the `watchdog` command (liveness beacon + daemon
1067
+ * revival + crash post-mortem) instead of blindly starting the daemon — the
1068
+ * beacon is what lets the console tell "daemon dead" apart from "machine off".
1069
+ */
1070
+ function ensureWindowsWatchdogLauncher() {
1071
+ return writeHiddenLauncher('watchdog.vbs', 'watchdog');
1072
+ }
990
1073
  const WINDOWS_RUN_KEY = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run';
991
1074
  const WINDOWS_RUN_VALUE = 'FullCourtDefenseDaemon';
992
1075
  function isWindowsRunKeyInstalled() {
@@ -1047,28 +1130,102 @@ function installWindows() {
1047
1130
  }
1048
1131
  // Watchdog: logon triggers only START the daemon — nothing restarts it if it
1049
1132
  // is killed or crashes mid-session. A time-based per-user task (no elevation
1050
- // needed, unlike ONLOGON) re-launches the daemon every 5 minutes;
1051
- // acquirePidLock() makes each tick a cheap no-op while the daemon is alive,
1052
- // and revives it within one tick when it is not. Best-effort: a missing
1053
- // watchdog never fails the install.
1133
+ // needed, unlike ONLOGON) runs the `watchdog` command every 5 minutes: it
1134
+ // beacons daemon liveness to the console (so "daemon dead" is visible even
1135
+ // when the daemon can't say so itself) and revives a dead daemon within one
1136
+ // tick. Best-effort: a missing watchdog never fails the install.
1054
1137
  (0, child_process_1.spawnSync)('schtasks', [
1055
- '/Create', '/TN', WATCHDOG_TASK_NAME, '/TR', `wscript.exe "${vbs}"`,
1138
+ '/Create', '/TN', WATCHDOG_TASK_NAME, '/TR', `wscript.exe "${ensureWindowsWatchdogLauncher()}"`,
1056
1139
  '/SC', 'MINUTE', '/MO', '5', '/F',
1057
1140
  ], { stdio: 'ignore', windowsHide: true });
1058
1141
  if (ok)
1059
1142
  startDaemonNowWindows(vbs, taskOk);
1060
1143
  return ok;
1061
1144
  }
1145
+ /**
1146
+ * Boot-time self-heal for the Windows autostart chain. Recreates a missing
1147
+ * daemon logon entry, refreshes the launcher scripts (paths move on upgrade),
1148
+ * and re-points the watchdog task at the `watchdog` launcher (older versions
1149
+ * pointed it at daemon.vbs, which restarts but never beacons). Creating
1150
+ * per-user MINUTE tasks needs no elevation; ONLOGON does — fall back to the
1151
+ * per-user Run key exactly like installWindows().
1152
+ */
1153
+ function ensureWindowsAutostartHealthy(logFn) {
1154
+ if (process.platform !== 'win32')
1155
+ return;
1156
+ // Kill-switch for sandboxed test daemons — a test-spawned daemon must never
1157
+ // (re)write the developer machine's real scheduled tasks.
1158
+ if (process.env.FCD_DAEMON_NO_TASK_SELF_HEAL === '1')
1159
+ return;
1160
+ try {
1161
+ const vbs = ensureWindowsLauncher();
1162
+ const watchdogVbs = ensureWindowsWatchdogLauncher();
1163
+ const daemonTask = (0, child_process_1.spawnSync)('schtasks', ['/Query', '/TN', TASK_NAME], { stdio: 'ignore', windowsHide: true, timeout: 10_000 });
1164
+ if (daemonTask.status !== 0 && !isWindowsRunKeyInstalled()) {
1165
+ const created = (0, child_process_1.spawnSync)('schtasks', [
1166
+ '/Create', '/TN', TASK_NAME, '/TR', `wscript.exe "${vbs}"`,
1167
+ '/SC', 'ONLOGON', '/F', '/RL', 'LIMITED',
1168
+ ], { stdio: 'ignore', windowsHide: true, timeout: 20_000 });
1169
+ if (created.status !== 0) {
1170
+ (0, child_process_1.spawnSync)('reg', [
1171
+ 'add', WINDOWS_RUN_KEY, '/v', WINDOWS_RUN_VALUE, '/t', 'REG_SZ',
1172
+ '/d', `wscript.exe "${vbs}"`, '/f',
1173
+ ], { stdio: 'ignore', windowsHide: true, timeout: 10_000 });
1174
+ }
1175
+ logFn('Self-heal: daemon autostart entry was missing — recreated.');
1176
+ }
1177
+ const watchdog = (0, child_process_1.spawnSync)('schtasks', [
1178
+ '/Create', '/TN', WATCHDOG_TASK_NAME, '/TR', `wscript.exe "${watchdogVbs}"`,
1179
+ '/SC', 'MINUTE', '/MO', '5', '/F',
1180
+ ], { stdio: 'ignore', windowsHide: true, timeout: 20_000 });
1181
+ if (watchdog.status !== 0) {
1182
+ logFn('Self-heal: could not (re)create the watchdog task — daemon revival relies on logon autostart only.');
1183
+ }
1184
+ }
1185
+ catch { /* self-heal is best-effort — never blocks daemon boot */ }
1186
+ }
1187
+ function daemonRuntimeState() {
1188
+ try {
1189
+ const pid = Number(fs.readFileSync(pidFile(), 'utf8').trim());
1190
+ if (Number.isFinite(pid) && pid > 0 && isPidAlive(pid)) {
1191
+ const meta = readDaemonMeta();
1192
+ const owned = meta?.pid === pid;
1193
+ return { alive: true, pid, version: owned ? meta?.version : undefined, startedAt: owned ? meta?.startedAt : undefined };
1194
+ }
1195
+ }
1196
+ catch { /* no pid file — not running */ }
1197
+ return { alive: false };
1198
+ }
1199
+ /** Relaunch the daemon outside our process tree (plain node — no shells). */
1200
+ function spawnDetachedDaemon() {
1201
+ try {
1202
+ const child = (0, child_process_1.spawn)(process.execPath, [cliEntry(), 'daemon'], {
1203
+ detached: true,
1204
+ stdio: 'ignore',
1205
+ windowsHide: true,
1206
+ });
1207
+ child.unref();
1208
+ return true;
1209
+ }
1210
+ catch {
1211
+ return false;
1212
+ }
1213
+ }
1214
+ /** Pid-alive check shared with the watchdog (EPERM still means alive). */
1215
+ function pidIsAlive(pid) {
1216
+ return isPidAlive(pid);
1217
+ }
1062
1218
  function uninstallWindows() {
1063
1219
  const task = (0, child_process_1.spawnSync)('schtasks', ['/Delete', '/TN', TASK_NAME, '/F'], { stdio: 'ignore', windowsHide: true });
1064
1220
  (0, child_process_1.spawnSync)('schtasks', ['/Delete', '/TN', WATCHDOG_TASK_NAME, '/F'], { stdio: 'ignore', windowsHide: true });
1065
1221
  const reg = (0, child_process_1.spawnSync)('reg', ['delete', WINDOWS_RUN_KEY, '/v', WINDOWS_RUN_VALUE, '/f'], { stdio: 'ignore', windowsHide: true });
1066
- const vbs = path.join(process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'), 'FullCourtDefense', 'daemon.vbs');
1067
- try {
1068
- if (fs.existsSync(vbs))
1069
- fs.unlinkSync(vbs);
1222
+ const launcherDir = path.join(process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'), 'FullCourtDefense');
1223
+ for (const launcher of ['daemon.vbs', 'watchdog.vbs']) {
1224
+ try {
1225
+ fs.unlinkSync(path.join(launcherDir, launcher));
1226
+ }
1227
+ catch { /* ignore */ }
1070
1228
  }
1071
- catch { /* ignore */ }
1072
1229
  return task.status === 0 || reg.status === 0;
1073
1230
  }
1074
1231
  function launchdPlistPath() {
@@ -1134,6 +1291,17 @@ function systemdUnitPath() {
1134
1291
  return path.join(base, 'systemd', 'user', SYSTEMD_UNIT);
1135
1292
  }
1136
1293
  /** Whether the daemon has been registered to start automatically. */
1294
+ /** Is the 5-minute watchdog scheduled task registered? (Windows-only feature.) */
1295
+ function isWatchdogTaskInstalled() {
1296
+ if (process.platform !== 'win32')
1297
+ return false;
1298
+ const result = (0, child_process_1.spawnSync)('schtasks', ['/Query', '/TN', WATCHDOG_TASK_NAME], {
1299
+ stdio: 'ignore',
1300
+ timeout: 5_000,
1301
+ windowsHide: true,
1302
+ });
1303
+ return result.status === 0;
1304
+ }
1137
1305
  function isDaemonAutostartInstalled() {
1138
1306
  if (process.platform === 'win32') {
1139
1307
  const result = (0, child_process_1.spawnSync)('schtasks', ['/Query', '/TN', TASK_NAME], {
@@ -64,6 +64,8 @@ export interface DesktopDiscoveryHost {
64
64
  probeMode: 'config' | 'deep';
65
65
  /** Windows-only: PowerShell audit coverage (ScriptBlock Logging + Transcription). */
66
66
  shellAudit?: ShellAuditReport;
67
+ /** Windows-only: EDR/AV products detected on this machine (read-only service probe). */
68
+ securityAgents?: string[];
67
69
  /** True when the scan ran on an ephemeral CI runner (GitHub Actions, GitLab, etc.) — not a user machine. */
68
70
  isCi?: boolean;
69
71
  }
@@ -51,6 +51,7 @@ const discoverAgentFiles_1 = require("./discoverAgentFiles");
51
51
  const discoverBlastRadius_1 = require("./discoverBlastRadius");
52
52
  const discoverSecrets_1 = require("./discoverSecrets");
53
53
  const windowsAudit_1 = require("./windowsAudit");
54
+ const securityAgents_1 = require("../securityAgents");
54
55
  const discoveryMarker_1 = require("../discoveryMarker");
55
56
  const machineIdentity_1 = require("../machineIdentity");
56
57
  const DEFAULT_API_URL = 'https://api.fullcourtdefense.ai';
@@ -126,6 +127,7 @@ function buildHostMetadata(userEmail, probeMode = 'config') {
126
127
  scannedAt: new Date().toISOString(),
127
128
  probeMode,
128
129
  shellAudit: (0, windowsAudit_1.getShellAuditReport)(),
130
+ securityAgents: (0, securityAgents_1.getSecurityAgentsReport)()?.products,
129
131
  isCi: isCiEnvironment() || undefined,
130
132
  };
131
133
  }
@@ -53,6 +53,8 @@ const machineIdentity_1 = require("../machineIdentity");
53
53
  const appDetection_1 = require("../appDetection");
54
54
  const integrity_1 = require("../integrity");
55
55
  const telemetry_1 = require("../telemetry");
56
+ const envDiagnostics_1 = require("../envDiagnostics");
57
+ const daemonForensics_1 = require("../daemonForensics");
56
58
  const onboardingJournal_1 = require("./onboardingJournal");
57
59
  const GREEN = '\x1b[32m';
58
60
  const RED = '\x1b[31m';
@@ -246,7 +248,7 @@ async function onboardCommand(args, config) {
246
248
  const apiUrl = creds.apiUrl;
247
249
  const token = (args.token || process.env.FCD_ENROLL_TOKEN || '').trim();
248
250
  // 1. Connectivity.
249
- console.log(`${BOLD}[1/6] Checking compatibility and connectivity…${RESET}`);
251
+ console.log(`${BOLD}[1/7] Checking compatibility and connectivity…${RESET}`);
250
252
  mark('preflight', 'running');
251
253
  // A stale cmd-guard AutoRun (registry points at a deleted autorun script)
252
254
  // poisons every cmd.exe exit code on the machine — child spawns and the MSI's
@@ -280,7 +282,7 @@ async function onboardCommand(args, config) {
280
282
  }
281
283
  mark('preflight', 'completed');
282
284
  // 2. Enrollment.
283
- console.log(`\n${BOLD}[2/6] Enrolling this machine…${RESET}`);
285
+ console.log(`\n${BOLD}[2/7] Enrolling this machine…${RESET}`);
284
286
  mark('enrollment', 'running');
285
287
  const alreadyEnrolled = Boolean(creds.shieldId && creds.shieldKey);
286
288
  if (dryRun) {
@@ -305,7 +307,7 @@ async function onboardCommand(args, config) {
305
307
  // Re-read config: login just wrote fresh shield credentials to ~/.fullcourtdefense.yml.
306
308
  const freshConfig = (0, config_1.loadConfig)();
307
309
  // 3. Protection.
308
- console.log(`\n${BOLD}[3/6] Installing protection (install-all)…${RESET}`);
310
+ console.log(`\n${BOLD}[3/7] Installing protection (install-all)…${RESET}`);
309
311
  if (dryRun) {
310
312
  mark('protection', 'skipped', 'would install MCP gateways, IDE hooks, and terminal guards');
311
313
  }
@@ -334,7 +336,7 @@ async function onboardCommand(args, config) {
334
336
  return;
335
337
  }
336
338
  }
337
- console.log(`\n${BOLD}[4/6] Installing background protection…${RESET}`);
339
+ console.log(`\n${BOLD}[4/7] Installing background protection…${RESET}`);
338
340
  if (dryRun || args.noDaemon === 'true') {
339
341
  mark('daemon', 'skipped', dryRun ? 'would register the resident daemon' : 'disabled by --no-daemon true');
340
342
  }
@@ -355,7 +357,7 @@ async function onboardCommand(args, config) {
355
357
  }
356
358
  // Discovery and scheduling are useful fleet telemetry, but must never mark a
357
359
  // device as unprotected or prevent a repair of the enforcement surfaces.
358
- console.log(`\n${BOLD}[5/6] Recording optional discovery and schedule…${RESET}`);
360
+ console.log(`\n${BOLD}[5/7] Recording optional discovery and schedule…${RESET}`);
359
361
  if (dryRun || args.discover === 'false') {
360
362
  mark('discovery', 'skipped', dryRun ? 'would upload desktop discovery' : 'disabled by --discover false');
361
363
  }
@@ -383,8 +385,38 @@ async function onboardCommand(args, config) {
383
385
  mark('schedule', 'failed', undefined, error instanceof Error ? error.message : String(error));
384
386
  }
385
387
  }
386
- // 4. Verification.
387
- console.log(`\n${BOLD}[6/6] Verifying protection surfaces…${RESET}`);
388
+ // 4. Environment diagnostics — the "will this machine stay alive?" snapshot.
389
+ // Runs while we still have an interactive session on the (possibly EDR-
390
+ // hardened) machine: EDR/AV inventory, PowerShell/DPAPI health, daemon
391
+ // kill-detection (alive now AND still alive seconds later), watchdog task,
392
+ // and any crash post-mortem from a previous install. Never critical — the
393
+ // findings ship to the fleet console in the first heartbeat below.
394
+ console.log(`\n${BOLD}[6/7] Collecting environment diagnostics…${RESET}`);
395
+ let envDiag;
396
+ if (dryRun) {
397
+ mark('diagnostics', 'skipped', 'would probe EDR/AV, PowerShell health, and daemon liveness');
398
+ }
399
+ else {
400
+ mark('diagnostics', 'running');
401
+ try {
402
+ envDiag = await (0, envDiagnostics_1.collectEnvironmentDiagnostics)({ recheckDelayMs: args.noDaemon === 'true' ? 0 : 5_000 });
403
+ const summary = (0, envDiagnostics_1.summarizeEnvironmentDiagnostics)(envDiag);
404
+ console.log(` ${DIM}${summary}${RESET}`);
405
+ if ((0, envDiagnostics_1.environmentLooksHealthy)(envDiag)) {
406
+ mark('diagnostics', 'completed', summary);
407
+ }
408
+ else {
409
+ // Non-critical: a hostile environment is REPORTED, not treated as an
410
+ // install failure — the protection surfaces below are what gate.
411
+ mark('diagnostics', 'failed', summary, summary);
412
+ }
413
+ }
414
+ catch (error) {
415
+ mark('diagnostics', 'failed', undefined, error instanceof Error ? error.message : String(error));
416
+ }
417
+ }
418
+ // 5. Verification.
419
+ console.log(`\n${BOLD}[7/7] Verifying protection surfaces…${RESET}`);
388
420
  mark('verification', 'running');
389
421
  const postCreds = (0, config_1.resolveCliCredentials)((0, config_1.loadConfig)(), { apiUrl });
390
422
  // Immediate heartbeat: the machine must appear ONLINE in the fleet console
@@ -409,9 +441,31 @@ async function onboardCommand(args, config) {
409
441
  integrityOk: integrity.ok,
410
442
  integrityReasons: integrity.reasons,
411
443
  integrityCheckedAt: integrity.checkedAt,
444
+ // Environment diagnostics ride the FIRST heartbeat: the fleet console
445
+ // knows this machine's EDR/AV products, PowerShell health, and any
446
+ // previous crash the moment onboarding finishes — before the machine
447
+ // has any chance to go silent.
448
+ securityAgents: envDiag?.securityAgents,
449
+ powershellSpawnOk: envDiag?.powershellSpawnOk,
450
+ powershellDecryptOk: envDiag?.powershellDecryptOk,
451
+ lastCrash: envDiag?.lastCrash
452
+ ? {
453
+ version: envDiag.lastCrash.version,
454
+ startedAt: envDiag.lastCrash.startedAt,
455
+ lastAliveAt: envDiag.lastCrash.lastAliveAt,
456
+ detectedAt: envDiag.lastCrash.detectedAt,
457
+ detectedBy: envDiag.lastCrash.detectedBy,
458
+ }
459
+ : undefined,
412
460
  timeoutMs: 8_000,
413
461
  });
414
462
  firstHeartbeatOk = flush !== null;
463
+ if (firstHeartbeatOk && envDiag?.lastCrash) {
464
+ try {
465
+ (0, daemonForensics_1.markPostmortemReported)();
466
+ }
467
+ catch { /* best-effort */ }
468
+ }
415
469
  }
416
470
  catch { /* the daemon heartbeat remains the backstop */ }
417
471
  }
@@ -455,6 +509,41 @@ async function onboardCommand(args, config) {
455
509
  ? 'this machine now shows ONLINE in the fleet console'
456
510
  : dryRun ? 'skipped (dry run)' : 'not delivered yet — the daemon retries within 5 minutes',
457
511
  },
512
+ // --- environment diagnostics (informational: report, never block) -------
513
+ {
514
+ label: 'Daemon survived post-install liveness check',
515
+ ok: envDiag ? envDiag.daemonSurvivedRecheck !== false && envDiag.daemonAlive : true,
516
+ optional: true,
517
+ detail: !envDiag
518
+ ? 'skipped (dry run)'
519
+ : envDiag.daemonSurvivedRecheck === false
520
+ ? 'daemon was KILLED seconds after starting — security software on this machine is terminating it'
521
+ : envDiag.daemonAlive ? `pid ${envDiag.daemonPid}` : 'daemon not running — the watchdog will start it within 5 minutes',
522
+ },
523
+ {
524
+ label: 'Watchdog liveness beacon (5-minute health reports)',
525
+ ok: envDiag ? envDiag.watchdogTaskInstalled : true,
526
+ optional: true,
527
+ detail: envDiag && !envDiag.watchdogTaskInstalled && process.platform === 'win32'
528
+ ? 'task not registered — the daemon re-creates it at every start'
529
+ : undefined,
530
+ },
531
+ {
532
+ label: 'PowerShell available for secure key storage',
533
+ ok: envDiag ? envDiag.powershellSpawnOk !== false : true,
534
+ optional: true,
535
+ detail: envDiag?.powershellSpawnOk === false
536
+ ? 'PowerShell is blocked on this machine (security policy) — credentials use a PowerShell-free path'
537
+ : envDiag?.powershellDecryptOk === false ? 'PowerShell runs but the stored key did not decrypt' : undefined,
538
+ },
539
+ {
540
+ label: 'Security software inventory',
541
+ ok: true,
542
+ optional: true,
543
+ detail: envDiag?.securityAgents
544
+ ? envDiag.securityAgents.length ? envDiag.securityAgents.join(', ') : 'none detected'
545
+ : 'not probed (non-Windows or dry run)',
546
+ },
458
547
  ];
459
548
  console.log('');
460
549
  for (const check of checks)
@@ -1,4 +1,4 @@
1
- export type OnboardingStepName = 'preflight' | 'enrollment' | 'protection' | 'daemon' | 'discovery' | 'schedule' | 'verification';
1
+ export type OnboardingStepName = 'preflight' | 'enrollment' | 'protection' | 'daemon' | 'discovery' | 'schedule' | 'diagnostics' | 'verification';
2
2
  export type OnboardingStepStatus = 'pending' | 'running' | 'completed' | 'failed' | 'skipped';
3
3
  export interface OnboardingStep {
4
4
  status: OnboardingStepStatus;
@@ -48,6 +48,10 @@ const STEP_DEFINITIONS = [
48
48
  ['daemon', true],
49
49
  ['discovery', false],
50
50
  ['schedule', false],
51
+ // Post-install environment diagnostics (EDR/AV inventory, PowerShell
52
+ // health, watchdog + daemon liveness). Never critical: diagnostics report
53
+ // problems, they must not BE one.
54
+ ['diagnostics', false],
51
55
  ['verification', true],
52
56
  ];
53
57
  function onboardingJournalPath(home = os.homedir()) {
@@ -0,0 +1,25 @@
1
+ import { BotGuardConfig } from '../config';
2
+ /**
3
+ * Watchdog tick — runs OUTSIDE the daemon (Windows scheduled task, every
4
+ * 5 minutes) so machine health stays observable even when the daemon itself
5
+ * is dead. Three jobs, all best-effort, never throws, exits 0:
6
+ *
7
+ * 1. Liveness: is the daemon running? If not, detect + persist the unclean
8
+ * death post-mortem (the daemon can no longer say how it died).
9
+ * 2. Revival: relaunch a dead daemon (plain detached node — no shells).
10
+ * 3. Beacon: report daemon state to the console so the dashboard can tell
11
+ * "daemon dead since 11:02" apart from "machine off".
12
+ *
13
+ * Shell-quiet by design: credential resolution NEVER attempts the DPAPI/
14
+ * PowerShell decrypt here (this runs every 5 minutes — exactly the hourly-
15
+ * PowerShell pattern that made EDRs kill us). On DPAPI-enrolled machines the
16
+ * beacon goes out WITHOUT a shield key; the backend accepts it on a
17
+ * constrained, rate-limited path that can only update watchdog liveness
18
+ * fields of an already-enrolled machine.
19
+ */
20
+ export interface WatchdogArgs {
21
+ apiUrl?: string;
22
+ shieldId?: string;
23
+ shieldKey?: string;
24
+ }
25
+ export declare function watchdogCommand(args: WatchdogArgs, config: BotGuardConfig): Promise<void>;