fullcourtdefense-cli 1.26.13 → 1.26.15

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.
@@ -109,16 +109,50 @@ const DEBOUNCE_MS = envMs('FCD_DAEMON_DEBOUNCE_MS', 2_000);
109
109
  const RESCAN_INTERVAL_MS = envMs('FCD_DAEMON_RESCAN_MS', 5 * 60_000);
110
110
  /** Heartbeat / spool flush cadence. */
111
111
  const HEARTBEAT_INTERVAL_MS = envMs('FCD_DAEMON_HEARTBEAT_MS', 5 * 60_000);
112
+ /**
113
+ * Floor between integrity-triggered repairs. The drift watcher reacts to config
114
+ * CHANGES; this path reacts to a failing verdict, which persists until fixed. If
115
+ * a repair cannot fix it (no write permission, a config owned by another
116
+ * install) an uncooled retry would re-run protect-all every heartbeat forever.
117
+ */
118
+ const INTEGRITY_HEAL_COOLDOWN_MS = envMs('FCD_INTEGRITY_HEAL_COOLDOWN_MS', 60 * 60_000);
112
119
  /** Bundle (mode / suspension / policy version) poll cadence. */
113
120
  const BUNDLE_POLL_MS = envMs('FCD_DAEMON_BUNDLE_POLL_MS', 60_000);
121
+ /**
122
+ * Fetch budget for the daemon's background bundle poll. Deliberately NOT the
123
+ * hot-path default (getRuntimeBundle's 1.5s), which exists so a hook never
124
+ * stalls a developer's command. Nothing waits on this poll — it runs on its own
125
+ * 60s timer inside a 45s deadline — so a slow-but-healthy control plane must be
126
+ * waited out, not reported as an outage. At 1.5s roughly 10% of polls aborted
127
+ * on ordinary p95 latency spikes and each one raised a `network_down` distress
128
+ * signal, while `doctor --health` (already passing 10s) stayed green against
129
+ * the same backend: alert noise with no failure behind it.
130
+ */
131
+ const BUNDLE_FETCH_TIMEOUT_MS = envMs('FCD_DAEMON_BUNDLE_TIMEOUT_MS', 10_000);
114
132
  /**
115
133
  * Hard ceilings for one poll attempt (see startPollLoop). Generous multiples of
116
- * the work each poll actually does — a bundle poll is a 1.5s fetch plus an 8s
134
+ * the work each poll actually does — a bundle poll is a 10s fetch plus an 8s
117
135
  * snapshot refresh, a heartbeat is a spool flush plus a log upload — so these
118
136
  * only ever fire on a genuinely hung socket, never on a merely slow network.
119
137
  */
120
138
  const BUNDLE_POLL_DEADLINE_MS = envMs('FCD_DAEMON_BUNDLE_DEADLINE_MS', 45_000);
121
139
  const HEARTBEAT_DEADLINE_MS = envMs('FCD_DAEMON_HEARTBEAT_DEADLINE_MS', 120_000);
140
+ /**
141
+ * Wake-from-sleep detection. Timers are the only awake clock we have: a beat
142
+ * that observes far more wall time than its own interval proves the machine
143
+ * was suspended in between. The jump bound is a generous multiple of the beat
144
+ * so ordinary event-loop lag or a busy CPU can never look like a resume.
145
+ */
146
+ const RESUME_BEAT_MS = envMs('FCD_DAEMON_RESUME_BEAT_MS', 30_000);
147
+ const RESUME_JUMP_MS = envMs('FCD_DAEMON_RESUME_JUMP_MS', 120_000);
148
+ /**
149
+ * Report-in attempts after a resume, and the base delay between them (linear
150
+ * backoff: 10s, 20s, 30s, 40s). Sized to outlast a normal Wi-Fi reassociation
151
+ * plus DHCP/DNS, so waking on a slow network costs seconds of staleness rather
152
+ * than a whole reporting window.
153
+ */
154
+ const RESUME_REPORT_ATTEMPTS = envMs('FCD_DAEMON_RESUME_ATTEMPTS', 5);
155
+ const RESUME_RETRY_BASE_MS = envMs('FCD_DAEMON_RESUME_RETRY_MS', 10_000);
122
156
  /** Delay before the one-time initial discovery sweep on a fresh machine. */
123
157
  const INITIAL_DISCOVER_DELAY_MS = envMs('FCD_DAEMON_INITIAL_DISCOVER_MS', 2 * 60_000);
124
158
  /** A discovery upload older than this is stale — the daemon catches up itself. */
@@ -505,6 +539,7 @@ async function runDaemon(args, config) {
505
539
  let quietUntil = 0; // ignore events until this time (self-writes)
506
540
  let debounceTimer = null;
507
541
  let reprotecting = false;
542
+ let lastIntegrityHealAt = 0; // cooldown anchor for integrity-triggered repairs
508
543
  let suspended = false;
509
544
  let stopped = false;
510
545
  const executingActionIds = new Set();
@@ -582,16 +617,23 @@ async function runDaemon(args, config) {
582
617
  }
583
618
  catch { /* next tick */ }
584
619
  };
585
- const reprotect = async (reasonPaths) => {
620
+ /**
621
+ * One repair path, two triggers: a watched config CHANGED (drift), or the
622
+ * integrity check reported a reason protect-all can fix (integrity). Keeping
623
+ * them on the same function preserves the reprotecting/quiet-window guards —
624
+ * a second copy would race this one and re-enter protect-all concurrently.
625
+ */
626
+ const reprotect = async (reasonPaths, cause = 'drift') => {
627
+ const headline = cause === 'integrity' ? 'Integrity self-heal' : 'Config drift detected';
586
628
  if (reprotecting || stopped)
587
629
  return;
588
630
  if (suspended) {
589
- log(`Config drift detected (${reasonPaths.join(', ')}) but machine is suspended — not re-protecting.`);
631
+ log(`${headline} (${reasonPaths.join(', ')}) but machine is suspended — not re-protecting.`);
590
632
  return;
591
633
  }
592
634
  reprotecting = true;
593
635
  quietUntil = Date.now() + SELF_WRITE_QUIET_MS;
594
- log(`Config drift detected: ${reasonPaths.join(', ')} — re-running protect-all.`);
636
+ log(`${headline}: ${reasonPaths.join(', ')} — re-running protect-all.`);
595
637
  try {
596
638
  const repaired = await (0, protectionRepair_1.repairProtection)({ ...args, dryRun: undefined }, config);
597
639
  quietUntil = Date.now() + SELF_WRITE_QUIET_MS;
@@ -601,7 +643,9 @@ async function runDaemon(args, config) {
601
643
  if (!quiet) {
602
644
  (0, notify_1.notifyOs)({
603
645
  title: 'FullCourtDefense re-protected this machine',
604
- message: 'An MCP or hook config changed; the FullCourtDefense gateway was re-applied.',
646
+ message: cause === 'integrity'
647
+ ? 'Protection was reported incomplete; the FullCourtDefense gateway was re-applied.'
648
+ : 'An MCP or hook config changed; the FullCourtDefense gateway was re-applied.',
605
649
  url: (0, notify_1.consoleUrl)('/agent-security/users?view=desktop'),
606
650
  });
607
651
  }
@@ -990,6 +1034,7 @@ async function runDaemon(args, config) {
990
1034
  machineId: identity.machineId,
991
1035
  force: true,
992
1036
  ttlMs: 0,
1037
+ timeoutMs: BUNDLE_FETCH_TIMEOUT_MS,
993
1038
  });
994
1039
  log(bundle.policyHash
995
1040
  ? `Policy refresh: bundle applied (hash ${bundle.policyHash.slice(0, 12)}…, ${bundle.policyCount ?? 0} policies).`
@@ -1155,6 +1200,7 @@ async function runDaemon(args, config) {
1155
1200
  machineName: identity.hostname,
1156
1201
  machineId: identity.machineId,
1157
1202
  force: true,
1203
+ timeoutMs: BUNDLE_FETCH_TIMEOUT_MS,
1158
1204
  });
1159
1205
  if (typeof bundle.pollIntervalMs === 'number' && Number.isFinite(bundle.pollIntervalMs)) {
1160
1206
  bundlePollBaseMs = Math.min(Math.max(bundle.pollIntervalMs, 15_000), 60 * 60_000);
@@ -1260,8 +1306,33 @@ async function runDaemon(args, config) {
1260
1306
  recoverCredentialsIfMissing();
1261
1307
  if (!creds.shieldId)
1262
1308
  return;
1309
+ // Did THIS beat actually record daemon liveness upstream? `flushSpool`
1310
+ // reports every failure by returning null and never throws, so without
1311
+ // this flag a heartbeat that never left the machine was indistinguishable
1312
+ // from a delivered one — and the loop happily waited a full cadence before
1313
+ // trying again. `null` also covers the case where a hook flusher held the
1314
+ // flush lock, which drops the daemon marker specifically (hook flushes do
1315
+ // not set `daemon: true`), so retrying soon is right in that case too.
1316
+ let reported = false;
1263
1317
  try {
1264
- const integrity = (0, integrity_1.getLocalIntegrityReport)({ requireDaemon: true });
1318
+ let integrity = (0, integrity_1.getLocalIntegrityReport)({ requireDaemon: true });
1319
+ // A failing verdict used to be REPORTED and nothing more, so a machine
1320
+ // could sit "Damaged" in the console indefinitely on a reason the daemon
1321
+ // already knows how to fix — the drift watcher only fires when a config
1322
+ // file changes, and a stale gateway path changes nothing. Heal first, then
1323
+ // report what is true AFTER the repair rather than the verdict that
1324
+ // triggered it.
1325
+ if (!integrity.ok
1326
+ && !suspended
1327
+ && Date.now() - lastIntegrityHealAt >= INTEGRITY_HEAL_COOLDOWN_MS
1328
+ && (0, integrity_1.hasHealableIntegrityReason)(integrity.reasons)) {
1329
+ lastIntegrityHealAt = Date.now();
1330
+ await reprotect(integrity.reasons, 'integrity');
1331
+ integrity = (0, integrity_1.getLocalIntegrityReport)({ requireDaemon: true });
1332
+ log(integrity.ok
1333
+ ? 'Integrity self-heal: protection restored.'
1334
+ : `Integrity self-heal: still failing (${integrity.reasons.join(', ')}) — reported for support.`);
1335
+ }
1265
1336
  // Report a previous daemon's unclean death exactly once.
1266
1337
  const crash = (0, daemonForensics_1.readPostmortem)();
1267
1338
  const unreportedCrash = crash && !crash.reportedAt ? crash : undefined;
@@ -1293,6 +1364,7 @@ async function runDaemon(args, config) {
1293
1364
  }
1294
1365
  : undefined,
1295
1366
  });
1367
+ reported = result !== null;
1296
1368
  if (result && unreportedCrash)
1297
1369
  (0, daemonForensics_1.markPostmortemReported)();
1298
1370
  if (result && result.accepted > 0)
@@ -1303,8 +1375,13 @@ async function runDaemon(args, config) {
1303
1375
  }
1304
1376
  catch { /* spool stays on disk for the next tick */ }
1305
1377
  // Settle any in-flight upgrade_cli action (success once the new build is
1306
- // live, failure when the target never arrived).
1378
+ // live, failure when the target never arrived). Runs BEFORE the failure
1379
+ // signal below so a lost heartbeat can never strand a pending upgrade.
1307
1380
  await verifyPendingUpgrade();
1381
+ // Surface the miss to the caller (poll loop / resume path) so it retries in
1382
+ // seconds. Nothing else can tell them: liveness has exactly one writer.
1383
+ if (!reported)
1384
+ throw new Error('heartbeat did not reach the backend — daemon liveness was not recorded');
1308
1385
  };
1309
1386
  // --- boot ---------------------------------------------------------------
1310
1387
  // Self-heal autostart + watchdog tasks: machines installed by older versions
@@ -1330,7 +1407,19 @@ async function runDaemon(args, config) {
1330
1407
  // would report a false "damaged" state (and fire admin integrity alerts)
1331
1408
  // for a condition the very next line repairs.
1332
1409
  await reprotect(['startup pass']);
1333
- await heartbeat();
1410
+ // The FIRST beat must not be able to abort the rest of this function.
1411
+ // `heartbeat` throws when the report did not reach the backend (that is how
1412
+ // the poll loop knows to retry in seconds), and an exception here propagates
1413
+ // out of `runDaemon` and skips every loop registered below it — the heartbeat
1414
+ // loop, the resume timer and the bundle poll. The process itself survives on
1415
+ // the `unhandledRejection` handler, which is worse than dying: it holds the
1416
+ // pid lock, so the scheduled task declines to start a replacement, and it
1417
+ // never polls the bundle, so no remote action can reach it. Booting before
1418
+ // the network is up (captive portal, VPN, resumed in a lift) is routine, and
1419
+ // the heartbeat loop below retries within seconds.
1420
+ await heartbeat().catch(error => {
1421
+ log(`First heartbeat did not reach the backend (${error?.message || String(error)}) — the heartbeat loop will retry shortly.`);
1422
+ });
1334
1423
  // Claude Desktop chat guard (Windows, advisory): Claude Desktop's regular
1335
1424
  // chat has no hook and never hits an MCP server, so it is the one machine
1336
1425
  // surface neither hooks nor the gateway can see. Supervise the advisory guard
@@ -1471,6 +1560,60 @@ async function runDaemon(args, config) {
1471
1560
  },
1472
1561
  onError: error => log(`Heartbeat error: ${error instanceof Error ? error.message : String(error)}`),
1473
1562
  });
1563
+ // ── Wake-from-sleep recovery ──────────────────────────────────────────────
1564
+ // Nothing in the agent reacts to a resume, so after a laptop wakes the
1565
+ // machine kept reporting "daemon down" until the next SCHEDULED heartbeat —
1566
+ // a full cadence later. Worse, the liveness marker stayed stale for that
1567
+ // whole window, which is what the watchdog reads before deciding the daemon
1568
+ // is wedged. Refreshing the marker the moment we notice the wall-clock jump
1569
+ // closes the reporting gap and removes the evidence of a hang that never
1570
+ // happened. Both calls go through runWithDeadline, so a resume that lands on
1571
+ // sockets which died during sleep cannot leak an unhandled rejection.
1572
+ // The resume beat is RETRIED, because it fires the instant the wall-clock
1573
+ // jump is noticed — which on a laptop is reliably before Wi-Fi has
1574
+ // reassociated. As a single shot, the one beat whose whole job is to prove
1575
+ // the daemon survived the sleep was also the likeliest to be lost, and modern
1576
+ // standby routinely re-suspends the machine before the next cadence tick, so
1577
+ // liveness could stay stale indefinitely while IDE-hook flushes kept the
1578
+ // machine "online" — a live daemon displayed as down.
1579
+ let resumeReportInFlight = false;
1580
+ const reportInAfterResume = async () => {
1581
+ if (resumeReportInFlight)
1582
+ return; // a previous resume is still catching up
1583
+ resumeReportInFlight = true;
1584
+ try {
1585
+ for (let attempt = 1; attempt <= RESUME_REPORT_ATTEMPTS; attempt++) {
1586
+ let failed = false;
1587
+ const outcome = await (0, pollLoop_1.runWithDeadline)({
1588
+ run: heartbeat,
1589
+ deadlineMs: HEARTBEAT_DEADLINE_MS,
1590
+ onError: () => { failed = true; },
1591
+ });
1592
+ if (outcome === 'settled' && !failed)
1593
+ return; // liveness recorded
1594
+ if (attempt === RESUME_REPORT_ATTEMPTS) {
1595
+ log(`Resume report-in did not land after ${attempt} attempt(s) — the scheduled heartbeat keeps retrying.`);
1596
+ return;
1597
+ }
1598
+ await new Promise(resolve => setTimeout(resolve, RESUME_RETRY_BASE_MS * attempt));
1599
+ }
1600
+ }
1601
+ finally {
1602
+ resumeReportInFlight = false;
1603
+ }
1604
+ };
1605
+ let lastResumeBeatMs = Date.now();
1606
+ const resumeTimer = setInterval(() => {
1607
+ const now = Date.now();
1608
+ const drift = now - lastResumeBeatMs;
1609
+ lastResumeBeatMs = now;
1610
+ if (drift < RESUME_JUMP_MS)
1611
+ return;
1612
+ log(`Resume detected: ${Math.round(drift / 1000)}s of wall time elapsed while suspended — refreshing liveness and reporting in now.`);
1613
+ (0, daemonForensics_1.touchDaemonAlive)();
1614
+ void reportInAfterResume();
1615
+ void (0, pollLoop_1.runWithDeadline)({ run: pollBundle, deadlineMs: BUNDLE_POLL_DEADLINE_MS });
1616
+ }, RESUME_BEAT_MS);
1474
1617
  // PowerShell transcript retention (Windows): the Transcription policy FCD
1475
1618
  // enables writes a file per session forever — prune anything older than the
1476
1619
  // retention window once a day (plus once shortly after boot, so laptops
@@ -1496,6 +1639,7 @@ async function runDaemon(args, config) {
1496
1639
  clearInterval(rescanTimer);
1497
1640
  bundleLoop.stop();
1498
1641
  heartbeatLoop.stop();
1642
+ clearInterval(resumeTimer);
1499
1643
  clearTimeout(transcriptPruneBootTimer);
1500
1644
  clearInterval(transcriptPruneTimer);
1501
1645
  clearTimeout(discoverCatchUpBootTimer);
@@ -66,6 +66,16 @@ async function watchdogCommand(args, config) {
66
66
  const state = (0, daemon_1.daemonRuntimeState)();
67
67
  let relaunched = false;
68
68
  let postmortem = (0, daemonForensics_1.readPostmortem)();
69
+ // Our own tick cadence is the only awake clock available (see
70
+ // detectMachineSuspension): a gap far larger than the interval proves the
71
+ // MACHINE was suspended, which fully explains a stale daemon marker. Read
72
+ // the previous tick BEFORE recording this one, and record unconditionally so
73
+ // an early return below still leaves the next tick a baseline.
74
+ const machineSuspended = (0, daemonForensics_1.detectMachineSuspension)({
75
+ previousTickAt: (0, daemonForensics_1.readWatchdogTick)()?.lastTickAt,
76
+ nowMs: Date.now(),
77
+ });
78
+ (0, daemonForensics_1.recordWatchdogTick)();
69
79
  // Beyond "the pid exists": a wedged daemon or a recycled pid both pass a
70
80
  // plain pid check while the machine has no working control plane. Classify
71
81
  // with the alive-marker freshness + a verdict-pipe probe.
@@ -77,6 +87,7 @@ async function watchdogCommand(args, config) {
77
87
  marker: (0, daemonForensics_1.readAliveMarker)(),
78
88
  staleAfterMs: (0, daemonForensics_1.daemonHungAfterMs)(),
79
89
  pipeProbe: await (0, verdictIpcClient_1.probeVerdictServer)(1_500),
90
+ machineSuspended,
80
91
  });
81
92
  }
82
93
  if (health === 'hung' && state.pid && process.env.FCD_WATCHDOG_NO_RELAUNCH !== '1') {
@@ -85,6 +96,9 @@ async function watchdogCommand(args, config) {
85
96
  // clean. The distress ledger survives; the fresh daemon's first heartbeat
86
97
  // ships the story to the fleet.
87
98
  (0, distress_1.reportDistress)('watchdog', distress_1.DISTRESS.DAEMON_HUNG, `daemon pid ${state.pid} unresponsive (alive-marker stale + verdict pipe silent) — recycled by watchdog`);
99
+ // Claim the kill first: otherwise the next daemon boots, finds a marker
100
+ // with no clean-exit stamp, and reports EDR/AV killed it.
101
+ (0, daemonForensics_1.recordWatchdogRecycle)(state.pid);
88
102
  (0, daemon_1.forceStopPid)(state.pid);
89
103
  }
90
104
  // NOTE for 'pid_reused': the pid belongs to an UNRELATED process — never
@@ -98,7 +98,81 @@ export declare function classifyDaemonHealth(input: {
98
98
  marker: DaemonAliveMarker | undefined;
99
99
  staleAfterMs: number;
100
100
  pipeProbe: 'responsive' | 'unresponsive' | 'no-endpoint' | 'not-probed';
101
+ /**
102
+ * True when the machine itself stopped running since the previous check
103
+ * (sleep/hibernate). Marker staleness is then not evidence of anything —
104
+ * see detectMachineSuspension.
105
+ */
106
+ machineSuspended?: boolean;
101
107
  nowMs?: number;
102
108
  }): DaemonHealthClass;
109
+ /**
110
+ * WHY THIS EXISTS (a real developer-machine incident, 2026-08)
111
+ * Staleness above is a WALL-CLOCK measurement, but the daemon can only refresh
112
+ * its marker while the CPU is actually running. A laptop that sleeps longer
113
+ * than `staleAfterMs` therefore produces a stale marker through no fault of
114
+ * the daemon — and the watchdog read that as "wedged" and killed a perfectly
115
+ * healthy process on every lid-open. One developer machine was recycled 8
116
+ * times in 7 days, each kill also writing a post-mortem blaming EDR/AV for
117
+ * what was in fact our own watchdog.
118
+ *
119
+ * There is no portable awake clock to compare against. On Windows
120
+ * `os.uptime()` still INCLUDES sleep (measured on the incident machine: 26.66h
121
+ * of "uptime" spanning a 13h sleep), and reading the kernel power log would
122
+ * mean spawning PowerShell every 5 minutes — precisely the pattern that gets
123
+ * this agent killed by EDR, which the watchdog is documented to avoid.
124
+ *
125
+ * What we do have is our OWN cadence. A fixed-interval scheduled task that
126
+ * observes far more wall time between two of its own ticks than its interval
127
+ * allows has proven the machine was not running in between. That gap explains
128
+ * the daemon's stale marker, so staleness stops counting as evidence.
129
+ */
130
+ /** The watchdog scheduled task's interval — its ticks are the awake clock. */
131
+ export declare const WATCHDOG_CADENCE_MS: number;
132
+ /** Gaps beyond cadence x this factor cannot be explained by jitter or a slow tick. */
133
+ export declare const SUSPEND_GAP_FACTOR = 3;
134
+ /** The watchdog's own proof that IT was running, persisted between ticks. */
135
+ export interface WatchdogTick {
136
+ lastTickAt: string;
137
+ }
138
+ export declare function watchdogTickFile(): string;
139
+ export declare function readWatchdogTick(): WatchdogTick | undefined;
140
+ export declare function recordWatchdogTick(now?: Date): void;
141
+ /**
142
+ * Pure: did the machine stop running between the previous watchdog tick and
143
+ * this one?
144
+ *
145
+ * A MISSING baseline answers FALSE (i.e. "not suspended", the pre-existing
146
+ * behavior) rather than granting grace. That looks backwards next to "never
147
+ * kill on a guess", but it is the safer failure mode overall: the grace
148
+ * depends on a tick file, and if that file can never be persisted (locked
149
+ * directory, full disk, AV quarantine) then every tick would see no baseline,
150
+ * grant grace forever, and silently disable the watchdog's ability to recycle
151
+ * a genuinely wedged daemon. Answering false means a persistence failure
152
+ * degrades to the known old behavior instead of to no protection at all, and
153
+ * costs at most one stale verdict on the very first tick after upgrade.
154
+ *
155
+ * A baseline that EXISTS but cannot be trusted (unparseable, or the clock
156
+ * moved backwards under us) still answers TRUE: there we have positive
157
+ * evidence that time is not measurable, which is exactly when a staleness
158
+ * comparison must not be believed.
159
+ */
160
+ export declare function detectMachineSuspension(input: {
161
+ previousTickAt?: string;
162
+ nowMs: number;
163
+ cadenceMs?: number;
164
+ factor?: number;
165
+ }): boolean;
166
+ /**
167
+ * Stamp the marker before the watchdog force-stops a wedged daemon, so the
168
+ * NEXT boot's `detectUncleanDeath` does not report our own recycle as an
169
+ * external kill. The reason is distinct from a real shutdown's signal name,
170
+ * and the recycle stays visible via the `daemon_hung` distress entry and the
171
+ * beacon's `daemonHealth` field — this only suppresses the false EDR/AV story.
172
+ *
173
+ * Keyed on the DAEMON's pid (not `process.pid`): the watchdog is a different
174
+ * process, which is exactly why `recordCleanExit` cannot be reused here.
175
+ */
176
+ export declare function recordWatchdogRecycle(daemonPid: number): void;
103
177
  /** Stamp the post-mortem as delivered so heartbeats stop re-sending it. */
104
178
  export declare function markPostmortemReported(): void;
@@ -33,6 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.SUSPEND_GAP_FACTOR = exports.WATCHDOG_CADENCE_MS = void 0;
36
37
  exports.aliveMarkerFile = aliveMarkerFile;
37
38
  exports.postmortemFile = postmortemFile;
38
39
  exports.readAliveMarker = readAliveMarker;
@@ -45,6 +46,11 @@ exports.touchDaemonAlive = touchDaemonAlive;
45
46
  exports.recordCleanExit = recordCleanExit;
46
47
  exports.daemonHungAfterMs = daemonHungAfterMs;
47
48
  exports.classifyDaemonHealth = classifyDaemonHealth;
49
+ exports.watchdogTickFile = watchdogTickFile;
50
+ exports.readWatchdogTick = readWatchdogTick;
51
+ exports.recordWatchdogTick = recordWatchdogTick;
52
+ exports.detectMachineSuspension = detectMachineSuspension;
53
+ exports.recordWatchdogRecycle = recordWatchdogRecycle;
48
54
  exports.markPostmortemReported = markPostmortemReported;
49
55
  const fs = __importStar(require("fs"));
50
56
  const os = __importStar(require("os"));
@@ -199,12 +205,109 @@ function classifyDaemonHealth(input) {
199
205
  return 'healthy';
200
206
  if (input.pipeProbe === 'responsive')
201
207
  return 'healthy';
208
+ // The marker is stale, but the daemon had no CPU with which to refresh it.
209
+ // Judging it now would convict a healthy daemon of the machine's sleep, so
210
+ // defer: the next tick runs after a full awake interval and decides fairly.
211
+ if (input.machineSuspended)
212
+ return 'indeterminate';
202
213
  if (input.pipeProbe === 'unresponsive')
203
214
  return 'hung';
204
215
  if (input.pipeProbe === 'no-endpoint')
205
216
  return 'pid_reused';
206
217
  return 'indeterminate';
207
218
  }
219
+ // ---------------------------------------------------------------------------
220
+ // Suspend awareness
221
+ // ---------------------------------------------------------------------------
222
+ /**
223
+ * WHY THIS EXISTS (a real developer-machine incident, 2026-08)
224
+ * Staleness above is a WALL-CLOCK measurement, but the daemon can only refresh
225
+ * its marker while the CPU is actually running. A laptop that sleeps longer
226
+ * than `staleAfterMs` therefore produces a stale marker through no fault of
227
+ * the daemon — and the watchdog read that as "wedged" and killed a perfectly
228
+ * healthy process on every lid-open. One developer machine was recycled 8
229
+ * times in 7 days, each kill also writing a post-mortem blaming EDR/AV for
230
+ * what was in fact our own watchdog.
231
+ *
232
+ * There is no portable awake clock to compare against. On Windows
233
+ * `os.uptime()` still INCLUDES sleep (measured on the incident machine: 26.66h
234
+ * of "uptime" spanning a 13h sleep), and reading the kernel power log would
235
+ * mean spawning PowerShell every 5 minutes — precisely the pattern that gets
236
+ * this agent killed by EDR, which the watchdog is documented to avoid.
237
+ *
238
+ * What we do have is our OWN cadence. A fixed-interval scheduled task that
239
+ * observes far more wall time between two of its own ticks than its interval
240
+ * allows has proven the machine was not running in between. That gap explains
241
+ * the daemon's stale marker, so staleness stops counting as evidence.
242
+ */
243
+ /** The watchdog scheduled task's interval — its ticks are the awake clock. */
244
+ exports.WATCHDOG_CADENCE_MS = 5 * 60_000;
245
+ /** Gaps beyond cadence x this factor cannot be explained by jitter or a slow tick. */
246
+ exports.SUSPEND_GAP_FACTOR = 3;
247
+ function watchdogTickFile() {
248
+ return path.join(stateDir(), 'watchdog.tick.json');
249
+ }
250
+ function readWatchdogTick() {
251
+ try {
252
+ const tick = JSON.parse(fs.readFileSync(watchdogTickFile(), 'utf8'));
253
+ return tick && typeof tick.lastTickAt === 'string' ? tick : undefined;
254
+ }
255
+ catch {
256
+ return undefined;
257
+ }
258
+ }
259
+ function recordWatchdogTick(now = new Date()) {
260
+ writeJson(watchdogTickFile(), { lastTickAt: now.toISOString() });
261
+ }
262
+ /**
263
+ * Pure: did the machine stop running between the previous watchdog tick and
264
+ * this one?
265
+ *
266
+ * A MISSING baseline answers FALSE (i.e. "not suspended", the pre-existing
267
+ * behavior) rather than granting grace. That looks backwards next to "never
268
+ * kill on a guess", but it is the safer failure mode overall: the grace
269
+ * depends on a tick file, and if that file can never be persisted (locked
270
+ * directory, full disk, AV quarantine) then every tick would see no baseline,
271
+ * grant grace forever, and silently disable the watchdog's ability to recycle
272
+ * a genuinely wedged daemon. Answering false means a persistence failure
273
+ * degrades to the known old behavior instead of to no protection at all, and
274
+ * costs at most one stale verdict on the very first tick after upgrade.
275
+ *
276
+ * A baseline that EXISTS but cannot be trusted (unparseable, or the clock
277
+ * moved backwards under us) still answers TRUE: there we have positive
278
+ * evidence that time is not measurable, which is exactly when a staleness
279
+ * comparison must not be believed.
280
+ */
281
+ function detectMachineSuspension(input) {
282
+ const cadence = input.cadenceMs && input.cadenceMs > 0 ? input.cadenceMs : exports.WATCHDOG_CADENCE_MS;
283
+ const factor = input.factor && input.factor > 0 ? input.factor : exports.SUSPEND_GAP_FACTOR;
284
+ if (!input.previousTickAt)
285
+ return false;
286
+ const previous = Date.parse(input.previousTickAt);
287
+ if (!Number.isFinite(previous))
288
+ return true;
289
+ const gap = input.nowMs - previous;
290
+ if (gap < 0)
291
+ return true;
292
+ return gap > cadence * factor;
293
+ }
294
+ /**
295
+ * Stamp the marker before the watchdog force-stops a wedged daemon, so the
296
+ * NEXT boot's `detectUncleanDeath` does not report our own recycle as an
297
+ * external kill. The reason is distinct from a real shutdown's signal name,
298
+ * and the recycle stays visible via the `daemon_hung` distress entry and the
299
+ * beacon's `daemonHealth` field — this only suppresses the false EDR/AV story.
300
+ *
301
+ * Keyed on the DAEMON's pid (not `process.pid`): the watchdog is a different
302
+ * process, which is exactly why `recordCleanExit` cannot be reused here.
303
+ */
304
+ function recordWatchdogRecycle(daemonPid) {
305
+ const marker = readAliveMarker();
306
+ if (!marker || marker.pid !== daemonPid)
307
+ return;
308
+ marker.cleanExit = { at: new Date().toISOString(), reason: 'watchdog-recycle' };
309
+ writeJson(aliveMarkerFile(), marker);
310
+ }
208
311
  /** Stamp the post-mortem as delivered so heartbeats stop re-sending it. */
209
312
  function markPostmortemReported() {
210
313
  const pm = readPostmortem();
@@ -1,4 +1,6 @@
1
1
  export type IntegrityReason = 'mcp_gateway_missing' | 'mcp_gateway_stale_path' | 'cursor_hook_missing' | 'cursor_hook_stale_path' | 'cursor_hook_changed' | 'claude_hook_missing' | 'claude_hook_stale_path' | 'claude_hook_changed' | 'daemon_not_running' | 'runtime_bundle_stale';
2
+ /** Pure: does this verdict contain anything a protect-all pass could fix? */
3
+ export declare function hasHealableIntegrityReason(reasons: readonly string[]): boolean;
2
4
  export interface LocalIntegrityReport {
3
5
  ok: boolean;
4
6
  reasons: IntegrityReason[];
package/dist/integrity.js CHANGED
@@ -33,6 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.hasHealableIntegrityReason = hasHealableIntegrityReason;
36
37
  exports.isDaemonRunning = isDaemonRunning;
37
38
  exports.getLocalIntegrityReport = getLocalIntegrityReport;
38
39
  const fs = __importStar(require("fs"));
@@ -42,6 +43,27 @@ const installClaudeHook_1 = require("./commands/installClaudeHook");
42
43
  const installCursorHook_1 = require("./commands/installCursorHook");
43
44
  const mcpGateway_1 = require("./commands/mcpGateway");
44
45
  const appDetection_1 = require("./appDetection");
46
+ /**
47
+ * The reasons `protect-all` can actually repair. Detecting a failure is only
48
+ * half the job: the daemon heals these instead of reporting them forever, which
49
+ * is why the taxonomy lives beside the reason type rather than in the caller.
50
+ *
51
+ * Excluded on purpose:
52
+ * - `daemon_not_running` — the daemon is the thing evaluating this; re-wrapping
53
+ * configs cannot start a process that is already running or already dead.
54
+ * - `runtime_bundle_stale` — a control-plane reachability symptom. Re-wrapping
55
+ * would churn every config on this machine and still not fetch the bundle,
56
+ * hiding a network problem behind protection noise.
57
+ */
58
+ const HEALABLE_REASONS = new Set([
59
+ 'mcp_gateway_missing', 'mcp_gateway_stale_path',
60
+ 'cursor_hook_missing', 'cursor_hook_stale_path', 'cursor_hook_changed',
61
+ 'claude_hook_missing', 'claude_hook_stale_path', 'claude_hook_changed',
62
+ ]);
63
+ /** Pure: does this verdict contain anything a protect-all pass could fix? */
64
+ function hasHealableIntegrityReason(reasons) {
65
+ return reasons.some(reason => HEALABLE_REASONS.has(reason));
66
+ }
45
67
  function readText(file) {
46
68
  try {
47
69
  return fs.readFileSync(file, 'utf8');
@@ -51,6 +51,12 @@ export interface PollLoopInput {
51
51
  onError?: (error: unknown) => void;
52
52
  /** Cadence jitter fraction (0..1, default 0.2) — a proxied fleet must not poll in lockstep. */
53
53
  jitter?: number;
54
+ /**
55
+ * First delay after an attempt that FAILED or was abandoned, doubled on each
56
+ * consecutive failure and capped at the normal cadence (so a retry is never
57
+ * lazier than the ordinary tick). Reset the moment an attempt succeeds.
58
+ */
59
+ retryDelayMs?: number;
54
60
  /** Injectable timers for tests. */
55
61
  timers?: {
56
62
  setTimeout: (fn: () => void, ms: number) => unknown;
package/dist/pollLoop.js CHANGED
@@ -32,6 +32,24 @@ Object.defineProperty(exports, "__esModule", { value: true });
32
32
  exports.runWithDeadline = runWithDeadline;
33
33
  exports.startPollLoop = startPollLoop;
34
34
  const MIN_DELAY_MS = 5_000;
35
+ /**
36
+ * Default first retry after a failed/abandoned attempt.
37
+ *
38
+ * WHY A RETRY EXISTS AT ALL (the "Online · daemon down" bug)
39
+ * The loop used to reschedule at the full cadence no matter how the attempt
40
+ * ended, so ONE failed heartbeat left the daemon unreported for 5-6 minutes.
41
+ * That is not rare on a laptop: the wake-from-sleep path fires a heartbeat the
42
+ * instant a resume is noticed, which is usually BEFORE Wi-Fi has reassociated,
43
+ * so the very beat meant to prove the daemon survived the sleep is the one most
44
+ * likely to fail. Modern standby then re-suspends the machine in ~10-minute
45
+ * blocks, often before the next cadence tick — so `daemonReportedAt` could stay
46
+ * stale indefinitely while IDE-hook flushes kept the machine "online", showing
47
+ * a live daemon as down and making remote actions undeliverable.
48
+ *
49
+ * Retrying in seconds (not minutes) means a network that is merely late to come
50
+ * back costs one short delay instead of a whole reporting window.
51
+ */
52
+ const RETRY_BASE_MS = 15_000;
35
53
  /**
36
54
  * Await `run()` but never longer than `deadlineMs`. Resolves 'settled' when the
37
55
  * attempt finished (fulfilled or rejected) and 'abandoned' when the deadline
@@ -75,22 +93,42 @@ function startPollLoop(input) {
75
93
  let stopped = false;
76
94
  let stalled = 0;
77
95
  let tickHandle;
78
- const nextDelay = () => {
96
+ /** Consecutive failed/abandoned attempts, driving the retry backoff. */
97
+ let consecutiveFailures = 0;
98
+ const cadenceMs = () => {
79
99
  const base = input.intervalMs();
80
- const safeBase = Number.isFinite(base) && base > 0 ? base : MIN_DELAY_MS;
100
+ return Number.isFinite(base) && base > 0 ? base : MIN_DELAY_MS;
101
+ };
102
+ const nextDelay = () => {
103
+ const base = cadenceMs();
81
104
  const factor = 1 - jitter + random() * jitter * 2;
82
- return Math.max(MIN_DELAY_MS, Math.round(safeBase * factor));
105
+ return Math.max(MIN_DELAY_MS, Math.round(base * factor));
106
+ };
107
+ /**
108
+ * Back off exponentially from RETRY_BASE_MS but never past the ordinary
109
+ * cadence: a retry may be more eager than a normal tick, never lazier.
110
+ */
111
+ const retryDelay = () => {
112
+ const base = Math.max(1, input.retryDelayMs ?? RETRY_BASE_MS);
113
+ const backoff = base * Math.pow(2, Math.max(0, consecutiveFailures - 1));
114
+ return Math.max(MIN_DELAY_MS, Math.round(Math.min(backoff, cadenceMs())));
83
115
  };
84
- const schedule = () => {
116
+ const schedule = (delayMs) => {
85
117
  if (stopped)
86
118
  return;
87
119
  tickHandle = timers.setTimeout(async () => {
88
120
  if (stopped)
89
121
  return;
122
+ // `runWithDeadline` reports settled-vs-abandoned; a settled attempt may
123
+ // still have REJECTED, and for retry purposes that is just as bad.
124
+ let rejected = false;
90
125
  const outcome = await runWithDeadline({
91
126
  run: input.run,
92
127
  deadlineMs: input.deadlineMs,
93
- onError: input.onError,
128
+ onError: error => {
129
+ rejected = true;
130
+ input.onError?.(error);
131
+ },
94
132
  timers,
95
133
  });
96
134
  if (outcome === 'abandoned') {
@@ -100,10 +138,16 @@ function startPollLoop(input) {
100
138
  }
101
139
  catch { /* never break the loop */ }
102
140
  }
103
- schedule();
104
- }, nextDelay());
141
+ if (outcome === 'abandoned' || rejected) {
142
+ consecutiveFailures += 1;
143
+ schedule(retryDelay());
144
+ return;
145
+ }
146
+ consecutiveFailures = 0;
147
+ schedule(nextDelay());
148
+ }, delayMs);
105
149
  };
106
- schedule();
150
+ schedule(nextDelay());
107
151
  return {
108
152
  stop() {
109
153
  stopped = true;
package/dist/version.json CHANGED
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "1.26.13"
2
+ "version": "1.26.15"
3
3
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullcourtdefense-cli",
3
- "version": "1.26.13",
3
+ "version": "1.26.15",
4
4
  "description": "Full Court Defense CLI — security scanning for AI agents from your terminal",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -30,6 +30,7 @@
30
30
  "test:audit-restore": "node scripts/test-audit-restore.js",
31
31
  "test:shell-guard": "npm run build && node scripts/test-shell-guard.js",
32
32
  "test:cmd-guard": "npm run build && node scripts/test-cmd-guard.js",
33
+ "test:cmd-guard-perf": "npm run build && node scripts/test-cmd-guard-perf.js",
33
34
  "test:posix-guard": "npm run build && node scripts/test-posix-guard.js",
34
35
  "test:secret-posture": "npm run build && node scripts/test-secret-posture-fixtures.js",
35
36
  "test:agent-file-posture": "npm run build && node scripts/test-agent-file-posture.js",