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.
- package/dist/commands/daemon.js +45 -7
- package/dist/distress.d.ts +8 -0
- package/dist/distress.js +26 -0
- package/dist/selfUpdate.d.ts +10 -0
- package/dist/selfUpdate.js +42 -1
- package/dist/version.json +1 -1
- package/package.json +2 -1
package/dist/commands/daemon.js
CHANGED
|
@@ -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
|
-
|
|
1443
|
-
|
|
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 { /*
|
|
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', [
|
package/dist/distress.d.ts
CHANGED
|
@@ -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.
|
package/dist/selfUpdate.d.ts
CHANGED
|
@@ -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
|
package/dist/selfUpdate.js
CHANGED
|
@@ -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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fullcourtdefense-cli",
|
|
3
|
-
"version": "1.22.
|
|
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
|
},
|