fullcourtdefense-cli 1.26.5 → 1.26.7
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.
- package/dist/commands/daemon.d.ts +14 -0
- package/dist/commands/daemon.js +181 -28
- package/dist/commands/desktopChatGuard.js +5 -4
- package/dist/commands/deterministicGuard.d.ts +8 -0
- package/dist/commands/deterministicGuard.js +6 -0
- package/dist/commands/doctor.d.ts +4 -0
- package/dist/commands/doctor.js +99 -0
- package/dist/commands/hook.d.ts +1 -0
- package/dist/commands/hook.js +25 -2
- package/dist/commands/installClaudeHook.d.ts +2 -0
- package/dist/commands/installClaudeHook.js +4 -0
- package/dist/commands/installCursorHook.d.ts +8 -0
- package/dist/commands/installCursorHook.js +5 -0
- package/dist/commands/mcpGateway.js +234 -43
- package/dist/commands/onboard.d.ts +9 -0
- package/dist/commands/onboard.js +7 -0
- package/dist/commands/watchdog.d.ts +5 -2
- package/dist/commands/watchdog.js +34 -4
- package/dist/config.js +6 -1
- package/dist/daemonForensics.d.ts +27 -0
- package/dist/daemonForensics.js +27 -0
- package/dist/distress.d.ts +17 -0
- package/dist/distress.js +17 -0
- package/dist/hookIo.js +1 -0
- package/dist/index.js +6 -0
- package/dist/integrity.js +8 -0
- package/dist/localSafetySnapshot.d.ts +9 -0
- package/dist/localSafetySnapshot.js +58 -2
- package/dist/machineIdentity.js +70 -4
- package/dist/runtimeConfig.d.ts +12 -0
- package/dist/runtimeConfig.js +181 -7
- package/dist/selfTest.d.ts +10 -0
- package/dist/selfTest.js +21 -8
- package/dist/selfUpdate.d.ts +19 -4
- package/dist/selfUpdate.js +108 -12
- package/dist/telemetry.js +67 -2
- package/dist/verdictIpc.js +76 -30
- package/dist/verdictIpcClient.d.ts +27 -0
- package/dist/verdictIpcClient.js +68 -0
- package/dist/version.json +1 -1
- package/package.json +5 -1
package/dist/runtimeConfig.js
CHANGED
|
@@ -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:
|
|
72
|
-
|
|
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
|
-
|
|
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' };
|
|
@@ -129,11 +258,17 @@ async function getRuntimeBundle(input) {
|
|
|
129
258
|
params.set('machineName', input.machineName);
|
|
130
259
|
if (input.machineId)
|
|
131
260
|
params.set('machineId', input.machineId);
|
|
132
|
-
const resp = await fetch(`${input.apiUrl}/api/cli/bundle?${params.toString()}`, { method: 'GET', headers, signal: AbortSignal.timeout(REFRESH_TIMEOUT_MS) });
|
|
261
|
+
const resp = await fetch(`${input.apiUrl}/api/cli/bundle?${params.toString()}`, { method: 'GET', headers, signal: AbortSignal.timeout(input.timeoutMs ?? 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
|
-
|
|
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
|
/**
|
|
@@ -209,10 +359,12 @@ async function validateShieldCredentials(input) {
|
|
|
209
359
|
}
|
|
210
360
|
try {
|
|
211
361
|
const params = new URLSearchParams({ shieldId: input.shieldId });
|
|
362
|
+
// Diagnostic default (10s), not the hot-path 1.5s: onboarding/doctor would
|
|
363
|
+
// rather wait than misreport a slow-but-healthy backend as unreachable.
|
|
212
364
|
const resp = await fetch(`${input.apiUrl}/api/cli/bundle?${params.toString()}`, {
|
|
213
365
|
method: 'GET',
|
|
214
366
|
headers: { 'Content-Type': 'application/json', 'x-shield-key': input.shieldKey },
|
|
215
|
-
signal: AbortSignal.timeout(
|
|
367
|
+
signal: AbortSignal.timeout(input.timeoutMs ?? 10_000),
|
|
216
368
|
});
|
|
217
369
|
if (resp.status === 401 || resp.status === 403) {
|
|
218
370
|
(0, distress_1.reportDistress)('bundle', distress_1.DISTRESS.AUTH_BROKEN, `credential validation HTTP ${resp.status}`);
|
|
@@ -253,6 +405,25 @@ function resetCacheOnEnrollment() {
|
|
|
253
405
|
writeCacheFile({});
|
|
254
406
|
}
|
|
255
407
|
catch { /* cache is best-effort — never fail an enrollment over it */ }
|
|
408
|
+
// Local Safety snapshots are keyed by shieldId + identity — after a
|
|
409
|
+
// re-enrollment the old shield's snapshot (possibly ENFORCING) must not
|
|
410
|
+
// survive under a key the new identity still happens to resolve. Lazy
|
|
411
|
+
// import keeps the hook hot path free of this module.
|
|
412
|
+
try {
|
|
413
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
414
|
+
const { clearAllLocalSafetySnapshotCaches } = require('./localSafetySnapshot');
|
|
415
|
+
clearAllLocalSafetySnapshotCaches();
|
|
416
|
+
}
|
|
417
|
+
catch { /* best-effort */ }
|
|
418
|
+
// Enrollment only reaches here after the SERVER accepted a valid fleet
|
|
419
|
+
// token, so clearing the last-known-suspended marker is legitimate — the
|
|
420
|
+
// old shield binding is gone and a stale marker would otherwise suspend the
|
|
421
|
+
// re-enrolled machine forever (marker entries are keyed by old shieldIds).
|
|
422
|
+
// If the org still suspends this machine, the first bundle poll re-asserts.
|
|
423
|
+
try {
|
|
424
|
+
fs.unlinkSync(KILLSWITCH_MARKER_PATH);
|
|
425
|
+
}
|
|
426
|
+
catch { /* marker may not exist */ }
|
|
256
427
|
}
|
|
257
428
|
/**
|
|
258
429
|
* Is ANY cached bundle marked suspended by an admin (kill-switch)?
|
|
@@ -266,7 +437,10 @@ function resetCacheOnEnrollment() {
|
|
|
266
437
|
function isSuspendedByCache() {
|
|
267
438
|
try {
|
|
268
439
|
const cache = readCacheFile();
|
|
269
|
-
|
|
440
|
+
if (Object.values(cache).some(entry => entry && entry.suspended === true))
|
|
441
|
+
return true;
|
|
442
|
+
// Signed last-known-suspended marker: survives cache edits AND deletion.
|
|
443
|
+
return hasValidSuspendMarker();
|
|
270
444
|
}
|
|
271
445
|
catch {
|
|
272
446
|
return false;
|
package/dist/selfTest.d.ts
CHANGED
|
@@ -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 = (() => {
|
|
@@ -90,6 +91,8 @@ async function runDeepSelfTest(input) {
|
|
|
90
91
|
machineId: input.machineId,
|
|
91
92
|
force: true,
|
|
92
93
|
ttlMs: 0,
|
|
94
|
+
// Diagnostic call — a 3s answer is a healthy backend, not a failure.
|
|
95
|
+
timeoutMs: 10_000,
|
|
93
96
|
});
|
|
94
97
|
add({
|
|
95
98
|
id: 'bundle_auth',
|
|
@@ -160,6 +163,7 @@ async function runDeepSelfTest(input) {
|
|
|
160
163
|
id: 'windowless_tasks',
|
|
161
164
|
label: 'Scheduled tasks are windowless',
|
|
162
165
|
ok: flashing.length === 0,
|
|
166
|
+
optional: true,
|
|
163
167
|
detail: flashing.length === 0
|
|
164
168
|
? conhostShielded.length === 0
|
|
165
169
|
? 'no FCD task can open a console window (S4U principals)'
|
|
@@ -167,7 +171,11 @@ async function runDeepSelfTest(input) {
|
|
|
167
171
|
: `visible console window on every trigger of: ${flashing.join(', ')} — bare console action on an InteractiveToken principal (neither S4U nor conhost --headless)`,
|
|
168
172
|
});
|
|
169
173
|
}
|
|
170
|
-
|
|
174
|
+
// Overall health gates on REQUIRED checks only. An advisory failure (e.g.
|
|
175
|
+
// PowerShell blocked on a hardened bank machine, or one of the redundant
|
|
176
|
+
// credential stores unavailable while auth demonstrably works) used to fail
|
|
177
|
+
// the whole report, training operators to ignore red self-tests entirely.
|
|
178
|
+
return { ok: checks.every(check => check.ok || check.optional === true), checks, ranAt: new Date().toISOString() };
|
|
171
179
|
}
|
|
172
180
|
/** FCD per-user tasks that could flash a console window on every trigger. */
|
|
173
181
|
const WINDOWED_TASK_CANDIDATES = ['FullCourtDefense Daemon', 'FullCourtDefense Daemon Watchdog', 'FullCourtDefense Auto-Protect', 'FullCourtDefenseDesktopDiscover'];
|
|
@@ -192,8 +200,13 @@ function listConhostShieldedFcdTasks() {
|
|
|
192
200
|
}
|
|
193
201
|
/** Compact one-line summary for the machine-action result card. */
|
|
194
202
|
function summarizeSelfTest(report) {
|
|
195
|
-
const failed = report.checks.filter(check => !check.ok);
|
|
196
|
-
|
|
203
|
+
const failed = report.checks.filter(check => !check.ok && check.optional !== true);
|
|
204
|
+
const advisory = report.checks.filter(check => !check.ok && check.optional === true);
|
|
205
|
+
if (failed.length === 0 && advisory.length === 0)
|
|
197
206
|
return `Deep self-test passed — all ${report.checks.length} subsystems healthy.`;
|
|
198
|
-
|
|
207
|
+
if (failed.length === 0) {
|
|
208
|
+
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('; ')}`;
|
|
209
|
+
}
|
|
210
|
+
const advisorySuffix = advisory.length > 0 ? ` — advisory: ${advisory.map(check => check.id).join(', ')}` : '';
|
|
211
|
+
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
212
|
}
|
package/dist/selfUpdate.d.ts
CHANGED
|
@@ -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
|
|
39
|
-
*
|
|
40
|
-
* PowerShell-free updater
|
|
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;
|
package/dist/selfUpdate.js
CHANGED
|
@@ -39,6 +39,7 @@ exports.compareCliVersions = compareCliVersions;
|
|
|
39
39
|
exports.resolveNpmCommand = resolveNpmCommand;
|
|
40
40
|
exports.buildUpdaterTaskCommand = buildUpdaterTaskCommand;
|
|
41
41
|
exports.updaterTaskNeedsModernization = updaterTaskNeedsModernization;
|
|
42
|
+
exports.buildUpdaterTaskXml = buildUpdaterTaskXml;
|
|
42
43
|
exports.modernizeUpdaterTask = modernizeUpdaterTask;
|
|
43
44
|
exports.readUpdaterLogTail = readUpdaterLogTail;
|
|
44
45
|
exports.clearResolvedUpdateLoop = clearResolvedUpdateLoop;
|
|
@@ -161,26 +162,121 @@ function buildUpdaterTaskCommand(root, exists = fs.existsSync) {
|
|
|
161
162
|
return undefined;
|
|
162
163
|
}
|
|
163
164
|
/**
|
|
164
|
-
* True when a registered task command
|
|
165
|
-
*
|
|
166
|
-
* PowerShell-free updater
|
|
165
|
+
* True when a registered task command should be re-registered:
|
|
166
|
+
* - it still points at the PowerShell updater while this install carries
|
|
167
|
+
* the Node updater (predates the PowerShell-free updater), or
|
|
168
|
+
* - it runs the Node updater WITHOUT `--api-url` (an old self-healed task —
|
|
169
|
+
* those default to prod, silently retargeting staging machines).
|
|
167
170
|
*/
|
|
168
171
|
function updaterTaskNeedsModernization(taskToRun, root, exists = fs.existsSync) {
|
|
169
|
-
|
|
172
|
+
const hasNodeUpdater = exists(path.join(root, 'Update-FullCourtDefense.js')) && exists(path.join(root, 'runtime', 'node.exe'));
|
|
173
|
+
if (!hasNodeUpdater)
|
|
170
174
|
return false;
|
|
171
|
-
|
|
175
|
+
if (/powershell/i.test(taskToRun))
|
|
176
|
+
return true;
|
|
177
|
+
return /Update-FullCourtDefense\.js/i.test(taskToRun) && !/--api-url/i.test(taskToRun);
|
|
178
|
+
}
|
|
179
|
+
function escapeXml(value) {
|
|
180
|
+
return value
|
|
181
|
+
.replace(/&/g, '&')
|
|
182
|
+
.replace(/</g, '<')
|
|
183
|
+
.replace(/>/g, '>')
|
|
184
|
+
.replace(/"/g, '"')
|
|
185
|
+
.replace(/'/g, ''');
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Task Scheduler XML for the updater task — settings-parity with the MSI's
|
|
189
|
+
* own registration (Install-FullCourtDefense.ps1): StartWhenAvailable so a
|
|
190
|
+
* laptop asleep at 03:07 catches up on wake, battery-friendly so the task is
|
|
191
|
+
* not skipped/killed unplugged, SYSTEM at highest run level, daily trigger.
|
|
192
|
+
* `schtasks /Create` flags cannot express these (its defaults silently drop
|
|
193
|
+
* StartWhenAvailable and stop on battery) — registration goes through
|
|
194
|
+
* `/Create /XML` instead.
|
|
195
|
+
*/
|
|
196
|
+
function buildUpdaterTaskXml(root, apiUrl) {
|
|
197
|
+
const nodeExe = path.join(root, 'runtime', 'node.exe');
|
|
198
|
+
const jsUpdater = path.join(root, 'Update-FullCourtDefense.js');
|
|
199
|
+
if (!fs.existsSync(nodeExe) || !fs.existsSync(jsUpdater))
|
|
200
|
+
return undefined;
|
|
201
|
+
return `<?xml version="1.0" encoding="UTF-16"?>
|
|
202
|
+
<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
|
|
203
|
+
<RegistrationInfo>
|
|
204
|
+
<Description>Silently updates FullCourtDefense (downloads, verifies, and installs new MSI releases).</Description>
|
|
205
|
+
</RegistrationInfo>
|
|
206
|
+
<Triggers>
|
|
207
|
+
<CalendarTrigger>
|
|
208
|
+
<StartBoundary>2020-01-01T03:07:00</StartBoundary>
|
|
209
|
+
<Enabled>true</Enabled>
|
|
210
|
+
<ScheduleByDay>
|
|
211
|
+
<DaysInterval>1</DaysInterval>
|
|
212
|
+
</ScheduleByDay>
|
|
213
|
+
</CalendarTrigger>
|
|
214
|
+
</Triggers>
|
|
215
|
+
<Principals>
|
|
216
|
+
<Principal id="Author">
|
|
217
|
+
<UserId>S-1-5-18</UserId>
|
|
218
|
+
<RunLevel>HighestAvailable</RunLevel>
|
|
219
|
+
</Principal>
|
|
220
|
+
</Principals>
|
|
221
|
+
<Settings>
|
|
222
|
+
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
|
|
223
|
+
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
|
|
224
|
+
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
|
|
225
|
+
<StartWhenAvailable>true</StartWhenAvailable>
|
|
226
|
+
<RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>
|
|
227
|
+
<AllowHardTerminate>true</AllowHardTerminate>
|
|
228
|
+
<Enabled>true</Enabled>
|
|
229
|
+
<Hidden>true</Hidden>
|
|
230
|
+
<ExecutionTimeLimit>PT2H</ExecutionTimeLimit>
|
|
231
|
+
</Settings>
|
|
232
|
+
<Actions Context="Author">
|
|
233
|
+
<Exec>
|
|
234
|
+
<Command>${escapeXml(nodeExe)}</Command>
|
|
235
|
+
<Arguments>${escapeXml(`"${jsUpdater}" --api-url "${apiUrl}"`)}</Arguments>
|
|
236
|
+
</Exec>
|
|
237
|
+
</Actions>
|
|
238
|
+
</Task>
|
|
239
|
+
`;
|
|
172
240
|
}
|
|
241
|
+
const DEFAULT_UPDATER_API_URL = 'https://api.fullcourtdefense.ai';
|
|
173
242
|
/**
|
|
174
243
|
* Self-heal a missing updater task. Machines installed from an older MSI (or
|
|
175
244
|
* where task creation was denied at install time) ship the updater SCRIPT but
|
|
176
245
|
* not the scheduled task — without this, every self-update tick dead-ends with
|
|
177
246
|
* "reinstall the MSI". Registration needs an elevated token, so this succeeds
|
|
178
247
|
* on elevated daemons and stays a silent no-op otherwise.
|
|
248
|
+
*
|
|
249
|
+
* Registers via XML for settings-parity with the MSI task (StartWhenAvailable
|
|
250
|
+
* + battery-friendly + `--api-url`); the flag-based `schtasks /Create` remains
|
|
251
|
+
* a fallback so self-heal never regresses where XML registration is denied.
|
|
179
252
|
*/
|
|
180
|
-
function tryRegisterMsiUpdaterTask(log) {
|
|
253
|
+
function tryRegisterMsiUpdaterTask(log, apiUrl) {
|
|
181
254
|
const root = msiInstallRoot();
|
|
182
255
|
if (!root)
|
|
183
256
|
return false;
|
|
257
|
+
const xml = buildUpdaterTaskXml(root, apiUrl || DEFAULT_UPDATER_API_URL);
|
|
258
|
+
if (xml) {
|
|
259
|
+
const xmlFile = path.join(os.tmpdir(), `fcd-updater-task-${process.pid}.xml`);
|
|
260
|
+
try {
|
|
261
|
+
// schtasks /XML requires the file encoding to match the declaration
|
|
262
|
+
// (encoding="UTF-16") — UTF-16LE with BOM.
|
|
263
|
+
fs.writeFileSync(xmlFile, `\ufeff${xml}`, 'utf16le');
|
|
264
|
+
const created = (0, child_process_1.spawnSync)('schtasks', [
|
|
265
|
+
'/Create', '/TN', exports.MSI_UPDATER_TASK_NAME, '/XML', xmlFile, '/F',
|
|
266
|
+
], { stdio: 'ignore', windowsHide: true, timeout: 20_000 });
|
|
267
|
+
if (created.status === 0) {
|
|
268
|
+
log('Self-update: registered the MSI updater task (self-heal, full settings parity with the MSI task).');
|
|
269
|
+
return true;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
catch { /* fall through to the flag-based create */ }
|
|
273
|
+
finally {
|
|
274
|
+
try {
|
|
275
|
+
fs.rmSync(xmlFile, { force: true });
|
|
276
|
+
}
|
|
277
|
+
catch { /* best effort */ }
|
|
278
|
+
}
|
|
279
|
+
}
|
|
184
280
|
const taskCommand = buildUpdaterTaskCommand(root);
|
|
185
281
|
if (!taskCommand)
|
|
186
282
|
return false;
|
|
@@ -201,7 +297,7 @@ function tryRegisterMsiUpdaterTask(log) {
|
|
|
201
297
|
* this converges them whenever a daemon runs elevated. Best-effort: needs an
|
|
202
298
|
* elevated token to re-register a SYSTEM task, silent no-op otherwise.
|
|
203
299
|
*/
|
|
204
|
-
function modernizeUpdaterTask(log) {
|
|
300
|
+
function modernizeUpdaterTask(log, apiUrl) {
|
|
205
301
|
if (process.platform !== 'win32')
|
|
206
302
|
return;
|
|
207
303
|
try {
|
|
@@ -216,15 +312,15 @@ function modernizeUpdaterTask(log) {
|
|
|
216
312
|
const taskToRun = (query.stdout.match(/^Task To Run:\s*(.*)$/m) || [])[1] || '';
|
|
217
313
|
if (!updaterTaskNeedsModernization(taskToRun, root))
|
|
218
314
|
return;
|
|
219
|
-
if (tryRegisterMsiUpdaterTask(log)) {
|
|
220
|
-
log('Self-update: updater task modernized
|
|
315
|
+
if (tryRegisterMsiUpdaterTask(log, apiUrl)) {
|
|
316
|
+
log('Self-update: updater task modernized (PowerShell-free Node updater, api-url preserved).');
|
|
221
317
|
}
|
|
222
318
|
}
|
|
223
319
|
catch { /* diagnostics-only convergence — never disturb the daemon */ }
|
|
224
320
|
}
|
|
225
|
-
function startMsiSelfUpdate(targetVersion, log) {
|
|
321
|
+
function startMsiSelfUpdate(targetVersion, log, apiUrl) {
|
|
226
322
|
const query = (0, child_process_1.spawnSync)('schtasks', ['/Query', '/TN', exports.MSI_UPDATER_TASK_NAME], { stdio: 'ignore', windowsHide: true, timeout: 10_000 });
|
|
227
|
-
if (query.status !== 0 && !tryRegisterMsiUpdaterTask(log)) {
|
|
323
|
+
if (query.status !== 0 && !tryRegisterMsiUpdaterTask(log, apiUrl)) {
|
|
228
324
|
log('Self-update: MSI updater task is not registered on this machine — reinstall the MSI to enable silent updates.');
|
|
229
325
|
(0, distress_1.reportDistress)('self-update', distress_1.DISTRESS.UPDATER_TASK_MISSING, `"${exports.MSI_UPDATER_TASK_NAME}" scheduled task missing and self-registration failed`);
|
|
230
326
|
return {
|
|
@@ -398,7 +494,7 @@ function maybeSelfUpdate(input) {
|
|
|
398
494
|
}
|
|
399
495
|
const kind = detectInstallKind();
|
|
400
496
|
const result = kind === 'msi'
|
|
401
|
-
? startMsiSelfUpdate(input.targetVersion, log)
|
|
497
|
+
? startMsiSelfUpdate(input.targetVersion, log, input.apiUrl)
|
|
402
498
|
: startNpmSelfUpdate(input.targetVersion, log);
|
|
403
499
|
if (!result.started) {
|
|
404
500
|
// Allow another attempt on the next tick after a shorter cooldown when we
|