fullcourtdefense-cli 1.21.31 → 1.21.34

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.
@@ -48,6 +48,8 @@ const os = __importStar(require("os"));
48
48
  const path = __importStar(require("path"));
49
49
  const child_process_1 = require("child_process");
50
50
  const config_1 = require("../config");
51
+ const distress_1 = require("../distress");
52
+ const selfTest_1 = require("../selfTest");
51
53
  const daemonForensics_1 = require("../daemonForensics");
52
54
  const securityAgents_1 = require("../securityAgents");
53
55
  const mcpGateway_1 = require("./mcpGateway");
@@ -345,9 +347,13 @@ async function runDaemon(args, config) {
345
347
  // that would restart a crashed process instantly.
346
348
  process.on('uncaughtException', error => {
347
349
  log(`Uncaught exception (daemon continues): ${error?.stack || String(error)}`);
350
+ // Ledger the unknown: failures nobody predicted still become structured,
351
+ // fleet-visible telemetry (code unexpected_error) on the next heartbeat.
352
+ (0, distress_1.reportUnexpected)('daemon', error);
348
353
  });
349
354
  process.on('unhandledRejection', reason => {
350
355
  log(`Unhandled rejection (daemon continues): ${reason?.stack || String(reason)}`);
356
+ (0, distress_1.reportUnexpected)('daemon', reason);
351
357
  });
352
358
  const creds = (0, config_1.resolveCliCredentials)(config, {
353
359
  shieldId: args.shieldId,
@@ -536,7 +542,9 @@ async function runDaemon(args, config) {
536
542
  return;
537
543
  try {
538
544
  const raw = fs.readFileSync(logFile(), 'utf8');
539
- const lines = raw.split(/\r?\n/).filter(line => line.trim()).slice(-80);
545
+ // 200 lines (~half a day of routine ticks): enough context to diagnose a
546
+ // failure loop remotely without asking the customer to paste anything.
547
+ const lines = raw.split(/\r?\n/).filter(line => line.trim()).slice(-200);
540
548
  const identity = (0, machineIdentity_1.getMachineIdentity)();
541
549
  await fetch(`${creds.apiUrl}/api/cli/machines/log`, {
542
550
  method: 'POST',
@@ -550,6 +558,64 @@ async function runDaemon(args, config) {
550
558
  }
551
559
  catch { /* log shipping is best-effort */ }
552
560
  };
561
+ // Ship a structured diagnostics payload (support bundle / self-test report)
562
+ // to the machine record. Works key-less too — a machine whose credential
563
+ // store is broken is EXACTLY the one whose diagnostics we need (the backend
564
+ // verifies the machine binding and flags the reduced trust level).
565
+ const uploadDiagnostics = async (payload) => {
566
+ if (!creds.shieldId)
567
+ return false;
568
+ try {
569
+ const identity = (0, machineIdentity_1.getMachineIdentity)();
570
+ const resp = await fetch(`${creds.apiUrl}/api/cli/machines/diagnostics`, {
571
+ method: 'POST',
572
+ headers: {
573
+ 'Content-Type': 'application/json',
574
+ ...(creds.shieldKey ? { 'x-shield-key': creds.shieldKey } : {}),
575
+ },
576
+ body: JSON.stringify({ shieldId: creds.shieldId, machineId: identity.machineId, ...payload }),
577
+ signal: AbortSignal.timeout(15_000),
578
+ });
579
+ return resp.ok;
580
+ }
581
+ catch {
582
+ log('Diagnostics upload failed (network) — the bundle stays available locally.');
583
+ return false;
584
+ }
585
+ };
586
+ // The complete remote support bundle: everything we asked Alex and Alin to
587
+ // paste by hand during the lptx1110 incident, collected in one action.
588
+ // Metadata + our own logs only — never customer files, prompts, or secrets.
589
+ const buildSupportBundle = () => {
590
+ const powershell = (0, config_1.getPowershellHealth)();
591
+ let integrity;
592
+ try {
593
+ integrity = (0, integrity_1.getLocalIntegrityReport)({ requireDaemon: true });
594
+ }
595
+ catch (error) {
596
+ integrity = { error: error.message };
597
+ }
598
+ let daemonLogTail = [];
599
+ try {
600
+ daemonLogTail = fs.readFileSync(logFile(), 'utf8')
601
+ .split(/\r?\n/).map(line => line.trim()).filter(Boolean).slice(-200).map(line => line.slice(0, 400));
602
+ }
603
+ catch { /* log unreadable — the bundle reports everything else */ }
604
+ return {
605
+ cliVersion: cliVersion(),
606
+ platform: process.platform,
607
+ collectedAt: new Date().toISOString(),
608
+ daemonLogTail,
609
+ updaterLogTail: (0, selfUpdate_1.readUpdaterLogTail)(60),
610
+ distress: (0, distress_1.readDistressLedger)(),
611
+ credentialTrace: (0, config_1.getCredentialResolutionTrace)(),
612
+ powershell,
613
+ securityAgents: (0, securityAgents_1.getSecurityAgentsReport)()?.products,
614
+ integrity,
615
+ lastCrash: (0, daemonForensics_1.readPostmortem)(),
616
+ watchdogTaskInstalled: isWatchdogTaskInstalled(),
617
+ };
618
+ };
553
619
  const reportMachineAction = async (actionId, status, detail) => {
554
620
  if (!creds.shieldId)
555
621
  return;
@@ -664,18 +730,37 @@ async function runDaemon(args, config) {
664
730
  try {
665
731
  let resultSummary = '';
666
732
  if (action.type === 'health_check') {
667
- log('Health check: verifying daemon + protection surfaces…');
733
+ // Deep self-test: actively EXERCISE every subsystem (DPAPI roundtrip,
734
+ // PowerShell mode, API reachability, authenticated fetch, hooks,
735
+ // updater task) instead of passively reporting. ~30s from the console
736
+ // to a per-subsystem pass/fail table.
737
+ log('Health check: running deep self-test across all subsystems…');
668
738
  await uploadLogTail();
669
- const integrity = (0, integrity_1.getLocalIntegrityReport)({ requireDaemon: true });
670
- log(integrity.ok
671
- ? `Health check: all protection points healthy (${integrity.protectedMcpConfigs}/${integrity.discoveredMcpConfigs} MCP configs wrapped).`
672
- : `Health check: issues found — ${integrity.reasons.join(', ')}.`);
673
- resultSummary = integrity.ok
674
- ? 'Daemon and required AgentGuard protection points are healthy.'
675
- : `Health check found: ${integrity.reasons.join(', ')}`;
676
- if (!integrity.ok)
739
+ const identity = (0, machineIdentity_1.getMachineIdentity)();
740
+ const report = await (0, selfTest_1.runDeepSelfTest)({
741
+ creds,
742
+ developerName: identity.developerName,
743
+ machineName: identity.hostname,
744
+ machineId: identity.machineId,
745
+ isWatchdogTaskInstalled,
746
+ log,
747
+ });
748
+ await uploadDiagnostics({ selfTest: report });
749
+ resultSummary = (0, selfTest_1.summarizeSelfTest)(report);
750
+ log(`Health check: ${resultSummary}`);
751
+ if (!report.ok)
677
752
  throw new Error(resultSummary);
678
753
  }
754
+ else if (action.type === 'collect_diagnostics') {
755
+ log('Collect diagnostics: assembling support bundle (logs + distress ledger + environment — metadata only)…');
756
+ await uploadLogTail();
757
+ const bundle = buildSupportBundle();
758
+ const uploaded = await uploadDiagnostics({ bundle });
759
+ if (!uploaded)
760
+ throw new Error('Support bundle could not be uploaded (network or backend rejection).');
761
+ log(`Collect diagnostics: bundle uploaded (${bundle.daemonLogTail.length} log lines, ${bundle.distress.length} distress entries).`);
762
+ resultSummary = `Support bundle uploaded: ${bundle.daemonLogTail.length} daemon log lines, ${bundle.updaterLogTail.length} updater log lines, ${bundle.distress.length} distress signal(s), credential trace, environment health.`;
763
+ }
679
764
  else if (action.type === 'policy_refresh') {
680
765
  if (!creds.shieldId)
681
766
  throw new Error('Shield not configured on this machine.');
@@ -777,9 +862,41 @@ async function runDaemon(args, config) {
777
862
  await uploadLogTail();
778
863
  }
779
864
  };
865
+ // Distress channel: a machine whose credential store is broken cannot fetch
866
+ // the authenticated bundle — which is how remote actions are delivered — so
867
+ // the one machine an admin most needs to reach is unreachable. This key-less
868
+ // poll returns SIGNED actions only; safety comes from the Ed25519 signature
869
+ // + org/machine binding verified in executeMachineAction (the transport was
870
+ // never the trust anchor). Rescue path: queue upgrade_cli / collect_diagnostics
871
+ // from the console and the bricked machine still receives it.
872
+ const pollDistressActions = async () => {
873
+ if (!creds.shieldId || creds.shieldKey)
874
+ return;
875
+ try {
876
+ const identity = (0, machineIdentity_1.getMachineIdentity)();
877
+ const params = new URLSearchParams({ shieldId: creds.shieldId, machineId: identity.machineId });
878
+ const resp = await fetch(`${creds.apiUrl}/api/cli/machine-actions/pending?${params.toString()}`, {
879
+ method: 'GET',
880
+ signal: AbortSignal.timeout(8_000),
881
+ });
882
+ if (!resp.ok)
883
+ return;
884
+ const body = await resp.json().catch(() => ({}));
885
+ if (body.data?.machineAction) {
886
+ log(`Distress channel: received action ${body.data.machineAction.type} key-less — Ed25519 verification gates execution.`);
887
+ void executeMachineAction(body.data.machineAction);
888
+ }
889
+ }
890
+ catch { /* offline — next poll retries */ }
891
+ };
780
892
  const pollBundle = async () => {
781
893
  if (!creds.shieldId)
782
894
  return;
895
+ if (!creds.shieldKey) {
896
+ // Credential-broken machine: stay reachable via the key-less signed-
897
+ // action channel while credential recovery keeps retrying.
898
+ await pollDistressActions();
899
+ }
783
900
  const identity = (0, machineIdentity_1.getMachineIdentity)();
784
901
  try {
785
902
  const bundle = await (0, runtimeConfig_1.getRuntimeBundle)({
@@ -882,6 +999,9 @@ async function runDaemon(args, config) {
882
999
  securityAgents: (0, securityAgents_1.getSecurityAgentsReport)()?.products,
883
1000
  powershellSpawnOk: powershell?.spawnOk,
884
1001
  powershellDecryptOk: powershell?.decryptOk,
1002
+ powershellLanguageMode: powershell?.languageMode,
1003
+ shieldKeySource: (0, config_1.getCredentialResolutionTrace)()?.shieldKeySource,
1004
+ distress: (0, distress_1.readDistressSnapshot)(),
885
1005
  lastCrash: unreportedCrash
886
1006
  ? {
887
1007
  version: unreportedCrash.version,
package/dist/config.d.ts CHANGED
@@ -30,8 +30,36 @@ export interface PowershellHealth {
30
30
  spawnOk: boolean;
31
31
  decryptOk: boolean;
32
32
  checkedAt: string;
33
+ /** FullLanguage / ConstrainedLanguage / RestrictedLanguage — captured only when a decrypt fails (extra spawn is failure-path-only). */
34
+ languageMode?: string;
33
35
  }
34
36
  export declare function getPowershellHealth(): PowershellHealth | undefined;
37
+ /**
38
+ * PowerShell language mode — the "is this a hardened WDAC/AppLocker fleet
39
+ * machine?" bit that explained the lptx1110 incident. Spawned only on the
40
+ * DPAPI failure path (and from the deep self-test), never on healthy runs.
41
+ */
42
+ export declare function capturePowershellLanguageMode(): string | undefined;
43
+ /**
44
+ * PSCredential.GetNetworkCredential() instead of Marshal::SecureStringToBSTR:
45
+ * hardened fleets run PowerShell in Constrained Language Mode, where the
46
+ * [Runtime.InteropServices.Marshal] calls are forbidden (script fails ->
47
+ * exit 1 -> "Shield key not available" -> every hook 401s fail-closed). The
48
+ * PSCredential technique is on CLM's approved-type list and decrypts the same
49
+ * DPAPI-protected SecureString in every language mode. Exported so the CLM
50
+ * regression test runs this EXACT text inside a ConstrainedLanguage session.
51
+ */
52
+ export declare const DPAPI_DECRYPT_SNIPPET = "$secure=ConvertTo-SecureString -String $env:FCD_DPAPI_VALUE; (New-Object System.Management.Automation.PSCredential('fcd', $secure)).GetNetworkCredential().Password";
53
+ /**
54
+ * Deep self-test probe: encrypt AND decrypt a throwaway value with the exact
55
+ * DPAPI snippets used for the shield key. Encryption alone can succeed on a
56
+ * machine that can never decrypt (the pre-v1.21.32 bricking bug) — only the
57
+ * full roundtrip proves the credential store works.
58
+ */
59
+ export declare function dpapiRoundtripProbe(): {
60
+ ok: boolean;
61
+ detail: string;
62
+ };
35
63
  export declare function getHomeConfigPath(): string;
36
64
  export declare function loadConfig(configPath?: string): BotGuardConfig;
37
65
  export declare function getDefaultConfigPath(): string;
@@ -66,6 +94,19 @@ export interface ResolvedCliCredentials {
66
94
  * Pure — unit-tested.
67
95
  */
68
96
  export declare function daemonHandoffShieldKey(env?: NodeJS.ProcessEnv): string | undefined;
97
+ /**
98
+ * Which source supplied the shield key on the most recent resolution — the
99
+ * codes-only trace shipped in the collect_diagnostics bundle (never the key
100
+ * itself). `none_dpapi_broken` is the credential-broken state: a DPAPI blob
101
+ * exists but would not decrypt and no fallback source was available.
102
+ */
103
+ export type ShieldKeySource = 'override' | 'daemon_handoff' | 'native_store' | 'dpapi' | 'plaintext_config' | 'env' | 'none' | 'none_dpapi_broken';
104
+ export interface CredentialResolutionTrace {
105
+ shieldKeySource: ShieldKeySource;
106
+ dpapiAttempted: boolean;
107
+ at: string;
108
+ }
109
+ export declare function getCredentialResolutionTrace(): CredentialResolutionTrace | undefined;
69
110
  /** Merge saved ~/.fullcourtdefense.yml + env + optional CLI flag overrides. */
70
111
  export declare function resolveCliCredentials(config: BotGuardConfig, overrides?: Partial<ResolvedCliCredentials>, options?: {
71
112
  skipDpapi?: boolean;
package/dist/config.js CHANGED
@@ -33,12 +33,16 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.DPAPI_DECRYPT_SNIPPET = void 0;
36
37
  exports.getPowershellHealth = getPowershellHealth;
38
+ exports.capturePowershellLanguageMode = capturePowershellLanguageMode;
39
+ exports.dpapiRoundtripProbe = dpapiRoundtripProbe;
37
40
  exports.getHomeConfigPath = getHomeConfigPath;
38
41
  exports.loadConfig = loadConfig;
39
42
  exports.getDefaultConfigPath = getDefaultConfigPath;
40
43
  exports.saveShieldConfig = saveShieldConfig;
41
44
  exports.daemonHandoffShieldKey = daemonHandoffShieldKey;
45
+ exports.getCredentialResolutionTrace = getCredentialResolutionTrace;
42
46
  exports.resolveCliCredentials = resolveCliCredentials;
43
47
  exports.resolveCliCredentialsShellFree = resolveCliCredentialsShellFree;
44
48
  exports.isCliSetupComplete = isCliSetupComplete;
@@ -49,6 +53,8 @@ const fs = __importStar(require("fs"));
49
53
  const os = __importStar(require("os"));
50
54
  const path = __importStar(require("path"));
51
55
  const child_process_1 = require("child_process");
56
+ const distress_1 = require("./distress");
57
+ const credentialStore_1 = require("./credentialStore");
52
58
  const CONFIG_FILENAMES = [
53
59
  '.fullcourtdefense.yml',
54
60
  '.fullcourtdefense.yaml',
@@ -161,6 +167,30 @@ let powershellHealth;
161
167
  function getPowershellHealth() {
162
168
  return powershellHealth;
163
169
  }
170
+ /**
171
+ * PowerShell language mode — the "is this a hardened WDAC/AppLocker fleet
172
+ * machine?" bit that explained the lptx1110 incident. Spawned only on the
173
+ * DPAPI failure path (and from the deep self-test), never on healthy runs.
174
+ */
175
+ function capturePowershellLanguageMode() {
176
+ if (process.platform !== 'win32')
177
+ return undefined;
178
+ for (const shell of ['powershell.exe', 'pwsh.exe']) {
179
+ try {
180
+ const raw = (0, child_process_1.execFileSync)(shell, [
181
+ '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass',
182
+ '-Command', '$ExecutionContext.SessionState.LanguageMode',
183
+ ], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 15_000, windowsHide: true }).trim();
184
+ if (raw) {
185
+ if (powershellHealth)
186
+ powershellHealth.languageMode = raw;
187
+ return raw;
188
+ }
189
+ }
190
+ catch { /* try the next shell */ }
191
+ }
192
+ return undefined;
193
+ }
164
194
  function powershellDpapi(script, value) {
165
195
  if (process.platform !== 'win32' || !value)
166
196
  return undefined;
@@ -204,8 +234,55 @@ function powershellDpapi(script, value) {
204
234
  function protectShieldKeyForCurrentWindowsUser(value) {
205
235
  return powershellDpapi('$secure=ConvertTo-SecureString -String $env:FCD_DPAPI_VALUE -AsPlainText -Force; ConvertFrom-SecureString -SecureString $secure', value);
206
236
  }
237
+ /**
238
+ * PSCredential.GetNetworkCredential() instead of Marshal::SecureStringToBSTR:
239
+ * hardened fleets run PowerShell in Constrained Language Mode, where the
240
+ * [Runtime.InteropServices.Marshal] calls are forbidden (script fails ->
241
+ * exit 1 -> "Shield key not available" -> every hook 401s fail-closed). The
242
+ * PSCredential technique is on CLM's approved-type list and decrypts the same
243
+ * DPAPI-protected SecureString in every language mode. Exported so the CLM
244
+ * regression test runs this EXACT text inside a ConstrainedLanguage session.
245
+ */
246
+ exports.DPAPI_DECRYPT_SNIPPET = "$secure=ConvertTo-SecureString -String $env:FCD_DPAPI_VALUE; (New-Object System.Management.Automation.PSCredential('fcd', $secure)).GetNetworkCredential().Password";
207
247
  function unprotectShieldKeyForCurrentWindowsUser(value) {
208
- return powershellDpapi('$secure=ConvertTo-SecureString -String $env:FCD_DPAPI_VALUE; $ptr=[Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure); try {[Runtime.InteropServices.Marshal]::PtrToStringBSTR($ptr)} finally {[Runtime.InteropServices.Marshal]::ZeroFreeBSTR($ptr)}', value);
248
+ const result = powershellDpapi(exports.DPAPI_DECRYPT_SNIPPET, value);
249
+ if (!result && value) {
250
+ // The machine HAS a DPAPI-protected key it cannot read — the exact
251
+ // credential-broken state that 401-bricked lptx1110. Emit a coded
252
+ // distress signal so the fleet console sees it without asking the user.
253
+ const health = powershellHealth;
254
+ if (health && !health.spawnOk) {
255
+ (0, distress_1.reportDistress)('credentials', distress_1.DISTRESS.POWERSHELL_BLOCKED, 'powershell.exe and pwsh.exe both failed to start (EDR/AppLocker?)');
256
+ }
257
+ else {
258
+ const mode = capturePowershellLanguageMode();
259
+ (0, distress_1.reportDistress)('credentials', distress_1.DISTRESS.DPAPI_DECRYPT_FAILED, mode ? `language mode: ${mode}` : 'decrypt returned nothing');
260
+ if (mode && mode !== 'FullLanguage') {
261
+ (0, distress_1.reportDistress)('credentials', distress_1.DISTRESS.POWERSHELL_CONSTRAINED, `PowerShell language mode is ${mode}`);
262
+ }
263
+ }
264
+ }
265
+ return result;
266
+ }
267
+ /**
268
+ * Deep self-test probe: encrypt AND decrypt a throwaway value with the exact
269
+ * DPAPI snippets used for the shield key. Encryption alone can succeed on a
270
+ * machine that can never decrypt (the pre-v1.21.32 bricking bug) — only the
271
+ * full roundtrip proves the credential store works.
272
+ */
273
+ function dpapiRoundtripProbe() {
274
+ if (process.platform !== 'win32')
275
+ return { ok: true, detail: 'not applicable (non-Windows)' };
276
+ const probe = `fcd-selftest-${Date.now()}`;
277
+ const encrypted = protectShieldKeyForCurrentWindowsUser(probe);
278
+ if (!encrypted) {
279
+ const health = powershellHealth;
280
+ return { ok: false, detail: health && !health.spawnOk ? 'PowerShell would not start (EDR/AppLocker?)' : 'DPAPI encrypt produced nothing' };
281
+ }
282
+ const decrypted = powershellDpapi(exports.DPAPI_DECRYPT_SNIPPET, encrypted);
283
+ if (decrypted === probe)
284
+ return { ok: true, detail: 'encrypt + decrypt roundtrip OK' };
285
+ return { ok: false, detail: decrypted ? 'decrypt returned a different value (profile mismatch?)' : 'decrypt returned nothing (CLM/EDR block?)' };
209
286
  }
210
287
  function mergeConfig(base, override) {
211
288
  return {
@@ -263,8 +340,83 @@ const DEFAULT_API_URL = 'https://api.fullcourtdefense.ai';
263
340
  function daemonHandoffShieldKey(env = process.env) {
264
341
  return env.FCD_SHIELD_ID && env.FCD_SHIELD_KEY ? env.FCD_SHIELD_KEY : undefined;
265
342
  }
343
+ let lastCredentialTrace;
344
+ function getCredentialResolutionTrace() {
345
+ return lastCredentialTrace;
346
+ }
347
+ // Once per process: hooks are short-lived (one attempt each) and the daemon
348
+ // long-lived (must not hammer the Credential Manager every 30s poll).
349
+ let nativeMigrationAttempted = false;
350
+ function maybeMigrateShieldKeyToNativeStore(shieldId, shieldKey) {
351
+ if (nativeMigrationAttempted || !(0, credentialStore_1.nativeCredentialStoreAvailable)())
352
+ return;
353
+ nativeMigrationAttempted = true;
354
+ try {
355
+ if ((0, credentialStore_1.readShieldKeyNative)(shieldId))
356
+ return; // already migrated
357
+ (0, credentialStore_1.saveShieldKeyNative)(shieldId, shieldKey); // read-back verified inside
358
+ }
359
+ catch { /* migration is opportunistic — legacy sources keep working */ }
360
+ }
266
361
  /** Merge saved ~/.fullcourtdefense.yml + env + optional CLI flag overrides. */
267
362
  function resolveCliCredentials(config, overrides = {}, options = {}) {
363
+ // Shield key: same precedence as the old || chain, evaluated stepwise so
364
+ // (a) the DPAPI decrypt stays lazy — no powershell.exe spawn when an
365
+ // earlier source wins — and (b) the winning source is recorded for the
366
+ // diagnostics trace.
367
+ let shieldKey = overrides.shieldKey;
368
+ let shieldKeySource = shieldKey ? 'override' : 'none';
369
+ let dpapiAttempted = false;
370
+ const resolvedShieldId = overrides.shieldId
371
+ || config.shieldId
372
+ || process.env.FCD_SHIELD_ID
373
+ || process.env.FULLCOURTDEFENSE_SHIELD_ID
374
+ || process.env.AGENTGUARD_SHIELD_ID;
375
+ if (!shieldKey) {
376
+ // Daemon handoff BEFORE the DPAPI decrypt: no powershell.exe spawn at
377
+ // all during daemon-driven sweeps (see daemonHandoffShieldKey docs).
378
+ shieldKey = daemonHandoffShieldKey();
379
+ if (shieldKey)
380
+ shieldKeySource = 'daemon_handoff';
381
+ }
382
+ if (!shieldKey && resolvedShieldId) {
383
+ // Native Credential Manager BEFORE the DPAPI/PowerShell decrypt: an
384
+ // in-process win32 API read that EDRs don't flag and CLM can't constrain.
385
+ // Silently absent on machines that never migrated (see the migration
386
+ // below) — the DPAPI chain still stands behind it.
387
+ shieldKey = (0, credentialStore_1.readShieldKeyNative)(resolvedShieldId);
388
+ if (shieldKey)
389
+ shieldKeySource = 'native_store';
390
+ }
391
+ if (!shieldKey && !options.skipDpapi && config.shieldKeyDpapi) {
392
+ dpapiAttempted = true;
393
+ shieldKey = unprotectShieldKeyForCurrentWindowsUser(config.shieldKeyDpapi);
394
+ if (shieldKey)
395
+ shieldKeySource = 'dpapi';
396
+ }
397
+ if (!shieldKey && config.shieldKey) {
398
+ shieldKey = config.shieldKey;
399
+ shieldKeySource = 'plaintext_config';
400
+ }
401
+ if (!shieldKey) {
402
+ shieldKey = process.env.FCD_SHIELD_KEY
403
+ || process.env.FULLCOURTDEFENSE_SHIELD_KEY
404
+ || process.env.AGENTGUARD_SHIELD_KEY;
405
+ if (shieldKey)
406
+ shieldKeySource = 'env';
407
+ }
408
+ if (!shieldKey && dpapiAttempted)
409
+ shieldKeySource = 'none_dpapi_broken';
410
+ lastCredentialTrace = { shieldKeySource, dpapiAttempted, at: new Date().toISOString() };
411
+ // Self-healing migration: whenever the key still resolves through a legacy
412
+ // source (DPAPI blob or plaintext config), copy it into the Credential
413
+ // Manager once. On fleets like lptx1110 the daemon's context CAN decrypt
414
+ // while IDE-spawned hooks CANNOT — after the daemon's first resolution the
415
+ // native entry exists and every process reads it without PowerShell.
416
+ if (shieldKey && resolvedShieldId
417
+ && (shieldKeySource === 'dpapi' || shieldKeySource === 'plaintext_config')) {
418
+ maybeMigrateShieldKeyToNativeStore(resolvedShieldId, shieldKey);
419
+ }
268
420
  return {
269
421
  apiKey: overrides.apiKey
270
422
  || config.apiKey
@@ -281,15 +433,7 @@ function resolveCliCredentials(config, overrides = {}, options = {}) {
281
433
  || process.env.FCD_SHIELD_ID
282
434
  || process.env.FULLCOURTDEFENSE_SHIELD_ID
283
435
  || process.env.AGENTGUARD_SHIELD_ID,
284
- shieldKey: overrides.shieldKey
285
- // Daemon handoff BEFORE the DPAPI decrypt: no powershell.exe spawn at
286
- // all during daemon-driven sweeps (see daemonHandoffShieldKey docs).
287
- || daemonHandoffShieldKey()
288
- || (options.skipDpapi ? undefined : unprotectShieldKeyForCurrentWindowsUser(config.shieldKeyDpapi || ''))
289
- || config.shieldKey
290
- || process.env.FCD_SHIELD_KEY
291
- || process.env.FULLCOURTDEFENSE_SHIELD_KEY
292
- || process.env.AGENTGUARD_SHIELD_KEY,
436
+ shieldKey,
293
437
  };
294
438
  }
295
439
  /**
@@ -349,12 +493,25 @@ function writeSetupConfig(target, input) {
349
493
  setTopLevel('apiKey', input.apiKey);
350
494
  setTopLevel('organizationId', input.organizationId);
351
495
  setTopLevel('shieldId', input.shieldId);
496
+ // Native Credential Manager write (read-back verified) — the store hooks
497
+ // read WITHOUT PowerShell. The DPAPI blob below is still written when it
498
+ // verifies: rolling back to a ≤1.21.33 CLI keeps working, and machines
499
+ // where the native binding is blocked keep their PowerShell path.
500
+ if (input.shieldKey && input.shieldId) {
501
+ (0, credentialStore_1.saveShieldKeyNative)(input.shieldId, input.shieldKey);
502
+ }
352
503
  const protectedShieldKey = input.shieldKey && process.platform === 'win32'
353
504
  ? protectShieldKeyForCurrentWindowsUser(input.shieldKey)
354
505
  : undefined;
355
- if (protectedShieldKey) {
506
+ // Roundtrip-verify before trusting DPAPI: encryption is cmdlet-only and
507
+ // succeeds even where decryption is blocked (Constrained Language Mode /
508
+ // EDR), which used to delete the plaintext key on a machine that could
509
+ // never read the encrypted one back — every hook then 401'd fail-closed.
510
+ const dpapiVerified = !!protectedShieldKey
511
+ && unprotectShieldKeyForCurrentWindowsUser(protectedShieldKey) === input.shieldKey;
512
+ if (protectedShieldKey && dpapiVerified) {
356
513
  setTopLevel('shieldKeyDpapi', protectedShieldKey);
357
- // Remove legacy plaintext key on a successful Windows DPAPI migration.
514
+ // Remove legacy plaintext key on a VERIFIED Windows DPAPI migration.
358
515
  const plaintextIndex = lines.findIndex(line => /^shieldKey:/.test(line.trim()));
359
516
  if (plaintextIndex >= 0)
360
517
  lines.splice(plaintextIndex, 1);
@@ -0,0 +1,19 @@
1
+ export declare function nativeCredentialStoreAvailable(): boolean;
2
+ /** Read the shield key from the Credential Manager. Undefined when absent/unavailable. */
3
+ export declare function readShieldKeyNative(shieldId: string): string | undefined;
4
+ /**
5
+ * Store the shield key, read-back verified. Returns false when the native
6
+ * store is unavailable or the verify failed — callers keep their fallback.
7
+ */
8
+ export declare function saveShieldKeyNative(shieldId: string, shieldKey: string): boolean;
9
+ /** Remove the entry (unenroll / re-enroll cleanup). Best-effort. */
10
+ export declare function deleteShieldKeyNative(shieldId: string): void;
11
+ /**
12
+ * Deep self-test probe: write + read + delete a throwaway entry with the exact
13
+ * code paths used for the shield key. Proves the native store works end-to-end
14
+ * on THIS machine under the current EDR/policy regime.
15
+ */
16
+ export declare function nativeStoreRoundtripProbe(): {
17
+ ok: boolean;
18
+ detail: string;
19
+ };
@@ -0,0 +1,136 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.nativeCredentialStoreAvailable = nativeCredentialStoreAvailable;
4
+ exports.readShieldKeyNative = readShieldKeyNative;
5
+ exports.saveShieldKeyNative = saveShieldKeyNative;
6
+ exports.deleteShieldKeyNative = deleteShieldKeyNative;
7
+ exports.nativeStoreRoundtripProbe = nativeStoreRoundtripProbe;
8
+ const distress_1 = require("./distress");
9
+ /**
10
+ * Native credential store — Windows Credential Manager via `@napi-rs/keyring`
11
+ * (a prebuilt N-API binding; in-process win32 API calls, no PowerShell).
12
+ *
13
+ * WHY: the lptx1110 incident class. The shield key at rest was DPAPI-protected
14
+ * and decrypted by spawning powershell.exe — exactly the pattern EDRs flag
15
+ * ("IDE process spawns PowerShell that touches credentials"). On hardened
16
+ * fleets PowerShell is additionally locked to Constrained Language Mode or
17
+ * blocked outright, so hooks resolve no key and 401 fail-closed. Credential
18
+ * Manager reads are ordinary in-process Windows API calls used by mainstream
19
+ * software — nothing to flag, nothing for CLM to constrain.
20
+ *
21
+ * SAFETY MODEL (this must never brick a machine):
22
+ * - the module is REQUIRED LAZILY inside try/catch — if the .node binary is
23
+ * missing, blocked, or ABI-incompatible, every call reports "unavailable"
24
+ * and the caller falls through to the existing DPAPI/PowerShell chain;
25
+ * - writes are read-back VERIFIED before the store is trusted;
26
+ * - enrollment keeps writing the DPAPI blob alongside — rolling back to an
27
+ * older CLI keeps working;
28
+ * - all failures land in the distress ledger, never as thrown errors.
29
+ *
30
+ * Windows-only for now: the EDR problem this solves is Windows-specific, and
31
+ * keeping other platforms untouched keeps the change surgical. (`keyring`
32
+ * itself supports macOS Keychain / libsecret when we choose to expand.)
33
+ */
34
+ const SERVICE_NAME = 'FullCourtDefense';
35
+ /** One entry per shield: multiple enrollments on one machine never collide. */
36
+ function accountName(shieldId) {
37
+ return `shield-key:${shieldId}`;
38
+ }
39
+ let cachedEntryCtor;
40
+ /** Lazy, cached, never-throwing loader for the native binding. */
41
+ function loadEntryCtor() {
42
+ if (cachedEntryCtor !== undefined)
43
+ return cachedEntryCtor;
44
+ if (process.platform !== 'win32') {
45
+ cachedEntryCtor = null;
46
+ return null;
47
+ }
48
+ try {
49
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
50
+ const mod = require('@napi-rs/keyring');
51
+ cachedEntryCtor = typeof mod.Entry === 'function' ? mod.Entry : null;
52
+ }
53
+ catch (error) {
54
+ // Missing/blocked native binary is an EXPECTED state (npm install layouts
55
+ // without optional deps, exotic ABIs) — log it once as distress detail so
56
+ // the fleet can see which machines lack the native store, then fall back.
57
+ (0, distress_1.reportDistress)('credentials', distress_1.DISTRESS.NATIVE_STORE_UNAVAILABLE, `keyring module did not load: ${error instanceof Error ? error.message.slice(0, 120) : String(error)}`);
58
+ cachedEntryCtor = null;
59
+ }
60
+ return cachedEntryCtor;
61
+ }
62
+ function nativeCredentialStoreAvailable() {
63
+ return loadEntryCtor() !== null;
64
+ }
65
+ /** Read the shield key from the Credential Manager. Undefined when absent/unavailable. */
66
+ function readShieldKeyNative(shieldId) {
67
+ const Entry = loadEntryCtor();
68
+ if (!Entry || !shieldId)
69
+ return undefined;
70
+ try {
71
+ const value = new Entry(SERVICE_NAME, accountName(shieldId)).getPassword();
72
+ return value || undefined;
73
+ }
74
+ catch {
75
+ // "No entry" throws in keyring — that is the normal not-enrolled case.
76
+ return undefined;
77
+ }
78
+ }
79
+ /**
80
+ * Store the shield key, read-back verified. Returns false when the native
81
+ * store is unavailable or the verify failed — callers keep their fallback.
82
+ */
83
+ function saveShieldKeyNative(shieldId, shieldKey) {
84
+ const Entry = loadEntryCtor();
85
+ if (!Entry || !shieldId || !shieldKey)
86
+ return false;
87
+ try {
88
+ const entry = new Entry(SERVICE_NAME, accountName(shieldId));
89
+ entry.setPassword(shieldKey);
90
+ const verified = new Entry(SERVICE_NAME, accountName(shieldId)).getPassword() === shieldKey;
91
+ if (!verified) {
92
+ (0, distress_1.reportDistress)('credentials', distress_1.DISTRESS.NATIVE_STORE_FAILED, 'write succeeded but read-back returned a different value');
93
+ }
94
+ return verified;
95
+ }
96
+ catch (error) {
97
+ (0, distress_1.reportDistress)('credentials', distress_1.DISTRESS.NATIVE_STORE_FAILED, `write failed: ${error instanceof Error ? error.message.slice(0, 120) : String(error)}`);
98
+ return false;
99
+ }
100
+ }
101
+ /** Remove the entry (unenroll / re-enroll cleanup). Best-effort. */
102
+ function deleteShieldKeyNative(shieldId) {
103
+ const Entry = loadEntryCtor();
104
+ if (!Entry || !shieldId)
105
+ return;
106
+ try {
107
+ new Entry(SERVICE_NAME, accountName(shieldId)).deletePassword();
108
+ }
109
+ catch { /* absent — fine */ }
110
+ }
111
+ /**
112
+ * Deep self-test probe: write + read + delete a throwaway entry with the exact
113
+ * code paths used for the shield key. Proves the native store works end-to-end
114
+ * on THIS machine under the current EDR/policy regime.
115
+ */
116
+ function nativeStoreRoundtripProbe() {
117
+ if (process.platform !== 'win32')
118
+ return { ok: true, detail: 'not applicable (non-Windows)' };
119
+ const Entry = loadEntryCtor();
120
+ if (!Entry)
121
+ return { ok: false, detail: 'native keyring module not loaded (missing binary or blocked) — DPAPI/PowerShell fallback in use' };
122
+ const probeAccount = `selftest:${Date.now()}`;
123
+ const probeValue = `fcd-native-probe-${Date.now()}`;
124
+ try {
125
+ const entry = new Entry(SERVICE_NAME, probeAccount);
126
+ entry.setPassword(probeValue);
127
+ const read = new Entry(SERVICE_NAME, probeAccount).getPassword();
128
+ entry.deletePassword();
129
+ return read === probeValue
130
+ ? { ok: true, detail: 'Credential Manager write + read + delete roundtrip OK (no PowerShell involved)' }
131
+ : { ok: false, detail: 'roundtrip read returned a different value' };
132
+ }
133
+ catch (error) {
134
+ return { ok: false, detail: `roundtrip failed: ${error instanceof Error ? error.message.slice(0, 160) : String(error)}` };
135
+ }
136
+ }