fullcourtdefense-cli 1.22.6 → 1.22.7

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.
@@ -385,6 +385,10 @@ async function runDaemon(args, config) {
385
385
  if (postmortem) {
386
386
  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.`);
387
387
  }
388
+ // A fresh daemon runs right after every successful MSI update: if a
389
+ // previously-reported update loop has been resolved by this version, clear
390
+ // it so heartbeats stop shipping it as live distress.
391
+ (0, selfUpdate_1.clearResolvedUpdateLoop)(cliVersion(), log);
388
392
  // --- state ---------------------------------------------------------------
389
393
  const watchers = new Map(); // directory -> watcher
390
394
  const rootWatchers = new Map(); // admin protection roots -> recursive watcher
@@ -1422,6 +1426,7 @@ function windowsTaskRunsInteractive(taskName) {
1422
1426
  * Upgrade an EXISTING (working) task to the windowless S4U principal. Tries
1423
1427
  * ONLY the S4U XML — on failure (unelevated daemon) the current task is left
1424
1428
  * untouched so autostart never regresses from "flashes" to "broken".
1429
+ * Returns true when the task now runs S4U.
1425
1430
  */
1426
1431
  function upgradeTaskWindowless(taskName, s4uXml, logFn) {
1427
1432
  const base = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
@@ -1438,12 +1443,40 @@ function upgradeTaskWindowless(taskName, s4uXml, logFn) {
1438
1443
  });
1439
1444
  if (created.status === 0) {
1440
1445
  logFn(`Self-heal: "${taskName}" migrated to the windowless S4U principal (no more console-window flash).`);
1446
+ return true;
1441
1447
  }
1442
- else {
1443
- logFn(`Self-heal: could not migrate "${taskName}" to S4U (needs elevation) — existing task kept as-is.`);
1444
- }
1448
+ logFn(`Self-heal: could not migrate "${taskName}" to S4U (needs elevation) — asking the elevated updater task to do it.`);
1449
+ return false;
1450
+ }
1451
+ catch {
1452
+ return false; /* keep the existing working task */
1453
+ }
1454
+ }
1455
+ /**
1456
+ * The unelevated daemon cannot rewrite task principals — but the SYSTEM
1457
+ * "FullCourtDefense Updater" task can, and its stage-1 repair pass migrates
1458
+ * InteractiveToken tasks BEFORE any network work (so it succeeds even behind
1459
+ * a blocked egress). Kick it ONCE per daemon lifetime when self-heal could
1460
+ * not flip a task itself: same-boot windowless fix instead of waiting for
1461
+ * the 03:07 daily run (lptx1110 flashed CMD windows for a day because the
1462
+ * MSI's PowerShell migration was silently blocked and self-heal had no
1463
+ * elevation). The task grants Authenticated Users read+execute, so an
1464
+ * unelevated /Run works by design (same path as org-pushed upgrades).
1465
+ */
1466
+ let updaterMigrationKicked = false;
1467
+ function kickUpdaterTaskForMigration(logFn) {
1468
+ if (updaterMigrationKicked)
1469
+ return;
1470
+ updaterMigrationKicked = true;
1471
+ try {
1472
+ const run = (0, child_process_1.spawnSync)('schtasks', ['/Run', '/TN', selfUpdate_1.MSI_UPDATER_TASK_NAME], {
1473
+ stdio: 'ignore', windowsHide: true, timeout: 20_000,
1474
+ });
1475
+ logFn(run.status === 0
1476
+ ? `Self-heal: elevated updater task triggered — it migrates the tasks to S4U now (windowless within a minute).`
1477
+ : `Self-heal: updater task unavailable for the S4U migration — its next daily run (03:07) will migrate instead.`);
1445
1478
  }
1446
- catch { /* keep the existing working task */ }
1479
+ catch { /* best-effort the daily schedule remains the backstop */ }
1447
1480
  }
1448
1481
  /** True when the per-user Run-key fallback still names wscript/a .vbs. */
1449
1482
  function windowsRunKeyReferencesScriptHost() {
@@ -1576,13 +1609,18 @@ function ensureWindowsAutostartHealthy(logFn) {
1576
1609
  }
1577
1610
  // Migration (1.22.1 -> 1.22.2): InteractiveToken tasks flash a console
1578
1611
  // window on every trigger. Upgrade healthy existing tasks to S4U; S4U-only
1579
- // attempt so a denied re-registration never breaks a working task.
1612
+ // attempt so a denied re-registration never breaks a working task. When
1613
+ // unelevated self-heal cannot flip them, kick the SYSTEM updater task
1614
+ // once — its repair pass migrates with real elevation (1.22.7).
1615
+ let migrationDenied = false;
1580
1616
  if (daemonTask.status === 0 && !legacyDaemon && windowsTaskRunsInteractive(TASK_NAME)) {
1581
- upgradeTaskWindowless(TASK_NAME, buildDaemonTaskXml()[0], logFn);
1617
+ migrationDenied = !upgradeTaskWindowless(TASK_NAME, buildDaemonTaskXml()[0], logFn) || migrationDenied;
1582
1618
  }
1583
1619
  if (watchdogQuery.status === 0 && !legacyWatchdog && windowsTaskRunsInteractive(WATCHDOG_TASK_NAME)) {
1584
- upgradeTaskWindowless(WATCHDOG_TASK_NAME, buildWatchdogTaskXml()[0], logFn);
1620
+ migrationDenied = !upgradeTaskWindowless(WATCHDOG_TASK_NAME, buildWatchdogTaskXml()[0], logFn) || migrationDenied;
1585
1621
  }
1622
+ if (migrationDenied)
1623
+ kickUpdaterTaskForMigration(logFn);
1586
1624
  // A stale Run-key fallback from an older install may still name wscript.
1587
1625
  if (windowsRunKeyReferencesScriptHost()) {
1588
1626
  (0, child_process_1.spawnSync)('reg', [
@@ -74,6 +74,14 @@ export declare function distressFile(): string;
74
74
  export declare function reportDistress(component: string, code: string, detail?: string): void;
75
75
  /** Structured capture for failures nobody predicted. */
76
76
  export declare function reportUnexpected(component: string, error: unknown): void;
77
+ /**
78
+ * Remove a code from the ledger once its condition is PROVEN resolved (e.g.
79
+ * update_loop after the installed version reaches the looping target). Keeps
80
+ * the shipped ledger truthful: resolved incidents must stop riding along in
81
+ * heartbeats, where they read as live problems in the console and the fleet
82
+ * alerter. Returns true when something was actually cleared.
83
+ */
84
+ export declare function clearDistress(code: string, component?: string): boolean;
77
85
  /**
78
86
  * Recent distress for the heartbeat: entries seen within `windowMs`
79
87
  * (default 24h), newest last, capped for transport.
package/dist/distress.js CHANGED
@@ -37,6 +37,7 @@ exports.DISTRESS = void 0;
37
37
  exports.distressFile = distressFile;
38
38
  exports.reportDistress = reportDistress;
39
39
  exports.reportUnexpected = reportUnexpected;
40
+ exports.clearDistress = clearDistress;
40
41
  exports.readDistressSnapshot = readDistressSnapshot;
41
42
  exports.readDistressLedger = readDistressLedger;
42
43
  const fs = __importStar(require("fs"));
@@ -172,6 +173,31 @@ function reportUnexpected(component, error) {
172
173
  const message = error instanceof Error ? error.message : String(error);
173
174
  reportDistress(component, exports.DISTRESS.UNEXPECTED, message);
174
175
  }
176
+ /**
177
+ * Remove a code from the ledger once its condition is PROVEN resolved (e.g.
178
+ * update_loop after the installed version reaches the looping target). Keeps
179
+ * the shipped ledger truthful: resolved incidents must stop riding along in
180
+ * heartbeats, where they read as live problems in the console and the fleet
181
+ * alerter. Returns true when something was actually cleared.
182
+ */
183
+ function clearDistress(code, component) {
184
+ try {
185
+ const entries = readLedger();
186
+ const remaining = entries.filter(e => !(e.code === code && (!component || e.component === component)));
187
+ if (remaining.length === entries.length)
188
+ return false;
189
+ writeLedger(remaining);
190
+ try {
191
+ const line = `[${new Date().toISOString()}] DISTRESS ${code} resolved — cleared from ledger\n`;
192
+ fs.appendFileSync(path.join(stateDir(), 'daemon.log'), line, 'utf8');
193
+ }
194
+ catch { /* best-effort */ }
195
+ return true;
196
+ }
197
+ catch {
198
+ return false;
199
+ }
200
+ }
175
201
  /**
176
202
  * Recent distress for the heartbeat: entries seen within `windowMs`
177
203
  * (default 24h), newest last, capped for transport.
@@ -56,6 +56,16 @@ export declare function modernizeUpdaterTask(log: (message: string) => void): vo
56
56
  * fleet-visible evidence of WHY).
57
57
  */
58
58
  export declare function readUpdaterLogTail(maxLines?: number): string[];
59
+ /**
60
+ * Clear update_loop distress once the machine PROVABLY moved past the loop:
61
+ * the installed version reached (or passed) the looping target, or the
62
+ * installed version changed at all since the attempts record was written.
63
+ * Called at daemon boot (a fresh daemon starts right after every MSI update)
64
+ * and when the bundle target is already satisfied. Without this, a resolved
65
+ * loop keeps shipping in the 24h ledger snapshot and reads as a live problem
66
+ * in the console. Cheap no-op when there is no attempts record.
67
+ */
68
+ export declare function clearResolvedUpdateLoop(currentVersion: string | undefined, log?: (message: string) => void): void;
59
69
  /**
60
70
  * Upgrade this machine to targetVersion if it is newer than currentVersion.
61
71
  * Cheap no-op when already current, when a kick is still pending, or when the
@@ -41,6 +41,7 @@ exports.buildUpdaterTaskCommand = buildUpdaterTaskCommand;
41
41
  exports.updaterTaskNeedsModernization = updaterTaskNeedsModernization;
42
42
  exports.modernizeUpdaterTask = modernizeUpdaterTask;
43
43
  exports.readUpdaterLogTail = readUpdaterLogTail;
44
+ exports.clearResolvedUpdateLoop = clearResolvedUpdateLoop;
44
45
  exports.maybeSelfUpdate = maybeSelfUpdate;
45
46
  exports.installedMsiVersion = installedMsiVersion;
46
47
  const fs = __importStar(require("fs"));
@@ -291,6 +292,42 @@ function recordUpdateAttempt(target, fromVersion) {
291
292
  return 1;
292
293
  }
293
294
  }
295
+ /**
296
+ * Clear update_loop distress once the machine PROVABLY moved past the loop:
297
+ * the installed version reached (or passed) the looping target, or the
298
+ * installed version changed at all since the attempts record was written.
299
+ * Called at daemon boot (a fresh daemon starts right after every MSI update)
300
+ * and when the bundle target is already satisfied. Without this, a resolved
301
+ * loop keeps shipping in the 24h ledger snapshot and reads as a live problem
302
+ * in the console. Cheap no-op when there is no attempts record.
303
+ */
304
+ function clearResolvedUpdateLoop(currentVersion, log) {
305
+ if (!currentVersion)
306
+ return;
307
+ try {
308
+ let record;
309
+ try {
310
+ record = JSON.parse(fs.readFileSync(updateAttemptsFile(), 'utf8'));
311
+ }
312
+ catch {
313
+ return; // nothing ever looped
314
+ }
315
+ if (!record || typeof record.target !== 'string')
316
+ return;
317
+ const reachedTarget = compareCliVersions(record.target, currentVersion) <= 0;
318
+ const versionMoved = typeof record.fromVersion === 'string' && record.fromVersion !== currentVersion;
319
+ if (!reachedTarget && !versionMoved)
320
+ return;
321
+ try {
322
+ fs.unlinkSync(updateAttemptsFile());
323
+ }
324
+ catch { /* best-effort */ }
325
+ if ((0, distress_1.clearDistress)(distress_1.DISTRESS.UPDATE_LOOP) && log) {
326
+ log(`Self-update: update loop resolved (installed ${currentVersion}, was looping toward ${record.target}) — distress cleared.`);
327
+ }
328
+ }
329
+ catch { /* resolution bookkeeping must never disturb the caller */ }
330
+ }
294
331
  /** How many times an update to the same target was kicked without the version moving. */
295
332
  const UPDATE_LOOP_THRESHOLD = 3;
296
333
  let updateInFlightSince = 0;
@@ -310,8 +347,12 @@ function maybeSelfUpdate(input) {
310
347
  return undefined;
311
348
  if (input.busy)
312
349
  return undefined;
313
- if (compareCliVersions(input.targetVersion, input.currentVersion) <= 0)
350
+ if (compareCliVersions(input.targetVersion, input.currentVersion) <= 0) {
351
+ // Already current — if a loop was previously reported toward this (or an
352
+ // older) target, it is resolved now: stop shipping it as live distress.
353
+ clearResolvedUpdateLoop(input.currentVersion, log);
314
354
  return undefined;
355
+ }
315
356
  // A NEW target always gets an immediate attempt. The old global cooldown
316
357
  // incorrectly suppressed 1.18.10 after a failed 1.18.9 task.
317
358
  if (!input.force
package/dist/version.json CHANGED
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "1.22.6"
2
+ "version": "1.22.7"
3
3
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullcourtdefense-cli",
3
- "version": "1.22.6",
3
+ "version": "1.22.7",
4
4
  "description": "Full Court Defense CLI — security scanning for AI agents from your terminal",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -65,6 +65,7 @@
65
65
  "test:blocking-approval": "npm run build && node scripts/test-blocking-approval-drill.js",
66
66
  "test:clipboard-scan": "npm run build && node scripts/test-clipboard-scan.js",
67
67
  "test:no-script-host": "npm run build && node scripts/test-no-script-host.js",
68
+ "test:s4u-migration": "npm run build && node scripts/test-s4u-task-migration.js",
68
69
  "build:msi": "powershell -NoProfile -ExecutionPolicy Bypass -File installer/windows/Build-Msi.ps1",
69
70
  "prepublishOnly": "npm run build"
70
71
  },