fullcourtdefense-cli 1.26.12 → 1.26.14
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/cmdGuard.d.ts +6 -0
- package/dist/commands/cmdGuard.js +52 -7
- package/dist/commands/daemon.js +83 -6
- package/dist/commands/doctor.js +4 -1
- package/dist/commands/verifyRemoved.js +1 -0
- package/dist/commands/watchdog.js +14 -0
- package/dist/daemonForensics.d.ts +74 -0
- package/dist/daemonForensics.js +103 -0
- package/dist/integrity.d.ts +2 -0
- package/dist/integrity.js +22 -0
- package/dist/perfSnapshot.d.ts +12 -1
- package/dist/perfSnapshot.js +27 -6
- package/dist/version.json +1 -1
- package/package.json +2 -1
|
@@ -5,6 +5,12 @@ export interface CmdGuardStatus {
|
|
|
5
5
|
rulesPresent: boolean;
|
|
6
6
|
ruleCount?: number;
|
|
7
7
|
mode?: string;
|
|
8
|
+
/**
|
|
9
|
+
* Whether the doskey macro file exists. Installed-but-missing means the bat
|
|
10
|
+
* fails open and NO command is intercepted, so this must be visible rather
|
|
11
|
+
* than hidden behind `installed: true`.
|
|
12
|
+
*/
|
|
13
|
+
macrosPresent?: boolean;
|
|
8
14
|
}
|
|
9
15
|
export declare function isCmdGuardInstalled(): boolean;
|
|
10
16
|
export declare function getCmdGuardStatus(): CmdGuardStatus;
|
|
@@ -58,6 +58,7 @@ const shellGuard_1 = require("./shellGuard");
|
|
|
58
58
|
const GUARD_RULES_PATH = path.join(os.homedir(), '.fullcourtdefense-shell-guard.json');
|
|
59
59
|
const GUARD_JS_PATH = path.join(os.homedir(), '.fullcourtdefense-cmd-guard.js');
|
|
60
60
|
const AUTORUN_BAT_PATH = path.join(os.homedir(), '.fullcourtdefense-cmd-autorun.bat');
|
|
61
|
+
const MACRO_FILE_PATH = path.join(os.homedir(), '.fullcourtdefense-cmd-macros.txt');
|
|
61
62
|
const CMD_AUTORUN_KEY = 'HKCU\\Software\\Microsoft\\Command Processor';
|
|
62
63
|
const AUTORUN_VALUE = 'AutoRun';
|
|
63
64
|
const AUTORUN_MARKER = 'fullcourtdefense-cmd-autorun.bat';
|
|
@@ -151,10 +152,22 @@ function buildGuardJs(nodePath, cliEntry) {
|
|
|
151
152
|
``,
|
|
152
153
|
].join('\n');
|
|
153
154
|
}
|
|
155
|
+
/**
|
|
156
|
+
* The doskey macro definitions, as a file for `doskey /macrofile=`.
|
|
157
|
+
*
|
|
158
|
+
* Deliberately contains NOTHING but macro lines: every line in a macrofile is a
|
|
159
|
+
* definition, so a comment or build stamp here would register a junk macro (a
|
|
160
|
+
* `REM` macro would shadow REM for anything the user types). The build stamp
|
|
161
|
+
* lives in the bat, which is what the staleness check reads.
|
|
162
|
+
*/
|
|
163
|
+
function buildMacroFile(nodePath) {
|
|
164
|
+
const guardJs = batQuote(GUARD_JS_PATH);
|
|
165
|
+
const node = batQuote(nodePath);
|
|
166
|
+
return `${INTERCEPTED_COMMANDS.map(cmd => `${cmd}=${node} ${guardJs} ${cmd} $*`).join('\r\n')}\r\n`;
|
|
167
|
+
}
|
|
154
168
|
function buildAutorunBat(nodePath) {
|
|
155
169
|
const guardJs = batQuote(GUARD_JS_PATH);
|
|
156
170
|
const node = batQuote(nodePath);
|
|
157
|
-
const doskeyLines = INTERCEPTED_COMMANDS.map(cmd => `doskey ${cmd}=${node} ${guardJs} ${cmd} $*`);
|
|
158
171
|
return [
|
|
159
172
|
'@echo off',
|
|
160
173
|
'REM FullCourtDefense cmd.exe guard (installed by fullcourtdefense-cli).',
|
|
@@ -168,7 +181,13 @@ function buildAutorunBat(nodePath) {
|
|
|
168
181
|
// must keep working natively. Never break the customer's tools.
|
|
169
182
|
`if not exist ${node} goto :eof`,
|
|
170
183
|
`if not exist ${guardJs} goto :eof`,
|
|
171
|
-
|
|
184
|
+
`if not exist ${batQuote(MACRO_FILE_PATH)} goto :eof`,
|
|
185
|
+
// ONE doskey call, not one per command. AutoRun runs on every cmd.exe start,
|
|
186
|
+
// and each separate `doskey` statement costs ~8ms, so the previous
|
|
187
|
+
// one-line-per-command form added ~320ms to every cmd.exe launch — paid by
|
|
188
|
+
// build scripts, CI and agents on every single `cmd /c`. Loading the same
|
|
189
|
+
// macros from a file is ~18ms for the whole set.
|
|
190
|
+
`doskey /macrofile=${batQuote(MACRO_FILE_PATH)}`,
|
|
172
191
|
'',
|
|
173
192
|
].join('\r\n');
|
|
174
193
|
}
|
|
@@ -229,6 +248,7 @@ function getCmdGuardStatus() {
|
|
|
229
248
|
rulesPresent,
|
|
230
249
|
ruleCount,
|
|
231
250
|
mode,
|
|
251
|
+
macrosPresent: fs.existsSync(MACRO_FILE_PATH),
|
|
232
252
|
};
|
|
233
253
|
}
|
|
234
254
|
catch {
|
|
@@ -245,6 +265,10 @@ async function installCmdGuardCommand(_args = {}) {
|
|
|
245
265
|
const nodePath = process.execPath;
|
|
246
266
|
const cliEntry = process.argv[1] || '';
|
|
247
267
|
fs.writeFileSync(GUARD_JS_PATH, buildGuardJs(nodePath, cliEntry), 'utf8');
|
|
268
|
+
// Macro file BEFORE the bat: the bat refuses to register macros without it,
|
|
269
|
+
// so this order can never leave a window where cmd.exe sources a bat whose
|
|
270
|
+
// macrofile does not exist yet.
|
|
271
|
+
(0, shellGuard_1.writeGuardArtifact)(MACRO_FILE_PATH, buildMacroFile(nodePath));
|
|
248
272
|
fs.writeFileSync(AUTORUN_BAT_PATH, buildAutorunBat(nodePath), 'utf8');
|
|
249
273
|
console.log(`Guard rules written: ${rules.rules.length} rule(s), mode "${rules.mode}".`);
|
|
250
274
|
const current = readCurrentAutorun();
|
|
@@ -280,6 +304,10 @@ async function uninstallCmdGuardCommand() {
|
|
|
280
304
|
fs.unlinkSync(AUTORUN_BAT_PATH);
|
|
281
305
|
}
|
|
282
306
|
catch { /* not present */ }
|
|
307
|
+
try {
|
|
308
|
+
fs.unlinkSync(MACRO_FILE_PATH);
|
|
309
|
+
}
|
|
310
|
+
catch { /* not present */ }
|
|
283
311
|
console.log('cmd.exe guard uninstalled. Open cmd windows keep doskey until closed.');
|
|
284
312
|
}
|
|
285
313
|
/** Refresh shared rules JSON when cmd guard is installed. */
|
|
@@ -290,21 +318,34 @@ function refreshCmdGuardRules() {
|
|
|
290
318
|
(0, shellGuard_1.writeShellGuardRules)();
|
|
291
319
|
// Self-heal after CLI updates: the planted checker/autorun carry the
|
|
292
320
|
// detection logic of the build that wrote them — rewrite when stale.
|
|
293
|
-
|
|
321
|
+
//
|
|
322
|
+
// A MISSING macro file is also healed here, and matters more than staleness:
|
|
323
|
+
// the bat fails open without it, so the guard would look installed while
|
|
324
|
+
// registering no macros at all. This is the only path that repairs that.
|
|
325
|
+
if ((0, shellGuard_1.guardArtifactStale)(GUARD_JS_PATH) || !fs.existsSync(MACRO_FILE_PATH)) {
|
|
294
326
|
const nodePath = process.execPath;
|
|
295
327
|
(0, shellGuard_1.writeGuardArtifact)(GUARD_JS_PATH, buildGuardJs(nodePath, process.argv[1] || ''));
|
|
328
|
+
(0, shellGuard_1.writeGuardArtifact)(MACRO_FILE_PATH, buildMacroFile(nodePath));
|
|
296
329
|
(0, shellGuard_1.writeGuardArtifact)(AUTORUN_BAT_PATH, buildAutorunBat(nodePath));
|
|
297
330
|
}
|
|
298
331
|
}
|
|
299
332
|
catch { /* best-effort */ }
|
|
300
333
|
}
|
|
301
334
|
/**
|
|
302
|
-
* The node.exe path baked into an installed autorun bat
|
|
303
|
-
*
|
|
335
|
+
* The node.exe path baked into an installed autorun bat.
|
|
336
|
+
*
|
|
337
|
+
* Reads the `if not exist "<node>" goto :eof` fail-open line, which is present
|
|
338
|
+
* in every build. The doskey fallback below covers bats written by builds that
|
|
339
|
+
* predate the macrofile change (`doskey <cmd>="<node>" "<guardJs>" <cmd> $*`):
|
|
340
|
+
* those machines are exactly the ones that still need repairing, so dropping
|
|
341
|
+
* the old shape would strand them.
|
|
304
342
|
*/
|
|
305
343
|
function autorunBatNodePath(batContent) {
|
|
306
|
-
const
|
|
307
|
-
|
|
344
|
+
const existsCheck = batContent.match(/^if not exist\s+"([^"]+node[^"]*)"\s+goto/im);
|
|
345
|
+
if (existsCheck)
|
|
346
|
+
return existsCheck[1];
|
|
347
|
+
const legacyDoskey = batContent.match(/^doskey\s+\S+="([^"]+)"\s/m);
|
|
348
|
+
return legacyDoskey ? legacyDoskey[1] : undefined;
|
|
308
349
|
}
|
|
309
350
|
/**
|
|
310
351
|
* Self-heal a stale/broken cmd-guard AutoRun. Two failure shapes are covered:
|
|
@@ -358,6 +399,10 @@ function repairStaleCmdAutorun() {
|
|
|
358
399
|
fs.unlinkSync(GUARD_JS_PATH);
|
|
359
400
|
}
|
|
360
401
|
catch { /* already gone */ }
|
|
402
|
+
try {
|
|
403
|
+
fs.unlinkSync(MACRO_FILE_PATH);
|
|
404
|
+
}
|
|
405
|
+
catch { /* already gone */ }
|
|
361
406
|
return true;
|
|
362
407
|
}
|
|
363
408
|
catch {
|
package/dist/commands/daemon.js
CHANGED
|
@@ -109,16 +109,42 @@ const DEBOUNCE_MS = envMs('FCD_DAEMON_DEBOUNCE_MS', 2_000);
|
|
|
109
109
|
const RESCAN_INTERVAL_MS = envMs('FCD_DAEMON_RESCAN_MS', 5 * 60_000);
|
|
110
110
|
/** Heartbeat / spool flush cadence. */
|
|
111
111
|
const HEARTBEAT_INTERVAL_MS = envMs('FCD_DAEMON_HEARTBEAT_MS', 5 * 60_000);
|
|
112
|
+
/**
|
|
113
|
+
* Floor between integrity-triggered repairs. The drift watcher reacts to config
|
|
114
|
+
* CHANGES; this path reacts to a failing verdict, which persists until fixed. If
|
|
115
|
+
* a repair cannot fix it (no write permission, a config owned by another
|
|
116
|
+
* install) an uncooled retry would re-run protect-all every heartbeat forever.
|
|
117
|
+
*/
|
|
118
|
+
const INTEGRITY_HEAL_COOLDOWN_MS = envMs('FCD_INTEGRITY_HEAL_COOLDOWN_MS', 60 * 60_000);
|
|
112
119
|
/** Bundle (mode / suspension / policy version) poll cadence. */
|
|
113
120
|
const BUNDLE_POLL_MS = envMs('FCD_DAEMON_BUNDLE_POLL_MS', 60_000);
|
|
121
|
+
/**
|
|
122
|
+
* Fetch budget for the daemon's background bundle poll. Deliberately NOT the
|
|
123
|
+
* hot-path default (getRuntimeBundle's 1.5s), which exists so a hook never
|
|
124
|
+
* stalls a developer's command. Nothing waits on this poll — it runs on its own
|
|
125
|
+
* 60s timer inside a 45s deadline — so a slow-but-healthy control plane must be
|
|
126
|
+
* waited out, not reported as an outage. At 1.5s roughly 10% of polls aborted
|
|
127
|
+
* on ordinary p95 latency spikes and each one raised a `network_down` distress
|
|
128
|
+
* signal, while `doctor --health` (already passing 10s) stayed green against
|
|
129
|
+
* the same backend: alert noise with no failure behind it.
|
|
130
|
+
*/
|
|
131
|
+
const BUNDLE_FETCH_TIMEOUT_MS = envMs('FCD_DAEMON_BUNDLE_TIMEOUT_MS', 10_000);
|
|
114
132
|
/**
|
|
115
133
|
* Hard ceilings for one poll attempt (see startPollLoop). Generous multiples of
|
|
116
|
-
* the work each poll actually does — a bundle poll is a
|
|
134
|
+
* the work each poll actually does — a bundle poll is a 10s fetch plus an 8s
|
|
117
135
|
* snapshot refresh, a heartbeat is a spool flush plus a log upload — so these
|
|
118
136
|
* only ever fire on a genuinely hung socket, never on a merely slow network.
|
|
119
137
|
*/
|
|
120
138
|
const BUNDLE_POLL_DEADLINE_MS = envMs('FCD_DAEMON_BUNDLE_DEADLINE_MS', 45_000);
|
|
121
139
|
const HEARTBEAT_DEADLINE_MS = envMs('FCD_DAEMON_HEARTBEAT_DEADLINE_MS', 120_000);
|
|
140
|
+
/**
|
|
141
|
+
* Wake-from-sleep detection. Timers are the only awake clock we have: a beat
|
|
142
|
+
* that observes far more wall time than its own interval proves the machine
|
|
143
|
+
* was suspended in between. The jump bound is a generous multiple of the beat
|
|
144
|
+
* so ordinary event-loop lag or a busy CPU can never look like a resume.
|
|
145
|
+
*/
|
|
146
|
+
const RESUME_BEAT_MS = envMs('FCD_DAEMON_RESUME_BEAT_MS', 30_000);
|
|
147
|
+
const RESUME_JUMP_MS = envMs('FCD_DAEMON_RESUME_JUMP_MS', 120_000);
|
|
122
148
|
/** Delay before the one-time initial discovery sweep on a fresh machine. */
|
|
123
149
|
const INITIAL_DISCOVER_DELAY_MS = envMs('FCD_DAEMON_INITIAL_DISCOVER_MS', 2 * 60_000);
|
|
124
150
|
/** A discovery upload older than this is stale — the daemon catches up itself. */
|
|
@@ -505,6 +531,7 @@ async function runDaemon(args, config) {
|
|
|
505
531
|
let quietUntil = 0; // ignore events until this time (self-writes)
|
|
506
532
|
let debounceTimer = null;
|
|
507
533
|
let reprotecting = false;
|
|
534
|
+
let lastIntegrityHealAt = 0; // cooldown anchor for integrity-triggered repairs
|
|
508
535
|
let suspended = false;
|
|
509
536
|
let stopped = false;
|
|
510
537
|
const executingActionIds = new Set();
|
|
@@ -582,16 +609,23 @@ async function runDaemon(args, config) {
|
|
|
582
609
|
}
|
|
583
610
|
catch { /* next tick */ }
|
|
584
611
|
};
|
|
585
|
-
|
|
612
|
+
/**
|
|
613
|
+
* One repair path, two triggers: a watched config CHANGED (drift), or the
|
|
614
|
+
* integrity check reported a reason protect-all can fix (integrity). Keeping
|
|
615
|
+
* them on the same function preserves the reprotecting/quiet-window guards —
|
|
616
|
+
* a second copy would race this one and re-enter protect-all concurrently.
|
|
617
|
+
*/
|
|
618
|
+
const reprotect = async (reasonPaths, cause = 'drift') => {
|
|
619
|
+
const headline = cause === 'integrity' ? 'Integrity self-heal' : 'Config drift detected';
|
|
586
620
|
if (reprotecting || stopped)
|
|
587
621
|
return;
|
|
588
622
|
if (suspended) {
|
|
589
|
-
log(
|
|
623
|
+
log(`${headline} (${reasonPaths.join(', ')}) but machine is suspended — not re-protecting.`);
|
|
590
624
|
return;
|
|
591
625
|
}
|
|
592
626
|
reprotecting = true;
|
|
593
627
|
quietUntil = Date.now() + SELF_WRITE_QUIET_MS;
|
|
594
|
-
log(
|
|
628
|
+
log(`${headline}: ${reasonPaths.join(', ')} — re-running protect-all.`);
|
|
595
629
|
try {
|
|
596
630
|
const repaired = await (0, protectionRepair_1.repairProtection)({ ...args, dryRun: undefined }, config);
|
|
597
631
|
quietUntil = Date.now() + SELF_WRITE_QUIET_MS;
|
|
@@ -601,7 +635,9 @@ async function runDaemon(args, config) {
|
|
|
601
635
|
if (!quiet) {
|
|
602
636
|
(0, notify_1.notifyOs)({
|
|
603
637
|
title: 'FullCourtDefense re-protected this machine',
|
|
604
|
-
message:
|
|
638
|
+
message: cause === 'integrity'
|
|
639
|
+
? 'Protection was reported incomplete; the FullCourtDefense gateway was re-applied.'
|
|
640
|
+
: 'An MCP or hook config changed; the FullCourtDefense gateway was re-applied.',
|
|
605
641
|
url: (0, notify_1.consoleUrl)('/agent-security/users?view=desktop'),
|
|
606
642
|
});
|
|
607
643
|
}
|
|
@@ -990,6 +1026,7 @@ async function runDaemon(args, config) {
|
|
|
990
1026
|
machineId: identity.machineId,
|
|
991
1027
|
force: true,
|
|
992
1028
|
ttlMs: 0,
|
|
1029
|
+
timeoutMs: BUNDLE_FETCH_TIMEOUT_MS,
|
|
993
1030
|
});
|
|
994
1031
|
log(bundle.policyHash
|
|
995
1032
|
? `Policy refresh: bundle applied (hash ${bundle.policyHash.slice(0, 12)}…, ${bundle.policyCount ?? 0} policies).`
|
|
@@ -1155,6 +1192,7 @@ async function runDaemon(args, config) {
|
|
|
1155
1192
|
machineName: identity.hostname,
|
|
1156
1193
|
machineId: identity.machineId,
|
|
1157
1194
|
force: true,
|
|
1195
|
+
timeoutMs: BUNDLE_FETCH_TIMEOUT_MS,
|
|
1158
1196
|
});
|
|
1159
1197
|
if (typeof bundle.pollIntervalMs === 'number' && Number.isFinite(bundle.pollIntervalMs)) {
|
|
1160
1198
|
bundlePollBaseMs = Math.min(Math.max(bundle.pollIntervalMs, 15_000), 60 * 60_000);
|
|
@@ -1261,7 +1299,24 @@ async function runDaemon(args, config) {
|
|
|
1261
1299
|
if (!creds.shieldId)
|
|
1262
1300
|
return;
|
|
1263
1301
|
try {
|
|
1264
|
-
|
|
1302
|
+
let integrity = (0, integrity_1.getLocalIntegrityReport)({ requireDaemon: true });
|
|
1303
|
+
// A failing verdict used to be REPORTED and nothing more, so a machine
|
|
1304
|
+
// could sit "Damaged" in the console indefinitely on a reason the daemon
|
|
1305
|
+
// already knows how to fix — the drift watcher only fires when a config
|
|
1306
|
+
// file changes, and a stale gateway path changes nothing. Heal first, then
|
|
1307
|
+
// report what is true AFTER the repair rather than the verdict that
|
|
1308
|
+
// triggered it.
|
|
1309
|
+
if (!integrity.ok
|
|
1310
|
+
&& !suspended
|
|
1311
|
+
&& Date.now() - lastIntegrityHealAt >= INTEGRITY_HEAL_COOLDOWN_MS
|
|
1312
|
+
&& (0, integrity_1.hasHealableIntegrityReason)(integrity.reasons)) {
|
|
1313
|
+
lastIntegrityHealAt = Date.now();
|
|
1314
|
+
await reprotect(integrity.reasons, 'integrity');
|
|
1315
|
+
integrity = (0, integrity_1.getLocalIntegrityReport)({ requireDaemon: true });
|
|
1316
|
+
log(integrity.ok
|
|
1317
|
+
? 'Integrity self-heal: protection restored.'
|
|
1318
|
+
: `Integrity self-heal: still failing (${integrity.reasons.join(', ')}) — reported for support.`);
|
|
1319
|
+
}
|
|
1265
1320
|
// Report a previous daemon's unclean death exactly once.
|
|
1266
1321
|
const crash = (0, daemonForensics_1.readPostmortem)();
|
|
1267
1322
|
const unreportedCrash = crash && !crash.reportedAt ? crash : undefined;
|
|
@@ -1471,6 +1526,27 @@ async function runDaemon(args, config) {
|
|
|
1471
1526
|
},
|
|
1472
1527
|
onError: error => log(`Heartbeat error: ${error instanceof Error ? error.message : String(error)}`),
|
|
1473
1528
|
});
|
|
1529
|
+
// ── Wake-from-sleep recovery ──────────────────────────────────────────────
|
|
1530
|
+
// Nothing in the agent reacts to a resume, so after a laptop wakes the
|
|
1531
|
+
// machine kept reporting "daemon down" until the next SCHEDULED heartbeat —
|
|
1532
|
+
// a full cadence later. Worse, the liveness marker stayed stale for that
|
|
1533
|
+
// whole window, which is what the watchdog reads before deciding the daemon
|
|
1534
|
+
// is wedged. Refreshing the marker the moment we notice the wall-clock jump
|
|
1535
|
+
// closes the reporting gap and removes the evidence of a hang that never
|
|
1536
|
+
// happened. Both calls go through runWithDeadline, so a resume that lands on
|
|
1537
|
+
// sockets which died during sleep cannot leak an unhandled rejection.
|
|
1538
|
+
let lastResumeBeatMs = Date.now();
|
|
1539
|
+
const resumeTimer = setInterval(() => {
|
|
1540
|
+
const now = Date.now();
|
|
1541
|
+
const drift = now - lastResumeBeatMs;
|
|
1542
|
+
lastResumeBeatMs = now;
|
|
1543
|
+
if (drift < RESUME_JUMP_MS)
|
|
1544
|
+
return;
|
|
1545
|
+
log(`Resume detected: ${Math.round(drift / 1000)}s of wall time elapsed while suspended — refreshing liveness and reporting in now.`);
|
|
1546
|
+
(0, daemonForensics_1.touchDaemonAlive)();
|
|
1547
|
+
void (0, pollLoop_1.runWithDeadline)({ run: heartbeat, deadlineMs: HEARTBEAT_DEADLINE_MS });
|
|
1548
|
+
void (0, pollLoop_1.runWithDeadline)({ run: pollBundle, deadlineMs: BUNDLE_POLL_DEADLINE_MS });
|
|
1549
|
+
}, RESUME_BEAT_MS);
|
|
1474
1550
|
// PowerShell transcript retention (Windows): the Transcription policy FCD
|
|
1475
1551
|
// enables writes a file per session forever — prune anything older than the
|
|
1476
1552
|
// retention window once a day (plus once shortly after boot, so laptops
|
|
@@ -1496,6 +1572,7 @@ async function runDaemon(args, config) {
|
|
|
1496
1572
|
clearInterval(rescanTimer);
|
|
1497
1573
|
bundleLoop.stop();
|
|
1498
1574
|
heartbeatLoop.stop();
|
|
1575
|
+
clearInterval(resumeTimer);
|
|
1499
1576
|
clearTimeout(transcriptPruneBootTimer);
|
|
1500
1577
|
clearInterval(transcriptPruneTimer);
|
|
1501
1578
|
clearTimeout(discoverCatchUpBootTimer);
|
package/dist/commands/doctor.js
CHANGED
|
@@ -111,7 +111,10 @@ async function perfCheck(apiUrl, config) {
|
|
|
111
111
|
console.log(`node spawn floor: ${hp.nodeSpawnFloorMs}ms median — OS+AV cost of any per-event process`);
|
|
112
112
|
console.log(`hook end-to-end: ${hp.hookMedianMs}ms median, ${hp.hookMinMs}-${hp.hookMaxMs}ms range — per IDE event (shell/MCP/file)`);
|
|
113
113
|
if (hp.cmdGuardOverheadMs !== undefined) {
|
|
114
|
-
console.log(`cmd guard overhead: +${hp.cmdGuardOverheadMs}ms median — per
|
|
114
|
+
console.log(`cmd guard overhead: +${hp.cmdGuardOverheadMs}ms median — per cmd.exe START (once per terminal window; every time for "cmd /c" callers such as build scripts and CI)`);
|
|
115
|
+
}
|
|
116
|
+
if (hp.cmdGuardCheckerMs !== undefined) {
|
|
117
|
+
console.log(`cmd guard checker: +${hp.cmdGuardCheckerMs}ms median — additional, only when the typed command is one we intercept`);
|
|
115
118
|
}
|
|
116
119
|
if (snapshot.processes.length > 0) {
|
|
117
120
|
console.log(`resident processes: ${snapshot.processes.length} FullCourtDefense process(es), ${snapshot.totalWorkingSetMB}MB working set total`);
|
|
@@ -177,6 +177,7 @@ function collectWindowsChecks() {
|
|
|
177
177
|
'.fullcourtdefense-shell-guard.ps1',
|
|
178
178
|
'.fullcourtdefense-cmd-guard.js',
|
|
179
179
|
'.fullcourtdefense-cmd-autorun.bat',
|
|
180
|
+
'.fullcourtdefense-cmd-macros.txt',
|
|
180
181
|
];
|
|
181
182
|
for (const name of profileFiles) {
|
|
182
183
|
const file = path.join(home, name);
|
|
@@ -66,6 +66,16 @@ async function watchdogCommand(args, config) {
|
|
|
66
66
|
const state = (0, daemon_1.daemonRuntimeState)();
|
|
67
67
|
let relaunched = false;
|
|
68
68
|
let postmortem = (0, daemonForensics_1.readPostmortem)();
|
|
69
|
+
// Our own tick cadence is the only awake clock available (see
|
|
70
|
+
// detectMachineSuspension): a gap far larger than the interval proves the
|
|
71
|
+
// MACHINE was suspended, which fully explains a stale daemon marker. Read
|
|
72
|
+
// the previous tick BEFORE recording this one, and record unconditionally so
|
|
73
|
+
// an early return below still leaves the next tick a baseline.
|
|
74
|
+
const machineSuspended = (0, daemonForensics_1.detectMachineSuspension)({
|
|
75
|
+
previousTickAt: (0, daemonForensics_1.readWatchdogTick)()?.lastTickAt,
|
|
76
|
+
nowMs: Date.now(),
|
|
77
|
+
});
|
|
78
|
+
(0, daemonForensics_1.recordWatchdogTick)();
|
|
69
79
|
// Beyond "the pid exists": a wedged daemon or a recycled pid both pass a
|
|
70
80
|
// plain pid check while the machine has no working control plane. Classify
|
|
71
81
|
// with the alive-marker freshness + a verdict-pipe probe.
|
|
@@ -77,6 +87,7 @@ async function watchdogCommand(args, config) {
|
|
|
77
87
|
marker: (0, daemonForensics_1.readAliveMarker)(),
|
|
78
88
|
staleAfterMs: (0, daemonForensics_1.daemonHungAfterMs)(),
|
|
79
89
|
pipeProbe: await (0, verdictIpcClient_1.probeVerdictServer)(1_500),
|
|
90
|
+
machineSuspended,
|
|
80
91
|
});
|
|
81
92
|
}
|
|
82
93
|
if (health === 'hung' && state.pid && process.env.FCD_WATCHDOG_NO_RELAUNCH !== '1') {
|
|
@@ -85,6 +96,9 @@ async function watchdogCommand(args, config) {
|
|
|
85
96
|
// clean. The distress ledger survives; the fresh daemon's first heartbeat
|
|
86
97
|
// ships the story to the fleet.
|
|
87
98
|
(0, distress_1.reportDistress)('watchdog', distress_1.DISTRESS.DAEMON_HUNG, `daemon pid ${state.pid} unresponsive (alive-marker stale + verdict pipe silent) — recycled by watchdog`);
|
|
99
|
+
// Claim the kill first: otherwise the next daemon boots, finds a marker
|
|
100
|
+
// with no clean-exit stamp, and reports EDR/AV killed it.
|
|
101
|
+
(0, daemonForensics_1.recordWatchdogRecycle)(state.pid);
|
|
88
102
|
(0, daemon_1.forceStopPid)(state.pid);
|
|
89
103
|
}
|
|
90
104
|
// NOTE for 'pid_reused': the pid belongs to an UNRELATED process — never
|
|
@@ -98,7 +98,81 @@ export declare function classifyDaemonHealth(input: {
|
|
|
98
98
|
marker: DaemonAliveMarker | undefined;
|
|
99
99
|
staleAfterMs: number;
|
|
100
100
|
pipeProbe: 'responsive' | 'unresponsive' | 'no-endpoint' | 'not-probed';
|
|
101
|
+
/**
|
|
102
|
+
* True when the machine itself stopped running since the previous check
|
|
103
|
+
* (sleep/hibernate). Marker staleness is then not evidence of anything —
|
|
104
|
+
* see detectMachineSuspension.
|
|
105
|
+
*/
|
|
106
|
+
machineSuspended?: boolean;
|
|
101
107
|
nowMs?: number;
|
|
102
108
|
}): DaemonHealthClass;
|
|
109
|
+
/**
|
|
110
|
+
* WHY THIS EXISTS (a real developer-machine incident, 2026-08)
|
|
111
|
+
* Staleness above is a WALL-CLOCK measurement, but the daemon can only refresh
|
|
112
|
+
* its marker while the CPU is actually running. A laptop that sleeps longer
|
|
113
|
+
* than `staleAfterMs` therefore produces a stale marker through no fault of
|
|
114
|
+
* the daemon — and the watchdog read that as "wedged" and killed a perfectly
|
|
115
|
+
* healthy process on every lid-open. One developer machine was recycled 8
|
|
116
|
+
* times in 7 days, each kill also writing a post-mortem blaming EDR/AV for
|
|
117
|
+
* what was in fact our own watchdog.
|
|
118
|
+
*
|
|
119
|
+
* There is no portable awake clock to compare against. On Windows
|
|
120
|
+
* `os.uptime()` still INCLUDES sleep (measured on the incident machine: 26.66h
|
|
121
|
+
* of "uptime" spanning a 13h sleep), and reading the kernel power log would
|
|
122
|
+
* mean spawning PowerShell every 5 minutes — precisely the pattern that gets
|
|
123
|
+
* this agent killed by EDR, which the watchdog is documented to avoid.
|
|
124
|
+
*
|
|
125
|
+
* What we do have is our OWN cadence. A fixed-interval scheduled task that
|
|
126
|
+
* observes far more wall time between two of its own ticks than its interval
|
|
127
|
+
* allows has proven the machine was not running in between. That gap explains
|
|
128
|
+
* the daemon's stale marker, so staleness stops counting as evidence.
|
|
129
|
+
*/
|
|
130
|
+
/** The watchdog scheduled task's interval — its ticks are the awake clock. */
|
|
131
|
+
export declare const WATCHDOG_CADENCE_MS: number;
|
|
132
|
+
/** Gaps beyond cadence x this factor cannot be explained by jitter or a slow tick. */
|
|
133
|
+
export declare const SUSPEND_GAP_FACTOR = 3;
|
|
134
|
+
/** The watchdog's own proof that IT was running, persisted between ticks. */
|
|
135
|
+
export interface WatchdogTick {
|
|
136
|
+
lastTickAt: string;
|
|
137
|
+
}
|
|
138
|
+
export declare function watchdogTickFile(): string;
|
|
139
|
+
export declare function readWatchdogTick(): WatchdogTick | undefined;
|
|
140
|
+
export declare function recordWatchdogTick(now?: Date): void;
|
|
141
|
+
/**
|
|
142
|
+
* Pure: did the machine stop running between the previous watchdog tick and
|
|
143
|
+
* this one?
|
|
144
|
+
*
|
|
145
|
+
* A MISSING baseline answers FALSE (i.e. "not suspended", the pre-existing
|
|
146
|
+
* behavior) rather than granting grace. That looks backwards next to "never
|
|
147
|
+
* kill on a guess", but it is the safer failure mode overall: the grace
|
|
148
|
+
* depends on a tick file, and if that file can never be persisted (locked
|
|
149
|
+
* directory, full disk, AV quarantine) then every tick would see no baseline,
|
|
150
|
+
* grant grace forever, and silently disable the watchdog's ability to recycle
|
|
151
|
+
* a genuinely wedged daemon. Answering false means a persistence failure
|
|
152
|
+
* degrades to the known old behavior instead of to no protection at all, and
|
|
153
|
+
* costs at most one stale verdict on the very first tick after upgrade.
|
|
154
|
+
*
|
|
155
|
+
* A baseline that EXISTS but cannot be trusted (unparseable, or the clock
|
|
156
|
+
* moved backwards under us) still answers TRUE: there we have positive
|
|
157
|
+
* evidence that time is not measurable, which is exactly when a staleness
|
|
158
|
+
* comparison must not be believed.
|
|
159
|
+
*/
|
|
160
|
+
export declare function detectMachineSuspension(input: {
|
|
161
|
+
previousTickAt?: string;
|
|
162
|
+
nowMs: number;
|
|
163
|
+
cadenceMs?: number;
|
|
164
|
+
factor?: number;
|
|
165
|
+
}): boolean;
|
|
166
|
+
/**
|
|
167
|
+
* Stamp the marker before the watchdog force-stops a wedged daemon, so the
|
|
168
|
+
* NEXT boot's `detectUncleanDeath` does not report our own recycle as an
|
|
169
|
+
* external kill. The reason is distinct from a real shutdown's signal name,
|
|
170
|
+
* and the recycle stays visible via the `daemon_hung` distress entry and the
|
|
171
|
+
* beacon's `daemonHealth` field — this only suppresses the false EDR/AV story.
|
|
172
|
+
*
|
|
173
|
+
* Keyed on the DAEMON's pid (not `process.pid`): the watchdog is a different
|
|
174
|
+
* process, which is exactly why `recordCleanExit` cannot be reused here.
|
|
175
|
+
*/
|
|
176
|
+
export declare function recordWatchdogRecycle(daemonPid: number): void;
|
|
103
177
|
/** Stamp the post-mortem as delivered so heartbeats stop re-sending it. */
|
|
104
178
|
export declare function markPostmortemReported(): void;
|
package/dist/daemonForensics.js
CHANGED
|
@@ -33,6 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.SUSPEND_GAP_FACTOR = exports.WATCHDOG_CADENCE_MS = void 0;
|
|
36
37
|
exports.aliveMarkerFile = aliveMarkerFile;
|
|
37
38
|
exports.postmortemFile = postmortemFile;
|
|
38
39
|
exports.readAliveMarker = readAliveMarker;
|
|
@@ -45,6 +46,11 @@ exports.touchDaemonAlive = touchDaemonAlive;
|
|
|
45
46
|
exports.recordCleanExit = recordCleanExit;
|
|
46
47
|
exports.daemonHungAfterMs = daemonHungAfterMs;
|
|
47
48
|
exports.classifyDaemonHealth = classifyDaemonHealth;
|
|
49
|
+
exports.watchdogTickFile = watchdogTickFile;
|
|
50
|
+
exports.readWatchdogTick = readWatchdogTick;
|
|
51
|
+
exports.recordWatchdogTick = recordWatchdogTick;
|
|
52
|
+
exports.detectMachineSuspension = detectMachineSuspension;
|
|
53
|
+
exports.recordWatchdogRecycle = recordWatchdogRecycle;
|
|
48
54
|
exports.markPostmortemReported = markPostmortemReported;
|
|
49
55
|
const fs = __importStar(require("fs"));
|
|
50
56
|
const os = __importStar(require("os"));
|
|
@@ -199,12 +205,109 @@ function classifyDaemonHealth(input) {
|
|
|
199
205
|
return 'healthy';
|
|
200
206
|
if (input.pipeProbe === 'responsive')
|
|
201
207
|
return 'healthy';
|
|
208
|
+
// The marker is stale, but the daemon had no CPU with which to refresh it.
|
|
209
|
+
// Judging it now would convict a healthy daemon of the machine's sleep, so
|
|
210
|
+
// defer: the next tick runs after a full awake interval and decides fairly.
|
|
211
|
+
if (input.machineSuspended)
|
|
212
|
+
return 'indeterminate';
|
|
202
213
|
if (input.pipeProbe === 'unresponsive')
|
|
203
214
|
return 'hung';
|
|
204
215
|
if (input.pipeProbe === 'no-endpoint')
|
|
205
216
|
return 'pid_reused';
|
|
206
217
|
return 'indeterminate';
|
|
207
218
|
}
|
|
219
|
+
// ---------------------------------------------------------------------------
|
|
220
|
+
// Suspend awareness
|
|
221
|
+
// ---------------------------------------------------------------------------
|
|
222
|
+
/**
|
|
223
|
+
* WHY THIS EXISTS (a real developer-machine incident, 2026-08)
|
|
224
|
+
* Staleness above is a WALL-CLOCK measurement, but the daemon can only refresh
|
|
225
|
+
* its marker while the CPU is actually running. A laptop that sleeps longer
|
|
226
|
+
* than `staleAfterMs` therefore produces a stale marker through no fault of
|
|
227
|
+
* the daemon — and the watchdog read that as "wedged" and killed a perfectly
|
|
228
|
+
* healthy process on every lid-open. One developer machine was recycled 8
|
|
229
|
+
* times in 7 days, each kill also writing a post-mortem blaming EDR/AV for
|
|
230
|
+
* what was in fact our own watchdog.
|
|
231
|
+
*
|
|
232
|
+
* There is no portable awake clock to compare against. On Windows
|
|
233
|
+
* `os.uptime()` still INCLUDES sleep (measured on the incident machine: 26.66h
|
|
234
|
+
* of "uptime" spanning a 13h sleep), and reading the kernel power log would
|
|
235
|
+
* mean spawning PowerShell every 5 minutes — precisely the pattern that gets
|
|
236
|
+
* this agent killed by EDR, which the watchdog is documented to avoid.
|
|
237
|
+
*
|
|
238
|
+
* What we do have is our OWN cadence. A fixed-interval scheduled task that
|
|
239
|
+
* observes far more wall time between two of its own ticks than its interval
|
|
240
|
+
* allows has proven the machine was not running in between. That gap explains
|
|
241
|
+
* the daemon's stale marker, so staleness stops counting as evidence.
|
|
242
|
+
*/
|
|
243
|
+
/** The watchdog scheduled task's interval — its ticks are the awake clock. */
|
|
244
|
+
exports.WATCHDOG_CADENCE_MS = 5 * 60_000;
|
|
245
|
+
/** Gaps beyond cadence x this factor cannot be explained by jitter or a slow tick. */
|
|
246
|
+
exports.SUSPEND_GAP_FACTOR = 3;
|
|
247
|
+
function watchdogTickFile() {
|
|
248
|
+
return path.join(stateDir(), 'watchdog.tick.json');
|
|
249
|
+
}
|
|
250
|
+
function readWatchdogTick() {
|
|
251
|
+
try {
|
|
252
|
+
const tick = JSON.parse(fs.readFileSync(watchdogTickFile(), 'utf8'));
|
|
253
|
+
return tick && typeof tick.lastTickAt === 'string' ? tick : undefined;
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
return undefined;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
function recordWatchdogTick(now = new Date()) {
|
|
260
|
+
writeJson(watchdogTickFile(), { lastTickAt: now.toISOString() });
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Pure: did the machine stop running between the previous watchdog tick and
|
|
264
|
+
* this one?
|
|
265
|
+
*
|
|
266
|
+
* A MISSING baseline answers FALSE (i.e. "not suspended", the pre-existing
|
|
267
|
+
* behavior) rather than granting grace. That looks backwards next to "never
|
|
268
|
+
* kill on a guess", but it is the safer failure mode overall: the grace
|
|
269
|
+
* depends on a tick file, and if that file can never be persisted (locked
|
|
270
|
+
* directory, full disk, AV quarantine) then every tick would see no baseline,
|
|
271
|
+
* grant grace forever, and silently disable the watchdog's ability to recycle
|
|
272
|
+
* a genuinely wedged daemon. Answering false means a persistence failure
|
|
273
|
+
* degrades to the known old behavior instead of to no protection at all, and
|
|
274
|
+
* costs at most one stale verdict on the very first tick after upgrade.
|
|
275
|
+
*
|
|
276
|
+
* A baseline that EXISTS but cannot be trusted (unparseable, or the clock
|
|
277
|
+
* moved backwards under us) still answers TRUE: there we have positive
|
|
278
|
+
* evidence that time is not measurable, which is exactly when a staleness
|
|
279
|
+
* comparison must not be believed.
|
|
280
|
+
*/
|
|
281
|
+
function detectMachineSuspension(input) {
|
|
282
|
+
const cadence = input.cadenceMs && input.cadenceMs > 0 ? input.cadenceMs : exports.WATCHDOG_CADENCE_MS;
|
|
283
|
+
const factor = input.factor && input.factor > 0 ? input.factor : exports.SUSPEND_GAP_FACTOR;
|
|
284
|
+
if (!input.previousTickAt)
|
|
285
|
+
return false;
|
|
286
|
+
const previous = Date.parse(input.previousTickAt);
|
|
287
|
+
if (!Number.isFinite(previous))
|
|
288
|
+
return true;
|
|
289
|
+
const gap = input.nowMs - previous;
|
|
290
|
+
if (gap < 0)
|
|
291
|
+
return true;
|
|
292
|
+
return gap > cadence * factor;
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Stamp the marker before the watchdog force-stops a wedged daemon, so the
|
|
296
|
+
* NEXT boot's `detectUncleanDeath` does not report our own recycle as an
|
|
297
|
+
* external kill. The reason is distinct from a real shutdown's signal name,
|
|
298
|
+
* and the recycle stays visible via the `daemon_hung` distress entry and the
|
|
299
|
+
* beacon's `daemonHealth` field — this only suppresses the false EDR/AV story.
|
|
300
|
+
*
|
|
301
|
+
* Keyed on the DAEMON's pid (not `process.pid`): the watchdog is a different
|
|
302
|
+
* process, which is exactly why `recordCleanExit` cannot be reused here.
|
|
303
|
+
*/
|
|
304
|
+
function recordWatchdogRecycle(daemonPid) {
|
|
305
|
+
const marker = readAliveMarker();
|
|
306
|
+
if (!marker || marker.pid !== daemonPid)
|
|
307
|
+
return;
|
|
308
|
+
marker.cleanExit = { at: new Date().toISOString(), reason: 'watchdog-recycle' };
|
|
309
|
+
writeJson(aliveMarkerFile(), marker);
|
|
310
|
+
}
|
|
208
311
|
/** Stamp the post-mortem as delivered so heartbeats stop re-sending it. */
|
|
209
312
|
function markPostmortemReported() {
|
|
210
313
|
const pm = readPostmortem();
|
package/dist/integrity.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
export type IntegrityReason = 'mcp_gateway_missing' | 'mcp_gateway_stale_path' | 'cursor_hook_missing' | 'cursor_hook_stale_path' | 'cursor_hook_changed' | 'claude_hook_missing' | 'claude_hook_stale_path' | 'claude_hook_changed' | 'daemon_not_running' | 'runtime_bundle_stale';
|
|
2
|
+
/** Pure: does this verdict contain anything a protect-all pass could fix? */
|
|
3
|
+
export declare function hasHealableIntegrityReason(reasons: readonly string[]): boolean;
|
|
2
4
|
export interface LocalIntegrityReport {
|
|
3
5
|
ok: boolean;
|
|
4
6
|
reasons: IntegrityReason[];
|
package/dist/integrity.js
CHANGED
|
@@ -33,6 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.hasHealableIntegrityReason = hasHealableIntegrityReason;
|
|
36
37
|
exports.isDaemonRunning = isDaemonRunning;
|
|
37
38
|
exports.getLocalIntegrityReport = getLocalIntegrityReport;
|
|
38
39
|
const fs = __importStar(require("fs"));
|
|
@@ -42,6 +43,27 @@ const installClaudeHook_1 = require("./commands/installClaudeHook");
|
|
|
42
43
|
const installCursorHook_1 = require("./commands/installCursorHook");
|
|
43
44
|
const mcpGateway_1 = require("./commands/mcpGateway");
|
|
44
45
|
const appDetection_1 = require("./appDetection");
|
|
46
|
+
/**
|
|
47
|
+
* The reasons `protect-all` can actually repair. Detecting a failure is only
|
|
48
|
+
* half the job: the daemon heals these instead of reporting them forever, which
|
|
49
|
+
* is why the taxonomy lives beside the reason type rather than in the caller.
|
|
50
|
+
*
|
|
51
|
+
* Excluded on purpose:
|
|
52
|
+
* - `daemon_not_running` — the daemon is the thing evaluating this; re-wrapping
|
|
53
|
+
* configs cannot start a process that is already running or already dead.
|
|
54
|
+
* - `runtime_bundle_stale` — a control-plane reachability symptom. Re-wrapping
|
|
55
|
+
* would churn every config on this machine and still not fetch the bundle,
|
|
56
|
+
* hiding a network problem behind protection noise.
|
|
57
|
+
*/
|
|
58
|
+
const HEALABLE_REASONS = new Set([
|
|
59
|
+
'mcp_gateway_missing', 'mcp_gateway_stale_path',
|
|
60
|
+
'cursor_hook_missing', 'cursor_hook_stale_path', 'cursor_hook_changed',
|
|
61
|
+
'claude_hook_missing', 'claude_hook_stale_path', 'claude_hook_changed',
|
|
62
|
+
]);
|
|
63
|
+
/** Pure: does this verdict contain anything a protect-all pass could fix? */
|
|
64
|
+
function hasHealableIntegrityReason(reasons) {
|
|
65
|
+
return reasons.some(reason => HEALABLE_REASONS.has(reason));
|
|
66
|
+
}
|
|
45
67
|
function readText(file) {
|
|
46
68
|
try {
|
|
47
69
|
return fs.readFileSync(file, 'utf8');
|
package/dist/perfSnapshot.d.ts
CHANGED
|
@@ -40,8 +40,19 @@ export interface PerfHotPathBench {
|
|
|
40
40
|
hookMinMs: number;
|
|
41
41
|
hookMaxMs: number;
|
|
42
42
|
hookRuns: number;
|
|
43
|
-
/**
|
|
43
|
+
/**
|
|
44
|
+
* Cost the cmd guard adds to STARTING a cmd.exe session, measured as a
|
|
45
|
+
* guarded `cmd /c` against the same command with AutoRun suppressed (`/d`).
|
|
46
|
+
*
|
|
47
|
+
* This is what a developer actually pays: AutoRun runs on every cmd.exe
|
|
48
|
+
* launch and registers the doskey macros, whether or not the command being
|
|
49
|
+
* run is one of the intercepted ones. An interactive terminal pays it once
|
|
50
|
+
* when the window opens; anything invoking `cmd /c` per command (build
|
|
51
|
+
* scripts, CI, agents) pays it every time.
|
|
52
|
+
*/
|
|
44
53
|
cmdGuardOverheadMs?: number;
|
|
54
|
+
/** Extra cost when the typed command IS doskey-intercepted and goes through the checker. */
|
|
55
|
+
cmdGuardCheckerMs?: number;
|
|
45
56
|
}
|
|
46
57
|
export interface PerfDiskState {
|
|
47
58
|
hookLogKB?: number;
|
package/dist/perfSnapshot.js
CHANGED
|
@@ -112,15 +112,35 @@ async function benchHotPath(entryJs) {
|
|
|
112
112
|
hookTimes.push((await runTimed(process.execPath, [entry, 'hook', '--event', 'shell', '--fcd-managed', 'true'], { input: hookEvent, timeoutMs: 60_000 })).elapsedMs);
|
|
113
113
|
}
|
|
114
114
|
let cmdGuardOverheadMs;
|
|
115
|
+
let cmdGuardCheckerMs;
|
|
115
116
|
const guardJs = path.join(os.homedir(), '.fullcourtdefense-cmd-guard.js');
|
|
116
117
|
if (process.platform === 'win32' && fs.existsSync(guardJs)) {
|
|
117
|
-
const
|
|
118
|
-
|
|
118
|
+
const comSpec = process.env.ComSpec || 'cmd.exe';
|
|
119
|
+
// What a real command pays: AutoRun fires (registering macros) vs `/d`,
|
|
120
|
+
// which suppresses it. Measuring `node guardJs echo` instead — as this
|
|
121
|
+
// did previously — reports only the checker's cost for an intercepted
|
|
122
|
+
// command and never observes the AutoRun work every cmd.exe start does,
|
|
123
|
+
// which understated the figure developers actually feel by ~5x.
|
|
124
|
+
const withAutoRun = [];
|
|
125
|
+
const withoutAutoRun = [];
|
|
119
126
|
for (let i = 0; i < 4; i++) {
|
|
120
|
-
|
|
121
|
-
|
|
127
|
+
// Alternate order so neither side systematically absorbs warm-up cost.
|
|
128
|
+
if (i % 2 === 0) {
|
|
129
|
+
withAutoRun.push((await runTimed(comSpec, ['/c', 'echo', 'fcd-perf'], { timeoutMs: 30_000 })).elapsedMs);
|
|
130
|
+
withoutAutoRun.push((await runTimed(comSpec, ['/d', '/c', 'echo', 'fcd-perf'], { timeoutMs: 30_000 })).elapsedMs);
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
withoutAutoRun.push((await runTimed(comSpec, ['/d', '/c', 'echo', 'fcd-perf'], { timeoutMs: 30_000 })).elapsedMs);
|
|
134
|
+
withAutoRun.push((await runTimed(comSpec, ['/c', 'echo', 'fcd-perf'], { timeoutMs: 30_000 })).elapsedMs);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
cmdGuardOverheadMs = Math.max(0, median(withAutoRun) - median(withoutAutoRun));
|
|
138
|
+
// Kept as a separate number: the checker cost for an intercepted command.
|
|
139
|
+
const checker = [];
|
|
140
|
+
for (let i = 0; i < 4; i++) {
|
|
141
|
+
checker.push((await runTimed(process.execPath, [guardJs, 'echo', 'fcd-perf'], { timeoutMs: 30_000 })).elapsedMs);
|
|
122
142
|
}
|
|
123
|
-
|
|
143
|
+
cmdGuardCheckerMs = Math.max(0, median(checker) - median(withoutAutoRun));
|
|
124
144
|
}
|
|
125
145
|
return {
|
|
126
146
|
nodeSpawnFloorMs: median(spawnTimes),
|
|
@@ -129,6 +149,7 @@ async function benchHotPath(entryJs) {
|
|
|
129
149
|
hookMaxMs: Math.max(...hookTimes),
|
|
130
150
|
hookRuns: hookTimes.length,
|
|
131
151
|
cmdGuardOverheadMs,
|
|
152
|
+
cmdGuardCheckerMs,
|
|
132
153
|
};
|
|
133
154
|
}
|
|
134
155
|
/**
|
|
@@ -246,7 +267,7 @@ async function collectPerfSnapshot(input = {}) {
|
|
|
246
267
|
}
|
|
247
268
|
/** One-line human summary for machine-action result reporting. */
|
|
248
269
|
function summarizePerfSnapshot(s) {
|
|
249
|
-
const guard = s.hotPath.cmdGuardOverheadMs !== undefined ? `, cmd guard +${s.hotPath.cmdGuardOverheadMs}ms` : '';
|
|
270
|
+
const guard = s.hotPath.cmdGuardOverheadMs !== undefined ? `, cmd guard +${s.hotPath.cmdGuardOverheadMs}ms/cmd.exe start` : '';
|
|
250
271
|
return `Hook ${s.hotPath.hookMedianMs}ms median (spawn floor ${s.hotPath.nodeSpawnFloorMs}ms${guard}); `
|
|
251
272
|
+ `${s.processes.length} FCD process(es) using ${s.totalWorkingSetMB}MB; `
|
|
252
273
|
+ `spool backlog ${s.disk.spoolBacklogCount ?? 0}, hook.log ${s.disk.hookLogKB ?? 0}KB${s.enforcement.mode ? `; mode ${s.enforcement.mode}` : ''}.`;
|
package/dist/version.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fullcourtdefense-cli",
|
|
3
|
-
"version": "1.26.
|
|
3
|
+
"version": "1.26.14",
|
|
4
4
|
"description": "Full Court Defense CLI — security scanning for AI agents from your terminal",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
"test:audit-restore": "node scripts/test-audit-restore.js",
|
|
31
31
|
"test:shell-guard": "npm run build && node scripts/test-shell-guard.js",
|
|
32
32
|
"test:cmd-guard": "npm run build && node scripts/test-cmd-guard.js",
|
|
33
|
+
"test:cmd-guard-perf": "npm run build && node scripts/test-cmd-guard-perf.js",
|
|
33
34
|
"test:posix-guard": "npm run build && node scripts/test-posix-guard.js",
|
|
34
35
|
"test:secret-posture": "npm run build && node scripts/test-secret-posture-fixtures.js",
|
|
35
36
|
"test:agent-file-posture": "npm run build && node scripts/test-agent-file-posture.js",
|