fullcourtdefense-cli 1.21.28 → 1.21.29

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,6 +32,18 @@ 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. */
37
49
  export declare function isDaemonAutostartInstalled(): boolean;
@@ -36,6 +36,9 @@ 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;
40
43
  exports.isDaemonAutostartInstalled = isDaemonAutostartInstalled;
41
44
  exports.daemonCommand = daemonCommand;
@@ -44,6 +47,8 @@ const os = __importStar(require("os"));
44
47
  const path = __importStar(require("path"));
45
48
  const child_process_1 = require("child_process");
46
49
  const config_1 = require("../config");
50
+ const daemonForensics_1 = require("../daemonForensics");
51
+ const securityAgents_1 = require("../securityAgents");
47
52
  const mcpGateway_1 = require("./mcpGateway");
48
53
  const protectionRepair_1 = require("./protectionRepair");
49
54
  const runtimeConfig_1 = require("../runtimeConfig");
@@ -351,6 +356,18 @@ async function runDaemon(args, config) {
351
356
  const quiet = args.quiet === 'true';
352
357
  log(`Daemon started (pid ${process.pid}, platform ${process.platform}).`);
353
358
  log(`Shield: ${creds.shieldId || '(none — protect-only mode, telemetry disabled)'} API: ${creds.apiUrl}`);
359
+ // A machine enrolled with a DPAPI key whose decrypt fails (blocked
360
+ // PowerShell) lands here: shieldId readable, key not. Say so LOUDLY — this
361
+ // state looks identical to "machine off" on the dashboard otherwise.
362
+ if (creds.shieldId && !creds.shieldKey) {
363
+ const ph = (0, config_1.getPowershellHealth)();
364
+ 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.`);
365
+ }
366
+ // Crash forensics: was the previous daemon killed without a clean shutdown?
367
+ const postmortem = (0, daemonForensics_1.recordDaemonStart)({ version: cliVersion(), isPidAlive });
368
+ if (postmortem) {
369
+ 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.`);
370
+ }
354
371
  // --- state ---------------------------------------------------------------
355
372
  const watchers = new Map(); // directory -> watcher
356
373
  const rootWatchers = new Map(); // admin protection roots -> recursive watcher
@@ -363,6 +380,34 @@ async function runDaemon(args, config) {
363
380
  const executingActionIds = new Set();
364
381
  /** Latest org auto-update policy seen on a bundle poll. */
365
382
  let autoUpdatePolicy;
383
+ /** Heartbeat ticks spent without full credentials (drives recovery cadence). */
384
+ let credRecoveryTicks = 0;
385
+ /**
386
+ * A daemon that starts without a usable shield key (DPAPI decrypt blocked,
387
+ * config written after start by `login`) must NOT stay credential-less
388
+ * forever — that silences telemetry AND auto-update, stranding the machine.
389
+ * Retry quickly right after start, then hourly (each retry may spawn
390
+ * PowerShell for DPAPI, which we keep rare on machines that block it).
391
+ */
392
+ const recoverCredentialsIfMissing = () => {
393
+ if (creds.shieldId && creds.shieldKey)
394
+ return;
395
+ credRecoveryTicks += 1;
396
+ if (credRecoveryTicks > 3 && credRecoveryTicks % 12 !== 0)
397
+ return;
398
+ try {
399
+ const fresh = (0, config_1.resolveCliCredentials)((0, config_1.loadConfig)(args.config), {
400
+ shieldId: args.shieldId,
401
+ shieldKey: args.shieldKey,
402
+ apiUrl: args.apiUrl,
403
+ });
404
+ if (fresh.shieldId && fresh.shieldKey) {
405
+ Object.assign(creds, fresh);
406
+ log('Credentials recovered — telemetry and control-plane sync restored.');
407
+ }
408
+ }
409
+ catch { /* next tick */ }
410
+ };
366
411
  const reprotect = async (reasonPaths) => {
367
412
  if (reprotecting || stopped)
368
413
  return;
@@ -812,10 +857,16 @@ async function runDaemon(args, config) {
812
857
  catch { /* offline — cached stance applies */ }
813
858
  };
814
859
  const heartbeat = async () => {
860
+ (0, daemonForensics_1.touchDaemonAlive)();
861
+ recoverCredentialsIfMissing();
815
862
  if (!creds.shieldId)
816
863
  return;
817
864
  try {
818
865
  const integrity = (0, integrity_1.getLocalIntegrityReport)({ requireDaemon: true });
866
+ // Report a previous daemon's unclean death exactly once.
867
+ const crash = (0, daemonForensics_1.readPostmortem)();
868
+ const unreportedCrash = crash && !crash.reportedAt ? crash : undefined;
869
+ const powershell = (0, config_1.getPowershellHealth)();
819
870
  const result = await (0, telemetry_1.flushSpool)({
820
871
  apiUrl: creds.apiUrl,
821
872
  shieldId: creds.shieldId,
@@ -827,7 +878,21 @@ async function runDaemon(args, config) {
827
878
  integrityReasons: integrity.reasons,
828
879
  integrityCheckedAt: integrity.checkedAt,
829
880
  desktopChatGuard: (0, desktopChatGuard_1.desktopChatGuardSupported)() && (0, desktopChatGuard_1.desktopChatGuardHealthy)(),
881
+ securityAgents: (0, securityAgents_1.getSecurityAgentsReport)()?.products,
882
+ powershellSpawnOk: powershell?.spawnOk,
883
+ powershellDecryptOk: powershell?.decryptOk,
884
+ lastCrash: unreportedCrash
885
+ ? {
886
+ version: unreportedCrash.version,
887
+ startedAt: unreportedCrash.startedAt,
888
+ lastAliveAt: unreportedCrash.lastAliveAt,
889
+ detectedAt: unreportedCrash.detectedAt,
890
+ detectedBy: unreportedCrash.detectedBy,
891
+ }
892
+ : undefined,
830
893
  });
894
+ if (result && unreportedCrash)
895
+ (0, daemonForensics_1.markPostmortemReported)();
831
896
  if (result && result.accepted > 0)
832
897
  log(`Heartbeat: flushed ${result.accepted} spooled event(s).`);
833
898
  if (!integrity.ok)
@@ -840,6 +905,9 @@ async function runDaemon(args, config) {
840
905
  await verifyPendingUpgrade();
841
906
  };
842
907
  // --- boot ---------------------------------------------------------------
908
+ // Self-heal autostart + watchdog tasks: machines installed by older versions
909
+ // (or where task creation failed once) must converge without a reinstall.
910
+ ensureWindowsAutostartHealthy(log);
843
911
  const watched = refreshWatchTargets();
844
912
  log(`Watching ${watched} config file(s) across ${watchers.size} director${watchers.size === 1 ? 'y' : 'ies'}.`);
845
913
  // First: if this boot IS the post-upgrade relaunch, confirm the pending
@@ -962,6 +1030,9 @@ async function runDaemon(args, config) {
962
1030
  watcher.close();
963
1031
  for (const watcher of rootWatchers.values())
964
1032
  watcher.close();
1033
+ // Forensics: this is an INTENTIONAL exit — without this stamp the next
1034
+ // boot would report it as an external kill.
1035
+ (0, daemonForensics_1.recordCleanExit)(signal);
965
1036
  releasePidLock();
966
1037
  process.exit(0);
967
1038
  };
@@ -975,18 +1046,29 @@ async function runDaemon(args, config) {
975
1046
  // Autostart install / uninstall / status
976
1047
  // ---------------------------------------------------------------------------
977
1048
  /** Hidden VBS launcher so the logon task doesn't flash a console window. */
978
- function ensureWindowsLauncher() {
1049
+ function writeHiddenLauncher(fileName, cliCommand) {
979
1050
  const base = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
980
1051
  const dir = path.join(base, 'FullCourtDefense');
981
1052
  fs.mkdirSync(dir, { recursive: true });
982
- const vbs = path.join(dir, 'daemon.vbs');
1053
+ const vbs = path.join(dir, fileName);
983
1054
  // Run node directly — routing through `cmd /c` breaks cmd's quote parsing
984
1055
  // when node/CLI live under a path with spaces (e.g. Program Files).
985
- const inner = `"${process.execPath}" "${cliEntry()}" daemon`;
1056
+ const inner = `"${process.execPath}" "${cliEntry()}" ${cliCommand}`;
986
1057
  const content = `CreateObject("WScript.Shell").Run "${inner.replace(/"/g, '""')}", 0, False\n`;
987
1058
  fs.writeFileSync(vbs, content, 'utf8');
988
1059
  return vbs;
989
1060
  }
1061
+ function ensureWindowsLauncher() {
1062
+ return writeHiddenLauncher('daemon.vbs', 'daemon');
1063
+ }
1064
+ /**
1065
+ * Watchdog launcher: runs the `watchdog` command (liveness beacon + daemon
1066
+ * revival + crash post-mortem) instead of blindly starting the daemon — the
1067
+ * beacon is what lets the console tell "daemon dead" apart from "machine off".
1068
+ */
1069
+ function ensureWindowsWatchdogLauncher() {
1070
+ return writeHiddenLauncher('watchdog.vbs', 'watchdog');
1071
+ }
990
1072
  const WINDOWS_RUN_KEY = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run';
991
1073
  const WINDOWS_RUN_VALUE = 'FullCourtDefenseDaemon';
992
1074
  function isWindowsRunKeyInstalled() {
@@ -1047,28 +1129,102 @@ function installWindows() {
1047
1129
  }
1048
1130
  // Watchdog: logon triggers only START the daemon — nothing restarts it if it
1049
1131
  // 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.
1132
+ // needed, unlike ONLOGON) runs the `watchdog` command every 5 minutes: it
1133
+ // beacons daemon liveness to the console (so "daemon dead" is visible even
1134
+ // when the daemon can't say so itself) and revives a dead daemon within one
1135
+ // tick. Best-effort: a missing watchdog never fails the install.
1054
1136
  (0, child_process_1.spawnSync)('schtasks', [
1055
- '/Create', '/TN', WATCHDOG_TASK_NAME, '/TR', `wscript.exe "${vbs}"`,
1137
+ '/Create', '/TN', WATCHDOG_TASK_NAME, '/TR', `wscript.exe "${ensureWindowsWatchdogLauncher()}"`,
1056
1138
  '/SC', 'MINUTE', '/MO', '5', '/F',
1057
1139
  ], { stdio: 'ignore', windowsHide: true });
1058
1140
  if (ok)
1059
1141
  startDaemonNowWindows(vbs, taskOk);
1060
1142
  return ok;
1061
1143
  }
1144
+ /**
1145
+ * Boot-time self-heal for the Windows autostart chain. Recreates a missing
1146
+ * daemon logon entry, refreshes the launcher scripts (paths move on upgrade),
1147
+ * and re-points the watchdog task at the `watchdog` launcher (older versions
1148
+ * pointed it at daemon.vbs, which restarts but never beacons). Creating
1149
+ * per-user MINUTE tasks needs no elevation; ONLOGON does — fall back to the
1150
+ * per-user Run key exactly like installWindows().
1151
+ */
1152
+ function ensureWindowsAutostartHealthy(logFn) {
1153
+ if (process.platform !== 'win32')
1154
+ return;
1155
+ // Kill-switch for sandboxed test daemons — a test-spawned daemon must never
1156
+ // (re)write the developer machine's real scheduled tasks.
1157
+ if (process.env.FCD_DAEMON_NO_TASK_SELF_HEAL === '1')
1158
+ return;
1159
+ try {
1160
+ const vbs = ensureWindowsLauncher();
1161
+ const watchdogVbs = ensureWindowsWatchdogLauncher();
1162
+ const daemonTask = (0, child_process_1.spawnSync)('schtasks', ['/Query', '/TN', TASK_NAME], { stdio: 'ignore', windowsHide: true, timeout: 10_000 });
1163
+ if (daemonTask.status !== 0 && !isWindowsRunKeyInstalled()) {
1164
+ const created = (0, child_process_1.spawnSync)('schtasks', [
1165
+ '/Create', '/TN', TASK_NAME, '/TR', `wscript.exe "${vbs}"`,
1166
+ '/SC', 'ONLOGON', '/F', '/RL', 'LIMITED',
1167
+ ], { stdio: 'ignore', windowsHide: true, timeout: 20_000 });
1168
+ if (created.status !== 0) {
1169
+ (0, child_process_1.spawnSync)('reg', [
1170
+ 'add', WINDOWS_RUN_KEY, '/v', WINDOWS_RUN_VALUE, '/t', 'REG_SZ',
1171
+ '/d', `wscript.exe "${vbs}"`, '/f',
1172
+ ], { stdio: 'ignore', windowsHide: true, timeout: 10_000 });
1173
+ }
1174
+ logFn('Self-heal: daemon autostart entry was missing — recreated.');
1175
+ }
1176
+ const watchdog = (0, child_process_1.spawnSync)('schtasks', [
1177
+ '/Create', '/TN', WATCHDOG_TASK_NAME, '/TR', `wscript.exe "${watchdogVbs}"`,
1178
+ '/SC', 'MINUTE', '/MO', '5', '/F',
1179
+ ], { stdio: 'ignore', windowsHide: true, timeout: 20_000 });
1180
+ if (watchdog.status !== 0) {
1181
+ logFn('Self-heal: could not (re)create the watchdog task — daemon revival relies on logon autostart only.');
1182
+ }
1183
+ }
1184
+ catch { /* self-heal is best-effort — never blocks daemon boot */ }
1185
+ }
1186
+ function daemonRuntimeState() {
1187
+ try {
1188
+ const pid = Number(fs.readFileSync(pidFile(), 'utf8').trim());
1189
+ if (Number.isFinite(pid) && pid > 0 && isPidAlive(pid)) {
1190
+ const meta = readDaemonMeta();
1191
+ const owned = meta?.pid === pid;
1192
+ return { alive: true, pid, version: owned ? meta?.version : undefined, startedAt: owned ? meta?.startedAt : undefined };
1193
+ }
1194
+ }
1195
+ catch { /* no pid file — not running */ }
1196
+ return { alive: false };
1197
+ }
1198
+ /** Relaunch the daemon outside our process tree (plain node — no shells). */
1199
+ function spawnDetachedDaemon() {
1200
+ try {
1201
+ const child = (0, child_process_1.spawn)(process.execPath, [cliEntry(), 'daemon'], {
1202
+ detached: true,
1203
+ stdio: 'ignore',
1204
+ windowsHide: true,
1205
+ });
1206
+ child.unref();
1207
+ return true;
1208
+ }
1209
+ catch {
1210
+ return false;
1211
+ }
1212
+ }
1213
+ /** Pid-alive check shared with the watchdog (EPERM still means alive). */
1214
+ function pidIsAlive(pid) {
1215
+ return isPidAlive(pid);
1216
+ }
1062
1217
  function uninstallWindows() {
1063
1218
  const task = (0, child_process_1.spawnSync)('schtasks', ['/Delete', '/TN', TASK_NAME, '/F'], { stdio: 'ignore', windowsHide: true });
1064
1219
  (0, child_process_1.spawnSync)('schtasks', ['/Delete', '/TN', WATCHDOG_TASK_NAME, '/F'], { stdio: 'ignore', windowsHide: true });
1065
1220
  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);
1221
+ const launcherDir = path.join(process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'), 'FullCourtDefense');
1222
+ for (const launcher of ['daemon.vbs', 'watchdog.vbs']) {
1223
+ try {
1224
+ fs.unlinkSync(path.join(launcherDir, launcher));
1225
+ }
1226
+ catch { /* ignore */ }
1070
1227
  }
1071
- catch { /* ignore */ }
1072
1228
  return task.status === 0 || reg.status === 0;
1073
1229
  }
1074
1230
  function launchdPlistPath() {
@@ -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
  }
@@ -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>;
@@ -0,0 +1,114 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.watchdogCommand = watchdogCommand;
37
+ const config_1 = require("../config");
38
+ const daemon_1 = require("./daemon");
39
+ const daemonForensics_1 = require("../daemonForensics");
40
+ const machineIdentity_1 = require("../machineIdentity");
41
+ const fs = __importStar(require("fs"));
42
+ const path = __importStar(require("path"));
43
+ function cliVersion() {
44
+ try {
45
+ const pkg = JSON.parse(fs.readFileSync(path.resolve(__dirname, '..', '..', 'package.json'), 'utf8'));
46
+ return typeof pkg.version === 'string' ? pkg.version : undefined;
47
+ }
48
+ catch {
49
+ return undefined;
50
+ }
51
+ }
52
+ function crashSummary(pm) {
53
+ if (!pm || pm.reportedAt)
54
+ return undefined;
55
+ return {
56
+ version: pm.version,
57
+ startedAt: pm.startedAt,
58
+ lastAliveAt: pm.lastAliveAt,
59
+ detectedAt: pm.detectedAt,
60
+ detectedBy: pm.detectedBy,
61
+ };
62
+ }
63
+ async function watchdogCommand(args, config) {
64
+ const state = (0, daemon_1.daemonRuntimeState)();
65
+ let relaunched = false;
66
+ let postmortem = (0, daemonForensics_1.readPostmortem)();
67
+ if (!state.alive) {
68
+ // The daemon is down — capture how it died BEFORE reviving it (the fresh
69
+ // daemon rewrites the alive marker on boot).
70
+ postmortem = (0, daemonForensics_1.recordUncleanDeathFromWatchdog)(daemon_1.pidIsAlive) || postmortem;
71
+ // Revival can be disabled for tests/diagnostics; default is on.
72
+ if (process.env.FCD_WATCHDOG_NO_RELAUNCH !== '1') {
73
+ relaunched = (0, daemon_1.spawnDetachedDaemon)();
74
+ }
75
+ }
76
+ // Beacon — fail-soft on every axis: missing credentials, endpoint not yet
77
+ // deployed (404), network down. The next tick retries in 5 minutes.
78
+ const creds = (0, config_1.resolveCliCredentialsShellFree)(config, {
79
+ shieldId: args.shieldId,
80
+ shieldKey: args.shieldKey,
81
+ apiUrl: args.apiUrl,
82
+ });
83
+ if (!creds.shieldId)
84
+ return;
85
+ const identity = (0, machineIdentity_1.getMachineIdentity)();
86
+ const beacon = {
87
+ shieldId: creds.shieldId,
88
+ machineId: identity.machineId,
89
+ agentVersion: cliVersion(),
90
+ daemonAlive: state.alive,
91
+ daemonPid: state.pid,
92
+ daemonVersion: state.version,
93
+ daemonStartedAt: state.startedAt,
94
+ relaunched: relaunched || undefined,
95
+ lastCrash: crashSummary(postmortem),
96
+ };
97
+ const headers = { 'Content-Type': 'application/json' };
98
+ if (creds.shieldKey)
99
+ headers['x-shield-key'] = creds.shieldKey;
100
+ try {
101
+ const resp = await fetch(`${creds.apiUrl}/api/cli/machines/watchdog`, {
102
+ method: 'POST',
103
+ headers,
104
+ body: JSON.stringify(beacon),
105
+ signal: AbortSignal.timeout(8_000),
106
+ });
107
+ // Only a trusted (shield-key) delivery consumes the crash report — the
108
+ // constrained path stores a hint but the daemon should still report the
109
+ // full post-mortem on its next authenticated heartbeat.
110
+ if (resp.ok && beacon.lastCrash && creds.shieldKey)
111
+ (0, daemonForensics_1.markPostmortemReported)();
112
+ }
113
+ catch { /* offline / endpoint not deployed yet — next tick retries */ }
114
+ }
package/dist/config.d.ts CHANGED
@@ -18,6 +18,20 @@ export interface BotGuardConfig {
18
18
  webhookFormat?: string;
19
19
  };
20
20
  }
21
+ /**
22
+ * Outcome of the most recent DPAPI PowerShell attempt in THIS process —
23
+ * the fleet's "is PowerShell blocked on this machine?" health bit, derived
24
+ * from work we had to do anyway (never a dedicated PowerShell spawn).
25
+ * spawnOk=false means powershell.exe/pwsh.exe could not even start (EDR/
26
+ * AppLocker block, missing shells); spawnOk=true + decryptOk=false means the
27
+ * shell ran but the blob would not decrypt (wrong user profile / corrupt).
28
+ */
29
+ export interface PowershellHealth {
30
+ spawnOk: boolean;
31
+ decryptOk: boolean;
32
+ checkedAt: string;
33
+ }
34
+ export declare function getPowershellHealth(): PowershellHealth | undefined;
21
35
  export declare function getHomeConfigPath(): string;
22
36
  export declare function loadConfig(configPath?: string): BotGuardConfig;
23
37
  export declare function getDefaultConfigPath(): string;
@@ -53,7 +67,17 @@ export interface ResolvedCliCredentials {
53
67
  */
54
68
  export declare function daemonHandoffShieldKey(env?: NodeJS.ProcessEnv): string | undefined;
55
69
  /** Merge saved ~/.fullcourtdefense.yml + env + optional CLI flag overrides. */
56
- export declare function resolveCliCredentials(config: BotGuardConfig, overrides?: Partial<ResolvedCliCredentials>): ResolvedCliCredentials;
70
+ export declare function resolveCliCredentials(config: BotGuardConfig, overrides?: Partial<ResolvedCliCredentials>, options?: {
71
+ skipDpapi?: boolean;
72
+ }): ResolvedCliCredentials;
73
+ /**
74
+ * Credential resolution that is guaranteed to spawn NO child process — for
75
+ * frequent low-privilege callers (watchdog beacon every 5 min). Skips the
76
+ * DPAPI/PowerShell decrypt entirely; on a DPAPI-enrolled machine this may
77
+ * yield no shieldKey, in which case the caller sends a constrained
78
+ * (unauthenticated) beacon instead.
79
+ */
80
+ export declare function resolveCliCredentialsShellFree(config: BotGuardConfig, overrides?: Partial<ResolvedCliCredentials>): ResolvedCliCredentials;
57
81
  export declare function isCliSetupComplete(config: BotGuardConfig): boolean;
58
82
  /** Fail fast with a clear message when setup was not run. */
59
83
  export declare function requireCliSetup(config: BotGuardConfig, overrides?: Partial<ResolvedCliCredentials>, options?: {
package/dist/config.js CHANGED
@@ -33,12 +33,14 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.getPowershellHealth = getPowershellHealth;
36
37
  exports.getHomeConfigPath = getHomeConfigPath;
37
38
  exports.loadConfig = loadConfig;
38
39
  exports.getDefaultConfigPath = getDefaultConfigPath;
39
40
  exports.saveShieldConfig = saveShieldConfig;
40
41
  exports.daemonHandoffShieldKey = daemonHandoffShieldKey;
41
42
  exports.resolveCliCredentials = resolveCliCredentials;
43
+ exports.resolveCliCredentialsShellFree = resolveCliCredentialsShellFree;
42
44
  exports.isCliSetupComplete = isCliSetupComplete;
43
45
  exports.requireCliSetup = requireCliSetup;
44
46
  exports.saveSetupConfig = saveSetupConfig;
@@ -155,15 +157,21 @@ function readConfigFile(filePath) {
155
157
  : undefined,
156
158
  };
157
159
  }
160
+ let powershellHealth;
161
+ function getPowershellHealth() {
162
+ return powershellHealth;
163
+ }
158
164
  function powershellDpapi(script, value) {
159
165
  if (process.platform !== 'win32' || !value)
160
166
  return undefined;
161
167
  // Windows PowerShell 5.1 first, then PowerShell 7 — both use DPAPI for
162
168
  // SecureString on Windows. Some locked-down/service contexts (e.g. CI
163
169
  // runners) break one shell but not the other.
170
+ let anyShellSpawned = false;
171
+ let output;
164
172
  for (const shell of ['powershell.exe', 'pwsh.exe']) {
165
173
  try {
166
- const output = (0, child_process_1.execFileSync)(shell, [
174
+ const raw = (0, child_process_1.execFileSync)(shell, [
167
175
  '-NoProfile',
168
176
  '-NonInteractive',
169
177
  '-ExecutionPolicy', 'Bypass',
@@ -175,12 +183,23 @@ function powershellDpapi(script, value) {
175
183
  timeout: 15_000,
176
184
  windowsHide: true,
177
185
  }).trim();
178
- if (output)
179
- return output;
186
+ anyShellSpawned = true;
187
+ if (raw) {
188
+ output = raw;
189
+ break;
190
+ }
191
+ }
192
+ catch (error) {
193
+ // A non-zero exit still proves the shell STARTED (status set); a spawn
194
+ // failure (ENOENT/EACCES/EPERM, or timeout kill) proves it did not.
195
+ const err = error;
196
+ if (typeof err.status === 'number')
197
+ anyShellSpawned = true;
198
+ /* try the next shell */
180
199
  }
181
- catch { /* try the next shell */ }
182
200
  }
183
- return undefined;
201
+ powershellHealth = { spawnOk: anyShellSpawned, decryptOk: !!output, checkedAt: new Date().toISOString() };
202
+ return output;
184
203
  }
185
204
  function protectShieldKeyForCurrentWindowsUser(value) {
186
205
  return powershellDpapi('$secure=ConvertTo-SecureString -String $env:FCD_DPAPI_VALUE -AsPlainText -Force; ConvertFrom-SecureString -SecureString $secure', value);
@@ -245,7 +264,7 @@ function daemonHandoffShieldKey(env = process.env) {
245
264
  return env.FCD_SHIELD_ID && env.FCD_SHIELD_KEY ? env.FCD_SHIELD_KEY : undefined;
246
265
  }
247
266
  /** Merge saved ~/.fullcourtdefense.yml + env + optional CLI flag overrides. */
248
- function resolveCliCredentials(config, overrides = {}) {
267
+ function resolveCliCredentials(config, overrides = {}, options = {}) {
249
268
  return {
250
269
  apiKey: overrides.apiKey
251
270
  || config.apiKey
@@ -266,13 +285,23 @@ function resolveCliCredentials(config, overrides = {}) {
266
285
  // Daemon handoff BEFORE the DPAPI decrypt: no powershell.exe spawn at
267
286
  // all during daemon-driven sweeps (see daemonHandoffShieldKey docs).
268
287
  || daemonHandoffShieldKey()
269
- || unprotectShieldKeyForCurrentWindowsUser(config.shieldKeyDpapi || '')
288
+ || (options.skipDpapi ? undefined : unprotectShieldKeyForCurrentWindowsUser(config.shieldKeyDpapi || ''))
270
289
  || config.shieldKey
271
290
  || process.env.FCD_SHIELD_KEY
272
291
  || process.env.FULLCOURTDEFENSE_SHIELD_KEY
273
292
  || process.env.AGENTGUARD_SHIELD_KEY,
274
293
  };
275
294
  }
295
+ /**
296
+ * Credential resolution that is guaranteed to spawn NO child process — for
297
+ * frequent low-privilege callers (watchdog beacon every 5 min). Skips the
298
+ * DPAPI/PowerShell decrypt entirely; on a DPAPI-enrolled machine this may
299
+ * yield no shieldKey, in which case the caller sends a constrained
300
+ * (unauthenticated) beacon instead.
301
+ */
302
+ function resolveCliCredentialsShellFree(config, overrides = {}) {
303
+ return resolveCliCredentials(config, overrides, { skipDpapi: true });
304
+ }
276
305
  function isCliSetupComplete(config) {
277
306
  const creds = resolveCliCredentials(config);
278
307
  return Boolean(creds.shieldId && creds.apiKey && creds.organizationId);
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Daemon crash forensics — answers "HOW did the daemon die?" after the fact.
3
+ *
4
+ * The daemon keeps an alive-marker on disk (pid + lastAliveAt, refreshed on
5
+ * every heartbeat tick) and records a clean-exit stamp when it shuts down
6
+ * normally. Whoever runs next — a restarted daemon or the watchdog task —
7
+ * compares the marker against reality: a marker without a clean exit whose
8
+ * pid is gone means the previous daemon was killed externally (EDR/AV kill,
9
+ * OOM, forced reboot). That produces a post-mortem record (with the last
10
+ * daemon.log lines) which is reported upstream via the heartbeat/beacon, so
11
+ * a remote fleet machine's death is diagnosable without hands-on access.
12
+ *
13
+ * Everything here is best-effort file I/O: forensics must never break the
14
+ * daemon or the watchdog. Pure logic (`detectUncleanDeath`) is injectable
15
+ * for offline tests.
16
+ */
17
+ export interface DaemonAliveMarker {
18
+ pid: number;
19
+ version?: string;
20
+ startedAt: string;
21
+ lastAliveAt: string;
22
+ /** Present only when the daemon shut down on purpose (SIGINT/SIGTERM/supersede). */
23
+ cleanExit?: {
24
+ at: string;
25
+ reason: string;
26
+ };
27
+ }
28
+ export interface DaemonPostmortem {
29
+ /** Version of the daemon that died. */
30
+ version?: string;
31
+ startedAt?: string;
32
+ /** Last proof of life — death happened within one heartbeat tick after this. */
33
+ lastAliveAt?: string;
34
+ detectedAt: string;
35
+ detectedBy: 'daemon' | 'watchdog';
36
+ uncleanExit: true;
37
+ /** Tail of daemon.log at detection time — the daemon's last words. */
38
+ lastLogLines: string[];
39
+ /** Set once the post-mortem reached the backend (stop re-sending). */
40
+ reportedAt?: string;
41
+ }
42
+ export declare function aliveMarkerFile(): string;
43
+ export declare function postmortemFile(): string;
44
+ export declare function readAliveMarker(): DaemonAliveMarker | undefined;
45
+ export declare function readPostmortem(): DaemonPostmortem | undefined;
46
+ /** Last non-empty lines of daemon.log (the dead daemon's final output). */
47
+ export declare function tailDaemonLog(maxLines?: number): string[];
48
+ /**
49
+ * Pure detection: did the previously recorded daemon die WITHOUT a clean exit?
50
+ * Returns the post-mortem (not yet persisted) or undefined when the previous
51
+ * daemon exited cleanly, is still alive, or nothing was recorded.
52
+ */
53
+ export declare function detectUncleanDeath(input: {
54
+ marker: DaemonAliveMarker | undefined;
55
+ isPidAlive: (pid: number) => boolean;
56
+ detectedBy: 'daemon' | 'watchdog';
57
+ lastLogLines?: string[];
58
+ ownPid?: number;
59
+ now?: string;
60
+ }): DaemonPostmortem | undefined;
61
+ /**
62
+ * Called once at daemon startup: detect an unclean previous death (persisting
63
+ * the post-mortem for reporting), then claim the alive marker for this pid.
64
+ * Returns the detected post-mortem so the daemon can log it.
65
+ */
66
+ export declare function recordDaemonStart(input: {
67
+ version?: string;
68
+ isPidAlive: (pid: number) => boolean;
69
+ }): DaemonPostmortem | undefined;
70
+ /** Watchdog-side detection (daemon not running): persist + return the post-mortem. */
71
+ export declare function recordUncleanDeathFromWatchdog(isPidAlive: (pid: number) => boolean): DaemonPostmortem | undefined;
72
+ /** Refresh proof-of-life; called from the daemon's heartbeat tick. */
73
+ export declare function touchDaemonAlive(): void;
74
+ /** Record an intentional shutdown so the next boot knows this was NOT a kill. */
75
+ export declare function recordCleanExit(reason: string): void;
76
+ /** Stamp the post-mortem as delivered so heartbeats stop re-sending it. */
77
+ export declare function markPostmortemReported(): void;
@@ -0,0 +1,188 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.aliveMarkerFile = aliveMarkerFile;
37
+ exports.postmortemFile = postmortemFile;
38
+ exports.readAliveMarker = readAliveMarker;
39
+ exports.readPostmortem = readPostmortem;
40
+ exports.tailDaemonLog = tailDaemonLog;
41
+ exports.detectUncleanDeath = detectUncleanDeath;
42
+ exports.recordDaemonStart = recordDaemonStart;
43
+ exports.recordUncleanDeathFromWatchdog = recordUncleanDeathFromWatchdog;
44
+ exports.touchDaemonAlive = touchDaemonAlive;
45
+ exports.recordCleanExit = recordCleanExit;
46
+ exports.markPostmortemReported = markPostmortemReported;
47
+ const fs = __importStar(require("fs"));
48
+ const os = __importStar(require("os"));
49
+ const path = __importStar(require("path"));
50
+ function stateDir() {
51
+ return path.join(os.homedir(), '.fullcourtdefense');
52
+ }
53
+ function aliveMarkerFile() {
54
+ return path.join(stateDir(), 'daemon.alive.json');
55
+ }
56
+ function postmortemFile() {
57
+ return path.join(stateDir(), 'daemon.postmortem.json');
58
+ }
59
+ function readAliveMarker() {
60
+ try {
61
+ const marker = JSON.parse(fs.readFileSync(aliveMarkerFile(), 'utf8'));
62
+ return marker && typeof marker.pid === 'number' ? marker : undefined;
63
+ }
64
+ catch {
65
+ return undefined;
66
+ }
67
+ }
68
+ function readPostmortem() {
69
+ try {
70
+ const pm = JSON.parse(fs.readFileSync(postmortemFile(), 'utf8'));
71
+ return pm && pm.uncleanExit === true ? pm : undefined;
72
+ }
73
+ catch {
74
+ return undefined;
75
+ }
76
+ }
77
+ function writeJson(file, value) {
78
+ try {
79
+ fs.mkdirSync(stateDir(), { recursive: true });
80
+ fs.writeFileSync(file, JSON.stringify(value, null, 2), 'utf8');
81
+ }
82
+ catch { /* best-effort */ }
83
+ }
84
+ /** Last non-empty lines of daemon.log (the dead daemon's final output). */
85
+ function tailDaemonLog(maxLines = 40) {
86
+ try {
87
+ const file = path.join(stateDir(), 'daemon.log');
88
+ const raw = fs.readFileSync(file, 'utf8');
89
+ return raw
90
+ .split(/\r?\n/)
91
+ .map(line => line.trim())
92
+ .filter(Boolean)
93
+ .slice(-maxLines)
94
+ .map(line => line.slice(0, 400));
95
+ }
96
+ catch {
97
+ return [];
98
+ }
99
+ }
100
+ /**
101
+ * Pure detection: did the previously recorded daemon die WITHOUT a clean exit?
102
+ * Returns the post-mortem (not yet persisted) or undefined when the previous
103
+ * daemon exited cleanly, is still alive, or nothing was recorded.
104
+ */
105
+ function detectUncleanDeath(input) {
106
+ const { marker } = input;
107
+ if (!marker || marker.cleanExit)
108
+ return undefined;
109
+ if (marker.pid === (input.ownPid ?? process.pid))
110
+ return undefined;
111
+ if (input.isPidAlive(marker.pid))
112
+ return undefined;
113
+ return {
114
+ version: marker.version,
115
+ startedAt: marker.startedAt,
116
+ lastAliveAt: marker.lastAliveAt,
117
+ detectedAt: input.now || new Date().toISOString(),
118
+ detectedBy: input.detectedBy,
119
+ uncleanExit: true,
120
+ lastLogLines: input.lastLogLines ?? [],
121
+ };
122
+ }
123
+ /**
124
+ * Called once at daemon startup: detect an unclean previous death (persisting
125
+ * the post-mortem for reporting), then claim the alive marker for this pid.
126
+ * Returns the detected post-mortem so the daemon can log it.
127
+ */
128
+ function recordDaemonStart(input) {
129
+ const postmortem = detectUncleanDeath({
130
+ marker: readAliveMarker(),
131
+ isPidAlive: input.isPidAlive,
132
+ detectedBy: 'daemon',
133
+ lastLogLines: tailDaemonLog(),
134
+ });
135
+ if (postmortem)
136
+ writeJson(postmortemFile(), postmortem);
137
+ const now = new Date().toISOString();
138
+ const marker = {
139
+ pid: process.pid,
140
+ version: input.version,
141
+ startedAt: now,
142
+ lastAliveAt: now,
143
+ };
144
+ writeJson(aliveMarkerFile(), marker);
145
+ return postmortem;
146
+ }
147
+ /** Watchdog-side detection (daemon not running): persist + return the post-mortem. */
148
+ function recordUncleanDeathFromWatchdog(isPidAlive) {
149
+ const existing = readPostmortem();
150
+ const postmortem = detectUncleanDeath({
151
+ marker: readAliveMarker(),
152
+ isPidAlive,
153
+ detectedBy: 'watchdog',
154
+ lastLogLines: tailDaemonLog(),
155
+ });
156
+ if (!postmortem)
157
+ return existing;
158
+ // Don't clobber an identical, possibly already-reported record.
159
+ if (existing && existing.lastAliveAt === postmortem.lastAliveAt && existing.startedAt === postmortem.startedAt) {
160
+ return existing;
161
+ }
162
+ writeJson(postmortemFile(), postmortem);
163
+ return postmortem;
164
+ }
165
+ /** Refresh proof-of-life; called from the daemon's heartbeat tick. */
166
+ function touchDaemonAlive() {
167
+ const marker = readAliveMarker();
168
+ if (!marker || marker.pid !== process.pid)
169
+ return;
170
+ marker.lastAliveAt = new Date().toISOString();
171
+ writeJson(aliveMarkerFile(), marker);
172
+ }
173
+ /** Record an intentional shutdown so the next boot knows this was NOT a kill. */
174
+ function recordCleanExit(reason) {
175
+ const marker = readAliveMarker();
176
+ if (!marker || marker.pid !== process.pid)
177
+ return;
178
+ marker.cleanExit = { at: new Date().toISOString(), reason: reason.slice(0, 120) };
179
+ writeJson(aliveMarkerFile(), marker);
180
+ }
181
+ /** Stamp the post-mortem as delivered so heartbeats stop re-sending it. */
182
+ function markPostmortemReported() {
183
+ const pm = readPostmortem();
184
+ if (!pm || pm.reportedAt)
185
+ return;
186
+ pm.reportedAt = new Date().toISOString();
187
+ writeJson(postmortemFile(), pm);
188
+ }
package/dist/index.js CHANGED
@@ -53,6 +53,7 @@ const onboard_1 = require("./commands/onboard");
53
53
  const uninstallAll_1 = require("./commands/uninstallAll");
54
54
  const autoProtect_1 = require("./commands/autoProtect");
55
55
  const daemon_1 = require("./commands/daemon");
56
+ const watchdog_1 = require("./commands/watchdog");
56
57
  const desktopChatGuard_1 = require("./commands/desktopChatGuard");
57
58
  const windowsAudit_1 = require("./commands/windowsAudit");
58
59
  const shellGuard_1 = require("./commands/shellGuard");
@@ -873,6 +874,17 @@ async function main() {
873
874
  await (0, daemon_1.daemonCommand)(args, config);
874
875
  break;
875
876
  }
877
+ case 'watchdog': {
878
+ // Internal: 5-minute scheduled-task tick — daemon liveness beacon,
879
+ // crash post-mortem capture, and dead-daemon revival. Shell-quiet.
880
+ const args = {
881
+ apiUrl: flags['api-url'],
882
+ shieldId: flags['shield-id'],
883
+ shieldKey: flags['shield-key'],
884
+ };
885
+ await (0, watchdog_1.watchdogCommand)(args, config);
886
+ break;
887
+ }
876
888
  case 'desktop-chat-guard': {
877
889
  const args = {
878
890
  apiUrl: flags['api-url'],
@@ -0,0 +1,26 @@
1
+ /**
2
+ * EDR / antivirus presence probe (Windows).
3
+ *
4
+ * Detects which endpoint-security products run on a fleet machine so the
5
+ * console can explain machine-side interference (blocked PowerShell, killed
6
+ * daemons, quarantined installs) instead of showing a bare "stale" badge.
7
+ *
8
+ * Deliberately shell-quiet: detection is read-only `reg.exe query` of known
9
+ * service keys — the same benign mechanism the posture probe already uses.
10
+ * NO PowerShell/WMI (spawning those is exactly the behavior EDRs flag).
11
+ * Results are cached in-process; the probe re-runs at most every 6 hours.
12
+ */
13
+ export interface SecurityAgentsReport {
14
+ /** Human-readable product names detected on this machine. */
15
+ products: string[];
16
+ checkedAt: string;
17
+ }
18
+ /** Windows service name → product label. Service keys are stable across versions. */
19
+ export declare const KNOWN_SECURITY_SERVICES: Record<string, string>;
20
+ /**
21
+ * Pure core — service-existence check is injectable for offline tests.
22
+ * Returns undefined on non-Windows platforms (field omitted from payloads).
23
+ */
24
+ export declare function detectSecurityAgents(serviceExists?: (service: string) => boolean, platform?: NodeJS.Platform): SecurityAgentsReport | undefined;
25
+ /** Cached probe for periodic callers (daemon heartbeat every 5 min must not re-spawn reg.exe each tick). */
26
+ export declare function getSecurityAgentsReport(): SecurityAgentsReport | undefined;
@@ -0,0 +1,65 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.KNOWN_SECURITY_SERVICES = void 0;
4
+ exports.detectSecurityAgents = detectSecurityAgents;
5
+ exports.getSecurityAgentsReport = getSecurityAgentsReport;
6
+ const child_process_1 = require("child_process");
7
+ /** Windows service name → product label. Service keys are stable across versions. */
8
+ exports.KNOWN_SECURITY_SERVICES = {
9
+ WinDefend: 'Microsoft Defender',
10
+ Sense: 'Microsoft Defender for Endpoint',
11
+ CSFalconService: 'CrowdStrike Falcon',
12
+ SentinelAgent: 'SentinelOne',
13
+ CylanceSvc: 'BlackBerry Cylance',
14
+ xagt: 'Trellix (FireEye) Endpoint',
15
+ CbDefense: 'Carbon Black Cloud',
16
+ CarbonBlack: 'Carbon Black EDR',
17
+ mfemms: 'Trellix (McAfee) ePO',
18
+ ekrn: 'ESET Endpoint',
19
+ EPSecurityService: 'Bitdefender Endpoint',
20
+ 'Sophos Endpoint Defense Service': 'Sophos Intercept X',
21
+ klnagent: 'Kaspersky Endpoint',
22
+ AVP: 'Kaspersky Endpoint',
23
+ Amsp: 'Trend Micro',
24
+ ElasticEndpoint: 'Elastic Defend',
25
+ HuntressAgent: 'Huntress',
26
+ cyserver: 'Palo Alto Cortex XDR',
27
+ CiscoAMP: 'Cisco Secure Endpoint',
28
+ WRSVC: 'Webroot',
29
+ };
30
+ function windowsServiceExists(service) {
31
+ const result = (0, child_process_1.spawnSync)('reg', [
32
+ 'query', `HKLM\\SYSTEM\\CurrentControlSet\\Services\\${service}`,
33
+ ], { stdio: 'ignore', windowsHide: true, timeout: 5_000 });
34
+ return result.status === 0;
35
+ }
36
+ /**
37
+ * Pure core — service-existence check is injectable for offline tests.
38
+ * Returns undefined on non-Windows platforms (field omitted from payloads).
39
+ */
40
+ function detectSecurityAgents(serviceExists = windowsServiceExists, platform = process.platform) {
41
+ if (platform !== 'win32')
42
+ return undefined;
43
+ const products = [];
44
+ for (const [service, product] of Object.entries(exports.KNOWN_SECURITY_SERVICES)) {
45
+ try {
46
+ if (serviceExists(service) && !products.includes(product))
47
+ products.push(product);
48
+ }
49
+ catch { /* one bad probe never kills the report */ }
50
+ }
51
+ return { products, checkedAt: new Date().toISOString() };
52
+ }
53
+ const CACHE_TTL_MS = 6 * 60 * 60_000;
54
+ let cached;
55
+ let cachedAt = 0;
56
+ /** Cached probe for periodic callers (daemon heartbeat every 5 min must not re-spawn reg.exe each tick). */
57
+ function getSecurityAgentsReport() {
58
+ if (process.platform !== 'win32')
59
+ return undefined;
60
+ if (!cached || Date.now() - cachedAt > CACHE_TTL_MS) {
61
+ cached = detectSecurityAgents();
62
+ cachedAt = Date.now();
63
+ }
64
+ return cached;
65
+ }
@@ -36,6 +36,20 @@ export interface FlushInput {
36
36
  daemon?: boolean;
37
37
  /** Windows Claude Desktop chat guard liveness (advisory prompt protection). */
38
38
  desktopChatGuard?: boolean;
39
+ /** EDR/AV products detected on this machine (Windows service probe). */
40
+ securityAgents?: string[];
41
+ /** Could powershell.exe/pwsh.exe be spawned at all? (from real DPAPI work, never a dedicated spawn) */
42
+ powershellSpawnOk?: boolean;
43
+ /** Did the DPAPI-protected shield key actually decrypt? */
44
+ powershellDecryptOk?: boolean;
45
+ /** Post-mortem of a previous daemon that died without a clean shutdown. */
46
+ lastCrash?: {
47
+ version?: string;
48
+ startedAt?: string;
49
+ lastAliveAt?: string;
50
+ detectedAt: string;
51
+ detectedBy: 'daemon' | 'watchdog';
52
+ };
39
53
  timeoutMs?: number;
40
54
  }
41
55
  /** Drain the spool to the backend in one batch (+ optional heartbeat). Returns accepted count. */
package/dist/telemetry.js CHANGED
@@ -170,6 +170,13 @@ async function flushSpool(input) {
170
170
  // Logging + Transcription). Keeps the fleet dashboard's coverage
171
171
  // fresh on every ping — undefined (omitted) on other platforms.
172
172
  shellAudit: (0, windowsAudit_1.getShellAuditReport)(),
173
+ // Machine-environment diagnostics: which EDR/AV runs here, can
174
+ // PowerShell spawn, and did a previous daemon die uncleanly —
175
+ // the data that explains a "stale" machine remotely.
176
+ securityAgents: input.securityAgents,
177
+ powershellSpawnOk: input.powershellSpawnOk,
178
+ powershellDecryptOk: input.powershellDecryptOk,
179
+ lastCrash: input.lastCrash,
173
180
  }
174
181
  : undefined,
175
182
  events: batch,
package/dist/version.json CHANGED
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "1.21.28"
2
+ "version": "1.21.29"
3
3
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullcourtdefense-cli",
3
- "version": "1.21.28",
3
+ "version": "1.21.29",
4
4
  "description": "Full Court Defense CLI — security scanning for AI agents from your terminal",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -36,6 +36,7 @@
36
36
  "test:daemon-e2e": "npm run build && node scripts/test-daemon-e2e.js",
37
37
  "test:daemon-discovery-args": "npm run build && node scripts/test-daemon-discovery-args.js",
38
38
  "test:daemon-discover-stderr": "npm run build && node scripts/test-daemon-discover-stderr.js",
39
+ "test:watchdog-forensics": "npm run build && node scripts/test-watchdog-forensics.js",
39
40
  "test:macos-daemon-path": "npm run build && node scripts/test-macos-daemon-path.js",
40
41
  "test:machine-action-signing": "npm run build && node scripts/test-machine-action-signing.js",
41
42
  "test:integrity": "npm run build && node scripts/test-integrity.js",