fullcourtdefense-cli 1.26.4 → 1.26.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/dist/commands/daemon.d.ts +14 -0
  2. package/dist/commands/daemon.js +181 -28
  3. package/dist/commands/desktopChatGuard.js +5 -4
  4. package/dist/commands/deterministicGuard.d.ts +8 -0
  5. package/dist/commands/deterministicGuard.js +6 -0
  6. package/dist/commands/doctor.d.ts +4 -0
  7. package/dist/commands/doctor.js +99 -0
  8. package/dist/commands/hook.d.ts +1 -0
  9. package/dist/commands/hook.js +61 -104
  10. package/dist/commands/installClaudeHook.d.ts +2 -0
  11. package/dist/commands/installClaudeHook.js +21 -3
  12. package/dist/commands/installCursorHook.d.ts +8 -0
  13. package/dist/commands/installCursorHook.js +29 -4
  14. package/dist/commands/mcpGateway.js +234 -43
  15. package/dist/commands/onboard.d.ts +9 -0
  16. package/dist/commands/onboard.js +7 -0
  17. package/dist/commands/watchdog.d.ts +5 -2
  18. package/dist/commands/watchdog.js +34 -4
  19. package/dist/config.js +6 -1
  20. package/dist/daemonForensics.d.ts +27 -0
  21. package/dist/daemonForensics.js +27 -0
  22. package/dist/distress.d.ts +17 -0
  23. package/dist/distress.js +17 -0
  24. package/dist/hookIo.d.ts +43 -0
  25. package/dist/hookIo.js +189 -0
  26. package/dist/hookSlim.d.ts +32 -0
  27. package/dist/hookSlim.js +142 -0
  28. package/dist/index.js +10 -14
  29. package/dist/integrity.js +8 -0
  30. package/dist/localSafetySnapshot.d.ts +9 -0
  31. package/dist/localSafetySnapshot.js +58 -2
  32. package/dist/machineIdentity.js +70 -4
  33. package/dist/runtimeConfig.d.ts +2 -0
  34. package/dist/runtimeConfig.js +177 -5
  35. package/dist/selfTest.d.ts +10 -0
  36. package/dist/selfTest.js +19 -8
  37. package/dist/selfUpdate.d.ts +19 -4
  38. package/dist/selfUpdate.js +108 -12
  39. package/dist/telemetry.js +67 -2
  40. package/dist/verdictIpc.d.ts +12 -75
  41. package/dist/verdictIpc.js +104 -188
  42. package/dist/verdictIpcClient.d.ts +103 -0
  43. package/dist/verdictIpcClient.js +251 -0
  44. package/dist/version.json +1 -1
  45. package/package.json +6 -1
@@ -40,6 +40,7 @@ const child_process_1 = require("child_process");
40
40
  const crypto = __importStar(require("crypto"));
41
41
  const fs = __importStar(require("fs"));
42
42
  const os = __importStar(require("os"));
43
+ const path = __importStar(require("path"));
43
44
  /**
44
45
  * OS-agnostic stable machine identity.
45
46
  *
@@ -100,6 +101,53 @@ function rawStableId() {
100
101
  return (safe(() => fs.readFileSync('/etc/machine-id', 'utf8'))
101
102
  || safe(() => fs.readFileSync('/var/lib/dbus/machine-id', 'utf8')));
102
103
  }
104
+ /**
105
+ * Where the FALLBACK machine id is persisted when no OS-native stable id is
106
+ * readable (locked-down registry / missing /etc/machine-id). Two locations:
107
+ *
108
+ * - machine-wide (ProgramData on Windows): shared by EVERY user AND the
109
+ * SYSTEM/S4U daemon tasks. Without it, the composite fallback — which
110
+ * includes the username — gives the daemon (running as SYSTEM) a DIFFERENT
111
+ * machineId than the enrolling user, creating duplicate fleet records with
112
+ * split telemetry for the same laptop.
113
+ * - per-user (~/.fullcourtdefense): fallback when ProgramData is not
114
+ * writable; keeps the id stable across upgrades for that user at least.
115
+ *
116
+ * The persisted value is the final HASHED id (never the raw composite).
117
+ */
118
+ function persistedIdPaths() {
119
+ const perUser = path.join(os.homedir(), '.fullcourtdefense', 'machine-id');
120
+ if (os.platform() === 'win32') {
121
+ const programData = process.env.ProgramData || 'C:\\ProgramData';
122
+ return { machineWide: path.join(programData, 'FullCourtDefense', 'machine-id'), perUser };
123
+ }
124
+ return { perUser };
125
+ }
126
+ function readPersistedFallbackId() {
127
+ const { machineWide, perUser } = persistedIdPaths();
128
+ for (const file of [machineWide, perUser]) {
129
+ if (!file)
130
+ continue;
131
+ const value = safe(() => fs.readFileSync(file, 'utf8'));
132
+ if (value && /^[0-9a-f]{16,64}$/i.test(value))
133
+ return value.toLowerCase();
134
+ }
135
+ return undefined;
136
+ }
137
+ function persistFallbackId(machineId) {
138
+ const { machineWide, perUser } = persistedIdPaths();
139
+ for (const file of [machineWide, perUser]) {
140
+ if (!file)
141
+ continue;
142
+ try {
143
+ fs.mkdirSync(path.dirname(file), { recursive: true });
144
+ const tmp = `${file}.tmp-${process.pid}`;
145
+ fs.writeFileSync(tmp, machineId, 'utf8');
146
+ fs.renameSync(tmp, file);
147
+ }
148
+ catch { /* best-effort — the composite recomputes deterministically for this user */ }
149
+ }
150
+ }
103
151
  function friendlyOs() {
104
152
  const platform = os.platform();
105
153
  const release = os.release();
@@ -149,10 +197,28 @@ function getMachineIdentity() {
149
197
  }
150
198
  const user = safe(() => os.userInfo().username) || process.env.USER || process.env.USERNAME || 'unknown-user';
151
199
  const hostname = normalizeHostname(safe(() => os.hostname()) || 'unknown-host');
152
- // Seed the hash with the OS-native id when available; fall back to a stable
153
- // composite so we still produce a deterministic id on locked-down machines.
154
- const raw = rawStableId() || `${hostname}|${user}|${os.platform()}|${os.arch()}`;
155
- const machineId = crypto.createHash('sha256').update(raw).digest('hex').slice(0, 32);
200
+ // Seed the hash with the OS-native id when available; otherwise reuse the
201
+ // persisted fallback id (so every user + the SYSTEM daemon on the same
202
+ // locked-down machine converge on ONE identity); only then compute the
203
+ // legacy composite — and persist it so future contexts converge on it.
204
+ const nativeRaw = rawStableId();
205
+ let machineId;
206
+ if (nativeRaw) {
207
+ machineId = crypto.createHash('sha256').update(nativeRaw).digest('hex').slice(0, 32);
208
+ }
209
+ else {
210
+ const persisted = readPersistedFallbackId();
211
+ if (persisted) {
212
+ machineId = persisted;
213
+ }
214
+ else {
215
+ // Composite keeps the pre-existing shape (incl. username) so machines
216
+ // already enrolled on this path keep their identity across upgrades.
217
+ const composite = `${hostname}|${user}|${os.platform()}|${os.arch()}`;
218
+ machineId = crypto.createHash('sha256').update(composite).digest('hex').slice(0, 32);
219
+ persistFallbackId(machineId);
220
+ }
221
+ }
156
222
  cached = {
157
223
  machineId,
158
224
  user,
@@ -101,6 +101,8 @@ export interface FetchBundleInput {
101
101
  }
102
102
  export interface EffectiveBundle extends RuntimeBundle {
103
103
  source: 'server' | 'cache' | 'default';
104
+ /** Epoch ms of the last server 200/304 that confirmed this bundle (absent on the 'default' fallback). */
105
+ serverValidatedAt?: number;
104
106
  }
105
107
  /**
106
108
  * Resolve the effective runtime bundle for a Shield, using the local cache when
@@ -39,6 +39,7 @@ exports.resetCacheOnEnrollment = resetCacheOnEnrollment;
39
39
  exports.isSuspendedByCache = isSuspendedByCache;
40
40
  exports.getCachedScanRootOverrides = getCachedScanRootOverrides;
41
41
  exports.getCachedExtraScanRoots = getCachedExtraScanRoots;
42
+ const crypto = __importStar(require("crypto"));
42
43
  const fs = __importStar(require("fs"));
43
44
  const os = __importStar(require("os"));
44
45
  const path = __importStar(require("path"));
@@ -47,6 +48,15 @@ const sessionLimits_1 = require("./sessionLimits");
47
48
  const CACHE_PATH = path.join(os.homedir(), '.fullcourtdefense-runtime.json');
48
49
  const DEFAULT_TTL_MS = 60_000;
49
50
  const REFRESH_TIMEOUT_MS = 1_500; // tight: the hook must stay fast
51
+ /**
52
+ * Hot-path monitor/shadow callers accept a stale cache because the resident
53
+ * daemon owns freshness (60s poll). But when the cache is THIS old the daemon
54
+ * is presumably dead — the machine would never learn about a kill-switch flip
55
+ * or a monitor->block promotion. Past this bound ONE hook per interval pays a
56
+ * bounded (≤1.5s) refresh; everyone else keeps taking the stale cache.
57
+ */
58
+ const HOT_PATH_MAX_STALE_MS = 10 * 60_000;
59
+ const HOT_PATH_REVALIDATE_INTERVAL_MS = 5 * 60_000;
50
60
  function readCacheFile() {
51
61
  try {
52
62
  const raw = fs.readFileSync(CACHE_PATH, 'utf-8');
@@ -66,10 +76,111 @@ function writeCacheFile(data) {
66
76
  function isMode(value) {
67
77
  return value === 'block' || value === 'monitor' || value === 'shadow';
68
78
  }
79
+ // ---------------------------------------------------------------------------
80
+ // Tamper-resistant kill-switch marker
81
+ //
82
+ // The runtime cache is a plain JSON file: editing `suspended: false` into it
83
+ // (or deleting the file) must NOT clear an admin suspension. A second,
84
+ // HMAC-signed marker file records last-known-suspended per shield; it is
85
+ // asserted on every server response that says suspended and cleared ONLY by a
86
+ // server-confirmed response that says not-suspended. Effective bundles overlay
87
+ // it, so both files must be forged/deleted together — and the daemon re-asserts
88
+ // the marker on every poll while the server still says suspended.
89
+ // ---------------------------------------------------------------------------
90
+ const KILLSWITCH_DIR = path.join(os.homedir(), '.fullcourtdefense');
91
+ const KILLSWITCH_MARKER_PATH = path.join(KILLSWITCH_DIR, 'killswitch.json');
92
+ const KILLSWITCH_SECRET_PATH = path.join(KILLSWITCH_DIR, '.ks-secret');
93
+ /** Atomic write-temp-rename — a crash mid-write must never corrupt the marker. */
94
+ function writeFileAtomic(file, data, mode) {
95
+ const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
96
+ fs.mkdirSync(path.dirname(file), { recursive: true });
97
+ fs.writeFileSync(tmp, data, { encoding: 'utf-8', mode });
98
+ fs.renameSync(tmp, file);
99
+ }
100
+ function killSwitchSecret(createIfMissing) {
101
+ try {
102
+ const raw = fs.readFileSync(KILLSWITCH_SECRET_PATH, 'utf-8').trim();
103
+ if (raw.length >= 32)
104
+ return raw;
105
+ }
106
+ catch { /* not created yet */ }
107
+ if (!createIfMissing)
108
+ return undefined;
109
+ try {
110
+ const secret = crypto.randomBytes(32).toString('hex');
111
+ writeFileAtomic(KILLSWITCH_SECRET_PATH, secret, 0o600);
112
+ return secret;
113
+ }
114
+ catch {
115
+ return undefined;
116
+ }
117
+ }
118
+ function killSwitchSig(secret, shieldId, suspendedAt) {
119
+ return crypto.createHmac('sha256', secret).update(`fcd-killswitch|${shieldId}|${suspendedAt}`).digest('hex');
120
+ }
121
+ function readKillSwitchMarker() {
122
+ try {
123
+ const parsed = JSON.parse(fs.readFileSync(KILLSWITCH_MARKER_PATH, 'utf-8'));
124
+ return parsed && typeof parsed === 'object' ? parsed : {};
125
+ }
126
+ catch {
127
+ return {};
128
+ }
129
+ }
130
+ function assertSuspendMarker(shieldId) {
131
+ try {
132
+ const secret = killSwitchSecret(true);
133
+ if (!secret)
134
+ return;
135
+ const marker = readKillSwitchMarker();
136
+ const entries = marker.entries && typeof marker.entries === 'object' ? marker.entries : {};
137
+ // Keep the original suspendedAt when re-asserting (audit-friendly).
138
+ const existing = entries[shieldId];
139
+ const suspendedAt = existing?.suspendedAt && killSwitchSig(secret, shieldId, existing.suspendedAt) === existing.sig
140
+ ? existing.suspendedAt
141
+ : new Date().toISOString();
142
+ entries[shieldId] = { suspendedAt, sig: killSwitchSig(secret, shieldId, suspendedAt) };
143
+ writeFileAtomic(KILLSWITCH_MARKER_PATH, JSON.stringify({ entries }), 0o600);
144
+ }
145
+ catch { /* marker is defense-in-depth — never break the caller */ }
146
+ }
147
+ function clearSuspendMarker(shieldId) {
148
+ try {
149
+ const marker = readKillSwitchMarker();
150
+ if (!marker.entries || !marker.entries[shieldId])
151
+ return;
152
+ delete marker.entries[shieldId];
153
+ writeFileAtomic(KILLSWITCH_MARKER_PATH, JSON.stringify({ entries: marker.entries }), 0o600);
154
+ }
155
+ catch { /* best-effort */ }
156
+ }
157
+ /** Any shield with a VALID (HMAC-verified) suspend marker? Never throws. */
158
+ function hasValidSuspendMarker() {
159
+ try {
160
+ const marker = readKillSwitchMarker();
161
+ if (!marker.entries)
162
+ return false;
163
+ const secret = killSwitchSecret(false);
164
+ if (!secret)
165
+ return false;
166
+ return Object.entries(marker.entries).some(([shieldId, entry]) => entry && typeof entry.suspendedAt === 'string' && typeof entry.sig === 'string'
167
+ && killSwitchSig(secret, shieldId, entry.suspendedAt) === entry.sig);
168
+ }
169
+ catch {
170
+ return false;
171
+ }
172
+ }
69
173
  /** A cached entry as an EffectiveBundle — single place that decides which fields survive the cache. */
70
174
  function cachedToEffective(cached) {
71
- const { fetchedAt: _fetchedAt, ...bundle } = cached;
72
- return { ...bundle, source: 'cache' };
175
+ const { fetchedAt, revalidateAttemptAt: _attemptAt, ...bundle } = cached;
176
+ // Kill-switch overlay: a hand-edited cache (`suspended: false`) cannot clear
177
+ // an admin suspension while the signed marker still stands.
178
+ const suspended = bundle.suspended === true || hasValidSuspendMarker();
179
+ // fetchedAt is refreshed on every 200 AND every 304, so it doubles as
180
+ // "when did the control plane last confirm this bundle" — consumers that
181
+ // must distinguish a validated cache from a network-down fallback (the
182
+ // daemon's update-policy mirror) read this instead of `source`.
183
+ return { ...bundle, suspended, source: 'cache', serverValidatedAt: fetchedAt };
73
184
  }
74
185
  /** Keep only a well-formed machine-role block (limits must be numbers or null; legacy keys normalized). */
75
186
  function sanitizeMachineRole(value) {
@@ -113,8 +224,26 @@ async function getRuntimeBundle(input) {
113
224
  }
114
225
  // MONITOR CONTRACT: hot-path callers never wait on a refresh for a
115
226
  // monitor/shadow machine — stale cache wins, the daemon refreshes it.
227
+ //
228
+ // BOUNDED: only while the cache is younger than HOT_PATH_MAX_STALE_MS. A
229
+ // cache that old means the daemon is dead and nothing else will ever pick
230
+ // up a kill-switch flip or a monitor->block promotion. Past the bound, ONE
231
+ // hook per revalidate interval falls through to the bounded (≤1.5s) refresh
232
+ // below; concurrent/subsequent hooks keep taking the stale cache.
116
233
  if (cached && !input.force && input.hotPath && cached.mode !== 'block') {
117
- return cachedToEffective(cached);
234
+ const age = Date.now() - cached.fetchedAt;
235
+ const sinceLastAttempt = Date.now() - (cached.revalidateAttemptAt || 0);
236
+ if (age < HOT_PATH_MAX_STALE_MS || sinceLastAttempt < HOT_PATH_REVALIDATE_INTERVAL_MS) {
237
+ return cachedToEffective(cached);
238
+ }
239
+ // Claim the revalidation slot BEFORE fetching so parallel hooks don't all
240
+ // pay the timeout together (docker compose spawns hooks in bursts).
241
+ try {
242
+ cached.revalidateAttemptAt = Date.now();
243
+ cache[input.shieldId] = cached;
244
+ writeCacheFile(cache);
245
+ }
246
+ catch { /* best-effort */ }
118
247
  }
119
248
  try {
120
249
  const headers = { 'Content-Type': 'application/json' };
@@ -131,9 +260,15 @@ async function getRuntimeBundle(input) {
131
260
  params.set('machineId', input.machineId);
132
261
  const resp = await fetch(`${input.apiUrl}/api/cli/bundle?${params.toString()}`, { method: 'GET', headers, signal: AbortSignal.timeout(REFRESH_TIMEOUT_MS) });
133
262
  // 304 Not Modified — cache is still valid; refresh its timestamp.
263
+ // Re-assert the marker when the cache says suspended, but NEVER clear it
264
+ // here: a 304 validates the version string, not the (locally editable)
265
+ // suspended field — a hand-edited `suspended: false` cache would otherwise
266
+ // launder the edit through the 304. Clearing requires a full server body.
134
267
  if (resp.status === 304 && cached) {
135
268
  cache[input.shieldId] = { ...cached, fetchedAt: Date.now() };
136
269
  writeCacheFile(cache);
270
+ if (cached.suspended === true)
271
+ assertSuspendMarker(input.shieldId);
137
272
  return cachedToEffective(cached);
138
273
  }
139
274
  if (resp.status === 401 || resp.status === 403) {
@@ -174,7 +309,15 @@ async function getRuntimeBundle(input) {
174
309
  };
175
310
  cache[input.shieldId] = entry;
176
311
  writeCacheFile(cache);
177
- return { ...entry, source: 'server' };
312
+ // Sync the tamper-resistant marker from the ONLY trustworthy source —
313
+ // a live server response. Suspended asserts it; not-suspended is the
314
+ // sole path that clears it.
315
+ if (entry.suspended)
316
+ assertSuspendMarker(input.shieldId);
317
+ else
318
+ clearSuspendMarker(input.shieldId);
319
+ const { fetchedAt, revalidateAttemptAt: _attemptAt, ...bundle } = entry;
320
+ return { ...bundle, source: 'server', serverValidatedAt: fetchedAt };
178
321
  }
179
322
  }
180
323
  }
@@ -191,6 +334,13 @@ async function getRuntimeBundle(input) {
191
334
  // not hard-block the developer. Machines that WERE told to enforce keep
192
335
  // enforcement through the stale-while-error cache above, and explicit
193
336
  // install-time --enforce/--fail-closed flags still win in the callers.
337
+ //
338
+ // EXCEPT the kill-switch: a machine whose last-known state is SUSPENDED
339
+ // (signed marker) stays suspended even when the cache file goes missing —
340
+ // deleting the cache must not unlock a suspended machine.
341
+ if (hasValidSuspendMarker()) {
342
+ return { mode: 'monitor', version: '', suspended: true, source: 'default' };
343
+ }
194
344
  return { mode: 'monitor', version: '', source: 'default' };
195
345
  }
196
346
  /**
@@ -253,6 +403,25 @@ function resetCacheOnEnrollment() {
253
403
  writeCacheFile({});
254
404
  }
255
405
  catch { /* cache is best-effort — never fail an enrollment over it */ }
406
+ // Local Safety snapshots are keyed by shieldId + identity — after a
407
+ // re-enrollment the old shield's snapshot (possibly ENFORCING) must not
408
+ // survive under a key the new identity still happens to resolve. Lazy
409
+ // import keeps the hook hot path free of this module.
410
+ try {
411
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
412
+ const { clearAllLocalSafetySnapshotCaches } = require('./localSafetySnapshot');
413
+ clearAllLocalSafetySnapshotCaches();
414
+ }
415
+ catch { /* best-effort */ }
416
+ // Enrollment only reaches here after the SERVER accepted a valid fleet
417
+ // token, so clearing the last-known-suspended marker is legitimate — the
418
+ // old shield binding is gone and a stale marker would otherwise suspend the
419
+ // re-enrolled machine forever (marker entries are keyed by old shieldIds).
420
+ // If the org still suspends this machine, the first bundle poll re-asserts.
421
+ try {
422
+ fs.unlinkSync(KILLSWITCH_MARKER_PATH);
423
+ }
424
+ catch { /* marker may not exist */ }
256
425
  }
257
426
  /**
258
427
  * Is ANY cached bundle marked suspended by an admin (kill-switch)?
@@ -266,7 +435,10 @@ function resetCacheOnEnrollment() {
266
435
  function isSuspendedByCache() {
267
436
  try {
268
437
  const cache = readCacheFile();
269
- return Object.values(cache).some(entry => entry && entry.suspended === true);
438
+ if (Object.values(cache).some(entry => entry && entry.suspended === true))
439
+ return true;
440
+ // Signed last-known-suspended marker: survives cache edits AND deletion.
441
+ return hasValidSuspendMarker();
270
442
  }
271
443
  catch {
272
444
  return false;
@@ -18,6 +18,16 @@ export interface SelfTestCheck {
18
18
  label: string;
19
19
  ok: boolean;
20
20
  detail: string;
21
+ /**
22
+ * Advisory check: a failure is worth SEEING (console shows it) but must not
23
+ * fail the machine's overall health. The redundant credential-store probes
24
+ * (native / DPAPI / key file — `credentials` + `bundle_auth` are the
25
+ * aggregate proof that auth actually works), the PowerShell probe (the
26
+ * product is PowerShell-free; hardened fleets legitimately block it), and
27
+ * the cosmetic windowless-tasks check are advisory. Required checks are the
28
+ * ones whose failure means protection or updates are actually broken.
29
+ */
30
+ optional?: boolean;
21
31
  }
22
32
  export interface SelfTestReport {
23
33
  ok: boolean;
package/dist/selfTest.js CHANGED
@@ -32,13 +32,14 @@ async function runDeepSelfTest(input) {
32
32
  if (process.platform === 'win32') {
33
33
  const mode = (0, config_1.capturePowershellLanguageMode)();
34
34
  if (!mode) {
35
- add({ id: 'powershell', label: 'PowerShell available', ok: false, detail: 'powershell.exe/pwsh.exe did not answer (EDR/AppLocker block?)' });
35
+ add({ id: 'powershell', label: 'PowerShell available', ok: false, optional: true, detail: 'powershell.exe/pwsh.exe did not answer (EDR/AppLocker block?) — advisory: the CLI is PowerShell-free on its enforcement paths' });
36
36
  }
37
37
  else {
38
38
  add({
39
39
  id: 'powershell',
40
40
  label: 'PowerShell available',
41
41
  ok: true,
42
+ optional: true,
42
43
  detail: `language mode: ${mode}${mode !== 'FullLanguage' ? ' (hardened — CLM-safe code paths required)' : ''}`,
43
44
  });
44
45
  }
@@ -46,20 +47,20 @@ async function runDeepSelfTest(input) {
46
47
  // store (CLI ≥1.21.34). A failure here is NOT fatal while DPAPI works,
47
48
  // but the console should see which machines run on the fallback.
48
49
  const native = (0, credentialStore_1.nativeStoreRoundtripProbe)();
49
- add({ id: 'native_store', label: 'Credential Manager (native) roundtrip', ok: native.ok, detail: native.detail });
50
+ add({ id: 'native_store', label: 'Credential Manager (native) roundtrip', ok: native.ok, optional: true, detail: native.detail });
50
51
  // 3. DPAPI roundtrip — encrypt AND decrypt a probe value with the exact
51
52
  // snippets the CLI uses for the shield key. This is the check that would
52
53
  // have identified the lptx1110 credential break in one click. Kept as its
53
54
  // own check: it is the fallback store for machines without the native
54
55
  // binding and the rollback path to ≤1.21.33.
55
56
  const probe = (0, config_1.dpapiRoundtripProbe)();
56
- add({ id: 'dpapi_roundtrip', label: 'Credential store (DPAPI) roundtrip', ok: probe.ok, detail: probe.detail });
57
+ add({ id: 'dpapi_roundtrip', label: 'Credential store (DPAPI) roundtrip', ok: probe.ok, optional: true, detail: probe.detail });
57
58
  // 4. Machine key file — the S4U-safe mirror the daemon/watchdog tasks
58
59
  // read (Credential Manager and DPAPI are both sealed to S4U sessions).
59
60
  // Without it, a daemon under the windowless S4U principal is key-less
60
61
  // and every bundle poll 401s.
61
62
  const keyFile = (0, machineKeyFile_1.machineKeyFileProbe)();
62
- add({ id: 'machine_key_file', label: 'Machine key file (S4U mirror) roundtrip', ok: keyFile.ok, detail: keyFile.detail });
63
+ add({ id: 'machine_key_file', label: 'Machine key file (S4U mirror) roundtrip', ok: keyFile.ok, optional: true, detail: keyFile.detail });
63
64
  }
64
65
  // 3. Credentials resolved on THIS machine right now.
65
66
  const trace = (() => {
@@ -160,6 +161,7 @@ async function runDeepSelfTest(input) {
160
161
  id: 'windowless_tasks',
161
162
  label: 'Scheduled tasks are windowless',
162
163
  ok: flashing.length === 0,
164
+ optional: true,
163
165
  detail: flashing.length === 0
164
166
  ? conhostShielded.length === 0
165
167
  ? 'no FCD task can open a console window (S4U principals)'
@@ -167,7 +169,11 @@ async function runDeepSelfTest(input) {
167
169
  : `visible console window on every trigger of: ${flashing.join(', ')} — bare console action on an InteractiveToken principal (neither S4U nor conhost --headless)`,
168
170
  });
169
171
  }
170
- return { ok: checks.every(check => check.ok), checks, ranAt: new Date().toISOString() };
172
+ // Overall health gates on REQUIRED checks only. An advisory failure (e.g.
173
+ // PowerShell blocked on a hardened bank machine, or one of the redundant
174
+ // credential stores unavailable while auth demonstrably works) used to fail
175
+ // the whole report, training operators to ignore red self-tests entirely.
176
+ return { ok: checks.every(check => check.ok || check.optional === true), checks, ranAt: new Date().toISOString() };
171
177
  }
172
178
  /** FCD per-user tasks that could flash a console window on every trigger. */
173
179
  const WINDOWED_TASK_CANDIDATES = ['FullCourtDefense Daemon', 'FullCourtDefense Daemon Watchdog', 'FullCourtDefense Auto-Protect', 'FullCourtDefenseDesktopDiscover'];
@@ -192,8 +198,13 @@ function listConhostShieldedFcdTasks() {
192
198
  }
193
199
  /** Compact one-line summary for the machine-action result card. */
194
200
  function summarizeSelfTest(report) {
195
- const failed = report.checks.filter(check => !check.ok);
196
- if (failed.length === 0)
201
+ const failed = report.checks.filter(check => !check.ok && check.optional !== true);
202
+ const advisory = report.checks.filter(check => !check.ok && check.optional === true);
203
+ if (failed.length === 0 && advisory.length === 0)
197
204
  return `Deep self-test passed — all ${report.checks.length} subsystems healthy.`;
198
- return `Deep self-test: ${report.checks.length - failed.length}/${report.checks.length} passed — FAILED: ${failed.map(check => `${check.id} (${check.detail})`).join('; ')}`;
205
+ if (failed.length === 0) {
206
+ return `Deep self-test passed (${report.checks.length - advisory.length}/${report.checks.length} required subsystems healthy) — advisory: ${advisory.map(check => `${check.id} (${check.detail})`).join('; ')}`;
207
+ }
208
+ const advisorySuffix = advisory.length > 0 ? ` — advisory: ${advisory.map(check => check.id).join(', ')}` : '';
209
+ return `Deep self-test: ${report.checks.length - failed.length - advisory.length}/${report.checks.length} passed — FAILED: ${failed.map(check => `${check.id} (${check.detail})`).join('; ')}${advisorySuffix}`;
199
210
  }
@@ -35,11 +35,23 @@ export declare function resolveNpmCommand(platform?: NodeJS.Platform, execPath?:
35
35
  */
36
36
  export declare function buildUpdaterTaskCommand(root: string, exists?: (p: string) => boolean): string | undefined;
37
37
  /**
38
- * True when a registered task command still points at the PowerShell updater
39
- * while this install carries the Node updater — i.e. the task predates the
40
- * PowerShell-free updater and should be re-registered.
38
+ * True when a registered task command should be re-registered:
39
+ * - it still points at the PowerShell updater while this install carries
40
+ * the Node updater (predates the PowerShell-free updater), or
41
+ * - it runs the Node updater WITHOUT `--api-url` (an old self-healed task —
42
+ * those default to prod, silently retargeting staging machines).
41
43
  */
42
44
  export declare function updaterTaskNeedsModernization(taskToRun: string, root: string, exists?: (p: string) => boolean): boolean;
45
+ /**
46
+ * Task Scheduler XML for the updater task — settings-parity with the MSI's
47
+ * own registration (Install-FullCourtDefense.ps1): StartWhenAvailable so a
48
+ * laptop asleep at 03:07 catches up on wake, battery-friendly so the task is
49
+ * not skipped/killed unplugged, SYSTEM at highest run level, daily trigger.
50
+ * `schtasks /Create` flags cannot express these (its defaults silently drop
51
+ * StartWhenAvailable and stop on battery) — registration goes through
52
+ * `/Create /XML` instead.
53
+ */
54
+ export declare function buildUpdaterTaskXml(root: string, apiUrl: string): string | undefined;
43
55
  /**
44
56
  * Upgrade an EXISTING updater task from the PowerShell command to the Node
45
57
  * command. Machines that upgraded from a pre-Node-updater MSI keep their old
@@ -47,7 +59,7 @@ export declare function updaterTaskNeedsModernization(taskToRun: string, root: s
47
59
  * this converges them whenever a daemon runs elevated. Best-effort: needs an
48
60
  * elevated token to re-register a SYSTEM task, silent no-op otherwise.
49
61
  */
50
- export declare function modernizeUpdaterTask(log: (message: string) => void): void;
62
+ export declare function modernizeUpdaterTask(log: (message: string) => void, apiUrl?: string): void;
51
63
  /**
52
64
  * Updater script log (%ProgramData%\FullCourtDefense\updater.log) — the
53
65
  * elevated task's own words. Read by the diagnostics bundle and the update-
@@ -81,6 +93,9 @@ export declare function maybeSelfUpdate(input: {
81
93
  /** Bypass the retry cooldown — used by explicit admin upgrade_cli actions so
82
94
  * they always attempt and report the REAL outcome instead of "in progress". */
83
95
  force?: boolean;
96
+ /** Control-plane URL this machine is enrolled against — preserved on any
97
+ * self-healed updater task so staging machines never retarget prod. */
98
+ apiUrl?: string;
84
99
  }): SelfUpdateResult | undefined;
85
100
  /** Version the MSI updater script reads from the installed package.json. */
86
101
  export declare function installedMsiVersion(installFolder: string): string | undefined;