fullcourtdefense-cli 1.21.29 → 1.21.30

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.
@@ -46,5 +46,7 @@ export declare function spawnDetachedDaemon(): boolean;
46
46
  export declare function pidIsAlive(pid: number): boolean;
47
47
  export declare function macosLaunchdPath(currentPath?: string, execPath?: string): string;
48
48
  /** Whether the daemon has been registered to start automatically. */
49
+ /** Is the 5-minute watchdog scheduled task registered? (Windows-only feature.) */
50
+ export declare function isWatchdogTaskInstalled(): boolean;
49
51
  export declare function isDaemonAutostartInstalled(): boolean;
50
52
  export declare function daemonCommand(args: DaemonArgs, config: BotGuardConfig): Promise<void>;
@@ -40,6 +40,7 @@ exports.daemonRuntimeState = daemonRuntimeState;
40
40
  exports.spawnDetachedDaemon = spawnDetachedDaemon;
41
41
  exports.pidIsAlive = pidIsAlive;
42
42
  exports.macosLaunchdPath = macosLaunchdPath;
43
+ exports.isWatchdogTaskInstalled = isWatchdogTaskInstalled;
43
44
  exports.isDaemonAutostartInstalled = isDaemonAutostartInstalled;
44
45
  exports.daemonCommand = daemonCommand;
45
46
  const fs = __importStar(require("fs"));
@@ -1290,6 +1291,17 @@ function systemdUnitPath() {
1290
1291
  return path.join(base, 'systemd', 'user', SYSTEMD_UNIT);
1291
1292
  }
1292
1293
  /** Whether the daemon has been registered to start automatically. */
1294
+ /** Is the 5-minute watchdog scheduled task registered? (Windows-only feature.) */
1295
+ function isWatchdogTaskInstalled() {
1296
+ if (process.platform !== 'win32')
1297
+ return false;
1298
+ const result = (0, child_process_1.spawnSync)('schtasks', ['/Query', '/TN', WATCHDOG_TASK_NAME], {
1299
+ stdio: 'ignore',
1300
+ timeout: 5_000,
1301
+ windowsHide: true,
1302
+ });
1303
+ return result.status === 0;
1304
+ }
1293
1305
  function isDaemonAutostartInstalled() {
1294
1306
  if (process.platform === 'win32') {
1295
1307
  const result = (0, child_process_1.spawnSync)('schtasks', ['/Query', '/TN', TASK_NAME], {
@@ -53,6 +53,8 @@ const machineIdentity_1 = require("../machineIdentity");
53
53
  const appDetection_1 = require("../appDetection");
54
54
  const integrity_1 = require("../integrity");
55
55
  const telemetry_1 = require("../telemetry");
56
+ const envDiagnostics_1 = require("../envDiagnostics");
57
+ const daemonForensics_1 = require("../daemonForensics");
56
58
  const onboardingJournal_1 = require("./onboardingJournal");
57
59
  const GREEN = '\x1b[32m';
58
60
  const RED = '\x1b[31m';
@@ -246,7 +248,7 @@ async function onboardCommand(args, config) {
246
248
  const apiUrl = creds.apiUrl;
247
249
  const token = (args.token || process.env.FCD_ENROLL_TOKEN || '').trim();
248
250
  // 1. Connectivity.
249
- console.log(`${BOLD}[1/6] Checking compatibility and connectivity…${RESET}`);
251
+ console.log(`${BOLD}[1/7] Checking compatibility and connectivity…${RESET}`);
250
252
  mark('preflight', 'running');
251
253
  // A stale cmd-guard AutoRun (registry points at a deleted autorun script)
252
254
  // poisons every cmd.exe exit code on the machine — child spawns and the MSI's
@@ -280,7 +282,7 @@ async function onboardCommand(args, config) {
280
282
  }
281
283
  mark('preflight', 'completed');
282
284
  // 2. Enrollment.
283
- console.log(`\n${BOLD}[2/6] Enrolling this machine…${RESET}`);
285
+ console.log(`\n${BOLD}[2/7] Enrolling this machine…${RESET}`);
284
286
  mark('enrollment', 'running');
285
287
  const alreadyEnrolled = Boolean(creds.shieldId && creds.shieldKey);
286
288
  if (dryRun) {
@@ -305,7 +307,7 @@ async function onboardCommand(args, config) {
305
307
  // Re-read config: login just wrote fresh shield credentials to ~/.fullcourtdefense.yml.
306
308
  const freshConfig = (0, config_1.loadConfig)();
307
309
  // 3. Protection.
308
- console.log(`\n${BOLD}[3/6] Installing protection (install-all)…${RESET}`);
310
+ console.log(`\n${BOLD}[3/7] Installing protection (install-all)…${RESET}`);
309
311
  if (dryRun) {
310
312
  mark('protection', 'skipped', 'would install MCP gateways, IDE hooks, and terminal guards');
311
313
  }
@@ -334,7 +336,7 @@ async function onboardCommand(args, config) {
334
336
  return;
335
337
  }
336
338
  }
337
- console.log(`\n${BOLD}[4/6] Installing background protection…${RESET}`);
339
+ console.log(`\n${BOLD}[4/7] Installing background protection…${RESET}`);
338
340
  if (dryRun || args.noDaemon === 'true') {
339
341
  mark('daemon', 'skipped', dryRun ? 'would register the resident daemon' : 'disabled by --no-daemon true');
340
342
  }
@@ -355,7 +357,7 @@ async function onboardCommand(args, config) {
355
357
  }
356
358
  // Discovery and scheduling are useful fleet telemetry, but must never mark a
357
359
  // device as unprotected or prevent a repair of the enforcement surfaces.
358
- console.log(`\n${BOLD}[5/6] Recording optional discovery and schedule…${RESET}`);
360
+ console.log(`\n${BOLD}[5/7] Recording optional discovery and schedule…${RESET}`);
359
361
  if (dryRun || args.discover === 'false') {
360
362
  mark('discovery', 'skipped', dryRun ? 'would upload desktop discovery' : 'disabled by --discover false');
361
363
  }
@@ -383,8 +385,38 @@ async function onboardCommand(args, config) {
383
385
  mark('schedule', 'failed', undefined, error instanceof Error ? error.message : String(error));
384
386
  }
385
387
  }
386
- // 4. Verification.
387
- console.log(`\n${BOLD}[6/6] Verifying protection surfaces…${RESET}`);
388
+ // 4. Environment diagnostics — the "will this machine stay alive?" snapshot.
389
+ // Runs while we still have an interactive session on the (possibly EDR-
390
+ // hardened) machine: EDR/AV inventory, PowerShell/DPAPI health, daemon
391
+ // kill-detection (alive now AND still alive seconds later), watchdog task,
392
+ // and any crash post-mortem from a previous install. Never critical — the
393
+ // findings ship to the fleet console in the first heartbeat below.
394
+ console.log(`\n${BOLD}[6/7] Collecting environment diagnostics…${RESET}`);
395
+ let envDiag;
396
+ if (dryRun) {
397
+ mark('diagnostics', 'skipped', 'would probe EDR/AV, PowerShell health, and daemon liveness');
398
+ }
399
+ else {
400
+ mark('diagnostics', 'running');
401
+ try {
402
+ envDiag = await (0, envDiagnostics_1.collectEnvironmentDiagnostics)({ recheckDelayMs: args.noDaemon === 'true' ? 0 : 5_000 });
403
+ const summary = (0, envDiagnostics_1.summarizeEnvironmentDiagnostics)(envDiag);
404
+ console.log(` ${DIM}${summary}${RESET}`);
405
+ if ((0, envDiagnostics_1.environmentLooksHealthy)(envDiag)) {
406
+ mark('diagnostics', 'completed', summary);
407
+ }
408
+ else {
409
+ // Non-critical: a hostile environment is REPORTED, not treated as an
410
+ // install failure — the protection surfaces below are what gate.
411
+ mark('diagnostics', 'failed', summary, summary);
412
+ }
413
+ }
414
+ catch (error) {
415
+ mark('diagnostics', 'failed', undefined, error instanceof Error ? error.message : String(error));
416
+ }
417
+ }
418
+ // 5. Verification.
419
+ console.log(`\n${BOLD}[7/7] Verifying protection surfaces…${RESET}`);
388
420
  mark('verification', 'running');
389
421
  const postCreds = (0, config_1.resolveCliCredentials)((0, config_1.loadConfig)(), { apiUrl });
390
422
  // Immediate heartbeat: the machine must appear ONLINE in the fleet console
@@ -409,9 +441,31 @@ async function onboardCommand(args, config) {
409
441
  integrityOk: integrity.ok,
410
442
  integrityReasons: integrity.reasons,
411
443
  integrityCheckedAt: integrity.checkedAt,
444
+ // Environment diagnostics ride the FIRST heartbeat: the fleet console
445
+ // knows this machine's EDR/AV products, PowerShell health, and any
446
+ // previous crash the moment onboarding finishes — before the machine
447
+ // has any chance to go silent.
448
+ securityAgents: envDiag?.securityAgents,
449
+ powershellSpawnOk: envDiag?.powershellSpawnOk,
450
+ powershellDecryptOk: envDiag?.powershellDecryptOk,
451
+ lastCrash: envDiag?.lastCrash
452
+ ? {
453
+ version: envDiag.lastCrash.version,
454
+ startedAt: envDiag.lastCrash.startedAt,
455
+ lastAliveAt: envDiag.lastCrash.lastAliveAt,
456
+ detectedAt: envDiag.lastCrash.detectedAt,
457
+ detectedBy: envDiag.lastCrash.detectedBy,
458
+ }
459
+ : undefined,
412
460
  timeoutMs: 8_000,
413
461
  });
414
462
  firstHeartbeatOk = flush !== null;
463
+ if (firstHeartbeatOk && envDiag?.lastCrash) {
464
+ try {
465
+ (0, daemonForensics_1.markPostmortemReported)();
466
+ }
467
+ catch { /* best-effort */ }
468
+ }
415
469
  }
416
470
  catch { /* the daemon heartbeat remains the backstop */ }
417
471
  }
@@ -455,6 +509,41 @@ async function onboardCommand(args, config) {
455
509
  ? 'this machine now shows ONLINE in the fleet console'
456
510
  : dryRun ? 'skipped (dry run)' : 'not delivered yet — the daemon retries within 5 minutes',
457
511
  },
512
+ // --- environment diagnostics (informational: report, never block) -------
513
+ {
514
+ label: 'Daemon survived post-install liveness check',
515
+ ok: envDiag ? envDiag.daemonSurvivedRecheck !== false && envDiag.daemonAlive : true,
516
+ optional: true,
517
+ detail: !envDiag
518
+ ? 'skipped (dry run)'
519
+ : envDiag.daemonSurvivedRecheck === false
520
+ ? 'daemon was KILLED seconds after starting — security software on this machine is terminating it'
521
+ : envDiag.daemonAlive ? `pid ${envDiag.daemonPid}` : 'daemon not running — the watchdog will start it within 5 minutes',
522
+ },
523
+ {
524
+ label: 'Watchdog liveness beacon (5-minute health reports)',
525
+ ok: envDiag ? envDiag.watchdogTaskInstalled : true,
526
+ optional: true,
527
+ detail: envDiag && !envDiag.watchdogTaskInstalled && process.platform === 'win32'
528
+ ? 'task not registered — the daemon re-creates it at every start'
529
+ : undefined,
530
+ },
531
+ {
532
+ label: 'PowerShell available for secure key storage',
533
+ ok: envDiag ? envDiag.powershellSpawnOk !== false : true,
534
+ optional: true,
535
+ detail: envDiag?.powershellSpawnOk === false
536
+ ? 'PowerShell is blocked on this machine (security policy) — credentials use a PowerShell-free path'
537
+ : envDiag?.powershellDecryptOk === false ? 'PowerShell runs but the stored key did not decrypt' : undefined,
538
+ },
539
+ {
540
+ label: 'Security software inventory',
541
+ ok: true,
542
+ optional: true,
543
+ detail: envDiag?.securityAgents
544
+ ? envDiag.securityAgents.length ? envDiag.securityAgents.join(', ') : 'none detected'
545
+ : 'not probed (non-Windows or dry run)',
546
+ },
458
547
  ];
459
548
  console.log('');
460
549
  for (const check of checks)
@@ -1,4 +1,4 @@
1
- export type OnboardingStepName = 'preflight' | 'enrollment' | 'protection' | 'daemon' | 'discovery' | 'schedule' | 'verification';
1
+ export type OnboardingStepName = 'preflight' | 'enrollment' | 'protection' | 'daemon' | 'discovery' | 'schedule' | 'diagnostics' | 'verification';
2
2
  export type OnboardingStepStatus = 'pending' | 'running' | 'completed' | 'failed' | 'skipped';
3
3
  export interface OnboardingStep {
4
4
  status: OnboardingStepStatus;
@@ -48,6 +48,10 @@ const STEP_DEFINITIONS = [
48
48
  ['daemon', true],
49
49
  ['discovery', false],
50
50
  ['schedule', false],
51
+ // Post-install environment diagnostics (EDR/AV inventory, PowerShell
52
+ // health, watchdog + daemon liveness). Never critical: diagnostics report
53
+ // problems, they must not BE one.
54
+ ['diagnostics', false],
51
55
  ['verification', true],
52
56
  ];
53
57
  function onboardingJournalPath(home = os.homedir()) {
@@ -0,0 +1,46 @@
1
+ import { DaemonPostmortem } from './daemonForensics';
2
+ /**
3
+ * Post-install environment diagnostics — the "will this machine stay alive?"
4
+ * snapshot taken at the END of onboarding, while we still have an interactive
5
+ * session. Fleet machines are usually remote and locked-down (bank laptops,
6
+ * EDR); this is our best — sometimes only — chance to capture:
7
+ *
8
+ * - which EDR/AV products run here (read-only service probe, no PowerShell)
9
+ * - whether PowerShell can spawn + whether the DPAPI key decrypts
10
+ * - whether the daemon that onboarding just started is alive AND SURVIVES
11
+ * a re-check a few seconds later (an EDR that kills the daemon does it
12
+ * within seconds — catching that at install time beats discovering a
13
+ * silent machine a day later)
14
+ * - whether the watchdog liveness-beacon task actually registered
15
+ * - any crash post-mortem left by a previous install's daemon
16
+ *
17
+ * Contract: NEVER throws, never blocks onboarding for more than the re-check
18
+ * delay, every field is optional/best-effort. Diagnostics report problems —
19
+ * they must never BE one.
20
+ */
21
+ export interface EnvironmentDiagnostics {
22
+ /** EDR/AV product names detected (Windows service probe). */
23
+ securityAgents?: string[];
24
+ powershellSpawnOk?: boolean;
25
+ powershellDecryptOk?: boolean;
26
+ daemonAlive: boolean;
27
+ daemonPid?: number;
28
+ /** Did the daemon still exist recheckDelayMs later? (false = killed within seconds — EDR signature) */
29
+ daemonSurvivedRecheck?: boolean;
30
+ watchdogTaskInstalled: boolean;
31
+ /** Unreported crash record from a previous daemon on this machine. */
32
+ lastCrash?: DaemonPostmortem;
33
+ collectedAt: string;
34
+ }
35
+ export declare function collectEnvironmentDiagnostics(options?: {
36
+ recheckDelayMs?: number;
37
+ }): Promise<EnvironmentDiagnostics>;
38
+ /** One-line human summary for the onboarding journal / MSI screen / daemon log. */
39
+ export declare function summarizeEnvironmentDiagnostics(diag: EnvironmentDiagnostics): string;
40
+ /**
41
+ * Verdict for the onboarding journal: healthy unless something on this
42
+ * machine is actively interfering (daemon killed, PowerShell blocked, or a
43
+ * fresh unexplained crash). Missing watchdog alone is a warning, not
44
+ * interference — the daemon self-heals it at every boot.
45
+ */
46
+ export declare function environmentLooksHealthy(diag: EnvironmentDiagnostics): boolean;
@@ -0,0 +1,98 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.collectEnvironmentDiagnostics = collectEnvironmentDiagnostics;
4
+ exports.summarizeEnvironmentDiagnostics = summarizeEnvironmentDiagnostics;
5
+ exports.environmentLooksHealthy = environmentLooksHealthy;
6
+ const config_1 = require("./config");
7
+ const daemon_1 = require("./commands/daemon");
8
+ const securityAgents_1 = require("./securityAgents");
9
+ const daemonForensics_1 = require("./daemonForensics");
10
+ const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
11
+ async function collectEnvironmentDiagnostics(options = {}) {
12
+ const recheckDelayMs = options.recheckDelayMs ?? 5_000;
13
+ const diag = {
14
+ daemonAlive: false,
15
+ watchdogTaskInstalled: false,
16
+ collectedAt: new Date().toISOString(),
17
+ };
18
+ try {
19
+ const agents = (0, securityAgents_1.getSecurityAgentsReport)();
20
+ if (agents)
21
+ diag.securityAgents = agents.products;
22
+ }
23
+ catch { /* best-effort */ }
24
+ // Exercise the real credential path once so PowerShell health reflects THIS
25
+ // machine's DPAPI reality (spawn blocked vs blob undecryptable). Onboarding
26
+ // is interactive and already ran PowerShell to store the key, so one more
27
+ // spawn here is safe — and it's the exact operation the daemon depends on.
28
+ try {
29
+ if (process.platform === 'win32') {
30
+ (0, config_1.resolveCliCredentials)((0, config_1.loadConfig)());
31
+ const health = (0, config_1.getPowershellHealth)();
32
+ if (health) {
33
+ diag.powershellSpawnOk = health.spawnOk;
34
+ diag.powershellDecryptOk = health.decryptOk;
35
+ }
36
+ }
37
+ }
38
+ catch { /* health stays unknown */ }
39
+ try {
40
+ const state = (0, daemon_1.daemonRuntimeState)();
41
+ diag.daemonAlive = state.alive;
42
+ diag.daemonPid = state.pid;
43
+ if (state.alive && recheckDelayMs > 0) {
44
+ // The EDR-kill signature: process starts, then dies within seconds.
45
+ await sleep(recheckDelayMs);
46
+ diag.daemonSurvivedRecheck = (0, daemon_1.daemonRuntimeState)().alive;
47
+ }
48
+ }
49
+ catch { /* daemon state unknown */ }
50
+ try {
51
+ diag.watchdogTaskInstalled = (0, daemon_1.isWatchdogTaskInstalled)();
52
+ }
53
+ catch { /* stays false */ }
54
+ try {
55
+ const crash = (0, daemonForensics_1.readPostmortem)();
56
+ if (crash && !crash.reportedAt)
57
+ diag.lastCrash = crash;
58
+ }
59
+ catch { /* best-effort */ }
60
+ return diag;
61
+ }
62
+ /** One-line human summary for the onboarding journal / MSI screen / daemon log. */
63
+ function summarizeEnvironmentDiagnostics(diag) {
64
+ const parts = [];
65
+ if (diag.securityAgents) {
66
+ parts.push(diag.securityAgents.length ? `EDR/AV: ${diag.securityAgents.join(', ')}` : 'EDR/AV: none detected');
67
+ }
68
+ if (diag.powershellSpawnOk !== undefined) {
69
+ parts.push(diag.powershellSpawnOk
70
+ ? `PowerShell OK${diag.powershellDecryptOk === false ? ' (key decrypt FAILED)' : ''}`
71
+ : 'PowerShell BLOCKED');
72
+ }
73
+ if (diag.daemonSurvivedRecheck === false) {
74
+ parts.push(`daemon KILLED seconds after start (pid ${diag.daemonPid ?? '?'})`);
75
+ }
76
+ else {
77
+ parts.push(diag.daemonAlive ? 'daemon alive' : 'daemon not running');
78
+ }
79
+ parts.push(diag.watchdogTaskInstalled ? 'watchdog installed' : 'watchdog task missing');
80
+ if (diag.lastCrash)
81
+ parts.push(`previous daemon died uncleanly at ${diag.lastCrash.lastAliveAt || 'unknown time'}`);
82
+ return parts.join('; ');
83
+ }
84
+ /**
85
+ * Verdict for the onboarding journal: healthy unless something on this
86
+ * machine is actively interfering (daemon killed, PowerShell blocked, or a
87
+ * fresh unexplained crash). Missing watchdog alone is a warning, not
88
+ * interference — the daemon self-heals it at every boot.
89
+ */
90
+ function environmentLooksHealthy(diag) {
91
+ if (diag.daemonSurvivedRecheck === false)
92
+ return false;
93
+ if (diag.powershellSpawnOk === false)
94
+ return false;
95
+ if (diag.lastCrash)
96
+ return false;
97
+ return true;
98
+ }
package/dist/version.json CHANGED
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "1.21.29"
2
+ "version": "1.21.30"
3
3
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullcourtdefense-cli",
3
- "version": "1.21.29",
3
+ "version": "1.21.30",
4
4
  "description": "Full Court Defense CLI — security scanning for AI agents from your terminal",
5
5
  "main": "dist/index.js",
6
6
  "bin": {