fullcourtdefense-cli 1.22.5 → 1.22.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -399,18 +399,33 @@ async function runDaemon(args, config) {
399
399
  let autoUpdatePolicy;
400
400
  /** Heartbeat ticks spent without full credentials (drives recovery cadence). */
401
401
  let credRecoveryTicks = 0;
402
+ /** Enrollment-file mtime at the last recovery attempt — a change means the
403
+ * user just ran login/onboard and fresh credentials are waiting. */
404
+ let lastEnrollmentMtimeMs = 0;
402
405
  /**
403
406
  * A daemon that starts without a usable shield key (DPAPI decrypt blocked,
404
407
  * config written after start by `login`) must NOT stay credential-less
405
408
  * forever — that silences telemetry AND auto-update, stranding the machine.
406
409
  * Retry quickly right after start, then hourly (each retry may spawn
407
- * PowerShell for DPAPI, which we keep rare on machines that block it).
410
+ * PowerShell for DPAPI, which we keep rare on machines that block it)
411
+ * EXCEPT when ~/.fullcourtdefense.yml changed, which means a re-enrollment
412
+ * just happened: retry on the very next tick, so a repaired machine comes
413
+ * back within one heartbeat instead of up to an hour later (the 8/12
414
+ * incident: re-enroll fixed the hooks instantly while the daemon kept
415
+ * 401-ing for 16 more minutes).
408
416
  */
409
417
  const recoverCredentialsIfMissing = () => {
410
418
  if (creds.shieldId && creds.shieldKey)
411
419
  return;
420
+ let enrollmentChanged = false;
421
+ try {
422
+ const mtime = fs.statSync((0, config_1.getHomeConfigPath)()).mtimeMs;
423
+ enrollmentChanged = mtime !== lastEnrollmentMtimeMs;
424
+ lastEnrollmentMtimeMs = mtime;
425
+ }
426
+ catch { /* no config file — the tick cadence below applies */ }
412
427
  credRecoveryTicks += 1;
413
- if (credRecoveryTicks > 3 && credRecoveryTicks % 12 !== 0)
428
+ if (!enrollmentChanged && credRecoveryTicks > 3 && credRecoveryTicks % 12 !== 0)
414
429
  return;
415
430
  try {
416
431
  const fresh = (0, config_1.resolveCliCredentials)((0, config_1.loadConfig)(args.config), {
@@ -1,6 +1,8 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.doctorCommand = doctorCommand;
4
+ const config_1 = require("../config");
5
+ const runtimeConfig_1 = require("../runtimeConfig");
4
6
  const DEFAULT_API_URL = 'https://api.fullcourtdefense.ai';
5
7
  function withTimeout(ms) {
6
8
  return AbortSignal.timeout(ms);
@@ -8,6 +10,35 @@ function withTimeout(ms) {
8
10
  function normalizeApiUrl(url) {
9
11
  return (url || DEFAULT_API_URL).replace(/\/$/, '');
10
12
  }
13
+ /**
14
+ * Credential health, printed after connectivity passes. Connectivity alone is
15
+ * a false comfort: the 8/12 incident machine had a green ping while the
16
+ * backend rejected its shield key on every call (broken local key store), so
17
+ * every protected action failed closed. Doctor must surface that state.
18
+ */
19
+ async function checkCredentials(apiUrl, config) {
20
+ const creds = (0, config_1.resolveCliCredentials)(config, { apiUrl });
21
+ if (!creds.shieldId || !creds.shieldKey) {
22
+ console.log('INFO not enrolled on this machine (no saved shield credentials)');
23
+ console.log(' Enroll with: fullcourtdefense onboard --token <fleet-enrollment-token>');
24
+ return true;
25
+ }
26
+ const started = Date.now();
27
+ const validation = await (0, runtimeConfig_1.validateShieldCredentials)({ apiUrl, shieldId: creds.shieldId, shieldKey: creds.shieldKey });
28
+ const elapsed = Date.now() - started;
29
+ if (validation.ok) {
30
+ console.log(`PASS machine credentials accepted by the backend (shield ${creds.shieldId}, ${elapsed}ms)`);
31
+ return true;
32
+ }
33
+ if (validation.authRejected) {
34
+ console.log(`FAIL backend REJECTS this machine's saved credentials (${validation.detail})`);
35
+ console.log(' Protected actions fail-closed until the machine re-enrolls.');
36
+ console.log(' Fix: fullcourtdefense onboard --token <fleet-enrollment-token>');
37
+ return false;
38
+ }
39
+ console.log(`WARN could not verify credentials right now (${validation.detail}); cached enforcement applies`);
40
+ return true;
41
+ }
11
42
  async function doctorCommand(args, config) {
12
43
  const apiUrl = normalizeApiUrl(args.apiUrl || config.apiUrl);
13
44
  const pingUrl = `${apiUrl}/api/health/ping`;
@@ -27,6 +58,9 @@ async function doctorCommand(args, config) {
27
58
  if (resp.ok) {
28
59
  console.log(`PASS outbound HTTPS open (${resp.status}, ${elapsed}ms)`);
29
60
  console.log(`Checked: ${pingUrl}`);
61
+ if (!(await checkCredentials(apiUrl, config))) {
62
+ process.exit(1);
63
+ }
30
64
  return;
31
65
  }
32
66
  console.log(`WARN health ping reachable but returned HTTP ${resp.status} (${elapsed}ms)`);
@@ -46,6 +80,9 @@ async function doctorCommand(args, config) {
46
80
  if (resp.ok) {
47
81
  console.log(`PASS root API reachable (${resp.status}, ${elapsed}ms)`);
48
82
  console.log(`Checked: ${rootUrl}`);
83
+ if (!(await checkCredentials(apiUrl, config))) {
84
+ process.exit(1);
85
+ }
49
86
  return;
50
87
  }
51
88
  console.log(`FAIL root API returned HTTP ${resp.status} (${elapsed}ms)`);
@@ -373,21 +373,31 @@ async function fetchWithOneRetry(url, init, timeoutMs, onFailure) {
373
373
  * spooled (`offlineEnforced`) and replayed to the backend when connectivity
374
374
  * returns, and distress signals make the episode visible to org admins.
375
375
  */
376
- function respondDegraded(ctx, detail, toolName) {
376
+ function respondDegraded(ctx, detail, toolName, authRejected = false) {
377
+ // "Unreachable" and "credentials rejected" need OPPOSITE fixes (wait vs
378
+ // re-enroll) and different alerts. The 8/12 incident machine showed users
379
+ // "restore connectivity" advice for hours while the real fix was a 30-second
380
+ // re-enrollment — never conflate the two again.
381
+ const cause = authRejected
382
+ ? 'the policy service rejected this machine\'s credentials'
383
+ : 'policy service unreachable';
384
+ const advice = authRejected
385
+ ? 'Re-enroll this machine: fullcourtdefense onboard --token <fleet-enrollment-token>.'
386
+ : 'Do not retry until the connection is restored.';
377
387
  if (ctx.failClosed && !ctx.shadow) {
378
388
  const health = (0, policyGateHealth_1.recordGateFailure)();
379
389
  if ((0, policyGateHealth_1.shouldFailClosed)(health)) {
380
390
  // Persistent outage on an enforcing machine: block, and raise a CRITICAL
381
391
  // distress code — the daemon heartbeat ships it and org admins get the
382
392
  // proactive alert email (fleet-alerts pipeline).
383
- (0, distress_1.reportDistress)('hook', distress_1.DISTRESS.HOOK_FAIL_CLOSED, `Blocking ${ctx.event} actions: policy gate unreachable ${health.consecutiveFailures} times in a row since ${health.firstFailureAt || 'now'} (${detail})`);
393
+ (0, distress_1.reportDistress)('hook', authRejected ? distress_1.DISTRESS.AUTH_BROKEN : distress_1.DISTRESS.HOOK_FAIL_CLOSED, `Blocking ${ctx.event} actions: ${cause} ${health.consecutiveFailures} times in a row since ${health.firstFailureAt || 'now'} (${detail})`);
384
394
  (0, telemetry_1.spoolEvent)({ decision: 'block', toolName, reason: `fail-closed: ${detail}`, offlineEnforced: true });
385
395
  (0, telemetry_1.triggerFlush)(true);
386
- ctx.respond(true, `Blocked by FullCourtDefense — policy service unreachable (${health.consecutiveFailures}x in a row) and this machine is set to fail-closed. ${detail}`, `FullCourtDefense could not verify this ${ctx.event} against your org policies (${detail}) and the outage is persistent, so fail-closed mode is blocking. Do not retry until the connection is restored.`);
396
+ ctx.respond(true, `Blocked by FullCourtDefense — ${cause} (${health.consecutiveFailures}x in a row) and this machine is set to fail-closed. ${detail} ${advice}`, `FullCourtDefense could not verify this ${ctx.event} against your org policies (${detail}) and the failure is persistent, so fail-closed mode is blocking. ${advice}`);
387
397
  }
388
398
  // Grace window: transient failure on a fail-closed machine — allow this
389
399
  // action, but record distress so the episode is visible in the console.
390
- (0, distress_1.reportDistress)('hook', distress_1.DISTRESS.NETWORK_DOWN, `Policy gate failure ${health.consecutiveFailures}/3 (grace window, still allowing): ${detail}`);
400
+ (0, distress_1.reportDistress)('hook', authRejected ? distress_1.DISTRESS.AUTH_BROKEN : distress_1.DISTRESS.NETWORK_DOWN, `Policy gate failure ${health.consecutiveFailures}/3 (grace window, still allowing): ${detail}`);
391
401
  dbg({ phase: 'policy_grace_allow', event: ctx.event, consecutiveFailures: health.consecutiveFailures, detail });
392
402
  (0, telemetry_1.spoolEvent)({ decision: 'allow', toolName, reason: `degraded (grace ${health.consecutiveFailures}/3): ${detail}`, offlineEnforced: true });
393
403
  (0, telemetry_1.triggerFlush)(false);
@@ -883,8 +893,19 @@ async function enforceActionPolicy(ctx) {
883
893
  }, timeoutMs, (attempt, err) => dbg({ phase: 'policy_retry', event, attempt, error: err }));
884
894
  if (!resp.ok) {
885
895
  dbg({ phase: 'policy_http_error', event, status: resp.status, failClosed: ctx.failClosed });
886
- if (!tryLocalPolicyEnforcement(ctx, call, `Backend returned HTTP ${resp.status}.`)) {
887
- respondDegraded(ctx, `Backend returned HTTP ${resp.status}.`, call.toolName);
896
+ const authRejected = resp.status === 401 || resp.status === 403;
897
+ const detail = authRejected
898
+ ? `The policy service rejected this machine's credentials (HTTP ${resp.status}) — the shield key saved here is broken or revoked, not a network problem.`
899
+ : `Backend returned HTTP ${resp.status}.`;
900
+ if (authRejected) {
901
+ // Rejected key = every future call fails identically. Cached policies
902
+ // (below) remain the best enforcement stance, but the distress code
903
+ // must say auth_broken so the org-admin alert email names the real
904
+ // fix (re-enroll) instead of "check connectivity".
905
+ (0, distress_1.reportDistress)('hook', distress_1.DISTRESS.AUTH_BROKEN, `check-tool-call HTTP ${resp.status}${shieldKey ? '' : ' (no shield key resolved)'}`);
906
+ }
907
+ if (!tryLocalPolicyEnforcement(ctx, call, detail)) {
908
+ respondDegraded(ctx, detail, call.toolName, authRejected);
888
909
  }
889
910
  return;
890
911
  }
@@ -587,6 +587,18 @@ class HttpMcpClient {
587
587
  }
588
588
  }
589
589
  }
590
+ /**
591
+ * The backend ANSWERED and rejected the machine's shield key (401/403).
592
+ * Deliberately a distinct type: the offline-fallback catch must report
593
+ * auth_broken (fix: re-enroll) instead of network_down (fix: wait) — the
594
+ * 8/12 incident hid a broken key store behind "unreachable" advice.
595
+ */
596
+ class CredentialsRejectedError extends Error {
597
+ constructor(status) {
598
+ super(`the policy service rejected this machine's credentials (HTTP ${status}) — re-enroll with: fullcourtdefense onboard --token <fleet-enrollment-token>`);
599
+ this.name = 'CredentialsRejectedError';
600
+ }
601
+ }
590
602
  class AgentGuardApi {
591
603
  config;
592
604
  constructor(config) {
@@ -608,6 +620,8 @@ class AgentGuardApi {
608
620
  source: 'runtime_sdk',
609
621
  });
610
622
  if (!result.success || !result.data) {
623
+ if (result.status === 401 || result.status === 403)
624
+ throw new CredentialsRejectedError(result.status);
611
625
  throw new Error(result.error || 'Tool-call policy check failed.');
612
626
  }
613
627
  return result.data;
@@ -706,7 +720,7 @@ class AgentGuardApi {
706
720
  });
707
721
  const data = await resp.json().catch(() => ({}));
708
722
  if (!resp.ok)
709
- return { success: false, error: data.error || `AgentGuard API error (${resp.status})` };
723
+ return { success: false, status: resp.status, error: data.error || `AgentGuard API error (${resp.status})` };
710
724
  return data;
711
725
  }
712
726
  async get(pathValue, timeoutMs = 10_000) {
@@ -717,7 +731,7 @@ class AgentGuardApi {
717
731
  });
718
732
  const data = await resp.json().catch(() => ({}));
719
733
  if (!resp.ok)
720
- return { success: false, error: data.error || `AgentGuard API error (${resp.status})` };
734
+ return { success: false, status: resp.status, error: data.error || `AgentGuard API error (${resp.status})` };
721
735
  return data;
722
736
  }
723
737
  headers() {
@@ -908,7 +922,8 @@ class McpGatewayServer {
908
922
  // same engine the server runs — the machine keeps working (and keeps
909
923
  // enforcing) through the outage instead of blanket fail-open/closed.
910
924
  const detail = err instanceof Error ? err.message : String(err);
911
- (0, distress_1.reportDistress)('gateway', distress_1.DISTRESS.NETWORK_DOWN, `check-tool-call unreachable: ${detail}`);
925
+ const authRejected = err instanceof CredentialsRejectedError;
926
+ (0, distress_1.reportDistress)('gateway', authRejected ? distress_1.DISTRESS.AUTH_BROKEN : distress_1.DISTRESS.NETWORK_DOWN, `check-tool-call ${authRejected ? 'credentials rejected' : 'unreachable'}: ${detail}`);
912
927
  if (cachedPolicies && cachedPolicies.length > 0) {
913
928
  const inferred = (0, actionPolicyEngine_1.inferToolContext)(toolName, toolArgs);
914
929
  const localOperation = operation || inferred.operation;
@@ -54,6 +54,7 @@ const appDetection_1 = require("../appDetection");
54
54
  const integrity_1 = require("../integrity");
55
55
  const telemetry_1 = require("../telemetry");
56
56
  const envDiagnostics_1 = require("../envDiagnostics");
57
+ const runtimeConfig_1 = require("../runtimeConfig");
57
58
  const daemonForensics_1 = require("../daemonForensics");
58
59
  const onboardingJournal_1 = require("./onboardingJournal");
59
60
  const GREEN = '\x1b[32m';
@@ -293,8 +294,30 @@ async function onboardCommand(args, config) {
293
294
  mark('enrollment', 'completed', 'machine enrolled');
294
295
  }
295
296
  else if (alreadyEnrolled) {
296
- console.log(` ${GREEN}✓${RESET} Already enrolled ${DIM}(shield ${creds.shieldId})${RESET} pass --token to re-enroll.`);
297
- mark('enrollment', 'completed', `reused shield ${creds.shieldId}`);
297
+ // A locally-resolved key is NOT proof of enrollment: the 8/12 incident
298
+ // machine printed "Already enrolled ✓" here while the backend 401'd every
299
+ // hook call (broken key store). Prove the key works before saying so —
300
+ // and keep the two failure kinds apart: rejected => re-enroll NOW;
301
+ // unreachable => fine, cached stance applies, don't block onboarding.
302
+ const validation = await (0, runtimeConfig_1.validateShieldCredentials)({ apiUrl, shieldId: creds.shieldId, shieldKey: creds.shieldKey });
303
+ if (validation.ok) {
304
+ console.log(` ${GREEN}✓${RESET} Already enrolled ${DIM}(shield ${creds.shieldId}, key verified against the backend)${RESET} — pass --token to re-enroll.`);
305
+ mark('enrollment', 'completed', `reused shield ${creds.shieldId} (key verified)`);
306
+ }
307
+ else if (validation.authRejected) {
308
+ console.log(` ${RED}✗${RESET} Saved credentials exist but the backend ${BOLD}rejects them${RESET} ${DIM}(${validation.detail})${RESET}.`);
309
+ console.log(` Protected actions on this machine would fail-closed until it re-enrolls.`);
310
+ console.log(` Get a fleet enrollment token (AI Fleet -> Settings -> Fleet enrollment token), then run:`);
311
+ console.log(` ${BOLD}fullcourtdefense onboard --token <fleet-enrollment-token>${RESET}`);
312
+ mark('enrollment', 'failed', undefined, `backend rejected saved credentials: ${validation.detail}`);
313
+ report('Re-enroll with a fresh fleet enrollment token: fullcourtdefense onboard --token <token>.');
314
+ process.exitCode = 1;
315
+ return;
316
+ }
317
+ else {
318
+ console.log(` ${YELLOW}!${RESET} Already enrolled ${DIM}(shield ${creds.shieldId})${RESET} — key not verified right now (${validation.detail}); cached enforcement applies.`);
319
+ mark('enrollment', 'completed', `reused shield ${creds.shieldId} (verification deferred: backend unreachable)`);
320
+ }
298
321
  }
299
322
  else {
300
323
  console.log(`\n${RED}No fleet enrollment token.${RESET} Ask an org admin for one (AI Fleet -> Settings -> Fleet enrollment token), then run:`);
package/dist/config.d.ts CHANGED
@@ -100,7 +100,7 @@ export declare function daemonHandoffShieldKey(env?: NodeJS.ProcessEnv): string
100
100
  * itself). `none_dpapi_broken` is the credential-broken state: a DPAPI blob
101
101
  * exists but would not decrypt and no fallback source was available.
102
102
  */
103
- export type ShieldKeySource = 'override' | 'daemon_handoff' | 'native_store' | 'dpapi' | 'plaintext_config' | 'env' | 'none' | 'none_dpapi_broken';
103
+ export type ShieldKeySource = 'override' | 'daemon_handoff' | 'native_store' | 'machine_file' | 'dpapi' | 'plaintext_config' | 'env' | 'none' | 'none_dpapi_broken';
104
104
  export interface CredentialResolutionTrace {
105
105
  shieldKeySource: ShieldKeySource;
106
106
  dpapiAttempted: boolean;
package/dist/config.js CHANGED
@@ -55,6 +55,7 @@ const path = __importStar(require("path"));
55
55
  const child_process_1 = require("child_process");
56
56
  const distress_1 = require("./distress");
57
57
  const credentialStore_1 = require("./credentialStore");
58
+ const machineKeyFile_1 = require("./machineKeyFile");
58
59
  const CONFIG_FILENAMES = [
59
60
  '.fullcourtdefense.yml',
60
61
  '.fullcourtdefense.yaml',
@@ -358,6 +359,26 @@ function maybeMigrateShieldKeyToNativeStore(shieldId, shieldKey) {
358
359
  }
359
360
  catch { /* migration is opportunistic — legacy sources keep working */ }
360
361
  }
362
+ // Once per process, same rationale as the native migration above.
363
+ let machineFileMirrorAttempted = false;
364
+ /**
365
+ * Self-heal mirror for the S4U daemon (the 1.22.2 regression): whenever an
366
+ * interactive-session process resolves the key from a store the S4U daemon
367
+ * cannot read (Credential Manager, DPAPI, plaintext config), copy it into the
368
+ * ACL-protected machine key file. The very next hook invocation after this
369
+ * code ships repairs every already-broken machine — no re-enrollment needed.
370
+ */
371
+ function maybeMirrorShieldKeyToMachineFile(shieldId, shieldKey) {
372
+ if (machineFileMirrorAttempted || process.platform !== 'win32')
373
+ return;
374
+ machineFileMirrorAttempted = true;
375
+ try {
376
+ if ((0, machineKeyFile_1.readShieldKeyFromMachineFile)(shieldId) === shieldKey)
377
+ return; // mirror current
378
+ (0, machineKeyFile_1.saveShieldKeyToMachineFile)(shieldId, shieldKey); // read-back verified inside
379
+ }
380
+ catch { /* mirroring is opportunistic — primary stores keep working */ }
381
+ }
361
382
  /** Merge saved ~/.fullcourtdefense.yml + env + optional CLI flag overrides. */
362
383
  function resolveCliCredentials(config, overrides = {}, options = {}) {
363
384
  // Shield key: same precedence as the old || chain, evaluated stepwise so
@@ -388,6 +409,16 @@ function resolveCliCredentials(config, overrides = {}, options = {}) {
388
409
  if (shieldKey)
389
410
  shieldKeySource = 'native_store';
390
411
  }
412
+ if (!shieldKey && resolvedShieldId) {
413
+ // S4U/task contexts (daemon, watchdog): Credential Manager and user-scope
414
+ // DPAPI are both sealed by the user's password-derived master key, which
415
+ // S4U logon sessions do not have — the read above returns nothing there.
416
+ // The machine key file is protected by NTFS ACL (SID-based, which S4U
417
+ // does carry) and is mirrored by interactive-session processes below.
418
+ shieldKey = (0, machineKeyFile_1.readShieldKeyFromMachineFile)(resolvedShieldId);
419
+ if (shieldKey)
420
+ shieldKeySource = 'machine_file';
421
+ }
391
422
  if (!shieldKey && !options.skipDpapi && config.shieldKeyDpapi) {
392
423
  dpapiAttempted = true;
393
424
  shieldKey = unprotectShieldKeyForCurrentWindowsUser(config.shieldKeyDpapi);
@@ -417,6 +448,14 @@ function resolveCliCredentials(config, overrides = {}, options = {}) {
417
448
  && (shieldKeySource === 'dpapi' || shieldKeySource === 'plaintext_config')) {
418
449
  maybeMigrateShieldKeyToNativeStore(resolvedShieldId, shieldKey);
419
450
  }
451
+ // S4U mirror self-heal: any resolution through a store the S4U daemon
452
+ // cannot read keeps the machine key file current for it. Skipped on the
453
+ // shell-free path (skipDpapi): a first-time mirror write spawns
454
+ // whoami/icacls for the ACL, which that path must never do.
455
+ if (shieldKey && resolvedShieldId && !options.skipDpapi
456
+ && (shieldKeySource === 'native_store' || shieldKeySource === 'dpapi' || shieldKeySource === 'plaintext_config')) {
457
+ maybeMirrorShieldKeyToMachineFile(resolvedShieldId, shieldKey);
458
+ }
420
459
  return {
421
460
  apiKey: overrides.apiKey
422
461
  || config.apiKey
@@ -499,6 +538,10 @@ function writeSetupConfig(target, input) {
499
538
  // where the native binding is blocked keep their PowerShell path.
500
539
  if (input.shieldKey && input.shieldId) {
501
540
  (0, credentialStore_1.saveShieldKeyNative)(input.shieldId, input.shieldKey);
541
+ // S4U mirror: the daemon/watchdog tasks cannot read Credential Manager or
542
+ // user-scope DPAPI (S4U logon has no password-derived master key) — give
543
+ // them the ACL-protected file at enrollment so they are never key-less.
544
+ (0, machineKeyFile_1.saveShieldKeyToMachineFile)(input.shieldId, input.shieldKey);
502
545
  }
503
546
  const protectedShieldKey = input.shieldKey && process.platform === 'win32'
504
547
  ? protectShieldKeyForCurrentWindowsUser(input.shieldKey)
@@ -42,6 +42,10 @@ export declare const DISTRESS: {
42
42
  readonly NATIVE_STORE_UNAVAILABLE: "native_store_unavailable";
43
43
  /** Native Credential Manager write/verify failed. */
44
44
  readonly NATIVE_STORE_FAILED: "native_store_failed";
45
+ /** S4U-safe machine key file write/verify failed (daemon may stay credential-less). */
46
+ readonly MACHINE_KEYFILE_FAILED: "machine_keyfile_failed";
47
+ /** Machine key file kept, but the owner-only ACL could not be applied. */
48
+ readonly MACHINE_KEYFILE_ACL_FAILED: "machine_keyfile_acl_failed";
45
49
  /** Fail-closed engaged: hook is BLOCKING user actions because the policy gate is persistently unreachable. */
46
50
  readonly HOOK_FAIL_CLOSED: "hook_fail_closed";
47
51
  /** Anything nobody predicted — reported via reportUnexpected(). */
package/dist/distress.js CHANGED
@@ -86,6 +86,10 @@ exports.DISTRESS = {
86
86
  NATIVE_STORE_UNAVAILABLE: 'native_store_unavailable',
87
87
  /** Native Credential Manager write/verify failed. */
88
88
  NATIVE_STORE_FAILED: 'native_store_failed',
89
+ /** S4U-safe machine key file write/verify failed (daemon may stay credential-less). */
90
+ MACHINE_KEYFILE_FAILED: 'machine_keyfile_failed',
91
+ /** Machine key file kept, but the owner-only ACL could not be applied. */
92
+ MACHINE_KEYFILE_ACL_FAILED: 'machine_keyfile_acl_failed',
89
93
  /** Fail-closed engaged: hook is BLOCKING user actions because the policy gate is persistently unreachable. */
90
94
  HOOK_FAIL_CLOSED: 'hook_fail_closed',
91
95
  /** Anything nobody predicted — reported via reportUnexpected(). */
@@ -0,0 +1,25 @@
1
+ export declare function machineKeyFilePath(): string;
2
+ /**
3
+ * Read the shield key mirrored for S4U/task contexts. Plain in-process file
4
+ * read — safe for the shell-free resolution path. Undefined when absent,
5
+ * unreadable, malformed, or recorded for a different shield.
6
+ */
7
+ export declare function readShieldKeyFromMachineFile(shieldId: string): string | undefined;
8
+ /**
9
+ * Mirror the shield key for S4U/task contexts, read-back verified. Called
10
+ * from contexts that already hold the decrypted key (enrollment, and the
11
+ * resolution self-heal in config.ts). Returns false on any failure — the
12
+ * caller's primary stores are untouched either way.
13
+ */
14
+ export declare function saveShieldKeyToMachineFile(shieldId: string, shieldKey: string): boolean;
15
+ /** Remove the mirror (unenroll / re-enroll cleanup). Best-effort. */
16
+ export declare function deleteMachineKeyFile(): void;
17
+ /**
18
+ * Deep self-test probe: write + read-back + ACL inspection with the exact
19
+ * code paths used for the shield key. Proves the S4U fallback store works
20
+ * end-to-end on THIS machine.
21
+ */
22
+ export declare function machineKeyFileProbe(): {
23
+ ok: boolean;
24
+ detail: string;
25
+ };
@@ -0,0 +1,226 @@
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.machineKeyFilePath = machineKeyFilePath;
37
+ exports.readShieldKeyFromMachineFile = readShieldKeyFromMachineFile;
38
+ exports.saveShieldKeyToMachineFile = saveShieldKeyToMachineFile;
39
+ exports.deleteMachineKeyFile = deleteMachineKeyFile;
40
+ exports.machineKeyFileProbe = machineKeyFileProbe;
41
+ const child_process_1 = require("child_process");
42
+ const fs = __importStar(require("fs"));
43
+ const os = __importStar(require("os"));
44
+ const path = __importStar(require("path"));
45
+ const distress_1 = require("./distress");
46
+ /**
47
+ * Machine key file — the S4U-safe credential fallback.
48
+ *
49
+ * WHY (the 1.22.2 S4U regression): the daemon/watchdog scheduled tasks run
50
+ * with the S4U logon type — the only supported windowless way to run a
51
+ * console app from Task Scheduler (see daemon.ts). But an S4U logon session
52
+ * carries the user's SID WITHOUT the user's password-derived secrets, so both
53
+ * credential stores fail inside it: user-scope DPAPI cannot derive its master
54
+ * key, and Credential Manager entries (themselves DPAPI-sealed) cannot be
55
+ * opened. The daemon therefore resolved no shield key and every bundle poll
56
+ * 401'd — "auth_broken: bundle fetch HTTP 401 (no shield key available)" —
57
+ * on every Windows machine running 1.22.2+, silently (the same failure
58
+ * prevented the distress ledger from being uploaded).
59
+ *
60
+ * File ACLs, unlike DPAPI, are enforced against the token's SID — which the
61
+ * S4U session DOES carry. So processes in the user's interactive session
62
+ * (enrollment, hooks), which can read the real stores, mirror the key into a
63
+ * file locked to the user + SYSTEM, and the daemon reads that file where
64
+ * DPAPI is unavailable.
65
+ *
66
+ * SECURITY MODEL: at-rest protection is the NTFS ACL (inheritance stripped;
67
+ * only the owning user's SID and SYSTEM are granted). Against a same-user
68
+ * attacker this is equivalent to Credential Manager — CredRead is open to any
69
+ * process running as the user anyway — and it matches the protection level of
70
+ * the long-standing `shieldKey` plaintext-config fallback in
71
+ * ~/.fullcourtdefense.yml. Offline-disk protection comes from BitLocker, as
72
+ * for every other file. The key is stored base64-wrapped only to keep it out
73
+ * of casual text greps; that is labeling, not encryption.
74
+ *
75
+ * Windows-only: the S4U problem this solves is Windows-specific, and keeping
76
+ * other platforms untouched keeps the change surgical (same policy as
77
+ * credentialStore.ts).
78
+ */
79
+ const FILE_VERSION = 1;
80
+ function machineKeyFilePath() {
81
+ return path.join(os.homedir(), '.fullcourtdefense', 'machine-key.json');
82
+ }
83
+ /** Cached `whoami /user` SID — one child process per process lifetime, max. */
84
+ let cachedUserSid;
85
+ function currentUserSid() {
86
+ if (cachedUserSid !== undefined)
87
+ return cachedUserSid;
88
+ try {
89
+ // CSV row: "user","DOMAIN\name","S-1-5-21-..." — the SID is the last field.
90
+ const csv = (0, child_process_1.execFileSync)('whoami', ['/user', '/fo', 'csv'], {
91
+ encoding: 'utf8', windowsHide: true, timeout: 10_000,
92
+ });
93
+ const match = csv.match(/"(S-1-5-[\d-]+)"/);
94
+ cachedUserSid = match ? match[1] : null;
95
+ }
96
+ catch {
97
+ cachedUserSid = null;
98
+ }
99
+ return cachedUserSid;
100
+ }
101
+ /**
102
+ * Lock the file to the owning user + SYSTEM via icacls (a plain Windows
103
+ * executable — no PowerShell, nothing for EDR/CLM to constrain). Returns
104
+ * false when the ACL could not be applied; the caller decides whether the
105
+ * default user-profile ACL (other users already denied) is acceptable.
106
+ */
107
+ function applyOwnerOnlyAcl(file) {
108
+ const sid = currentUserSid();
109
+ if (!sid)
110
+ return false;
111
+ try {
112
+ (0, child_process_1.execFileSync)('icacls', [file, '/inheritance:r', '/grant:r', `*${sid}:F`, '*S-1-5-18:F'], {
113
+ stdio: 'ignore', windowsHide: true, timeout: 10_000,
114
+ });
115
+ return true;
116
+ }
117
+ catch {
118
+ return false;
119
+ }
120
+ }
121
+ /**
122
+ * Read the shield key mirrored for S4U/task contexts. Plain in-process file
123
+ * read — safe for the shell-free resolution path. Undefined when absent,
124
+ * unreadable, malformed, or recorded for a different shield.
125
+ */
126
+ function readShieldKeyFromMachineFile(shieldId) {
127
+ if (process.platform !== 'win32' || !shieldId)
128
+ return undefined;
129
+ try {
130
+ const parsed = JSON.parse(fs.readFileSync(machineKeyFilePath(), 'utf8'));
131
+ if (!parsed || parsed.v !== FILE_VERSION || parsed.shieldId !== shieldId || !parsed.key)
132
+ return undefined;
133
+ const key = Buffer.from(parsed.key, 'base64').toString('utf8');
134
+ return key || undefined;
135
+ }
136
+ catch {
137
+ return undefined;
138
+ }
139
+ }
140
+ /**
141
+ * Mirror the shield key for S4U/task contexts, read-back verified. Called
142
+ * from contexts that already hold the decrypted key (enrollment, and the
143
+ * resolution self-heal in config.ts). Returns false on any failure — the
144
+ * caller's primary stores are untouched either way.
145
+ */
146
+ function saveShieldKeyToMachineFile(shieldId, shieldKey) {
147
+ if (process.platform !== 'win32' || !shieldId || !shieldKey)
148
+ return false;
149
+ const file = machineKeyFilePath();
150
+ try {
151
+ const contents = {
152
+ v: FILE_VERSION,
153
+ shieldId,
154
+ key: Buffer.from(shieldKey, 'utf8').toString('base64'),
155
+ savedAt: new Date().toISOString(),
156
+ };
157
+ fs.mkdirSync(path.dirname(file), { recursive: true });
158
+ fs.writeFileSync(file, JSON.stringify(contents, null, 2), 'utf8');
159
+ if (!applyOwnerOnlyAcl(file)) {
160
+ // The file lives inside the user profile, where other non-admin users
161
+ // are already denied — keep the mirror (a credential-less daemon is the
162
+ // worse failure) but surface the weaker-than-intended ACL to the fleet.
163
+ (0, distress_1.reportDistress)('credentials', distress_1.DISTRESS.MACHINE_KEYFILE_ACL_FAILED, 'icacls owner-only ACL could not be applied; file kept with profile-default ACL');
164
+ }
165
+ const verified = readShieldKeyFromMachineFile(shieldId) === shieldKey;
166
+ if (!verified) {
167
+ (0, distress_1.reportDistress)('credentials', distress_1.DISTRESS.MACHINE_KEYFILE_FAILED, 'write succeeded but read-back returned a different value');
168
+ try {
169
+ fs.unlinkSync(file);
170
+ }
171
+ catch { /* best effort */ }
172
+ }
173
+ return verified;
174
+ }
175
+ catch (error) {
176
+ (0, distress_1.reportDistress)('credentials', distress_1.DISTRESS.MACHINE_KEYFILE_FAILED, `write failed: ${error instanceof Error ? error.message.slice(0, 120) : String(error)}`);
177
+ return false;
178
+ }
179
+ }
180
+ /** Remove the mirror (unenroll / re-enroll cleanup). Best-effort. */
181
+ function deleteMachineKeyFile() {
182
+ try {
183
+ fs.unlinkSync(machineKeyFilePath());
184
+ }
185
+ catch { /* absent — fine */ }
186
+ }
187
+ /**
188
+ * Deep self-test probe: write + read-back + ACL inspection with the exact
189
+ * code paths used for the shield key. Proves the S4U fallback store works
190
+ * end-to-end on THIS machine.
191
+ */
192
+ function machineKeyFileProbe() {
193
+ if (process.platform !== 'win32')
194
+ return { ok: true, detail: 'not applicable (non-Windows)' };
195
+ const existing = fs.existsSync(machineKeyFilePath());
196
+ const probeShieldId = `selftest-${Date.now()}`;
197
+ const probeKey = `fcd-machinefile-probe-${Date.now()}`;
198
+ // Never disturb a real mirror: probe against a sibling path by swapping the
199
+ // real read/write helpers' target through a temp copy of the logic.
200
+ const probeFile = path.join(path.dirname(machineKeyFilePath()), `machine-key.selftest-${process.pid}.json`);
201
+ try {
202
+ fs.mkdirSync(path.dirname(probeFile), { recursive: true });
203
+ fs.writeFileSync(probeFile, JSON.stringify({
204
+ v: FILE_VERSION, shieldId: probeShieldId,
205
+ key: Buffer.from(probeKey, 'utf8').toString('base64'),
206
+ savedAt: new Date().toISOString(),
207
+ }, null, 2), 'utf8');
208
+ const aclOk = applyOwnerOnlyAcl(probeFile);
209
+ const parsed = JSON.parse(fs.readFileSync(probeFile, 'utf8'));
210
+ const readOk = Buffer.from(parsed.key, 'base64').toString('utf8') === probeKey;
211
+ if (!readOk)
212
+ return { ok: false, detail: 'roundtrip read returned a different value' };
213
+ return aclOk
214
+ ? { ok: true, detail: `write + owner-only ACL + read roundtrip OK${existing ? ' (live mirror present)' : ''}` }
215
+ : { ok: true, detail: 'roundtrip OK; owner-only ACL not applied (profile-default ACL in effect)' };
216
+ }
217
+ catch (error) {
218
+ return { ok: false, detail: `probe failed: ${error instanceof Error ? error.message.slice(0, 160) : String(error)}` };
219
+ }
220
+ finally {
221
+ try {
222
+ fs.unlinkSync(probeFile);
223
+ }
224
+ catch { /* best effort */ }
225
+ }
226
+ }
@@ -86,6 +86,27 @@ export interface EffectiveBundle extends RuntimeBundle {
86
86
  * or a 'default' marker so the caller can apply its local fallback.
87
87
  */
88
88
  export declare function getRuntimeBundle(input: FetchBundleInput): Promise<EffectiveBundle>;
89
+ export interface CredentialValidationResult {
90
+ ok: boolean;
91
+ /** true when the backend answered 401/403 — the key is WRONG, not offline. */
92
+ authRejected: boolean;
93
+ detail: string;
94
+ }
95
+ /**
96
+ * Prove the machine's credentials actually WORK against the backend — a
97
+ * direct, cache-bypassing bundle fetch that exposes the HTTP outcome.
98
+ * getRuntimeBundle() deliberately never fails (cache/default fallback keeps
99
+ * enforcement alive offline), which is exactly wrong for onboarding/doctor:
100
+ * "resolved a key locally" is not "the backend accepts it". The 8/12 incident
101
+ * machine printed "Already enrolled ✓" while every hook call 401'd —
102
+ * validation must separate rejected (re-enroll now) from unreachable (fine,
103
+ * cached stance applies).
104
+ */
105
+ export declare function validateShieldCredentials(input: {
106
+ apiUrl: string;
107
+ shieldId: string;
108
+ shieldKey?: string;
109
+ }): Promise<CredentialValidationResult>;
89
110
  /**
90
111
  * Admin scan-folder choices from the most recent cached bundle (any shield):
91
112
  * extra folders to add and default folders the admin removed. Read-only and
@@ -34,6 +34,7 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.getRuntimeBundle = getRuntimeBundle;
37
+ exports.validateShieldCredentials = validateShieldCredentials;
37
38
  exports.getCachedScanRootOverrides = getCachedScanRootOverrides;
38
39
  exports.getCachedExtraScanRoots = getCachedExtraScanRoots;
39
40
  const fs = __importStar(require("fs"));
@@ -157,6 +158,44 @@ async function getRuntimeBundle(input) {
157
158
  }
158
159
  return { mode: 'block', version: '', source: 'default' };
159
160
  }
161
+ /**
162
+ * Prove the machine's credentials actually WORK against the backend — a
163
+ * direct, cache-bypassing bundle fetch that exposes the HTTP outcome.
164
+ * getRuntimeBundle() deliberately never fails (cache/default fallback keeps
165
+ * enforcement alive offline), which is exactly wrong for onboarding/doctor:
166
+ * "resolved a key locally" is not "the backend accepts it". The 8/12 incident
167
+ * machine printed "Already enrolled ✓" while every hook call 401'd —
168
+ * validation must separate rejected (re-enroll now) from unreachable (fine,
169
+ * cached stance applies).
170
+ */
171
+ async function validateShieldCredentials(input) {
172
+ if (!input.shieldKey) {
173
+ return { ok: false, authRejected: false, detail: 'no shield key resolved on this machine' };
174
+ }
175
+ try {
176
+ const params = new URLSearchParams({ shieldId: input.shieldId });
177
+ const resp = await fetch(`${input.apiUrl}/api/cli/bundle?${params.toString()}`, {
178
+ method: 'GET',
179
+ headers: { 'Content-Type': 'application/json', 'x-shield-key': input.shieldKey },
180
+ signal: AbortSignal.timeout(REFRESH_TIMEOUT_MS),
181
+ });
182
+ if (resp.status === 401 || resp.status === 403) {
183
+ (0, distress_1.reportDistress)('bundle', distress_1.DISTRESS.AUTH_BROKEN, `credential validation HTTP ${resp.status}`);
184
+ return { ok: false, authRejected: true, detail: `backend rejected the shield key (HTTP ${resp.status})` };
185
+ }
186
+ if (!resp.ok) {
187
+ return { ok: false, authRejected: false, detail: `backend answered HTTP ${resp.status}` };
188
+ }
189
+ return { ok: true, authRejected: false, detail: 'backend accepted the shield key' };
190
+ }
191
+ catch (error) {
192
+ return {
193
+ ok: false,
194
+ authRejected: false,
195
+ detail: `control plane unreachable: ${error instanceof Error ? error.message : String(error)}`,
196
+ };
197
+ }
198
+ }
160
199
  /**
161
200
  * Admin scan-folder choices from the most recent cached bundle (any shield):
162
201
  * extra folders to add and default folders the admin removed. Read-only and
package/dist/selfTest.js CHANGED
@@ -5,6 +5,7 @@ exports.summarizeSelfTest = summarizeSelfTest;
5
5
  const child_process_1 = require("child_process");
6
6
  const config_1 = require("./config");
7
7
  const credentialStore_1 = require("./credentialStore");
8
+ const machineKeyFile_1 = require("./machineKeyFile");
8
9
  const runtimeConfig_1 = require("./runtimeConfig");
9
10
  const integrity_1 = require("./integrity");
10
11
  const selfUpdate_1 = require("./selfUpdate");
@@ -52,6 +53,12 @@ async function runDeepSelfTest(input) {
52
53
  // binding and the rollback path to ≤1.21.33.
53
54
  const probe = (0, config_1.dpapiRoundtripProbe)();
54
55
  add({ id: 'dpapi_roundtrip', label: 'Credential store (DPAPI) roundtrip', ok: probe.ok, detail: probe.detail });
56
+ // 4. Machine key file — the S4U-safe mirror the daemon/watchdog tasks
57
+ // read (Credential Manager and DPAPI are both sealed to S4U sessions).
58
+ // Without it, a daemon under the windowless S4U principal is key-less
59
+ // and every bundle poll 401s.
60
+ const keyFile = (0, machineKeyFile_1.machineKeyFileProbe)();
61
+ add({ id: 'machine_key_file', label: 'Machine key file (S4U mirror) roundtrip', ok: keyFile.ok, detail: keyFile.detail });
55
62
  }
56
63
  // 3. Credentials resolved on THIS machine right now.
57
64
  const trace = (() => {
package/dist/version.json CHANGED
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "1.22.5"
2
+ "version": "1.22.6"
3
3
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullcourtdefense-cli",
3
- "version": "1.22.5",
3
+ "version": "1.22.6",
4
4
  "description": "Full Court Defense CLI — security scanning for AI agents from your terminal",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -54,6 +54,8 @@
54
54
  "test:update-loop": "npm run build && node scripts/test-update-loop-detection.js",
55
55
  "test:bricked-rescue": "npm run build && node scripts/test-bricked-machine-rescue.js",
56
56
  "test:native-credstore": "npm run build && node scripts/test-native-credential-store.js",
57
+ "test:machine-key-file": "npm run build && node scripts/test-machine-key-file.js",
58
+ "test:auth-rejection": "npm run build && node scripts/test-auth-rejection.js",
57
59
  "test:real-life": "npm run build && node scripts/test-real-life-scenarios.js",
58
60
  "test:node-updater": "npm run build && node scripts/test-node-updater.js",
59
61
  "test:desktop-chat-guard": "npm run build && node scripts/test-desktop-chat-guard.js",