fullcourtdefense-cli 1.26.4 → 1.26.6

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.
Files changed (45) hide show
  1. package/dist/commands/daemon.d.ts +14 -0
  2. package/dist/commands/daemon.js +181 -28
  3. package/dist/commands/desktopChatGuard.js +5 -4
  4. package/dist/commands/deterministicGuard.d.ts +8 -0
  5. package/dist/commands/deterministicGuard.js +6 -0
  6. package/dist/commands/doctor.d.ts +4 -0
  7. package/dist/commands/doctor.js +99 -0
  8. package/dist/commands/hook.d.ts +1 -0
  9. package/dist/commands/hook.js +61 -104
  10. package/dist/commands/installClaudeHook.d.ts +2 -0
  11. package/dist/commands/installClaudeHook.js +21 -3
  12. package/dist/commands/installCursorHook.d.ts +8 -0
  13. package/dist/commands/installCursorHook.js +29 -4
  14. package/dist/commands/mcpGateway.js +234 -43
  15. package/dist/commands/onboard.d.ts +9 -0
  16. package/dist/commands/onboard.js +7 -0
  17. package/dist/commands/watchdog.d.ts +5 -2
  18. package/dist/commands/watchdog.js +34 -4
  19. package/dist/config.js +6 -1
  20. package/dist/daemonForensics.d.ts +27 -0
  21. package/dist/daemonForensics.js +27 -0
  22. package/dist/distress.d.ts +17 -0
  23. package/dist/distress.js +17 -0
  24. package/dist/hookIo.d.ts +43 -0
  25. package/dist/hookIo.js +189 -0
  26. package/dist/hookSlim.d.ts +32 -0
  27. package/dist/hookSlim.js +142 -0
  28. package/dist/index.js +10 -14
  29. package/dist/integrity.js +8 -0
  30. package/dist/localSafetySnapshot.d.ts +9 -0
  31. package/dist/localSafetySnapshot.js +58 -2
  32. package/dist/machineIdentity.js +70 -4
  33. package/dist/runtimeConfig.d.ts +2 -0
  34. package/dist/runtimeConfig.js +177 -5
  35. package/dist/selfTest.d.ts +10 -0
  36. package/dist/selfTest.js +19 -8
  37. package/dist/selfUpdate.d.ts +19 -4
  38. package/dist/selfUpdate.js +108 -12
  39. package/dist/telemetry.js +67 -2
  40. package/dist/verdictIpc.d.ts +12 -75
  41. package/dist/verdictIpc.js +104 -188
  42. package/dist/verdictIpcClient.d.ts +103 -0
  43. package/dist/verdictIpcClient.js +251 -0
  44. package/dist/version.json +1 -1
  45. package/package.json +6 -1
@@ -8,6 +8,18 @@ export interface DaemonArgs extends ProtectAllArgs {
8
8
  /** Suppress OS toasts (still logs). */
9
9
  quiet?: string;
10
10
  }
11
+ /** Kill a process and wait (up to ~5s) for it to actually exit. */
12
+ /**
13
+ * Uncaught-exception circuit breaker: one bad tick is survivable (log and
14
+ * continue — no supervisor restarts a Windows process instantly), but a BURST
15
+ * means process state is corrupted (broken closure, poisoned cache, leaked
16
+ * handle storm). Limping on undermines enforcement silently; exiting lets the
17
+ * supervisor (watchdog task / systemd / launchd) revive us with clean state.
18
+ */
19
+ export declare const UNCAUGHT_BURST_MAX = 5;
20
+ export declare const UNCAUGHT_BURST_WINDOW_MS: number;
21
+ /** Pure: mutates `timestamps` (drops entries outside the window), returns whether the burst bound is hit. */
22
+ export declare function shouldExitForUncaughtBurst(timestamps: number[], nowMs: number): boolean;
11
23
  /** Arguments shared by first-boot, stale catch-up, and web-triggered scans. */
12
24
  export declare function daemonDiscoverSweepArgs(): string[];
13
25
  export interface DiscoverSweepOutcome {
@@ -97,6 +109,8 @@ export declare function daemonRuntimeState(): DaemonRuntimeState;
97
109
  export declare function spawnDetachedDaemon(): boolean;
98
110
  /** Pid-alive check shared with the watchdog (EPERM still means alive). */
99
111
  export declare function pidIsAlive(pid: number): boolean;
112
+ /** Force-stop a pid (graceful, then taskkill/SIGKILL) — shared with the watchdog's hung-daemon recycle. */
113
+ export declare function forceStopPid(pid: number): boolean;
100
114
  export declare function macosLaunchdPath(currentPath?: string, execPath?: string): string;
101
115
  export declare function xmlEscape(value: string): string;
102
116
  /** Whether the daemon has been registered to start automatically. */
@@ -33,7 +33,8 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.windowsTaskRunsVisibly = exports.windowsTaskRunsInteractive = void 0;
36
+ exports.windowsTaskRunsVisibly = exports.windowsTaskRunsInteractive = exports.UNCAUGHT_BURST_WINDOW_MS = exports.UNCAUGHT_BURST_MAX = void 0;
37
+ exports.shouldExitForUncaughtBurst = shouldExitForUncaughtBurst;
37
38
  exports.daemonDiscoverSweepArgs = daemonDiscoverSweepArgs;
38
39
  exports.summarizeDiscoverStderr = summarizeDiscoverStderr;
39
40
  exports.discoverSweepCredentialEnv = discoverSweepCredentialEnv;
@@ -48,6 +49,7 @@ exports.windowsTaskReferencesScriptHost = windowsTaskReferencesScriptHost;
48
49
  exports.daemonRuntimeState = daemonRuntimeState;
49
50
  exports.spawnDetachedDaemon = spawnDetachedDaemon;
50
51
  exports.pidIsAlive = pidIsAlive;
52
+ exports.forceStopPid = forceStopPid;
51
53
  exports.macosLaunchdPath = macosLaunchdPath;
52
54
  exports.xmlEscape = xmlEscape;
53
55
  exports.isWatchdogTaskInstalled = isWatchdogTaskInstalled;
@@ -62,6 +64,7 @@ const distress_1 = require("../distress");
62
64
  const pollLoop_1 = require("../pollLoop");
63
65
  const selfTest_1 = require("../selfTest");
64
66
  const daemonForensics_1 = require("../daemonForensics");
67
+ const verdictIpcClient_1 = require("../verdictIpcClient");
65
68
  const securityAgents_1 = require("../securityAgents");
66
69
  const mcpGateway_1 = require("./mcpGateway");
67
70
  const protectionRepair_1 = require("./protectionRepair");
@@ -161,6 +164,22 @@ function writeDaemonMeta() {
161
164
  catch { /* best-effort */ }
162
165
  }
163
166
  /** Kill a process and wait (up to ~5s) for it to actually exit. */
167
+ /**
168
+ * Uncaught-exception circuit breaker: one bad tick is survivable (log and
169
+ * continue — no supervisor restarts a Windows process instantly), but a BURST
170
+ * means process state is corrupted (broken closure, poisoned cache, leaked
171
+ * handle storm). Limping on undermines enforcement silently; exiting lets the
172
+ * supervisor (watchdog task / systemd / launchd) revive us with clean state.
173
+ */
174
+ exports.UNCAUGHT_BURST_MAX = 5;
175
+ exports.UNCAUGHT_BURST_WINDOW_MS = 10 * 60_000;
176
+ /** Pure: mutates `timestamps` (drops entries outside the window), returns whether the burst bound is hit. */
177
+ function shouldExitForUncaughtBurst(timestamps, nowMs) {
178
+ const cutoff = nowMs - exports.UNCAUGHT_BURST_WINDOW_MS;
179
+ while (timestamps.length > 0 && timestamps[0] < cutoff)
180
+ timestamps.shift();
181
+ return timestamps.length >= exports.UNCAUGHT_BURST_MAX;
182
+ }
164
183
  function stopPid(pid) {
165
184
  try {
166
185
  process.kill(pid);
@@ -319,17 +338,52 @@ function compareVersions(a, b) {
319
338
  }
320
339
  return 0;
321
340
  }
341
+ /**
342
+ * Detect the "recycled pid" trap at startup: the pid file points at a LIVE
343
+ * process, but that process is not our daemon — the daemon died uncleanly and
344
+ * the OS reused its pid. Without this check acquirePidLock would yield to the
345
+ * impostor forever and the machine would never run a daemon again. Evidence
346
+ * required before we call it recycled: the alive-marker is stale past the
347
+ * hung bound AND no verdict endpoint exists (the kernel frees a named pipe
348
+ * when its owner dies — a wedged-but-real daemon still holds the endpoint).
349
+ */
350
+ async function detectRecycledPidOwner() {
351
+ try {
352
+ const pid = Number(fs.readFileSync(pidFile(), 'utf8').trim());
353
+ if (!Number.isFinite(pid) || pid <= 0 || pid === process.pid || !isPidAlive(pid))
354
+ return undefined;
355
+ const health = (0, daemonForensics_1.classifyDaemonHealth)({
356
+ pidAlive: true,
357
+ pid,
358
+ marker: (0, daemonForensics_1.readAliveMarker)(),
359
+ staleAfterMs: (0, daemonForensics_1.daemonHungAfterMs)(),
360
+ pipeProbe: await (0, verdictIpcClient_1.probeVerdictServer)(1_000),
361
+ });
362
+ return health === 'pid_reused' ? pid : undefined;
363
+ }
364
+ catch {
365
+ return undefined;
366
+ }
367
+ }
322
368
  /**
323
369
  * Take the single-instance lock. A NEWER build supersedes a running older
324
370
  * daemon (e.g. the MSI-bundled copy kept running after an npm update): the old
325
371
  * process is stopped and this one takes over, so the fleet never keeps running
326
372
  * stale enforcement code silently. Same-or-newer versions keep the lock.
373
+ * `recycledPid` (from detectRecycledPidOwner) marks a pid-file owner that is
374
+ * NOT our daemon — take over without touching that unrelated process.
327
375
  */
328
- function acquirePidLock() {
376
+ function acquirePidLock(recycledPid) {
329
377
  fs.mkdirSync(daemonDir(), { recursive: true });
330
378
  try {
331
379
  const existing = Number(fs.readFileSync(pidFile(), 'utf8').trim());
332
380
  if (Number.isFinite(existing) && existing > 0 && existing !== process.pid && isPidAlive(existing)) {
381
+ if (existing === recycledPid) {
382
+ log(`Pid file points at live pid ${existing}, but it is NOT our daemon (alive-marker stale, no verdict endpoint) — the OS recycled the pid. Taking over the lock; that process is left alone.`);
383
+ fs.writeFileSync(pidFile(), String(process.pid), 'utf8');
384
+ writeDaemonMeta();
385
+ return true;
386
+ }
333
387
  const meta = readDaemonMeta();
334
388
  const runningVersion = meta && meta.pid === existing ? meta.version : undefined;
335
389
  // No meta = pre-1.15.4 build (never wrote one) → treated as older.
@@ -377,19 +431,36 @@ function hookConfigFiles() {
377
431
  // The resident loop
378
432
  // ---------------------------------------------------------------------------
379
433
  async function runDaemon(args, config) {
380
- if (!acquirePidLock()) {
434
+ if (!acquirePidLock(await detectRecycledPidOwner())) {
381
435
  console.log(`${COLOR.yellow}Another FullCourtDefense daemon is already running (pid file: ${pidFile()}).${COLOR.reset}`);
382
436
  return;
383
437
  }
384
438
  // The resident loop must never die silently from one bad tick (fs watcher
385
439
  // callback, fetch, timer). Log and keep running — the loop is timer-driven,
386
440
  // so surviving a failed tick is safe, and on Windows there is no supervisor
387
- // that would restart a crashed process instantly.
441
+ // that would restart a crashed process instantly. BUT a burst of uncaught
442
+ // exceptions means process state is corrupted: exit so the supervisor
443
+ // (watchdog task / systemd / launchd) revives us with a clean slate instead
444
+ // of limping on with broken enforcement.
445
+ const uncaughtAt = [];
388
446
  process.on('uncaughtException', error => {
389
447
  log(`Uncaught exception (daemon continues): ${error?.stack || String(error)}`);
390
448
  // Ledger the unknown: failures nobody predicted still become structured,
391
449
  // fleet-visible telemetry (code unexpected_error) on the next heartbeat.
392
450
  (0, distress_1.reportUnexpected)('daemon', error);
451
+ uncaughtAt.push(Date.now());
452
+ if (shouldExitForUncaughtBurst(uncaughtAt, Date.now())) {
453
+ log(`${exports.UNCAUGHT_BURST_MAX} uncaught exceptions within ${Math.round(exports.UNCAUGHT_BURST_WINDOW_MS / 60_000)} min — process state is suspect. Exiting so the supervisor restarts the daemon clean.`);
454
+ // The distress ledger survives on disk; the fresh daemon's first
455
+ // heartbeat ships it, so the fleet sees WHY this daemon recycled.
456
+ (0, distress_1.reportDistress)('daemon', distress_1.DISTRESS.UNCAUGHT_BURST, `${exports.UNCAUGHT_BURST_MAX} uncaught exceptions in ${Math.round(exports.UNCAUGHT_BURST_WINDOW_MS / 60_000)}min — self-recycled`);
457
+ // Intentional exit: without the clean-exit stamp the next boot would
458
+ // misreport this as an external kill (EDR/AV) — the distress code above
459
+ // already carries the true story.
460
+ (0, daemonForensics_1.recordCleanExit)('uncaught-exception burst — self-recycled');
461
+ releasePidLock();
462
+ process.exit(1);
463
+ }
393
464
  });
394
465
  process.on('unhandledRejection', reason => {
395
466
  log(`Unhandled rejection (daemon continues): ${reason?.stack || String(reason)}`);
@@ -437,6 +508,35 @@ async function runDaemon(args, config) {
437
508
  let suspended = false;
438
509
  let stopped = false;
439
510
  const executingActionIds = new Set();
511
+ // ── Replay guard: persisted ledger of action ids this machine has already
512
+ // executed. `executingActionIds` only dedupes within ONE daemon lifetime — a
513
+ // signed action is valid for its whole TTL, so a daemon restart (or a
514
+ // replayed/cached bundle) could re-run the same action: harmless for a
515
+ // health check, not for upgrade_cli or repair_protection. The ledger is
516
+ // capped and best-effort (a lost ledger degrades to today's behavior).
517
+ const executedActionsFile = () => path.join(daemonDir(), 'executed-actions.json');
518
+ const EXECUTED_ACTIONS_CAP = 100;
519
+ const readExecutedActionIds = () => {
520
+ try {
521
+ const parsed = JSON.parse(fs.readFileSync(executedActionsFile(), 'utf8'));
522
+ return Array.isArray(parsed) ? parsed.filter((id) => typeof id === 'string').slice(-EXECUTED_ACTIONS_CAP) : [];
523
+ }
524
+ catch {
525
+ return [];
526
+ }
527
+ };
528
+ const recordExecutedActionId = (actionId) => {
529
+ try {
530
+ const ids = readExecutedActionIds().filter(id => id !== actionId);
531
+ ids.push(actionId);
532
+ const file = executedActionsFile();
533
+ fs.mkdirSync(path.dirname(file), { recursive: true });
534
+ const tmp = `${file}.tmp-${process.pid}`;
535
+ fs.writeFileSync(tmp, JSON.stringify(ids.slice(-EXECUTED_ACTIONS_CAP)), 'utf8');
536
+ fs.renameSync(tmp, file);
537
+ }
538
+ catch { /* best-effort — worst case an action re-runs once, as before */ }
539
+ };
440
540
  /** Latest org auto-update policy seen on a bundle poll. */
441
541
  let autoUpdatePolicy;
442
542
  /** Heartbeat ticks spent without full credentials (drives recovery cadence). */
@@ -701,7 +801,17 @@ async function runDaemon(args, config) {
701
801
  watchdogTaskInstalled: isWatchdogTaskInstalled(),
702
802
  };
703
803
  };
704
- const reportMachineAction = async (actionId, status, detail) => {
804
+ const reportMachineAction = async (actionId, status, detail,
805
+ /**
806
+ * Proof-of-possession for the KEY-LESS reporting channel: the Ed25519
807
+ * signature the control plane attached to this action. A credential-broken
808
+ * machine has no shield key, but only the machine that RECEIVED the signed
809
+ * action holds its signature — the backend recomputes and compares it, so
810
+ * nobody can spoof results onto a real action with just (shieldId,
811
+ * machineId, actionId). Harmless on authenticated reports (backend ignores
812
+ * it when a valid shield key is present).
813
+ */
814
+ signatureProof) => {
705
815
  if (!creds.shieldId)
706
816
  return;
707
817
  try {
@@ -716,6 +826,7 @@ async function runDaemon(args, config) {
716
826
  shieldId: creds.shieldId,
717
827
  machineId: identity.machineId,
718
828
  status,
829
+ ...(signatureProof ? { signatureProof } : {}),
719
830
  ...detail,
720
831
  }),
721
832
  signal: AbortSignal.timeout(8_000),
@@ -732,13 +843,17 @@ async function runDaemon(args, config) {
732
843
  // dashboard should never show "succeeded" for a version that never arrived.
733
844
  const pendingUpgradeFile = () => path.join(daemonDir(), 'pending-upgrade.json');
734
845
  const PENDING_UPGRADE_STALE_MS = 15 * 60_000;
735
- const writePendingUpgradeMarker = (actionId, target) => {
846
+ const writePendingUpgradeMarker = (actionId, target, signatureProof) => {
736
847
  try {
737
848
  fs.writeFileSync(pendingUpgradeFile(), JSON.stringify({
738
849
  actionId,
739
850
  target,
740
851
  fromVersion: cliVersion(),
741
852
  startedAt: new Date().toISOString(),
853
+ // Persisted so the freshly upgraded daemon can report the outcome even
854
+ // on a key-less machine (proof-of-possession channel). Optional field:
855
+ // markers written by older daemons simply omit it.
856
+ ...(signatureProof ? { signatureProof } : {}),
742
857
  }), 'utf8');
743
858
  }
744
859
  catch { /* marker is best-effort; the server TTL expires the action safely */ }
@@ -766,7 +881,7 @@ async function runDaemon(args, config) {
766
881
  catch { /* ignore */ }
767
882
  await reportMachineAction(marker.actionId, 'succeeded', {
768
883
  resultSummary: `CLI updated to ${current} and the daemon restarted on the new build.`,
769
- });
884
+ }, marker.signatureProof);
770
885
  log(`Upgrade verified: CLI ${marker.fromVersion || 'unknown'} -> ${current} is live (action ${marker.actionId}).`);
771
886
  await uploadLogTail();
772
887
  return;
@@ -779,7 +894,7 @@ async function runDaemon(args, config) {
779
894
  catch { /* ignore */ }
780
895
  await reportMachineAction(marker.actionId, 'failed', {
781
896
  error: `The updater was triggered but the CLI still reports ${current || 'unknown'} after ${Math.round(PENDING_UPGRADE_STALE_MS / 60_000)} minutes (target ${marker.target}). Check %ProgramData%\\FullCourtDefense\\updater.log on the machine.`,
782
- });
897
+ }, marker.signatureProof);
783
898
  log(`Upgrade verification failed: still on ${current || 'unknown'}, target was ${marker.target} (action ${marker.actionId}).`);
784
899
  await uploadLogTail();
785
900
  }
@@ -789,6 +904,13 @@ async function runDaemon(args, config) {
789
904
  const executeMachineAction = async (action) => {
790
905
  if (executingActionIds.has(action.id))
791
906
  return;
907
+ // Replay guard: an action this machine ALREADY completed must not run
908
+ // again while its signature/TTL is still valid (restarted daemon, cached
909
+ // or replayed bundle). The server was already told the outcome.
910
+ if (readExecutedActionIds().includes(action.id)) {
911
+ log(`Remote action ${action.type} (${action.id}) already executed on this machine — replay ignored.`);
912
+ return;
913
+ }
792
914
  executingActionIds.add(action.id);
793
915
  // Cryptographic gate: never execute an action the control plane didn't
794
916
  // sign — a Firestore/backend compromise must not become fleet-wide RCE.
@@ -801,14 +923,14 @@ async function runDaemon(args, config) {
801
923
  const verdict = (0, machineActionVerify_1.verifyMachineAction)(action, { localMachineId: localIdentity.machineId });
802
924
  if (!verdict.ok) {
803
925
  log(`Remote action REJECTED: ${action.type} (${action.id}) — ${verdict.reason}`);
804
- await reportMachineAction(action.id, 'failed', { error: `Action verification failed: ${verdict.reason}` });
926
+ await reportMachineAction(action.id, 'failed', { error: `Action verification failed: ${verdict.reason}` }, action.signature?.signature);
805
927
  executingActionIds.delete(action.id);
806
928
  await uploadLogTail();
807
929
  return;
808
930
  }
809
931
  if (verdict.reason)
810
932
  log(`Remote action verification notice: ${verdict.reason}`);
811
- await reportMachineAction(action.id, 'running');
933
+ await reportMachineAction(action.id, 'running', undefined, action.signature?.signature);
812
934
  log(`Remote action started: ${action.type} (${action.id}).`);
813
935
  await uploadLogTail();
814
936
  try {
@@ -850,13 +972,13 @@ async function runDaemon(args, config) {
850
972
  throw new Error('Shield not configured on this machine.');
851
973
  const shieldId = creds.shieldId;
852
974
  const identity = (0, machineIdentity_1.getMachineIdentity)();
853
- log('Policy refresh: clearing Local Safety snapshot cache…');
854
- (0, localSafetySnapshot_1.clearLocalSafetySnapshotCache)({
855
- apiUrl: creds.apiUrl,
856
- shieldId,
857
- developerName: identity.developerName,
858
- machineName: identity.hostname,
859
- });
975
+ log('Policy refresh: clearing Local Safety snapshot caches…');
976
+ // Clear EVERY snapshot cache file, not one derived key: the hooks key
977
+ // their cache by hookSnapshotIdentity() (raw username@hostname) while
978
+ // getMachineIdentity() normalizes both — a targeted unlink under the
979
+ // normalized key used to MISS the hooks' actual cache file, so
980
+ // "policy refresh" never actually refreshed the hot-path snapshot.
981
+ (0, localSafetySnapshot_1.clearAllLocalSafetySnapshotCaches)();
860
982
  await uploadLogTail();
861
983
  log('Policy refresh: pulling latest policy bundle from control plane…');
862
984
  const bundle = await (0, runtimeConfig_1.getRuntimeBundle)({
@@ -900,7 +1022,7 @@ async function runDaemon(args, config) {
900
1022
  // force: bypass the auto-update retry cooldown — an explicit admin
901
1023
  // action must actually attempt and report the real outcome, not
902
1024
  // "in progress" while a broken path silently retries hourly.
903
- const outcome = (0, selfUpdate_1.maybeSelfUpdate)({ currentVersion: cliVersion(), targetVersion: target, enabled: true, force: true, log });
1025
+ const outcome = (0, selfUpdate_1.maybeSelfUpdate)({ currentVersion: cliVersion(), targetVersion: target, enabled: true, force: true, log, apiUrl: creds.apiUrl });
904
1026
  if (!outcome) {
905
1027
  resultSummary = `CLI ${cliVersion() || 'unknown'} is already at ${target}.`;
906
1028
  }
@@ -908,7 +1030,12 @@ async function runDaemon(args, config) {
908
1030
  // The install replaces this process — DON'T report success yet.
909
1031
  // Leave the action "running" with a marker; the freshly upgraded
910
1032
  // daemon confirms the new version (or the check reports failure).
911
- writePendingUpgradeMarker(action.id, target);
1033
+ writePendingUpgradeMarker(action.id, target, action.signature?.signature);
1034
+ // Recorded NOW (not on completion): the installer replaces this
1035
+ // process, and the freshly upgraded daemon may still see this action
1036
+ // in a bundle — it must verify the pending marker, never re-trigger
1037
+ // the installer.
1038
+ recordExecutedActionId(action.id);
912
1039
  log(`Upgrade CLI: installer started — success will be confirmed once the new daemon is on ${target}.`);
913
1040
  executingActionIds.delete(action.id);
914
1041
  await uploadLogTail();
@@ -962,12 +1089,17 @@ async function runDaemon(args, config) {
962
1089
  resultSummary = (0, perfSnapshot_1.summarizePerfSnapshot)(snapshot);
963
1090
  log(`Perf snapshot: ${resultSummary}`);
964
1091
  }
965
- await reportMachineAction(action.id, 'succeeded', { resultSummary });
1092
+ recordExecutedActionId(action.id);
1093
+ await reportMachineAction(action.id, 'succeeded', { resultSummary }, action.signature?.signature);
966
1094
  log(`Remote action succeeded: ${action.type}.`);
967
1095
  }
968
1096
  catch (error) {
969
1097
  const message = error.message || 'Remote action failed';
970
- await reportMachineAction(action.id, 'failed', { error: message });
1098
+ // Terminal failure is recorded too: the action reached the server as
1099
+ // 'failed' — retrying the SAME signed action id would just repeat the
1100
+ // failure while looking like fresh progress in the console.
1101
+ recordExecutedActionId(action.id);
1102
+ await reportMachineAction(action.id, 'failed', { error: message }, action.signature?.signature);
971
1103
  log(`Remote action failed: ${action.type}: ${message}`);
972
1104
  }
973
1105
  finally {
@@ -1051,9 +1183,22 @@ async function runDaemon(args, config) {
1051
1183
  autoUpdatePolicy = bundle.autoUpdate;
1052
1184
  // Mirror the org policy for the elevated MSI updater task (it runs as
1053
1185
  // SYSTEM and cannot read the shield-key-authenticated bundle itself).
1054
- if (bundle.autoUpdate) {
1186
+ // Only refresh the mirror when the control plane actually confirmed the
1187
+ // bundle (200 or 304) within the last few minutes. The updater treats a
1188
+ // FRESH mirror without a targetVersion as an authoritative staged-
1189
+ // rollout hold — rewriting it from a network-down cache fallback would
1190
+ // keep a stale hold looking fresh forever on a half-connected machine.
1191
+ const bundleServerConfirmed = typeof bundle.serverValidatedAt === 'number'
1192
+ && Date.now() - bundle.serverValidatedAt < 10 * 60_000;
1193
+ if (bundle.autoUpdate && bundleServerConfirmed) {
1055
1194
  try {
1056
- fs.writeFileSync(path.join(daemonDir(), 'update-policy.json'), JSON.stringify({ ...bundle.autoUpdate, updatedAt: new Date().toISOString() }), 'utf8');
1195
+ // Atomic write-temp-rename: the SYSTEM updater task reads this file
1196
+ // and treats its freshness as proof of a live daemon relaying the
1197
+ // staged-rollout decision — a torn read must never be possible.
1198
+ const mirrorPath = path.join(daemonDir(), 'update-policy.json');
1199
+ const tmpPath = `${mirrorPath}.tmp-${process.pid}`;
1200
+ fs.writeFileSync(tmpPath, JSON.stringify({ ...bundle.autoUpdate, updatedAt: new Date().toISOString() }), 'utf8');
1201
+ fs.renameSync(tmpPath, mirrorPath);
1057
1202
  }
1058
1203
  catch { /* mirror is best-effort; the updater defaults to enabled */ }
1059
1204
  }
@@ -1064,6 +1209,7 @@ async function runDaemon(args, config) {
1064
1209
  enabled: true,
1065
1210
  busy: executingActionIds.size > 0,
1066
1211
  log,
1212
+ apiUrl: creds.apiUrl,
1067
1213
  });
1068
1214
  }
1069
1215
  // Honeypot decoys: org-controlled via fleet settings. Planting is
@@ -1166,7 +1312,7 @@ async function runDaemon(args, config) {
1166
1312
  ensureWindowsAutostartHealthy(log);
1167
1313
  // Converge pre-Node-updater machines onto the PowerShell-free updater task
1168
1314
  // (best-effort; needs an elevated daemon to rewrite a SYSTEM task).
1169
- (0, selfUpdate_1.modernizeUpdaterTask)(log);
1315
+ (0, selfUpdate_1.modernizeUpdaterTask)(log, creds.apiUrl);
1170
1316
  // Heal a stale/broken cmd-guard AutoRun (bat deleted, or macros pointing at
1171
1317
  // a dead node.exe) so wrapped commands (docker, git, …) never stay broken.
1172
1318
  if ((0, cmdGuard_1.repairStaleCmdAutorun)()) {
@@ -1873,6 +2019,10 @@ function spawnDetachedDaemon() {
1873
2019
  function pidIsAlive(pid) {
1874
2020
  return isPidAlive(pid);
1875
2021
  }
2022
+ /** Force-stop a pid (graceful, then taskkill/SIGKILL) — shared with the watchdog's hung-daemon recycle. */
2023
+ function forceStopPid(pid) {
2024
+ return stopPid(pid);
2025
+ }
1876
2026
  function uninstallWindows() {
1877
2027
  const task = (0, child_process_1.spawnSync)('schtasks', ['/Delete', '/TN', TASK_NAME, '/F'], { stdio: 'ignore', windowsHide: true });
1878
2028
  (0, child_process_1.spawnSync)('schtasks', ['/Delete', '/TN', WATCHDOG_TASK_NAME, '/F'], { stdio: 'ignore', windowsHide: true });
@@ -1983,10 +2133,13 @@ Description=FullCourtDefense resident daemon (config watch + heartbeat)
1983
2133
  [Service]
1984
2134
  ExecStart=${JSON.stringify(process.execPath)} ${JSON.stringify(cliEntry())} daemon
1985
2135
  Restart=always
1986
- RestartSec=30
1987
- # Crash-loop circuit breaker: a daemon that dies 8 times in 10 minutes stops
1988
- # being restarted (no infinite 10s spawn storm eating CPU on a broken install).
1989
- StartLimitIntervalSec=600
2136
+ RestartSec=45
2137
+ # Crash-loop damping WITHOUT a permanent stop: 8 restarts x 45s = 360s, which
2138
+ # exceeds the 300s window, so the start limit is mathematically unreachable
2139
+ # systemd keeps restarting forever at a bounded ~45s cadence. A permanent stop
2140
+ # would strand the machine: on the systemd path there is no watchdog cron to
2141
+ # revive a daemon that systemd has given up on.
2142
+ StartLimitIntervalSec=300
1990
2143
  StartLimitBurst=8
1991
2144
 
1992
2145
  [Install]
@@ -50,7 +50,6 @@ const deterministicGuard_1 = require("./deterministicGuard");
50
50
  const clipboardScan_1 = require("./clipboardScan");
51
51
  const telemetry_1 = require("../telemetry");
52
52
  const notify_1 = require("../notify");
53
- const machineIdentity_1 = require("../machineIdentity");
54
53
  const discoverPaths_1 = require("./discoverPaths");
55
54
  /**
56
55
  * Claude Desktop chat guard (Windows, advisory).
@@ -448,17 +447,19 @@ function startDesktopChatGuard(runtime) {
448
447
  let findings = 0;
449
448
  const lastToastAt = new Map();
450
449
  let scanOptions = (0, localSafetySnapshot_1.snapshotToScanOptions)(undefined);
451
- const identity = (0, machineIdentity_1.getMachineIdentity)();
452
450
  const refreshSnapshot = async () => {
453
451
  if (!runtime.shieldId)
454
452
  return;
455
453
  try {
454
+ // Shared hook identity → same cache file the daemon warms (the
455
+ // normalized getMachineIdentity() names hash to a different, cold key).
456
+ const snapshotIdentity = (0, localSafetySnapshot_1.hookSnapshotIdentity)();
456
457
  const snapshot = await (0, localSafetySnapshot_1.loadLocalSafetySnapshot)({
457
458
  apiUrl: runtime.apiUrl,
458
459
  shieldId: runtime.shieldId,
459
460
  shieldKey: runtime.shieldKey,
460
- developerName: identity.developerName,
461
- machineName: identity.hostname,
461
+ developerName: snapshotIdentity.developerName,
462
+ machineName: snapshotIdentity.machineName,
462
463
  });
463
464
  scanOptions = (0, localSafetySnapshot_1.snapshotToScanOptions)(snapshot);
464
465
  }
@@ -41,6 +41,14 @@ export interface LocalSafetyScanOptions {
41
41
  disabledBuiltInItemIds?: string[];
42
42
  /** Built-in items with a NON-default action (absent item = 'block'). */
43
43
  itemActions?: Record<string, LocalSafetyRuleAction>;
44
+ /**
45
+ * Whether an actual Local Safety snapshot was resolved for this scan. When
46
+ * explicitly `false` (cold cache / wiped file / fetch failed with nothing on
47
+ * disk) the guard must NOT hard-block on the catalog default — it mirrors the
48
+ * server's warn-first gate for unarmed machines. `undefined` preserves the
49
+ * legacy "absent item = block" contract for callers that don't set it.
50
+ */
51
+ snapshotPresent?: boolean;
44
52
  customBlocks?: LocalSafetyCustomBlock[];
45
53
  policyHash?: string;
46
54
  cwd?: string;
@@ -424,6 +424,12 @@ function actionFor(itemId, options) {
424
424
  const action = options?.itemActions?.[itemId];
425
425
  if (action === 'warn' || action === 'mask' || action === 'block')
426
426
  return action;
427
+ // No snapshot resolved at all: never hard-block on the catalog default —
428
+ // an unarmed / offline machine mirrors the server's warn-first gate rather
429
+ // than over-blocking (the class of failure Romania hit). A present snapshot
430
+ // keeps the strict "absent item = block" contract below.
431
+ if (options?.snapshotPresent === false)
432
+ return 'warn';
427
433
  return DEFAULT_WARN_BUILT_INS.has(itemId) ? 'warn' : 'block';
428
434
  }
429
435
  function builtIn(itemId, categoryId, category, ruleId, reason, findingEvidence, explanation, options) {
@@ -3,5 +3,9 @@ export interface DoctorArgs {
3
3
  apiUrl?: string;
4
4
  /** `doctor --perf` — measure this machine's real per-event overhead. */
5
5
  perf?: string;
6
+ /** `doctor --health` — mdatp-health-style per-subsystem status table. */
7
+ health?: string;
8
+ /** `doctor --edr` — print the narrowest EDR/AV allowlist guidance for IT. */
9
+ edr?: string;
6
10
  }
7
11
  export declare function doctorCommand(args: DoctorArgs, config: BotGuardConfig): Promise<void>;
@@ -35,6 +35,7 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.doctorCommand = doctorCommand;
37
37
  const os = __importStar(require("os"));
38
+ const path = __importStar(require("path"));
38
39
  const config_1 = require("../config");
39
40
  const runtimeConfig_1 = require("../runtimeConfig");
40
41
  const perfSnapshot_1 = require("../perfSnapshot");
@@ -129,12 +130,110 @@ async function perfCheck(apiUrl, config) {
129
130
  console.log(`WARN per-event overhead above budget (${hp.hookMedianMs}ms > ${budgetMs}ms) — if the node spawn floor is also high, AV/EDR is scanning every process spawn; ask IT about an exclusion for the FullCourtDefense install folder.`);
130
131
  }
131
132
  }
133
+ /**
134
+ * `doctor --health` — mdatp-health-style status: one line per subsystem with
135
+ * PASS/FAIL and detail. Runs the SAME deep self-test the fleet console
136
+ * triggers remotely (`deep_selftest` machine action), so a customer reading
137
+ * this table and an admin reading the console see identical facts.
138
+ */
139
+ async function healthCheck(apiUrl, config) {
140
+ const { cliVersion, isWatchdogTaskInstalled } = await Promise.resolve().then(() => __importStar(require('./daemon')));
141
+ const { runDeepSelfTest, summarizeSelfTest } = await Promise.resolve().then(() => __importStar(require('../selfTest')));
142
+ const { getMachineIdentity } = await Promise.resolve().then(() => __importStar(require('../machineIdentity')));
143
+ const version = cliVersion();
144
+ console.log('');
145
+ console.log(`FullCourtDefense health${version ? ` v${version}` : ''} — ${os.hostname()} (${os.platform()} ${os.release()})`);
146
+ const creds = (0, config_1.resolveCliCredentials)(config, { apiUrl });
147
+ if (creds.shieldId) {
148
+ try {
149
+ const bundle = await (0, runtimeConfig_1.getRuntimeBundle)({
150
+ apiUrl, shieldId: creds.shieldId, shieldKey: creds.shieldKey,
151
+ developerName: `${os.userInfo().username}@${os.hostname()}`, machineName: os.hostname(),
152
+ hotPath: true,
153
+ });
154
+ console.log(`enrolled: yes (shield ${creds.shieldId}) | mode: ${bundle.mode} (source: ${bundle.source}${bundle.policyHash ? `, policy ${bundle.policyHash.slice(0, 12)}` : ''})`);
155
+ }
156
+ catch {
157
+ console.log(`enrolled: yes (shield ${creds.shieldId}) | mode: cached (policy fetch unavailable right now)`);
158
+ }
159
+ }
160
+ else {
161
+ console.log('enrolled: no (local defaults, monitor-first)');
162
+ }
163
+ console.log('');
164
+ const identity = getMachineIdentity();
165
+ const report = await runDeepSelfTest({
166
+ creds,
167
+ developerName: identity.developerName,
168
+ machineName: identity.hostname,
169
+ machineId: identity.machineId,
170
+ isWatchdogTaskInstalled,
171
+ });
172
+ for (const check of report.checks) {
173
+ const status = check.ok ? 'PASS' : (check.optional ? 'WARN' : 'FAIL');
174
+ console.log(`${status} ${check.label}${check.detail ? ` — ${check.detail}` : ''}`);
175
+ }
176
+ console.log('');
177
+ console.log(summarizeSelfTest(report));
178
+ if (!report.ok) {
179
+ console.log('For AV/EDR tuning guidance run: fullcourtdefense doctor --edr true');
180
+ process.exit(1);
181
+ }
182
+ }
183
+ /**
184
+ * `doctor --edr` — the narrowest AV/EDR allowlist guidance, printable and
185
+ * pasteable into an IT ticket. Mirrors docs/av-edr-coexistence.md; most
186
+ * fleets need none of it.
187
+ */
188
+ function edrGuidance(apiUrl) {
189
+ const installRoot = path.resolve(__dirname, '..', '..');
190
+ const nodeExe = path.join(installRoot, 'runtime', 'node.exe');
191
+ const hostname = new URL(normalizeApiUrl(apiUrl)).hostname;
192
+ console.log('');
193
+ console.log('FullCourtDefense — AV/EDR co-existence guidance (for your IT/security team)');
194
+ console.log('');
195
+ console.log('Most fleets need NO exclusions. Add them only if `doctor --perf true` shows');
196
+ console.log('hook latency dominated by process start (EDR scanning every node spawn).');
197
+ console.log('');
198
+ console.log('1. Process exclusion (preferred, narrowest):');
199
+ console.log(` ${nodeExe}`);
200
+ console.log(' Signed binary in an admin-write-only path. Removes the per-spawn scan');
201
+ console.log(' cost, which is the entire hot-path problem.');
202
+ console.log('2. File exclusions for hot state files (high write frequency, never executed):');
203
+ console.log(' %USERPROFILE%\\.fullcourtdefense-spool.jsonl');
204
+ console.log(' %USERPROFILE%\\.fullcourtdefense-hook.log');
205
+ console.log(' %USERPROFILE%\\.fullcourtdefense-runtime.json');
206
+ console.log('3. Folder exclusion (only if 1-2 are unavailable):');
207
+ console.log(` ${installRoot}\\`);
208
+ console.log(' Do NOT exclude %USERPROFILE%\\.fullcourtdefense* wholesale and do NOT');
209
+ console.log(' exclude %ProgramData%\\FullCourtDefense — they are user-writable; keep them scanned.');
210
+ console.log('');
211
+ console.log('Network allowlist (proxy / TLS inspection):');
212
+ console.log(` Allow outbound HTTPS (TCP 443) to: ${hostname}`);
213
+ console.log(' Everything (policy bundle, verdicts, telemetry, update manifest) uses that one hostname.');
214
+ console.log('');
215
+ console.log('AppLocker / WDAC:');
216
+ console.log(' The MSI and installer scripts are Authenticode-signed — prefer a PUBLISHER');
217
+ console.log(' rule over path rules. The bundled runtime\\node.exe carries the official');
218
+ console.log(' Node.js signature. Install/update custom actions are PowerShell-free, so');
219
+ console.log(' PowerShell-blocking policies do not affect install, update, or enforcement.');
220
+ console.log('');
221
+ console.log('Full guide: sdks/cli/docs/av-edr-coexistence.md (in the product repo).');
222
+ }
132
223
  async function doctorCommand(args, config) {
133
224
  const apiUrl = normalizeApiUrl(args.apiUrl || config.apiUrl);
134
225
  if (args.perf === 'true') {
135
226
  await perfCheck(apiUrl, config);
136
227
  return;
137
228
  }
229
+ if (args.health === 'true') {
230
+ await healthCheck(apiUrl, config);
231
+ return;
232
+ }
233
+ if (args.edr === 'true') {
234
+ edrGuidance(apiUrl);
235
+ return;
236
+ }
138
237
  const pingUrl = `${apiUrl}/api/health/ping`;
139
238
  const rootUrl = `${apiUrl}/`;
140
239
  console.log('');
@@ -26,6 +26,7 @@ export interface HookArgs {
26
26
  shieldKey?: string;
27
27
  shadow?: string;
28
28
  enforce?: string;
29
+ fcdManaged?: string;
29
30
  failClosed?: string;
30
31
  localOnly?: string;
31
32
  timeout?: string;