fullcourtdefense-cli 1.36.3 → 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.
Files changed (95) hide show
  1. package/dist/actionPolicyEngine.d.ts +19 -0
  2. package/dist/actionPolicyEngine.js +147 -0
  3. package/dist/askDialog.d.ts +1 -0
  4. package/dist/askDialog.js +20 -3
  5. package/dist/callerEnv.d.ts +29 -2
  6. package/dist/callerEnv.js +48 -2
  7. package/dist/commandNarrative.js +87 -7
  8. package/dist/commands/daemon.d.ts +10 -0
  9. package/dist/commands/daemon.js +348 -170
  10. package/dist/commands/desktopChatGuard.js +2 -0
  11. package/dist/commands/deterministicGuard.d.ts +11 -0
  12. package/dist/commands/deterministicGuard.js +445 -28
  13. package/dist/commands/discoverPaths.d.ts +12 -9
  14. package/dist/commands/discoverPaths.js +30 -0
  15. package/dist/commands/ephemeralStack.js +7 -0
  16. package/dist/commands/flushSpool.d.ts +2 -0
  17. package/dist/commands/flushSpool.js +9 -2
  18. package/dist/commands/hook.d.ts +14 -0
  19. package/dist/commands/hook.js +302 -101
  20. package/dist/commands/httpGatewayCommand.d.ts +1 -0
  21. package/dist/commands/httpGatewayCommand.js +7 -1
  22. package/dist/commands/installClaudeHook.js +32 -10
  23. package/dist/commands/installCursorHook.js +20 -9
  24. package/dist/commands/login.js +8 -1
  25. package/dist/commands/mcpGateway.d.ts +21 -0
  26. package/dist/commands/mcpGateway.js +90 -32
  27. package/dist/commands/refreshPolicyCache.d.ts +28 -0
  28. package/dist/commands/refreshPolicyCache.js +59 -0
  29. package/dist/commands/watchdog.d.ts +2 -0
  30. package/dist/commands/watchdog.js +30 -5
  31. package/dist/config.d.ts +36 -0
  32. package/dist/config.js +78 -12
  33. package/dist/daemonDetectorBoot.d.ts +1 -0
  34. package/dist/daemonDetectorBoot.js +12 -0
  35. package/dist/daemonForensics.d.ts +14 -1
  36. package/dist/daemonForensics.js +32 -0
  37. package/dist/detectorInProcess.d.ts +18 -0
  38. package/dist/detectorInProcess.js +234 -0
  39. package/dist/detectorInThread.d.ts +15 -0
  40. package/dist/detectorInThread.js +129 -0
  41. package/dist/distress.d.ts +2 -0
  42. package/dist/distress.js +2 -0
  43. package/dist/fileWriteCanon.d.ts +17 -0
  44. package/dist/fileWriteCanon.js +141 -0
  45. package/dist/gateReports.d.ts +144 -0
  46. package/dist/gateReports.js +488 -0
  47. package/dist/hookIo.d.ts +59 -0
  48. package/dist/hookIo.js +319 -9
  49. package/dist/hookSlim.d.ts +15 -8
  50. package/dist/hookSlim.js +187 -25
  51. package/dist/housekeeping.d.ts +64 -0
  52. package/dist/housekeeping.js +384 -0
  53. package/dist/housekeepingOps.d.ts +150 -0
  54. package/dist/housekeepingOps.js +218 -0
  55. package/dist/housekeepingWorker.d.ts +1 -0
  56. package/dist/housekeepingWorker.js +113 -0
  57. package/dist/index.d.ts +1 -1
  58. package/dist/index.js +19 -0
  59. package/dist/localDetectionData.d.ts +10 -0
  60. package/dist/localDetectionData.js +48 -0
  61. package/dist/localDetectionUpdates.d.ts +2 -2
  62. package/dist/localDetectionUpdates.js +4 -4
  63. package/dist/localSafetySnapshot.d.ts +48 -2
  64. package/dist/localSafetySnapshot.js +241 -25
  65. package/dist/machineIdentity.d.ts +7 -0
  66. package/dist/machineIdentity.js +20 -2
  67. package/dist/monitorFastPath.d.ts +191 -0
  68. package/dist/monitorFastPath.js +1198 -0
  69. package/dist/nativeHook.js +9 -2
  70. package/dist/pendingDrain.d.ts +82 -0
  71. package/dist/pendingDrain.js +215 -0
  72. package/dist/pendingDrainer.d.ts +30 -0
  73. package/dist/pendingDrainer.js +217 -0
  74. package/dist/perfSnapshot.d.ts +1 -1
  75. package/dist/perfSnapshot.js +28 -4
  76. package/dist/policyCacheFiles.d.ts +73 -0
  77. package/dist/policyCacheFiles.js +320 -0
  78. package/dist/policyGateHealth.d.ts +8 -0
  79. package/dist/policyGateHealth.js +17 -0
  80. package/dist/policyRefresh.d.ts +28 -0
  81. package/dist/policyRefresh.js +69 -0
  82. package/dist/protectionMaintenance.d.ts +15 -0
  83. package/dist/protectionMaintenance.js +43 -1
  84. package/dist/runtimeConfig.d.ts +77 -20
  85. package/dist/runtimeConfig.js +278 -57
  86. package/dist/selfTest.d.ts +6 -1
  87. package/dist/selfTest.js +11 -4
  88. package/dist/telemetry.d.ts +136 -4
  89. package/dist/telemetry.js +493 -96
  90. package/dist/verdictIpc.d.ts +60 -0
  91. package/dist/verdictIpc.js +504 -19
  92. package/dist/verdictIpcClient.d.ts +175 -18
  93. package/dist/verdictIpcClient.js +277 -59
  94. package/dist/version.json +1 -1
  95. package/package.json +2 -2
@@ -84,27 +84,39 @@ async function watchdogCommand(args, config) {
84
84
  // with the alive-marker freshness + a verdict-pipe probe.
85
85
  let health = state.alive ? 'healthy' : 'dead';
86
86
  if (state.alive && state.pid) {
87
+ const probe = await (0, verdictIpcClient_1.probeVerdictServerDetail)(1_500);
87
88
  health = (0, daemonForensics_1.classifyDaemonHealth)({
88
89
  pidAlive: true,
89
90
  pid: state.pid,
90
91
  marker: (0, daemonForensics_1.readAliveMarker)(),
91
92
  staleAfterMs: (0, daemonForensics_1.daemonHungAfterMs)(),
92
- pipeProbe: await (0, verdictIpcClient_1.probeVerdictServer)(1_500),
93
+ pipeProbe: probe.result,
94
+ pipeAuthorized: probe.authorized,
93
95
  machineSuspended,
94
96
  });
95
97
  }
98
+ // The daemon repairs a replaced token itself within a minute (verdictIpc
99
+ // checkToken). Still rejecting hooks one tick later (>= 5 min): recycle it
100
+ // like a hung daemon so the restart publishes a fresh token.
101
+ const notServingTicks = (0, daemonForensics_1.recordNotServing)(health === 'not_serving' ? state.pid : undefined);
102
+ if (health === 'not_serving' && notServingTicks === 1) {
103
+ (0, distress_1.reportDistress)('watchdog', distress_1.DISTRESS.IPC_NOT_SERVING, `daemon pid ${state.pid} rejects this profile's verdict token (token file replaced) — waiting one tick for its self-repair`);
104
+ }
105
+ const recycleNotServing = health === 'not_serving' && notServingTicks >= 2;
96
106
  // An updater run is replacing the install: the daemon is down ON PURPOSE.
97
107
  // Reviving the old build now puts a second daemon (and its update kick)
98
108
  // in the middle of msiexec. Stand back; stage 2 relaunches the daemon when
99
109
  // it finishes, and a lock older than its max age no longer counts, so the
100
110
  // next tick revives normally if the run never completes.
101
111
  const updaterRun = (0, selfUpdate_1.readActiveUpdaterRun)();
102
- if (health === 'hung' && state.pid && !updaterRun && process.env.FCD_WATCHDOG_NO_RELAUNCH !== '1') {
112
+ if ((health === 'hung' || recycleNotServing) && state.pid && !updaterRun && process.env.FCD_WATCHDOG_NO_RELAUNCH !== '1') {
103
113
  // Positive evidence this pid IS our daemon (it still owns the verdict
104
114
  // endpoint) and it is wedged — recycle it so the relaunch below starts
105
115
  // clean. The distress ledger survives; the fresh daemon's first heartbeat
106
116
  // ships the story to the fleet.
107
- (0, distress_1.reportDistress)('watchdog', distress_1.DISTRESS.DAEMON_HUNG, `daemon pid ${state.pid} unresponsive (alive-marker stale + verdict pipe silent) — recycled by watchdog`);
117
+ (0, distress_1.reportDistress)('watchdog', recycleNotServing ? distress_1.DISTRESS.IPC_NOT_SERVING : distress_1.DISTRESS.DAEMON_HUNG, recycleNotServing
118
+ ? `daemon pid ${state.pid} kept rejecting this profile's verdict token for ${notServingTicks} ticks — recycled by watchdog`
119
+ : `daemon pid ${state.pid} unresponsive (alive-marker stale + verdict pipe silent) — recycled by watchdog`);
108
120
  // Claim the kill first: otherwise the next daemon boots, finds a marker
109
121
  // with no clean-exit stamp, and reports EDR/AV killed it.
110
122
  (0, daemonForensics_1.recordWatchdogRecycle)(state.pid);
@@ -114,7 +126,7 @@ async function watchdogCommand(args, config) {
114
126
  // kill it. The daemon is simply gone; fall through to the dead path. The
115
127
  // relaunched daemon's own startup detects the recycled pid-file owner and
116
128
  // takes over the lock without touching that process.
117
- const effectivelyDead = !state.alive || health === 'hung' || health === 'pid_reused';
129
+ const effectivelyDead = !state.alive || health === 'hung' || health === 'pid_reused' || recycleNotServing;
118
130
  // No post-mortem during an updater run either: a daemon the updater
119
131
  // stopped was not killed by EDR/AV.
120
132
  if (effectivelyDead && !updaterRun) {
@@ -151,7 +163,7 @@ async function watchdogCommand(args, config) {
151
163
  daemonVersion: state.version,
152
164
  daemonStartedAt: state.startedAt,
153
165
  relaunched: relaunched || undefined,
154
- daemonHealth: health === 'hung' || health === 'pid_reused' ? health : undefined,
166
+ daemonHealth: health === 'hung' || health === 'pid_reused' || health === 'not_serving' ? health : undefined,
155
167
  lastCrash: crashSummary(postmortem),
156
168
  };
157
169
  const headers = { 'Content-Type': 'application/json' };
@@ -171,4 +183,17 @@ async function watchdogCommand(args, config) {
171
183
  (0, daemonForensics_1.markPostmortemReported)();
172
184
  }
173
185
  catch { /* offline / endpoint not deployed yet — next tick retries */ }
186
+ // Policy freshness while the daemon is down. Hooks and gateways never fetch
187
+ // policy, so without the daemon's poll a suspension or a mode change would
188
+ // not reach this machine; this tick delivers it within one interval.
189
+ // Loaded lazily: a healthy tick never pays for the policy modules. Not
190
+ // during an updater run: the install is being replaced (the daemon is down
191
+ // on purpose), and hooks keep serving the last good cache meanwhile.
192
+ if (effectivelyDead && !updaterRun) {
193
+ try {
194
+ const { refreshPolicyCachesInBackground } = await Promise.resolve().then(() => __importStar(require('./refreshPolicyCache')));
195
+ await refreshPolicyCachesInBackground(creds, { scheduled: true });
196
+ }
197
+ catch { /* quiet: the next tick retries */ }
198
+ }
174
199
  }
package/dist/config.d.ts CHANGED
@@ -113,6 +113,12 @@ export declare function getCredentialResolutionTrace(): CredentialResolutionTrac
113
113
  export declare function resolveCliCredentials(config: BotGuardConfig, overrides?: Partial<ResolvedCliCredentials>, options?: {
114
114
  skipDpapi?: boolean;
115
115
  }): ResolvedCliCredentials;
116
+ /**
117
+ * Shield, endpoint and override key only — no key store is read. For verdict
118
+ * paths that decide from the offline cache and resolve the key
119
+ * (resolveCliCredentials) only when a network call needs it.
120
+ */
121
+ export declare function resolveCliCredentialsWithoutKey(config: BotGuardConfig, overrides?: Partial<ResolvedCliCredentials>): ResolvedCliCredentials;
116
122
  /**
117
123
  * Credential resolution that is guaranteed to spawn NO child process — for
118
124
  * frequent low-privilege callers (watchdog beacon every 5 min). Skips the
@@ -121,6 +127,36 @@ export declare function resolveCliCredentials(config: BotGuardConfig, overrides?
121
127
  * (unauthenticated) beacon instead.
122
128
  */
123
129
  export declare function resolveCliCredentialsShellFree(config: BotGuardConfig, overrides?: Partial<ResolvedCliCredentials>): ResolvedCliCredentials;
130
+ /**
131
+ * The key this machine holds for one particular shield — what a hook of that
132
+ * shield resolves on its shell-free path (env handoff, Credential Manager,
133
+ * machine key file, saved config, env), but each source only for the shield it
134
+ * belongs to: never another shield's key. Read-only and spawn-free (no DPAPI
135
+ * decrypt, no migration or mirror write). `shieldId` must come from this
136
+ * process's configuration (machineEnrollment), never from a file another
137
+ * process could write.
138
+ */
139
+ export declare function storedShieldKeyFor(config: BotGuardConfig, shieldId: string, env?: NodeJS.ProcessEnv): string | undefined;
140
+ /**
141
+ * This machine's own enrollment as a hook started without flags resolves it:
142
+ * shield and API URL from ~/.fullcourtdefense.yml and the environment, and the
143
+ * key stored for THAT shield (storedShieldKeyFor). Read-only and spawn-free.
144
+ * The key belongs with this API URL only: a caller sends it nowhere else.
145
+ */
146
+ export declare function machineEnrollment(env?: NodeJS.ProcessEnv): {
147
+ shieldId?: string;
148
+ apiUrl: string;
149
+ shieldKey?: string;
150
+ };
151
+ /**
152
+ * machineEnrollment without the key: the shield this machine is enrolled
153
+ * with and the API URL configured with it — the only destination its key may
154
+ * go to. Reads ~/.fullcourtdefense.yml and the environment, no key store.
155
+ */
156
+ export declare function machineEnrollmentTarget(env?: NodeJS.ProcessEnv): {
157
+ shieldId?: string;
158
+ apiUrl: string;
159
+ };
124
160
  export declare function isCliSetupComplete(config: BotGuardConfig): boolean;
125
161
  /** Fail fast with a clear message when setup was not run. */
126
162
  export declare function requireCliSetup(config: BotGuardConfig, overrides?: Partial<ResolvedCliCredentials>, options?: {
package/dist/config.js CHANGED
@@ -45,7 +45,11 @@ exports.saveShieldConfig = saveShieldConfig;
45
45
  exports.daemonHandoffShieldKey = daemonHandoffShieldKey;
46
46
  exports.getCredentialResolutionTrace = getCredentialResolutionTrace;
47
47
  exports.resolveCliCredentials = resolveCliCredentials;
48
+ exports.resolveCliCredentialsWithoutKey = resolveCliCredentialsWithoutKey;
48
49
  exports.resolveCliCredentialsShellFree = resolveCliCredentialsShellFree;
50
+ exports.storedShieldKeyFor = storedShieldKeyFor;
51
+ exports.machineEnrollment = machineEnrollment;
52
+ exports.machineEnrollmentTarget = machineEnrollmentTarget;
49
53
  exports.isCliSetupComplete = isCliSetupComplete;
50
54
  exports.requireCliSetup = requireCliSetup;
51
55
  exports.saveSetupConfig = saveSetupConfig;
@@ -55,8 +59,12 @@ const os = __importStar(require("os"));
55
59
  const path = __importStar(require("path"));
56
60
  const child_process_1 = require("child_process");
57
61
  const distress_1 = require("./distress");
58
- const credentialStore_1 = require("./credentialStore");
59
- const machineKeyFile_1 = require("./machineKeyFile");
62
+ // The credential stores load only when a shield key is actually resolved or
63
+ // saved: a hook deciding from the offline cache never touches them.
64
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
65
+ const credentialStore = () => require('./credentialStore');
66
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
67
+ const machineKeyFile = () => require('./machineKeyFile');
60
68
  const CONFIG_FILENAMES = [
61
69
  '.fullcourtdefense.yml',
62
70
  '.fullcourtdefense.yaml',
@@ -354,13 +362,13 @@ function getCredentialResolutionTrace() {
354
362
  // long-lived (must not hammer the Credential Manager every 30s poll).
355
363
  let nativeMigrationAttempted = false;
356
364
  function maybeMigrateShieldKeyToNativeStore(shieldId, shieldKey) {
357
- if (nativeMigrationAttempted || !(0, credentialStore_1.nativeCredentialStoreAvailable)())
365
+ if (nativeMigrationAttempted || !credentialStore().nativeCredentialStoreAvailable())
358
366
  return;
359
367
  nativeMigrationAttempted = true;
360
368
  try {
361
- if ((0, credentialStore_1.readShieldKeyNative)(shieldId))
369
+ if (credentialStore().readShieldKeyNative(shieldId))
362
370
  return; // already migrated
363
- (0, credentialStore_1.saveShieldKeyNative)(shieldId, shieldKey); // read-back verified inside
371
+ credentialStore().saveShieldKeyNative(shieldId, shieldKey); // read-back verified inside
364
372
  }
365
373
  catch { /* migration is opportunistic — legacy sources keep working */ }
366
374
  }
@@ -378,9 +386,9 @@ function maybeMirrorShieldKeyToMachineFile(shieldId, shieldKey) {
378
386
  return;
379
387
  machineFileMirrorAttempted = true;
380
388
  try {
381
- if ((0, machineKeyFile_1.readShieldKeyFromMachineFile)(shieldId) === shieldKey)
389
+ if (machineKeyFile().readShieldKeyFromMachineFile(shieldId) === shieldKey)
382
390
  return; // mirror current
383
- (0, machineKeyFile_1.saveShieldKeyToMachineFile)(shieldId, shieldKey); // read-back verified inside
391
+ machineKeyFile().saveShieldKeyToMachineFile(shieldId, shieldKey); // read-back verified inside
384
392
  }
385
393
  catch { /* mirroring is opportunistic — primary stores keep working */ }
386
394
  }
@@ -410,7 +418,7 @@ function resolveCliCredentials(config, overrides = {}, options = {}) {
410
418
  // in-process win32 API read that EDRs don't flag and CLM can't constrain.
411
419
  // Silently absent on machines that never migrated (see the migration
412
420
  // below) — the DPAPI chain still stands behind it.
413
- shieldKey = (0, credentialStore_1.readShieldKeyNative)(resolvedShieldId);
421
+ shieldKey = credentialStore().readShieldKeyNative(resolvedShieldId);
414
422
  if (shieldKey)
415
423
  shieldKeySource = 'native_store';
416
424
  }
@@ -420,7 +428,7 @@ function resolveCliCredentials(config, overrides = {}, options = {}) {
420
428
  // S4U logon sessions do not have — the read above returns nothing there.
421
429
  // The machine key file is protected by NTFS ACL (SID-based, which S4U
422
430
  // does carry) and is mirrored by interactive-session processes below.
423
- shieldKey = (0, machineKeyFile_1.readShieldKeyFromMachineFile)(resolvedShieldId);
431
+ shieldKey = machineKeyFile().readShieldKeyFromMachineFile(resolvedShieldId);
424
432
  if (shieldKey)
425
433
  shieldKeySource = 'machine_file';
426
434
  }
@@ -461,6 +469,10 @@ function resolveCliCredentials(config, overrides = {}, options = {}) {
461
469
  && (shieldKeySource === 'native_store' || shieldKeySource === 'dpapi' || shieldKeySource === 'plaintext_config')) {
462
470
  maybeMirrorShieldKeyToMachineFile(resolvedShieldId, shieldKey);
463
471
  }
472
+ return { ...credentialFields(config, overrides), shieldKey };
473
+ }
474
+ /** Everything but the shield key: config, env and overrides only. */
475
+ function credentialFields(config, overrides) {
464
476
  return {
465
477
  apiKey: overrides.apiKey
466
478
  || config.apiKey
@@ -477,9 +489,16 @@ function resolveCliCredentials(config, overrides = {}, options = {}) {
477
489
  || process.env.FCD_SHIELD_ID
478
490
  || process.env.FULLCOURTDEFENSE_SHIELD_ID
479
491
  || process.env.AGENTGUARD_SHIELD_ID,
480
- shieldKey,
481
492
  };
482
493
  }
494
+ /**
495
+ * Shield, endpoint and override key only — no key store is read. For verdict
496
+ * paths that decide from the offline cache and resolve the key
497
+ * (resolveCliCredentials) only when a network call needs it.
498
+ */
499
+ function resolveCliCredentialsWithoutKey(config, overrides = {}) {
500
+ return { ...credentialFields(config, overrides), shieldKey: overrides.shieldKey };
501
+ }
483
502
  /**
484
503
  * Credential resolution that is guaranteed to spawn NO child process — for
485
504
  * frequent low-privilege callers (watchdog beacon every 5 min). Skips the
@@ -490,6 +509,53 @@ function resolveCliCredentials(config, overrides = {}, options = {}) {
490
509
  function resolveCliCredentialsShellFree(config, overrides = {}) {
491
510
  return resolveCliCredentials(config, overrides, { skipDpapi: true });
492
511
  }
512
+ /**
513
+ * The key this machine holds for one particular shield — what a hook of that
514
+ * shield resolves on its shell-free path (env handoff, Credential Manager,
515
+ * machine key file, saved config, env), but each source only for the shield it
516
+ * belongs to: never another shield's key. Read-only and spawn-free (no DPAPI
517
+ * decrypt, no migration or mirror write). `shieldId` must come from this
518
+ * process's configuration (machineEnrollment), never from a file another
519
+ * process could write.
520
+ */
521
+ function storedShieldKeyFor(config, shieldId, env = process.env) {
522
+ if (!shieldId)
523
+ return undefined;
524
+ if (env.FCD_SHIELD_ID === shieldId && env.FCD_SHIELD_KEY)
525
+ return env.FCD_SHIELD_KEY;
526
+ const stored = credentialStore().readShieldKeyNative(shieldId) || machineKeyFile().readShieldKeyFromMachineFile(shieldId);
527
+ if (stored)
528
+ return stored;
529
+ const envShieldId = env.FCD_SHIELD_ID || env.FULLCOURTDEFENSE_SHIELD_ID || env.AGENTGUARD_SHIELD_ID;
530
+ if (config.shieldKey && (config.shieldId || envShieldId) === shieldId)
531
+ return config.shieldKey;
532
+ const envKey = env.FCD_SHIELD_KEY || env.FULLCOURTDEFENSE_SHIELD_KEY || env.AGENTGUARD_SHIELD_KEY;
533
+ return envKey && (envShieldId || config.shieldId) === shieldId ? envKey : undefined;
534
+ }
535
+ /**
536
+ * This machine's own enrollment as a hook started without flags resolves it:
537
+ * shield and API URL from ~/.fullcourtdefense.yml and the environment, and the
538
+ * key stored for THAT shield (storedShieldKeyFor). Read-only and spawn-free.
539
+ * The key belongs with this API URL only: a caller sends it nowhere else.
540
+ */
541
+ function machineEnrollment(env = process.env) {
542
+ const config = loadMachineConfig();
543
+ const { shieldId, apiUrl } = enrollmentTargetOf(config, env);
544
+ return { shieldId, apiUrl, shieldKey: shieldId ? storedShieldKeyFor(config, shieldId, env) : undefined };
545
+ }
546
+ /**
547
+ * machineEnrollment without the key: the shield this machine is enrolled
548
+ * with and the API URL configured with it — the only destination its key may
549
+ * go to. Reads ~/.fullcourtdefense.yml and the environment, no key store.
550
+ */
551
+ function machineEnrollmentTarget(env = process.env) {
552
+ return enrollmentTargetOf(loadMachineConfig(), env);
553
+ }
554
+ function enrollmentTargetOf(config, env) {
555
+ const shieldId = config.shieldId || env.FCD_SHIELD_ID || env.FULLCOURTDEFENSE_SHIELD_ID || env.AGENTGUARD_SHIELD_ID;
556
+ const apiUrl = (config.apiUrl || env.FULLCOURTDEFENSE_API_URL || env.FCD_API_URL || DEFAULT_API_URL).replace(/\/$/, '');
557
+ return { shieldId, apiUrl };
558
+ }
493
559
  function isCliSetupComplete(config) {
494
560
  const creds = resolveCliCredentials(config);
495
561
  return Boolean(creds.shieldId && creds.apiKey && creds.organizationId);
@@ -542,11 +608,11 @@ function writeSetupConfig(target, input) {
542
608
  // verifies: rolling back to a ≤1.21.33 CLI keeps working, and machines
543
609
  // where the native binding is blocked keep their PowerShell path.
544
610
  if (input.shieldKey && input.shieldId) {
545
- (0, credentialStore_1.saveShieldKeyNative)(input.shieldId, input.shieldKey);
611
+ credentialStore().saveShieldKeyNative(input.shieldId, input.shieldKey);
546
612
  // S4U mirror: the daemon/watchdog tasks cannot read Credential Manager or
547
613
  // user-scope DPAPI (S4U logon has no password-derived master key) — give
548
614
  // them the ACL-protected file at enrollment so they are never key-less.
549
- (0, machineKeyFile_1.saveShieldKeyToMachineFile)(input.shieldId, input.shieldKey);
615
+ machineKeyFile().saveShieldKeyToMachineFile(input.shieldId, input.shieldKey);
550
616
  }
551
617
  const protectedShieldKey = input.shieldKey && process.platform === 'win32'
552
618
  ? protectShieldKeyForCurrentWindowsUser(input.shieldKey)
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,12 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ /**
4
+ * Side-effect module, imported FIRST by index.ts: in the resident daemon the
5
+ * signed detector is compiled on the verdict thread (detectorInThread.ts), and
6
+ * that choice must be made before the hook module graph imported next loads
7
+ * the detector. Every other command (hooks included) loads nothing here.
8
+ */
9
+ if (process.argv[2] === 'daemon') {
10
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
11
+ require('./detectorInThread').useInThreadDetector();
12
+ }
@@ -116,7 +116,12 @@ export declare function recordCleanExit(reason: string): void;
116
116
  * marker stale + no pipe endpoint → pid recycled (daemon is gone; do NOT
117
117
  * kill the unrelated process)
118
118
  */
119
- export type DaemonHealthClass = 'dead' | 'healthy' | 'hung' | 'pid_reused' | 'indeterminate';
119
+ /**
120
+ * 'not_serving': the daemon is alive and its pipe answers, but it rejects this
121
+ * profile's token ('unauthorized') — the token file is not the serving
122
+ * daemon's, so every hook evaluates locally while heartbeats look healthy.
123
+ */
124
+ export type DaemonHealthClass = 'dead' | 'healthy' | 'hung' | 'pid_reused' | 'not_serving' | 'indeterminate';
120
125
  /** Marker older than this is stale — 4 missed 5-minute heartbeats. */
121
126
  export declare function daemonHungAfterMs(): number;
122
127
  export declare function classifyDaemonHealth(input: {
@@ -125,6 +130,8 @@ export declare function classifyDaemonHealth(input: {
125
130
  marker: DaemonAliveMarker | undefined;
126
131
  staleAfterMs: number;
127
132
  pipeProbe: 'responsive' | 'unresponsive' | 'no-endpoint' | 'not-probed';
133
+ /** With a 'responsive' probe: false when the endpoint rejected this profile's token. */
134
+ pipeAuthorized?: boolean;
128
135
  /**
129
136
  * True when the machine itself stopped running since the previous check
130
137
  * (sleep/hibernate). Marker staleness is then not evidence of anything —
@@ -165,6 +172,12 @@ export interface WatchdogTick {
165
172
  export declare function watchdogTickFile(): string;
166
173
  export declare function readWatchdogTick(): WatchdogTick | undefined;
167
174
  export declare function recordWatchdogTick(now?: Date): void;
175
+ /**
176
+ * Consecutive watchdog ticks on which daemon `pid` was alive but rejected this
177
+ * profile's token (DaemonHealthClass 'not_serving'). `undefined` (any other
178
+ * class) resets the count. Returns the count including this tick.
179
+ */
180
+ export declare function recordNotServing(pid: number | undefined, now?: Date): number;
168
181
  /**
169
182
  * Pure: did the machine stop running between the previous watchdog tick and
170
183
  * this one?
@@ -52,6 +52,7 @@ exports.classifyDaemonHealth = classifyDaemonHealth;
52
52
  exports.watchdogTickFile = watchdogTickFile;
53
53
  exports.readWatchdogTick = readWatchdogTick;
54
54
  exports.recordWatchdogTick = recordWatchdogTick;
55
+ exports.recordNotServing = recordNotServing;
55
56
  exports.detectMachineSuspension = detectMachineSuspension;
56
57
  exports.recordWatchdogRecycle = recordWatchdogRecycle;
57
58
  exports.markPostmortemReported = markPostmortemReported;
@@ -246,6 +247,10 @@ function classifyDaemonHealth(input) {
246
247
  // freshness cannot be judged — stay conservative, never kill on a guess.
247
248
  if (!marker || marker.pid !== input.pid)
248
249
  return 'indeterminate';
250
+ // Alive and answering, but not for this profile's hooks: a fresh marker
251
+ // does not make that healthy (2026-10-02: 11 h of 'unauthorized').
252
+ if (input.pipeProbe === 'responsive' && input.pipeAuthorized === false)
253
+ return 'not_serving';
249
254
  const last = Date.parse(marker.lastAliveAt || marker.startedAt || '');
250
255
  const age = (input.nowMs ?? Date.now()) - last;
251
256
  if (!Number.isFinite(age) || age <= input.staleAfterMs)
@@ -306,6 +311,33 @@ function readWatchdogTick() {
306
311
  function recordWatchdogTick(now = new Date()) {
307
312
  writeJson(watchdogTickFile(), { lastTickAt: now.toISOString() });
308
313
  }
314
+ function notServingFile() {
315
+ return path.join(stateDir(), 'watchdog.not-serving.json');
316
+ }
317
+ /**
318
+ * Consecutive watchdog ticks on which daemon `pid` was alive but rejected this
319
+ * profile's token (DaemonHealthClass 'not_serving'). `undefined` (any other
320
+ * class) resets the count. Returns the count including this tick.
321
+ */
322
+ function recordNotServing(pid, now = new Date()) {
323
+ if (pid === undefined) {
324
+ try {
325
+ fs.unlinkSync(notServingFile());
326
+ }
327
+ catch { /* none */ }
328
+ return 0;
329
+ }
330
+ let ticks = 0;
331
+ try {
332
+ const prev = JSON.parse(fs.readFileSync(notServingFile(), 'utf8'));
333
+ if (prev && prev.pid === pid && typeof prev.ticks === 'number')
334
+ ticks = prev.ticks;
335
+ }
336
+ catch { /* first tick */ }
337
+ ticks += 1;
338
+ writeJson(notServingFile(), { pid, ticks, lastAt: now.toISOString() });
339
+ return ticks;
340
+ }
309
341
  /**
310
342
  * Pure: did the machine stop running between the previous watchdog tick and
311
343
  * this one?
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Make the signed detector available to this evaluation without loading it:
3
+ * the signed vocabulary is read (cheap), and a placeholder is installed that
4
+ * verifies and compiles the artifact on the engine's first inference. No-op
5
+ * where the Worker loader already owns the detector.
6
+ */
7
+ export declare function ensureEvaluationDetector(): void;
8
+ /**
9
+ * Detector stamp for spooled events (revision / sha256 / active), without
10
+ * loading anything: the Worker loader's status where it owns the detector,
11
+ * otherwise the in-process detector once a decision has used it. Undefined
12
+ * when this process never consulted the detector.
13
+ */
14
+ export declare function detectorStampForEvent(): {
15
+ revision: number;
16
+ sha256?: string;
17
+ active: boolean;
18
+ } | undefined;
@@ -0,0 +1,234 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.ensureEvaluationDetector = ensureEvaluationDetector;
37
+ exports.detectorStampForEvent = detectorStampForEvent;
38
+ const fs = __importStar(require("fs"));
39
+ const os = __importStar(require("os"));
40
+ const path = __importStar(require("path"));
41
+ const vm_1 = require("vm");
42
+ const detectionData_1 = require("./detectionData");
43
+ const detectorArtifact_1 = require("./detectorArtifact");
44
+ const detectorRuntime_1 = require("./detectorRuntime");
45
+ const localDetectionData_1 = require("./localDetectionData");
46
+ const machineIdentity_1 = require("./machineIdentity");
47
+ const MAX_INPUT_BYTES = 256 * 1024;
48
+ const MAX_OUTPUT_BYTES = 512 * 1024;
49
+ let installed = false;
50
+ let resolved = false;
51
+ let chosen;
52
+ /** The Worker loader owns the detector in this process (daemon, gateways). */
53
+ function longRunningLoaderLoaded() {
54
+ try {
55
+ return Boolean(require.cache[require.resolve('./localDetectionUpdates')]);
56
+ }
57
+ catch {
58
+ return false;
59
+ }
60
+ }
61
+ /** Same VM setup as detectorModule's Worker, on this thread. Synchronous, no Atomics.wait. */
62
+ function compileInProcess(artifact) {
63
+ const context = (0, vm_1.createContext)(Object.create(null), { codeGeneration: { strings: false, wasm: false } });
64
+ // Expose parsed scalar fields, not Node's host URL constructor or native
65
+ // exceptions. The wrapper and all values visible to code belong to the VM.
66
+ const makeUrl = new vm_1.Script('(parse => class URL { constructor(input, base) { const fields=JSON.parse(parse(String(input), base===undefined?undefined:String(base))); if(fields.error)throw new TypeError("Invalid URL"); Object.assign(this,fields); } toString(){return this.href;} })').runInContext(context, { timeout: 1000 });
67
+ context.URL = makeUrl((input, base) => {
68
+ try {
69
+ if (typeof input !== 'string' || input.length > 256 * 1024 || (base !== undefined && (typeof base !== 'string' || base.length > 256 * 1024)))
70
+ return '{"error":true}';
71
+ const value = new URL(input, base);
72
+ const fields = {};
73
+ for (const key of ['href', 'origin', 'protocol', 'username', 'password', 'host', 'hostname', 'port', 'pathname', 'search', 'hash'])
74
+ fields[key] = value[key];
75
+ return JSON.stringify(fields);
76
+ }
77
+ catch {
78
+ return '{"error":true}';
79
+ }
80
+ });
81
+ new vm_1.Script(artifact.code).runInContext(context, { timeout: 1000 });
82
+ new vm_1.Script('if(typeof FcdDetector?.inferToolContext!=="function")throw new Error("Missing export")').runInContext(context, { timeout: 1000 });
83
+ const evaluate = new vm_1.Script('JSON.stringify(FcdDetector.inferToolContext(...JSON.parse(inputJson)))');
84
+ let stopped = false;
85
+ const invoke = (tool, args) => {
86
+ if (stopped)
87
+ throw new detectorRuntime_1.DetectorWorkerUnavailable('Detector unavailable');
88
+ const input = JSON.stringify([tool, args, (0, detectionData_1.getDetectionData)()]);
89
+ if (Buffer.byteLength(input) > MAX_INPUT_BYTES)
90
+ throw new Error('Detector input exceeds bound');
91
+ let text;
92
+ try {
93
+ context.inputJson = input;
94
+ text = evaluate.runInContext(context, { timeout: 250 });
95
+ if (typeof text !== 'string')
96
+ throw new Error('Invalid output');
97
+ if (Buffer.byteLength(text) > MAX_OUTPUT_BYTES)
98
+ throw new Error('Output too large');
99
+ }
100
+ catch {
101
+ // The Worker retires itself on any inference fault; so does this copy.
102
+ stopped = true;
103
+ throw new detectorRuntime_1.DetectorWorkerUnavailable('Detector timed out or failed');
104
+ }
105
+ finally {
106
+ delete context.inputJson;
107
+ }
108
+ return JSON.parse(text);
109
+ };
110
+ return Object.assign(invoke, { dispose: () => { stopped = true; } });
111
+ }
112
+ /** Compile and smoke-test exactly as detectorUpdates.prepareDetector does. */
113
+ function prepare(artifact) {
114
+ let infer;
115
+ try {
116
+ infer = compileInProcess(artifact);
117
+ (0, detectorRuntime_1.validateDetectorFacts)(infer('read_file', { path: 'sample.txt' }));
118
+ return infer;
119
+ }
120
+ catch {
121
+ infer?.dispose();
122
+ return undefined;
123
+ }
124
+ }
125
+ function bundledBaseline() {
126
+ try {
127
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
128
+ const { BUNDLED_DETECTOR_ENVELOPE } = require('./detectorBaseline');
129
+ if (!BUNDLED_DETECTOR_ENVELOPE)
130
+ return undefined;
131
+ const issuedAt = JSON.parse(JSON.parse(BUNDLED_DETECTOR_ENVELOPE).payload).issuedAt;
132
+ const bundled = (0, detectorArtifact_1.verifyDetectorArtifact)(BUNDLED_DETECTOR_ENVELOPE, Math.min(Date.now(), issuedAt));
133
+ return bundled.machineIds === null ? bundled : undefined;
134
+ }
135
+ catch {
136
+ return undefined;
137
+ }
138
+ }
139
+ /** The daemon's accepted update cache, under the same verification and eligibility as DetectorUpdates.loadLocal. */
140
+ function cachedUpdate() {
141
+ try {
142
+ const file = path.join(os.homedir(), '.fullcourtdefense', 'detector-update-v1.json');
143
+ if (fs.statSync(file).size > detectorArtifact_1.MAX_DETECTOR_BYTES)
144
+ return undefined;
145
+ const raw = fs.readFileSync(file, 'utf8');
146
+ // Expiration gates first acceptance, not continued offline use.
147
+ const data = (0, detectorArtifact_1.verifyDetectorArtifact)(raw, Math.min(Date.now(), JSON.parse(JSON.parse(raw).payload).issuedAt));
148
+ if (data.machineIds !== null && !data.machineIds.includes((0, machineIdentity_1.getMachineIdentity)().machineId))
149
+ return undefined;
150
+ return data;
151
+ }
152
+ catch {
153
+ return undefined;
154
+ }
155
+ }
156
+ /**
157
+ * The artifact the Worker loader would have active: it activates the baseline
158
+ * first, then a cached update only when strictly newer than what is active
159
+ * (any revision when the baseline did not activate). Each candidate must
160
+ * compile and pass the smoke inference.
161
+ */
162
+ function selectDetector() {
163
+ const baseline = bundledBaseline();
164
+ const update = cachedUpdate();
165
+ const updateFirst = Boolean(update && (!baseline || update.revision > baseline.revision));
166
+ if (update && updateFirst) {
167
+ const infer = prepare(update);
168
+ if (infer)
169
+ return { artifact: update, infer };
170
+ }
171
+ if (baseline) {
172
+ const infer = prepare(baseline);
173
+ if (infer)
174
+ return { artifact: baseline, infer };
175
+ }
176
+ if (update && !updateFirst) {
177
+ const infer = prepare(update);
178
+ if (infer)
179
+ return { artifact: update, infer };
180
+ }
181
+ return undefined;
182
+ }
183
+ /**
184
+ * Make the signed detector available to this evaluation without loading it:
185
+ * the signed vocabulary is read (cheap), and a placeholder is installed that
186
+ * verifies and compiles the artifact on the engine's first inference. No-op
187
+ * where the Worker loader already owns the detector.
188
+ */
189
+ function ensureEvaluationDetector() {
190
+ try {
191
+ localDetectionData_1.localDetectionUpdates.loadLocal();
192
+ }
193
+ catch { /* bundled vocabulary decides */ }
194
+ if (installed || longRunningLoaderLoaded() || (0, detectorRuntime_1.detectorRuntimeStatus)().revision > 0)
195
+ return;
196
+ installed = true;
197
+ let infer;
198
+ const lazy = Object.assign((tool, args) => {
199
+ if (!resolved) {
200
+ resolved = true;
201
+ const selected = selectDetector();
202
+ chosen = selected?.artifact;
203
+ infer = selected?.infer;
204
+ }
205
+ // No usable artifact: retire the placeholder; the bundled inference decides.
206
+ if (!infer)
207
+ throw new detectorRuntime_1.DetectorWorkerUnavailable('No signed detector available');
208
+ return infer(tool, args);
209
+ }, { dispose: () => { infer?.dispose(); } });
210
+ // Revision 1 orders below every published artifact, so a Worker loader that
211
+ // starts later in this process still replaces the placeholder.
212
+ try {
213
+ (0, detectorRuntime_1.installDetector)(lazy, 1);
214
+ }
215
+ catch {
216
+ installed = false;
217
+ }
218
+ }
219
+ /**
220
+ * Detector stamp for spooled events (revision / sha256 / active), without
221
+ * loading anything: the Worker loader's status where it owns the detector,
222
+ * otherwise the in-process detector once a decision has used it. Undefined
223
+ * when this process never consulted the detector.
224
+ */
225
+ function detectorStampForEvent() {
226
+ if (longRunningLoaderLoaded()) {
227
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
228
+ const status = require('./localDetectionUpdates').localDetectorUpdates.getDetectorStatus();
229
+ return { revision: status.revision, sha256: status.sha256, active: status.active };
230
+ }
231
+ if (!installed || !resolved)
232
+ return undefined;
233
+ return { revision: chosen?.revision ?? 0, sha256: chosen?.sha256, active: (0, detectorRuntime_1.detectorRuntimeStatus)().active };
234
+ }