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