fullcourtdefense-cli 1.36.4 → 1.37.0
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/askDialog.d.ts +1 -0
- package/dist/askDialog.js +20 -3
- package/dist/callerEnv.d.ts +29 -2
- package/dist/callerEnv.js +48 -2
- package/dist/commands/daemon.d.ts +10 -0
- package/dist/commands/daemon.js +348 -170
- package/dist/commands/desktopChatGuard.js +2 -0
- package/dist/commands/discoverPaths.d.ts +12 -9
- package/dist/commands/discoverPaths.js +30 -0
- package/dist/commands/ephemeralStack.js +7 -0
- package/dist/commands/flushSpool.d.ts +2 -0
- package/dist/commands/flushSpool.js +9 -2
- package/dist/commands/hook.d.ts +14 -0
- package/dist/commands/hook.js +302 -101
- package/dist/commands/httpGatewayCommand.d.ts +1 -0
- package/dist/commands/httpGatewayCommand.js +7 -1
- package/dist/commands/installClaudeHook.js +32 -10
- package/dist/commands/installCursorHook.js +20 -9
- package/dist/commands/login.js +8 -1
- package/dist/commands/mcpGateway.d.ts +21 -0
- package/dist/commands/mcpGateway.js +90 -32
- package/dist/commands/refreshPolicyCache.d.ts +28 -0
- package/dist/commands/refreshPolicyCache.js +59 -0
- package/dist/commands/watchdog.d.ts +2 -0
- package/dist/commands/watchdog.js +30 -5
- package/dist/config.d.ts +36 -0
- package/dist/config.js +78 -12
- package/dist/daemonDetectorBoot.d.ts +1 -0
- package/dist/daemonDetectorBoot.js +12 -0
- package/dist/daemonForensics.d.ts +14 -1
- package/dist/daemonForensics.js +32 -0
- package/dist/detectorInProcess.d.ts +18 -0
- package/dist/detectorInProcess.js +234 -0
- package/dist/detectorInThread.d.ts +15 -0
- package/dist/detectorInThread.js +129 -0
- package/dist/distress.d.ts +2 -0
- package/dist/distress.js +2 -0
- package/dist/fileWriteCanon.d.ts +17 -0
- package/dist/fileWriteCanon.js +141 -0
- package/dist/gateReports.d.ts +144 -0
- package/dist/gateReports.js +488 -0
- package/dist/hookIo.d.ts +59 -0
- package/dist/hookIo.js +319 -9
- package/dist/hookSlim.d.ts +15 -8
- package/dist/hookSlim.js +187 -25
- package/dist/housekeeping.d.ts +64 -0
- package/dist/housekeeping.js +384 -0
- package/dist/housekeepingOps.d.ts +150 -0
- package/dist/housekeepingOps.js +218 -0
- package/dist/housekeepingWorker.d.ts +1 -0
- package/dist/housekeepingWorker.js +113 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +19 -0
- package/dist/localDetectionData.d.ts +10 -0
- package/dist/localDetectionData.js +48 -0
- package/dist/localDetectionUpdates.d.ts +2 -2
- package/dist/localDetectionUpdates.js +4 -4
- package/dist/localSafetySnapshot.d.ts +48 -2
- package/dist/localSafetySnapshot.js +241 -25
- package/dist/machineIdentity.d.ts +7 -0
- package/dist/machineIdentity.js +20 -2
- package/dist/monitorFastPath.d.ts +191 -0
- package/dist/monitorFastPath.js +1198 -0
- package/dist/nativeHook.js +9 -2
- package/dist/pendingDrain.d.ts +82 -0
- package/dist/pendingDrain.js +215 -0
- package/dist/pendingDrainer.d.ts +30 -0
- package/dist/pendingDrainer.js +217 -0
- package/dist/perfSnapshot.d.ts +1 -1
- package/dist/perfSnapshot.js +28 -4
- package/dist/policyCacheFiles.d.ts +73 -0
- package/dist/policyCacheFiles.js +320 -0
- package/dist/policyGateHealth.d.ts +8 -0
- package/dist/policyGateHealth.js +17 -0
- package/dist/policyRefresh.d.ts +28 -0
- package/dist/policyRefresh.js +69 -0
- package/dist/protectionMaintenance.d.ts +15 -0
- package/dist/protectionMaintenance.js +43 -1
- package/dist/runtimeConfig.d.ts +77 -20
- package/dist/runtimeConfig.js +278 -57
- package/dist/selfTest.d.ts +6 -1
- package/dist/selfTest.js +11 -4
- package/dist/telemetry.d.ts +136 -4
- package/dist/telemetry.js +493 -96
- package/dist/verdictIpc.d.ts +60 -0
- package/dist/verdictIpc.js +504 -19
- package/dist/verdictIpcClient.d.ts +175 -18
- package/dist/verdictIpcClient.js +277 -59
- package/dist/version.json +1 -1
- package/package.json +2 -2
package/dist/commands/daemon.js
CHANGED
|
@@ -46,6 +46,7 @@ exports.buildTaskXml = buildTaskXml;
|
|
|
46
46
|
exports.buildTaskXmlVariants = buildTaskXmlVariants;
|
|
47
47
|
exports.registerHiddenTask = registerHiddenTask;
|
|
48
48
|
exports.windowsTaskReferencesScriptHost = windowsTaskReferencesScriptHost;
|
|
49
|
+
exports.ensureWindowsAutostartHealthy = ensureWindowsAutostartHealthy;
|
|
49
50
|
exports.daemonRuntimeState = daemonRuntimeState;
|
|
50
51
|
exports.spawnDetachedDaemon = spawnDetachedDaemon;
|
|
51
52
|
exports.pidIsAlive = pidIsAlive;
|
|
@@ -58,6 +59,7 @@ exports.daemonCommand = daemonCommand;
|
|
|
58
59
|
const fs = __importStar(require("fs"));
|
|
59
60
|
const os = __importStar(require("os"));
|
|
60
61
|
const path = __importStar(require("path"));
|
|
62
|
+
const perf_hooks_1 = require("perf_hooks");
|
|
61
63
|
const child_process_1 = require("child_process");
|
|
62
64
|
const config_1 = require("../config");
|
|
63
65
|
const distress_1 = require("../distress");
|
|
@@ -65,12 +67,8 @@ const pollLoop_1 = require("../pollLoop");
|
|
|
65
67
|
const selfTest_1 = require("../selfTest");
|
|
66
68
|
const daemonForensics_1 = require("../daemonForensics");
|
|
67
69
|
const verdictIpcClient_1 = require("../verdictIpcClient");
|
|
68
|
-
const securityAgents_1 = require("../securityAgents");
|
|
69
|
-
const mcpGateway_1 = require("./mcpGateway");
|
|
70
|
-
const protectionRepair_1 = require("./protectionRepair");
|
|
71
70
|
const runtimeConfig_1 = require("../runtimeConfig");
|
|
72
71
|
const discoverPaths_1 = require("./discoverPaths");
|
|
73
|
-
const localSafetySnapshot_1 = require("../localSafetySnapshot");
|
|
74
72
|
const policyRefresh_1 = require("../policyRefresh");
|
|
75
73
|
const telemetry_1 = require("../telemetry");
|
|
76
74
|
const identityRecovery_1 = require("../identityRecovery");
|
|
@@ -81,18 +79,23 @@ const machineIdentity_1 = require("../machineIdentity");
|
|
|
81
79
|
const localDetectionUpdates_1 = require("../localDetectionUpdates");
|
|
82
80
|
const discoveryMarker_1 = require("../discoveryMarker");
|
|
83
81
|
const selfUpdate_1 = require("../selfUpdate");
|
|
84
|
-
const cmdGuard_1 = require("./cmdGuard");
|
|
85
82
|
const machineActionVerify_1 = require("../machineActionVerify");
|
|
86
83
|
const perfSnapshot_1 = require("../perfSnapshot");
|
|
87
84
|
const desktopChatGuard_1 = require("./desktopChatGuard");
|
|
88
85
|
const honeypot_1 = require("../honeypot");
|
|
89
|
-
const msiProductCode_1 = require("../msiProductCode");
|
|
90
86
|
const windowsAudit_1 = require("./windowsAudit");
|
|
91
87
|
const hook_1 = require("./hook");
|
|
88
|
+
// hook.ts loads the ask envelope lazily (one-shot hooks rarely ask). The
|
|
89
|
+
// resident daemon loads it at boot, as it always did: an in-place npm upgrade
|
|
90
|
+
// must not hand a long-running daemon a newer copy at its first ask.
|
|
91
|
+
require("../askDialog");
|
|
92
92
|
const verdictIpc_1 = require("../verdictIpc");
|
|
93
93
|
const windowsTaskState_1 = require("../windowsTaskState");
|
|
94
94
|
Object.defineProperty(exports, "windowsTaskRunsInteractive", { enumerable: true, get: function () { return windowsTaskState_1.windowsTaskRunsInteractive; } });
|
|
95
95
|
Object.defineProperty(exports, "windowsTaskRunsVisibly", { enumerable: true, get: function () { return windowsTaskState_1.windowsTaskRunsVisibly; } });
|
|
96
|
+
const housekeeping_1 = require("../housekeeping");
|
|
97
|
+
const hookIo_1 = require("../hookIo");
|
|
98
|
+
const detectorInThread_1 = require("../detectorInThread");
|
|
96
99
|
const COLOR = {
|
|
97
100
|
reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m',
|
|
98
101
|
red: '\x1b[31m', yellow: '\x1b[33m', green: '\x1b[32m', cyan: '\x1b[36m', gray: '\x1b[90m',
|
|
@@ -457,13 +460,32 @@ function releasePidLock() {
|
|
|
457
460
|
}
|
|
458
461
|
catch { /* best-effort */ }
|
|
459
462
|
}
|
|
460
|
-
/**
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
463
|
+
/**
|
|
464
|
+
* Task Scheduler starts the daemon at its default priority 7: BelowNormal CPU
|
|
465
|
+
* and Low I/O. Under load the one process every hook waits on was preempted
|
|
466
|
+
* first, by the very hooks and fallbacks waiting for it. Windows only, and
|
|
467
|
+
* only upward to Normal; the task definitions themselves are not touched.
|
|
468
|
+
*/
|
|
469
|
+
function raiseDaemonPriority() {
|
|
470
|
+
let launched;
|
|
471
|
+
try {
|
|
472
|
+
launched = os.getPriority();
|
|
473
|
+
}
|
|
474
|
+
catch {
|
|
475
|
+
return undefined;
|
|
476
|
+
}
|
|
477
|
+
if (process.platform !== 'win32' || process.env.FCD_DAEMON_PRIORITY === 'inherit')
|
|
478
|
+
return launched;
|
|
479
|
+
if (launched <= os.constants.priority.PRIORITY_NORMAL)
|
|
480
|
+
return launched;
|
|
481
|
+
try {
|
|
482
|
+
os.setPriority(os.constants.priority.PRIORITY_NORMAL);
|
|
483
|
+
log(`Daemon CPU priority raised to normal (was ${launched >= os.constants.priority.PRIORITY_LOW ? 'idle' : 'below normal'}) so hook verdicts are not starved under load.`);
|
|
484
|
+
}
|
|
485
|
+
catch (error) {
|
|
486
|
+
log(`Could not raise the daemon CPU priority (${error.message}) — continuing at the inherited priority.`);
|
|
487
|
+
}
|
|
488
|
+
return launched;
|
|
467
489
|
}
|
|
468
490
|
// ---------------------------------------------------------------------------
|
|
469
491
|
// The resident loop
|
|
@@ -492,6 +514,19 @@ async function runDaemon(args, config) {
|
|
|
492
514
|
console.log(`${COLOR.yellow}Another FullCourtDefense daemon is already running (pid file: ${pidFile()}).${COLOR.reset}`);
|
|
493
515
|
return;
|
|
494
516
|
}
|
|
517
|
+
// This event loop serves verdicts (verdict IPC, desktop chat guard): a
|
|
518
|
+
// policy refresh one of them requests (a hook pinned to another shield, an
|
|
519
|
+
// entry overdue after sleep) runs as an async task here, never as a spawned
|
|
520
|
+
// process — CreateProcess would stall every verdict in flight with it.
|
|
521
|
+
(0, runtimeConfig_1.refreshPolicyCachesInThisProcess)();
|
|
522
|
+
const launchPriority = raiseDaemonPriority();
|
|
523
|
+
// Housekeeping (protect-all, integrity walks, heartbeat diagnostics, DPAPI
|
|
524
|
+
// recovery, task self-heal) runs in a worker process so it never holds the
|
|
525
|
+
// event loop that answers hooks, at the priority this daemon was launched
|
|
526
|
+
// with: what all of it ran at before. Created before the crash handlers that stop it.
|
|
527
|
+
const housekeeping = new housekeeping_1.HousekeepingClient({ cliEntry: cliEntry(), log, priority: launchPriority });
|
|
528
|
+
// hook.log appends from in-daemon evaluations leave the verdict loop too.
|
|
529
|
+
(0, hookIo_1.useAsyncHookLog)();
|
|
495
530
|
// The resident loop must never die silently from one bad tick (fs watcher
|
|
496
531
|
// callback, fetch, timer). Log and keep running — the loop is timer-driven,
|
|
497
532
|
// so surviving a failed tick is safe, and on Windows there is no supervisor
|
|
@@ -515,6 +550,8 @@ async function runDaemon(args, config) {
|
|
|
515
550
|
// misreport this as an external kill (EDR/AV) — the distress code above
|
|
516
551
|
// already carries the true story.
|
|
517
552
|
(0, daemonForensics_1.recordCleanExit)('uncaught-exception burst — self-recycled');
|
|
553
|
+
housekeeping.stop();
|
|
554
|
+
(0, hookIo_1.flushHookLogSync)();
|
|
518
555
|
releasePidLock();
|
|
519
556
|
process.exit(1);
|
|
520
557
|
}
|
|
@@ -531,17 +568,64 @@ async function runDaemon(args, config) {
|
|
|
531
568
|
const quiet = args.quiet === 'true';
|
|
532
569
|
log(`Daemon started (pid ${process.pid}, platform ${process.platform}).`);
|
|
533
570
|
log(`Shield: ${creds.shieldId || '(none — protect-only mode, telemetry disabled)'} API: ${creds.apiUrl}`);
|
|
571
|
+
// Settle the machine id before anything reports: the only place a pinned
|
|
572
|
+
// but unconfirmed fallback id may be re-checked against the OS (#1853).
|
|
573
|
+
// Verdict evaluations report too, so this precedes the verdict server.
|
|
574
|
+
try {
|
|
575
|
+
const id = (0, machineIdentity_1.reconcileMachineIdentity)('heal');
|
|
576
|
+
log(`Machine id ${(0, machineIdentity_1.shortMachineId)(id.machineId)} (${id.source}${id.confirmed ? ', backend-confirmed' : ''}${id.probed ? `, native read ${id.probeError ? `failed: ${id.probeError}` : 'ok'}` : ''}).`
|
|
577
|
+
+ (id.healed ? ` Healed from stale fallback id ${(0, machineIdentity_1.shortMachineId)(id.healed.from)}.` : '')
|
|
578
|
+
+ (id.conflict ? ` Native id ${(0, machineIdentity_1.shortMachineId)(id.conflict.native)} disagrees with the confirmed id — kept the confirmed one (machine_id_conflict).` : ''));
|
|
579
|
+
}
|
|
580
|
+
catch (error) {
|
|
581
|
+
log(`Machine id reconcile failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
582
|
+
}
|
|
583
|
+
// Verdict IPC server (resident-agent pattern): per-event hook processes
|
|
584
|
+
// become thin clients that ask THIS process for the verdict over a named
|
|
585
|
+
// pipe / Unix socket — everything (config, bundle, snapshot, policy engine)
|
|
586
|
+
// is warm here, so verdicts resolve in milliseconds instead of repeating
|
|
587
|
+
// cold-start work per IDE event. Config is re-read per request (small YAML,
|
|
588
|
+
// cheap) so on-disk edits apply without a daemon restart. Hooks fall back
|
|
589
|
+
// to full local evaluation whenever this server is down — never a blocker.
|
|
590
|
+
//
|
|
591
|
+
// Started FIRST, serving from the on-disk policy cache: boot self-heal, the
|
|
592
|
+
// bundle poll, the startup protection pass and the first heartbeat used to
|
|
593
|
+
// run before it, leaving every hook on the cold local path for 10-190s after
|
|
594
|
+
// each daemon start. None of them is needed to answer a hook, and all of the
|
|
595
|
+
// synchronous ones now run in the housekeeping worker.
|
|
596
|
+
let verdictServer;
|
|
597
|
+
// Evaluations served here only append to the spool. A debounced timer
|
|
598
|
+
// (never a verdict) hands the upload — spool read and trim, queued native
|
|
599
|
+
// allow reports, the POST, the guard-rule refresh — to the housekeeping
|
|
600
|
+
// worker, so neither the work nor a process spawn lands on this loop. In
|
|
601
|
+
// the degraded in-process housekeeping mode a detached flusher does it.
|
|
602
|
+
(0, telemetry_1.serveSpoolFlushOffLoop)(() => Boolean(creds.shieldId), {
|
|
603
|
+
upload: ({ refreshGuardRules }) => (housekeeping.offLoop
|
|
604
|
+
? housekeeping.run({
|
|
605
|
+
op: 'spoolFlush',
|
|
606
|
+
input: { apiUrl: creds.apiUrl, shieldId: creds.shieldId || '', shieldKey: creds.shieldKey, heartbeat: false, refreshGuardRules },
|
|
607
|
+
})
|
|
608
|
+
: undefined),
|
|
609
|
+
});
|
|
610
|
+
try {
|
|
611
|
+
verdictServer = (0, verdictIpc_1.startVerdictServer)((hookArgs, stdin) => (0, hook_1.evaluateHookRequest)(hookArgs, stdin, (0, config_1.loadConfig)(), { viaDaemon: true }), log);
|
|
612
|
+
}
|
|
613
|
+
catch (error) {
|
|
614
|
+
log(`Verdict IPC: failed to start (${error.message}) — hooks will evaluate locally.`);
|
|
615
|
+
}
|
|
616
|
+
// How long the verdict loop was held, reported with each heartbeat.
|
|
617
|
+
// Sampled ten times a second: cheap enough to leave on for the daemon's life.
|
|
618
|
+
const loopDelay = (0, perf_hooks_1.monitorEventLoopDelay)({ resolution: envMs('FCD_DAEMON_LOOP_SAMPLE_MS', 100) });
|
|
619
|
+
loopDelay.enable();
|
|
534
620
|
if (process.platform === 'win32') {
|
|
535
621
|
// `reg query /s` over the uninstall hive can take seconds; keep it off the startup path.
|
|
536
622
|
setTimeout(() => {
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
if ((0, msiProductCode_1.recordMsiProductCode)(productCode))
|
|
623
|
+
housekeeping.run({ op: 'msiProductCode' }).then(({ productCode, recorded }) => {
|
|
624
|
+
if (recorded)
|
|
540
625
|
log(`Recorded installed MSI ProductCode ${productCode} for the Local Safety uninstall rule.`);
|
|
541
|
-
}
|
|
542
|
-
catch (error) {
|
|
626
|
+
}, error => {
|
|
543
627
|
log(`Could not record the installed MSI ProductCode: ${error?.message || String(error)}`);
|
|
544
|
-
}
|
|
628
|
+
});
|
|
545
629
|
}, 20_000).unref();
|
|
546
630
|
}
|
|
547
631
|
// A machine enrolled with a DPAPI key whose decrypt fails (blocked
|
|
@@ -652,7 +736,12 @@ async function runDaemon(args, config) {
|
|
|
652
736
|
* Now a changed enrollment file reloads credentials whether or not the
|
|
653
737
|
* daemon had some — one heartbeat later it is on the new key.
|
|
654
738
|
*/
|
|
655
|
-
|
|
739
|
+
// The DPAPI decrypt behind a recovery spawns PowerShell: it runs in the
|
|
740
|
+
// housekeeping worker, whose view of PowerShell and of the key source then
|
|
741
|
+
// describes this daemon's credentials in the heartbeat.
|
|
742
|
+
let recoveredPowershell;
|
|
743
|
+
let recoveredTrace;
|
|
744
|
+
const recoverCredentialsIfMissing = async () => {
|
|
656
745
|
let enrollmentChanged = false;
|
|
657
746
|
try {
|
|
658
747
|
const mtime = fs.statSync((0, config_1.getHomeConfigPath)()).mtimeMs;
|
|
@@ -670,11 +759,16 @@ async function runDaemon(args, config) {
|
|
|
670
759
|
return;
|
|
671
760
|
}
|
|
672
761
|
try {
|
|
673
|
-
const
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
apiUrl: args.apiUrl,
|
|
762
|
+
const recovered = await housekeeping.run({
|
|
763
|
+
op: 'recoverCredentials',
|
|
764
|
+
configPath: args.config,
|
|
765
|
+
overrides: { shieldId: args.shieldId, shieldKey: args.shieldKey, apiUrl: args.apiUrl },
|
|
677
766
|
});
|
|
767
|
+
if (recovered.powershell)
|
|
768
|
+
recoveredPowershell = recovered.powershell;
|
|
769
|
+
if (recovered.trace)
|
|
770
|
+
recoveredTrace = recovered.trace;
|
|
771
|
+
const fresh = recovered.creds;
|
|
678
772
|
if (!fresh.shieldId || !fresh.shieldKey)
|
|
679
773
|
return;
|
|
680
774
|
const rotated = fresh.shieldId !== creds.shieldId || fresh.shieldKey !== creds.shieldKey || fresh.apiUrl !== creds.apiUrl;
|
|
@@ -733,23 +827,28 @@ async function runDaemon(args, config) {
|
|
|
733
827
|
attentionTimer = undefined;
|
|
734
828
|
if (stopped || suspended)
|
|
735
829
|
return;
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
830
|
+
void (async () => {
|
|
831
|
+
let verified = false;
|
|
832
|
+
try {
|
|
833
|
+
verified = !repairFailed && (await housekeeping.run({ op: 'integrity', requireDaemon: true })).ok;
|
|
834
|
+
}
|
|
835
|
+
catch { /* cannot verify */ }
|
|
836
|
+
if (stopped || suspended)
|
|
837
|
+
return;
|
|
838
|
+
deliverProtectionHealth(verified);
|
|
839
|
+
})();
|
|
742
840
|
}, PROTECTION_NOTICE_DELAY_MS);
|
|
743
841
|
}
|
|
744
842
|
};
|
|
745
|
-
const inspectProtection = () => {
|
|
843
|
+
const inspectProtection = async (pass) => {
|
|
746
844
|
// Snapshot BEFORE verification. An external write during/after inspection
|
|
747
845
|
// remains different from this baseline and its queued event gets checked.
|
|
748
|
-
const snapshot = (
|
|
749
|
-
|
|
750
|
-
observedConfigurations = snapshot;
|
|
846
|
+
const { snapshot, integrity } = await housekeeping.run({ op: 'inspect', files: [...watchedFiles], pass });
|
|
847
|
+
observedConfigurations = new Map(snapshot);
|
|
751
848
|
return integrity;
|
|
752
849
|
};
|
|
850
|
+
// One scan-root walk serves every step of a protection pass (see setProjectConfigScanPass).
|
|
851
|
+
let passSeq = 0;
|
|
753
852
|
/** One serialized repair path, including remote requests and new discovery. */
|
|
754
853
|
const reprotect = async (reasonPaths, cause = 'drift') => {
|
|
755
854
|
if (repairInFlight) {
|
|
@@ -767,8 +866,18 @@ async function runDaemon(args, config) {
|
|
|
767
866
|
return;
|
|
768
867
|
}
|
|
769
868
|
repairInFlight = (async () => {
|
|
869
|
+
const pass = `repair-${++passSeq}`;
|
|
770
870
|
try {
|
|
771
|
-
const before = inspectProtection();
|
|
871
|
+
const before = await inspectProtection(pass);
|
|
872
|
+
// The inspection ran in the housekeeping worker; an admin suspension
|
|
873
|
+
// (or shutdown) may have arrived meanwhile. A suspended machine is not
|
|
874
|
+
// re-protected, whenever the pass was asked for.
|
|
875
|
+
if (stopped)
|
|
876
|
+
return before;
|
|
877
|
+
if (suspended) {
|
|
878
|
+
log(`${headline} (${reasonPaths.join(', ')}) but machine is suspended — not re-protecting.`);
|
|
879
|
+
return before;
|
|
880
|
+
}
|
|
772
881
|
// Integrity/discovery triggers use the shared healable taxonomy. A real
|
|
773
882
|
// content change still runs the full idempotent repair pipeline: the
|
|
774
883
|
// integrity summary does not cover every hook migration/companion.
|
|
@@ -784,9 +893,9 @@ async function runDaemon(args, config) {
|
|
|
784
893
|
// after our write, including when its watch event is lost. Persistent
|
|
785
894
|
// failures still enter the existing backoff, never a repair spin loop.
|
|
786
895
|
for (let attempt = 1; attempt <= 2; attempt++) {
|
|
787
|
-
const repaired = await (
|
|
788
|
-
refreshWatchTargets();
|
|
789
|
-
integrity = inspectProtection();
|
|
896
|
+
const repaired = await housekeeping.run({ op: 'repair', args: { ...args, dryRun: undefined }, config, pass });
|
|
897
|
+
await refreshWatchTargets(pass);
|
|
898
|
+
integrity = await inspectProtection(pass);
|
|
790
899
|
log(`Re-protection pass complete (Cursor hooks ${repaired.cursorHooksHealthy ? 'healthy' : 'unhealthy'}, Claude hooks ${repaired.claudeHooksHealthy ? 'healthy' : 'unhealthy'}).`);
|
|
791
900
|
if (!(0, integrity_1.hasHealableIntegrityReason)(integrity.reasons) || suspended || stopped)
|
|
792
901
|
break;
|
|
@@ -821,26 +930,78 @@ async function runDaemon(args, config) {
|
|
|
821
930
|
repairInFlight = undefined;
|
|
822
931
|
}
|
|
823
932
|
};
|
|
824
|
-
|
|
933
|
+
/**
|
|
934
|
+
* The drift check's fingerprints, taken in the housekeeping worker. If the
|
|
935
|
+
* worker cannot answer (wedged past its ceiling, lost twice), they are taken
|
|
936
|
+
* here, as before the worker existed: a few file hashes, never a walk.
|
|
937
|
+
*/
|
|
938
|
+
const captureWatchedFiles = async () => {
|
|
939
|
+
const files = [...watchedFiles];
|
|
940
|
+
try {
|
|
941
|
+
return new Map((await housekeeping.run({ op: 'capture', files })).snapshot);
|
|
942
|
+
}
|
|
943
|
+
catch (error) {
|
|
944
|
+
if (stopped)
|
|
945
|
+
throw error;
|
|
946
|
+
log(`Protection change check: the housekeeping worker could not capture (${error?.message || String(error)}) — capturing in the daemon.`);
|
|
947
|
+
return (0, protectionMaintenance_1.captureProtectionConfiguration)(files);
|
|
948
|
+
}
|
|
949
|
+
};
|
|
950
|
+
/** Consecutive failed checks; a failed check is retried with back-off, never dropped. */
|
|
951
|
+
let checkFailures = 0;
|
|
952
|
+
const scheduleProtectionCheck = (delayMs = DEBOUNCE_MS) => {
|
|
825
953
|
if (debounceTimer)
|
|
826
954
|
clearTimeout(debounceTimer);
|
|
827
955
|
debounceTimer = setTimeout(() => {
|
|
828
956
|
debounceTimer = null;
|
|
829
957
|
void (async () => {
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
958
|
+
// The capture runs in the housekeeping worker, so a repair pass can
|
|
959
|
+
// start and observe the files while it is in flight. Comparing such a
|
|
960
|
+
// stale capture with the newer observation reads the pass's own writes
|
|
961
|
+
// as drift: capture again after that pass. A check is never dropped —
|
|
962
|
+
// after a few overtaken captures the last one is compared anyway (at
|
|
963
|
+
// worst one extra, idempotent pass).
|
|
964
|
+
for (let attempt = 1;; attempt++) {
|
|
965
|
+
// How that pass ended is its caller's to report; this check still runs.
|
|
966
|
+
if (repairInFlight)
|
|
967
|
+
await repairInFlight.catch(() => undefined);
|
|
968
|
+
if (stopped)
|
|
969
|
+
return;
|
|
970
|
+
if (refreshPending) {
|
|
971
|
+
refreshPending = false;
|
|
972
|
+
// A failed scan keeps the refresh pending for the retry below. A
|
|
973
|
+
// check's scan is protection convergence: it runs ahead of
|
|
974
|
+
// heartbeat walks and rescans in the worker.
|
|
975
|
+
try {
|
|
976
|
+
await refreshWatchTargets(`check-${++passSeq}`);
|
|
977
|
+
}
|
|
978
|
+
catch (error) {
|
|
979
|
+
refreshPending = true;
|
|
980
|
+
throw error;
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
const observed = observedConfigurations;
|
|
984
|
+
const current = await captureWatchedFiles();
|
|
985
|
+
if (stopped)
|
|
986
|
+
return;
|
|
987
|
+
if ((observed !== observedConfigurations || repairInFlight) && attempt < 3)
|
|
988
|
+
continue;
|
|
989
|
+
const changed = (0, protectionMaintenance_1.changedProtectionConfiguration)(observedConfigurations, current);
|
|
990
|
+
if (changed.length > 0)
|
|
991
|
+
await reprotect(changed);
|
|
833
992
|
return;
|
|
834
|
-
if (refreshPending) {
|
|
835
|
-
refreshPending = false;
|
|
836
|
-
refreshWatchTargets();
|
|
837
993
|
}
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
994
|
+
})().then(() => { checkFailures = 0; }, error => {
|
|
995
|
+
if (stopped)
|
|
996
|
+
return;
|
|
997
|
+
// The change that triggered this check emits no second event: retry it.
|
|
998
|
+
const retryMs = Math.min(60_000, DEBOUNCE_MS * 2 ** Math.min(checkFailures, 5));
|
|
999
|
+
checkFailures += 1;
|
|
1000
|
+
log(`Protection change check failed: ${error?.message || String(error)} — checking again in ${(retryMs / 1000).toFixed(1)}s.`);
|
|
1001
|
+
if (!debounceTimer)
|
|
1002
|
+
scheduleProtectionCheck(retryMs);
|
|
1003
|
+
});
|
|
1004
|
+
}, delayMs);
|
|
844
1005
|
};
|
|
845
1006
|
const onFsEvent = (dir, filename) => {
|
|
846
1007
|
if (stopped)
|
|
@@ -872,13 +1033,13 @@ async function runDaemon(args, config) {
|
|
|
872
1033
|
/**
|
|
873
1034
|
* (Re)build the watch set from the current MCP client configs + hook files.
|
|
874
1035
|
* Watches parent directories (not files) so atomic replaces on Windows and
|
|
875
|
-
* editor save-via-rename still emit events.
|
|
1036
|
+
* editor save-via-rename still emit events. The scan-root walk that finds
|
|
1037
|
+
* the files runs in the housekeeping worker; only the watchers live here.
|
|
876
1038
|
*/
|
|
877
|
-
const refreshWatchTargets = () => {
|
|
878
|
-
const targets =
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
];
|
|
1039
|
+
const refreshWatchTargets = async (pass) => {
|
|
1040
|
+
const { targets } = await housekeeping.run({ op: 'scanTargets', configPath: args.config, pass });
|
|
1041
|
+
if (stopped)
|
|
1042
|
+
return targets.length;
|
|
882
1043
|
watchedFiles.clear();
|
|
883
1044
|
watchedFileKeys.clear();
|
|
884
1045
|
const wantedDirs = new Set();
|
|
@@ -933,7 +1094,7 @@ async function runDaemon(args, config) {
|
|
|
933
1094
|
if (!creds.shieldId)
|
|
934
1095
|
return;
|
|
935
1096
|
try {
|
|
936
|
-
const raw = fs.
|
|
1097
|
+
const raw = await fs.promises.readFile(logFile(), 'utf8');
|
|
937
1098
|
// 200 lines (~half a day of routine ticks): enough context to diagnose a
|
|
938
1099
|
// failure loop remotely without asking the customer to paste anything.
|
|
939
1100
|
const lines = raw.split(/\r?\n/).filter(line => line.trim()).slice(-200);
|
|
@@ -986,18 +1147,18 @@ async function runDaemon(args, config) {
|
|
|
986
1147
|
// The complete remote support bundle: everything we asked Alex and Alin to
|
|
987
1148
|
// paste by hand during the lptx1110 incident, collected in one action.
|
|
988
1149
|
// Metadata + our own logs only — never customer files, prompts, or secrets.
|
|
989
|
-
const buildSupportBundle = () => {
|
|
990
|
-
const powershell = (0, config_1.getPowershellHealth)();
|
|
1150
|
+
const buildSupportBundle = async () => {
|
|
1151
|
+
const powershell = recoveredPowershell ?? (0, config_1.getPowershellHealth)();
|
|
991
1152
|
let integrity;
|
|
992
1153
|
try {
|
|
993
|
-
integrity =
|
|
1154
|
+
integrity = await housekeeping.run({ op: 'integrity', requireDaemon: true });
|
|
994
1155
|
}
|
|
995
1156
|
catch (error) {
|
|
996
1157
|
integrity = { error: error.message };
|
|
997
1158
|
}
|
|
998
1159
|
let daemonLogTail = [];
|
|
999
1160
|
try {
|
|
1000
|
-
daemonLogTail = fs.
|
|
1161
|
+
daemonLogTail = (await fs.promises.readFile(logFile(), 'utf8'))
|
|
1001
1162
|
.split(/\r?\n/).map(line => line.trim()).filter(Boolean).slice(-200).map(line => line.slice(0, 400));
|
|
1002
1163
|
}
|
|
1003
1164
|
catch { /* log unreadable — the bundle reports everything else */ }
|
|
@@ -1006,10 +1167,15 @@ async function runDaemon(args, config) {
|
|
|
1006
1167
|
// content previews exist only when support opted into FCD_HOOK_DEBUG.
|
|
1007
1168
|
let hookLogTail = [];
|
|
1008
1169
|
try {
|
|
1009
|
-
hookLogTail = fs.
|
|
1170
|
+
hookLogTail = (await fs.promises.readFile(path.join(os.homedir(), '.fullcourtdefense-hook.log'), 'utf8'))
|
|
1010
1171
|
.split(/\r?\n/).map(line => line.trim()).filter(Boolean).slice(-80).map(line => line.slice(0, 400));
|
|
1011
1172
|
}
|
|
1012
1173
|
catch { /* no hook activity on this machine yet */ }
|
|
1174
|
+
let securityAgents;
|
|
1175
|
+
try {
|
|
1176
|
+
securityAgents = (await housekeeping.run({ op: 'securityAgents' })).products;
|
|
1177
|
+
}
|
|
1178
|
+
catch { /* reported without it */ }
|
|
1013
1179
|
return {
|
|
1014
1180
|
cliVersion: cliVersion(),
|
|
1015
1181
|
platform: process.platform,
|
|
@@ -1018,9 +1184,9 @@ async function runDaemon(args, config) {
|
|
|
1018
1184
|
hookLogTail,
|
|
1019
1185
|
updaterLogTail: (0, selfUpdate_1.readUpdaterLogTail)(60),
|
|
1020
1186
|
distress: (0, distress_1.readDistressLedger)(),
|
|
1021
|
-
credentialTrace: (0, config_1.getCredentialResolutionTrace)(),
|
|
1187
|
+
credentialTrace: recoveredTrace ?? (0, config_1.getCredentialResolutionTrace)(),
|
|
1022
1188
|
powershell,
|
|
1023
|
-
securityAgents
|
|
1189
|
+
securityAgents,
|
|
1024
1190
|
integrity,
|
|
1025
1191
|
lastCrash: (0, daemonForensics_1.readPostmortem)(),
|
|
1026
1192
|
watchdogTaskInstalled: isWatchdogTaskInstalled(),
|
|
@@ -1178,13 +1344,15 @@ async function runDaemon(args, config) {
|
|
|
1178
1344
|
log('Health check: running deep self-test across all subsystems…');
|
|
1179
1345
|
await uploadLogTail();
|
|
1180
1346
|
const identity = (0, machineIdentity_1.getMachineIdentity)();
|
|
1181
|
-
|
|
1347
|
+
// PowerShell and DPAPI probes, schtasks queries and an integrity walk:
|
|
1348
|
+
// seconds of synchronous work, run in the housekeeping worker.
|
|
1349
|
+
const report = await housekeeping.run({
|
|
1350
|
+
op: 'selfTest',
|
|
1182
1351
|
creds,
|
|
1183
1352
|
developerName: identity.developerName,
|
|
1184
1353
|
machineName: identity.hostname,
|
|
1185
1354
|
machineId: identity.machineId,
|
|
1186
|
-
|
|
1187
|
-
log,
|
|
1355
|
+
credentialTrace: recoveredTrace ?? (0, config_1.getCredentialResolutionTrace)(),
|
|
1188
1356
|
});
|
|
1189
1357
|
await uploadDiagnostics({ selfTest: report });
|
|
1190
1358
|
resultSummary = (0, selfTest_1.summarizeSelfTest)(report);
|
|
@@ -1195,7 +1363,7 @@ async function runDaemon(args, config) {
|
|
|
1195
1363
|
else if (action.type === 'collect_diagnostics') {
|
|
1196
1364
|
log('Collect diagnostics: assembling support bundle (logs + distress ledger + environment — metadata only)…');
|
|
1197
1365
|
await uploadLogTail();
|
|
1198
|
-
const bundle = buildSupportBundle();
|
|
1366
|
+
const bundle = await buildSupportBundle();
|
|
1199
1367
|
const uploaded = await uploadDiagnostics({ bundle });
|
|
1200
1368
|
if (!uploaded)
|
|
1201
1369
|
throw new Error('Support bundle could not be uploaded (network or backend rejection).');
|
|
@@ -1234,7 +1402,7 @@ async function runDaemon(args, config) {
|
|
|
1234
1402
|
await uploadLogTail();
|
|
1235
1403
|
await reprotect(['remote repair request'], 'manual');
|
|
1236
1404
|
log('Repair protection: verifying hooks, gateways and daemon integrity…');
|
|
1237
|
-
const verification =
|
|
1405
|
+
const verification = await housekeeping.run({ op: 'integrity', requireDaemon: true });
|
|
1238
1406
|
if (!verification.ok) {
|
|
1239
1407
|
throw new Error(`Repair completed but verification still reports: ${verification.reasons.join(', ')}`);
|
|
1240
1408
|
}
|
|
@@ -1378,16 +1546,20 @@ async function runDaemon(args, config) {
|
|
|
1378
1546
|
}
|
|
1379
1547
|
const identity = (0, machineIdentity_1.getMachineIdentity)();
|
|
1380
1548
|
try {
|
|
1381
|
-
|
|
1549
|
+
// The hooks' Local Safety snapshot rides the same refresh: fetched
|
|
1550
|
+
// unless the one on disk already is the bundle's policy hash, written
|
|
1551
|
+
// before the new bundle (hooks never fetch either cache — this poll is
|
|
1552
|
+
// what keeps them fresh). `scheduled` records the attempt, so hot paths
|
|
1553
|
+
// start no refresher of their own for this shield while it runs.
|
|
1554
|
+
const bundle = await (0, policyRefresh_1.refreshPolicyCaches)({
|
|
1382
1555
|
apiUrl: creds.apiUrl,
|
|
1383
1556
|
shieldId: creds.shieldId,
|
|
1384
1557
|
shieldKey: creds.shieldKey,
|
|
1385
1558
|
developerName: identity.developerName,
|
|
1386
1559
|
machineName: identity.hostname,
|
|
1387
1560
|
machineId: identity.machineId,
|
|
1388
|
-
force: true,
|
|
1389
1561
|
timeoutMs: BUNDLE_FETCH_TIMEOUT_MS,
|
|
1390
|
-
|
|
1562
|
+
scheduled: true,
|
|
1391
1563
|
});
|
|
1392
1564
|
if (typeof bundle.pollIntervalMs === 'number' && Number.isFinite(bundle.pollIntervalMs)) {
|
|
1393
1565
|
bundlePollBaseMs = Math.min(Math.max(bundle.pollIntervalMs, 15_000), 60 * 60_000);
|
|
@@ -1469,22 +1641,6 @@ async function runDaemon(args, config) {
|
|
|
1469
1641
|
log(`Honeypot: removal failed: ${error.message}`);
|
|
1470
1642
|
}
|
|
1471
1643
|
}
|
|
1472
|
-
// Keep the IDE hooks' Local Safety snapshot warm. Hooks on monitor
|
|
1473
|
-
// machines read this cache with preferCached and never fetch — the
|
|
1474
|
-
// daemon is the only process paying the network cost, off the hot path.
|
|
1475
|
-
try {
|
|
1476
|
-
const hookIdentity = (0, localSafetySnapshot_1.hookSnapshotIdentity)();
|
|
1477
|
-
await (0, localSafetySnapshot_1.loadLocalSafetySnapshot)({
|
|
1478
|
-
apiUrl: creds.apiUrl,
|
|
1479
|
-
shieldId: creds.shieldId,
|
|
1480
|
-
shieldKey: creds.shieldKey,
|
|
1481
|
-
developerName: hookIdentity.developerName,
|
|
1482
|
-
machineName: hookIdentity.machineName,
|
|
1483
|
-
expectedPolicyHash: bundle.policyHash || bundle.version,
|
|
1484
|
-
timeoutMs: 8_000,
|
|
1485
|
-
});
|
|
1486
|
-
}
|
|
1487
|
-
catch { /* hooks fall back to their own bounded fetch */ }
|
|
1488
1644
|
}
|
|
1489
1645
|
catch (error) {
|
|
1490
1646
|
// Local enforcement can keep using its cache, but this delivery check
|
|
@@ -1493,9 +1649,22 @@ async function runDaemon(args, config) {
|
|
|
1493
1649
|
throw error;
|
|
1494
1650
|
}
|
|
1495
1651
|
};
|
|
1652
|
+
/** Verdict-loop health for this heartbeat window: the server's own counters plus how long the loop was held. */
|
|
1653
|
+
const verdictIpcHealth = () => {
|
|
1654
|
+
const delay = loopDelay.count > 0
|
|
1655
|
+
? { eventLoopDelayP99Ms: Math.round(loopDelay.percentile(99) / 1e6), eventLoopDelayMaxMs: Math.round(loopDelay.max / 1e6) }
|
|
1656
|
+
: {};
|
|
1657
|
+
return { ...(verdictServer ? verdictServer.stats() : { listening: false, state: 'closed' }), ...delay, detectorInThread: (0, detectorInThread_1.inThreadDetectorActive)() };
|
|
1658
|
+
};
|
|
1659
|
+
/** A delivered heartbeat starts the next percentile window. */
|
|
1660
|
+
const resetHealthWindows = () => {
|
|
1661
|
+
verdictServer?.stats(true);
|
|
1662
|
+
housekeeping.stats(true);
|
|
1663
|
+
loopDelay.reset();
|
|
1664
|
+
};
|
|
1496
1665
|
const heartbeat = async () => {
|
|
1497
1666
|
(0, daemonForensics_1.touchDaemonAlive)();
|
|
1498
|
-
recoverCredentialsIfMissing();
|
|
1667
|
+
await recoverCredentialsIfMissing();
|
|
1499
1668
|
if (!creds.shieldId)
|
|
1500
1669
|
return;
|
|
1501
1670
|
// Did THIS beat actually record daemon liveness upstream? `flushSpool`
|
|
@@ -1508,7 +1677,7 @@ async function runDaemon(args, config) {
|
|
|
1508
1677
|
let reported = false;
|
|
1509
1678
|
let refusal;
|
|
1510
1679
|
try {
|
|
1511
|
-
let integrity =
|
|
1680
|
+
let integrity = await housekeeping.run({ op: 'integrity', requireDaemon: true });
|
|
1512
1681
|
// Recovery outside our repair pass (for example a manual fix) also clears
|
|
1513
1682
|
// the old failure's backoff. It must not suppress an unrelated later gap.
|
|
1514
1683
|
// An execution error can concern a component outside this summary (e.g.
|
|
@@ -1527,7 +1696,7 @@ async function runDaemon(args, config) {
|
|
|
1527
1696
|
|| Date.now() - lastIntegrityHealFailureAt >= INTEGRITY_HEAL_COOLDOWN_MS)
|
|
1528
1697
|
&& (repairFailed || (0, integrity_1.hasHealableIntegrityReason)(integrity.reasons))) {
|
|
1529
1698
|
const repaired = await reprotect(integrity.reasons, 'integrity');
|
|
1530
|
-
integrity =
|
|
1699
|
+
integrity = await housekeeping.run({ op: 'integrity', requireDaemon: true });
|
|
1531
1700
|
if (repaired) {
|
|
1532
1701
|
log(integrity.ok
|
|
1533
1702
|
? 'Integrity self-heal: protection restored.'
|
|
@@ -1539,39 +1708,52 @@ async function runDaemon(args, config) {
|
|
|
1539
1708
|
// Report a previous daemon's unclean death exactly once.
|
|
1540
1709
|
const crash = (0, daemonForensics_1.readPostmortem)();
|
|
1541
1710
|
const unreportedCrash = crash && !crash.reportedAt ? crash : undefined;
|
|
1542
|
-
const powershell = (0, config_1.getPowershellHealth)();
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1711
|
+
const powershell = recoveredPowershell ?? (0, config_1.getPowershellHealth)();
|
|
1712
|
+
// The flush (spool read/rewrite, shell-audit registry probes, guard-rule
|
|
1713
|
+
// refresh, EDR probe) runs in the housekeeping worker; what only this
|
|
1714
|
+
// process knows travels with it.
|
|
1715
|
+
const { result, failure } = await housekeeping.run({
|
|
1716
|
+
op: 'heartbeatFlush',
|
|
1717
|
+
securityAgents: true,
|
|
1718
|
+
input: {
|
|
1719
|
+
apiUrl: creds.apiUrl,
|
|
1720
|
+
shieldId: creds.shieldId,
|
|
1721
|
+
shieldKey: creds.shieldKey,
|
|
1722
|
+
heartbeat: true,
|
|
1723
|
+
daemon: true,
|
|
1724
|
+
agentVersion: cliVersion(),
|
|
1725
|
+
integrityOk: integrity.ok,
|
|
1726
|
+
integrityReasons: integrity.reasons,
|
|
1727
|
+
integrityCheckedAt: integrity.checkedAt,
|
|
1728
|
+
desktopChatGuard: (0, desktopChatGuard_1.desktopChatGuardSupported)() && (0, desktopChatGuard_1.desktopChatGuardHealthy)(),
|
|
1729
|
+
powershellSpawnOk: powershell?.spawnOk,
|
|
1730
|
+
powershellDecryptOk: powershell?.decryptOk,
|
|
1731
|
+
powershellLanguageMode: powershell?.languageMode,
|
|
1732
|
+
shieldKeySource: (recoveredTrace ?? (0, config_1.getCredentialResolutionTrace)())?.shieldKeySource,
|
|
1733
|
+
distress: (0, distress_1.readDistressSnapshot)(),
|
|
1734
|
+
lastCrash: unreportedCrash
|
|
1735
|
+
? {
|
|
1736
|
+
version: unreportedCrash.version,
|
|
1737
|
+
startedAt: unreportedCrash.startedAt,
|
|
1738
|
+
lastAliveAt: unreportedCrash.lastAliveAt,
|
|
1739
|
+
detectedAt: unreportedCrash.detectedAt,
|
|
1740
|
+
detectedBy: unreportedCrash.detectedBy,
|
|
1741
|
+
cause: unreportedCrash.cause,
|
|
1742
|
+
machineBootedAt: unreportedCrash.machineBootedAt,
|
|
1743
|
+
}
|
|
1744
|
+
: undefined,
|
|
1745
|
+
verdictIpc: verdictIpcHealth(),
|
|
1746
|
+
housekeeping: { ...housekeeping.stats() },
|
|
1747
|
+
// The detector that decides verdicts is this process's, not the worker's.
|
|
1748
|
+
detectionUpdatesStatus: localDetectionUpdates_1.localDetectionUpdates.getStatus(),
|
|
1749
|
+
detectorUpdatesStatus: localDetectionUpdates_1.localDetectorUpdates.getDetectorStatus(),
|
|
1750
|
+
},
|
|
1571
1751
|
});
|
|
1572
1752
|
reported = result !== null;
|
|
1573
1753
|
if (!reported)
|
|
1574
|
-
refusal =
|
|
1754
|
+
refusal = failure;
|
|
1755
|
+
else
|
|
1756
|
+
resetHealthWindows();
|
|
1575
1757
|
if (result && unreportedCrash)
|
|
1576
1758
|
(0, daemonForensics_1.markPostmortemReported)();
|
|
1577
1759
|
if (result && result.accepted > 0)
|
|
@@ -1596,29 +1778,26 @@ async function runDaemon(args, config) {
|
|
|
1596
1778
|
}
|
|
1597
1779
|
};
|
|
1598
1780
|
// --- boot ---------------------------------------------------------------
|
|
1599
|
-
//
|
|
1600
|
-
//
|
|
1781
|
+
// The machine id is settled and the verdict server is already serving (see
|
|
1782
|
+
// above). Autostart/watchdog task self-heal, updater task convergence and a
|
|
1783
|
+
// stale cmd AutoRun repair spawn schtasks/reg: housekeeping worker.
|
|
1601
1784
|
try {
|
|
1602
|
-
const
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1785
|
+
const healed = await housekeeping.run({ op: 'bootSelfHeal', apiUrl: creds.apiUrl });
|
|
1786
|
+
if (healed.cmdAutorunRepaired)
|
|
1787
|
+
log('Repaired a stale cmd.exe AutoRun / doskey guard left by an earlier install.');
|
|
1788
|
+
if (healed.cacheTempsSwept)
|
|
1789
|
+
log(`Removed ${healed.cacheTempsSwept} stale policy-cache temp file(s).`);
|
|
1606
1790
|
}
|
|
1607
1791
|
catch (error) {
|
|
1608
|
-
log(`
|
|
1792
|
+
log(`Boot self-heal failed: ${error?.message || String(error)} — retried on the next daemon start.`);
|
|
1793
|
+
}
|
|
1794
|
+
let watched = 0;
|
|
1795
|
+
try {
|
|
1796
|
+
watched = await refreshWatchTargets();
|
|
1797
|
+
}
|
|
1798
|
+
catch (error) {
|
|
1799
|
+
log(`Config scan failed: ${error?.message || String(error)} — the rescan tick retries.`);
|
|
1609
1800
|
}
|
|
1610
|
-
// Self-heal autostart + watchdog tasks: machines installed by older versions
|
|
1611
|
-
// (or where task creation failed once) must converge without a reinstall.
|
|
1612
|
-
ensureWindowsAutostartHealthy(log);
|
|
1613
|
-
// Converge pre-Node-updater machines onto the PowerShell-free updater task
|
|
1614
|
-
// (best-effort; needs an elevated daemon to rewrite a SYSTEM task).
|
|
1615
|
-
(0, selfUpdate_1.modernizeUpdaterTask)(log, creds.apiUrl);
|
|
1616
|
-
// Heal a stale/broken cmd-guard AutoRun (bat deleted, or macros pointing at
|
|
1617
|
-
// a dead node.exe) so wrapped commands (docker, git, …) never stay broken.
|
|
1618
|
-
if ((0, cmdGuard_1.repairStaleCmdAutorun)()) {
|
|
1619
|
-
log('Repaired a stale cmd.exe AutoRun / doskey guard left by an earlier install.');
|
|
1620
|
-
}
|
|
1621
|
-
const watched = refreshWatchTargets();
|
|
1622
1801
|
log(`Watching ${watched} config file(s) across ${watchers.size} director${watchers.size === 1 ? 'y' : 'ies'}.`);
|
|
1623
1802
|
// First: if this boot IS the post-upgrade relaunch, confirm the pending
|
|
1624
1803
|
// upgrade_cli action before anything else touches the control plane.
|
|
@@ -1663,20 +1842,6 @@ async function runDaemon(args, config) {
|
|
|
1663
1842
|
if (desktopChatGuard)
|
|
1664
1843
|
log('Claude Desktop chat guard: supervising (advisory — warns on secrets typed into Claude Desktop).');
|
|
1665
1844
|
}
|
|
1666
|
-
// Verdict IPC server (resident-agent pattern): per-event hook processes
|
|
1667
|
-
// become thin clients that ask THIS process for the verdict over a named
|
|
1668
|
-
// pipe / Unix socket — everything (config, bundle, snapshot, policy engine)
|
|
1669
|
-
// is warm here, so verdicts resolve in milliseconds instead of repeating
|
|
1670
|
-
// cold-start work per IDE event. Config is re-read per request (small YAML,
|
|
1671
|
-
// cheap) so on-disk edits apply without a daemon restart. Hooks fall back
|
|
1672
|
-
// to full local evaluation whenever this server is down — never a blocker.
|
|
1673
|
-
let verdictServer;
|
|
1674
|
-
try {
|
|
1675
|
-
verdictServer = (0, verdictIpc_1.startVerdictServer)((hookArgs, stdin) => (0, hook_1.evaluateHookRequest)(hookArgs, stdin, (0, config_1.loadConfig)(), { viaDaemon: true }), log);
|
|
1676
|
-
}
|
|
1677
|
-
catch (error) {
|
|
1678
|
-
log(`Verdict IPC: failed to start (${error.message}) — hooks will evaluate locally.`);
|
|
1679
|
-
}
|
|
1680
1845
|
// Fresh machines have never uploaded an inventory (MSI/onboard defers the
|
|
1681
1846
|
// initial discovery to keep setup fast), so the dashboard shows "Never" for
|
|
1682
1847
|
// discovery + posture until the daily scheduled job fires — up to 24h later.
|
|
@@ -1748,18 +1913,30 @@ async function runDaemon(args, config) {
|
|
|
1748
1913
|
// re-check still guarantees same-day freshness.
|
|
1749
1914
|
const discoverCatchUpBootTimer = setTimeout(() => { void maybeCatchUpDiscovery('after start'); }, DISCOVER_BOOT_CATCHUP_DELAY_MS + Math.floor(Math.random() * DISCOVER_BOOT_CATCHUP_JITTER_MS));
|
|
1750
1915
|
const discoverCatchUpTimer = setInterval(() => { void maybeCatchUpDiscovery('hourly check'); }, DISCOVER_CHECK_INTERVAL_MS);
|
|
1916
|
+
let rescanInFlight = false;
|
|
1751
1917
|
const rescanTimer = setInterval(() => {
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1918
|
+
// The walk runs in the housekeeping worker; a slow one never stacks. A
|
|
1919
|
+
// repair pass in flight rescans the targets itself when it finishes.
|
|
1920
|
+
if (rescanInFlight || repairInFlight)
|
|
1921
|
+
return;
|
|
1922
|
+
rescanInFlight = true;
|
|
1923
|
+
void (async () => {
|
|
1924
|
+
const pass = `rescan-${++passSeq}`;
|
|
1925
|
+
const previousTargets = new Set(watchedFiles);
|
|
1926
|
+
const count = await refreshWatchTargets(pass);
|
|
1927
|
+
if (stopped)
|
|
1928
|
+
return;
|
|
1929
|
+
log(`Rescan: watching ${count} config file(s).`);
|
|
1930
|
+
// A config created before its parent was watched emits no event for us.
|
|
1931
|
+
// Discovery is therefore a repair trigger, even during another gap's
|
|
1932
|
+
// failure backoff. Repeated scans of the same targets do not retry it.
|
|
1933
|
+
const added = [...watchedFiles].filter(file => !previousTargets.has(file));
|
|
1934
|
+
if (added.length > 0 && !suspended && !stopped
|
|
1935
|
+
&& (0, integrity_1.hasHealableIntegrityReason)((await housekeeping.run({ op: 'integrity', pass })).reasons)) {
|
|
1936
|
+
void reprotect(added, 'discovery');
|
|
1937
|
+
}
|
|
1938
|
+
})().catch(error => log(`Rescan failed: ${error?.message || String(error)}`))
|
|
1939
|
+
.finally(() => { rescanInFlight = false; });
|
|
1763
1940
|
}, RESCAN_INTERVAL_MS);
|
|
1764
1941
|
// Jittered self-rescheduling polls (±20%) instead of fixed setInterval:
|
|
1765
1942
|
// a fleet enrolled behind one corporate proxy must not hit the gate in
|
|
@@ -1856,14 +2033,12 @@ async function runDaemon(args, config) {
|
|
|
1856
2033
|
// that never stay up 24h still get cleaned). Only touches FCD-managed
|
|
1857
2034
|
// transcript folders; see pruneTranscripts.
|
|
1858
2035
|
const runTranscriptPrune = () => {
|
|
1859
|
-
|
|
1860
|
-
const result = (0, windowsAudit_1.pruneTranscripts)();
|
|
2036
|
+
housekeeping.run({ op: 'pruneTranscripts' }).then(result => {
|
|
1861
2037
|
if (result.pruned > 0)
|
|
1862
2038
|
log(`Transcript retention: pruned ${result.pruned} transcript file(s) older than ${(0, windowsAudit_1.transcriptRetentionDays)()} day(s).`);
|
|
1863
2039
|
if ((result.prunedBySize || 0) > 0)
|
|
1864
2040
|
log(`Transcript retention: pruned ${result.prunedBySize} more transcript file(s) (oldest first) to stay under the ${(0, windowsAudit_1.transcriptMaxMb)()}MB size budget.`);
|
|
1865
|
-
}
|
|
1866
|
-
catch { /* never let housekeeping hurt the daemon */ }
|
|
2041
|
+
}, () => { });
|
|
1867
2042
|
};
|
|
1868
2043
|
const transcriptPruneBootTimer = setTimeout(runTranscriptPrune, 5 * 60_000);
|
|
1869
2044
|
const transcriptPruneTimer = setInterval(runTranscriptPrune, 24 * 60 * 60_000);
|
|
@@ -1890,6 +2065,9 @@ async function runDaemon(args, config) {
|
|
|
1890
2065
|
desktopChatGuard.stop();
|
|
1891
2066
|
if (verdictServer)
|
|
1892
2067
|
verdictServer.close();
|
|
2068
|
+
housekeeping.stop();
|
|
2069
|
+
loopDelay.disable();
|
|
2070
|
+
(0, hookIo_1.flushHookLogSync)();
|
|
1893
2071
|
for (const watcher of watchers.values())
|
|
1894
2072
|
watcher.close();
|
|
1895
2073
|
for (const watcher of rootWatchers.values())
|