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.
- 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 +61 -104
- package/dist/commands/installClaudeHook.d.ts +2 -0
- package/dist/commands/installClaudeHook.js +21 -3
- package/dist/commands/installCursorHook.d.ts +8 -0
- package/dist/commands/installCursorHook.js +29 -4
- 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.d.ts +43 -0
- package/dist/hookIo.js +189 -0
- package/dist/hookSlim.d.ts +32 -0
- package/dist/hookSlim.js +142 -0
- package/dist/index.js +10 -14
- 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 +2 -0
- package/dist/runtimeConfig.js +177 -5
- package/dist/selfTest.d.ts +10 -0
- package/dist/selfTest.js +19 -8
- package/dist/selfUpdate.d.ts +19 -4
- package/dist/selfUpdate.js +108 -12
- package/dist/telemetry.js +67 -2
- package/dist/verdictIpc.d.ts +12 -75
- package/dist/verdictIpc.js +104 -188
- package/dist/verdictIpcClient.d.ts +103 -0
- package/dist/verdictIpcClient.js +251 -0
- package/dist/version.json +1 -1
- package/package.json +6 -1
package/dist/machineIdentity.js
CHANGED
|
@@ -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;
|
|
153
|
-
//
|
|
154
|
-
|
|
155
|
-
|
|
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,
|
package/dist/runtimeConfig.d.ts
CHANGED
|
@@ -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
|
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' };
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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;
|
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 = (() => {
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
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;
|