fullcourtdefense-cli 1.26.2 → 1.26.4
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.js +40 -31
- package/dist/commands/hook.js +14 -1
- package/dist/commands/mcpGateway.js +10 -0
- package/dist/distress.d.ts +6 -0
- package/dist/distress.js +6 -0
- package/dist/pollLoop.d.ts +72 -0
- package/dist/pollLoop.js +116 -0
- package/dist/telemetry.d.ts +37 -0
- package/dist/telemetry.js +47 -0
- package/dist/verdictIpc.d.ts +9 -1
- package/dist/verdictIpc.js +11 -6
- package/dist/version.json +1 -1
- package/package.json +2 -1
package/dist/commands/daemon.js
CHANGED
|
@@ -59,6 +59,7 @@ const path = __importStar(require("path"));
|
|
|
59
59
|
const child_process_1 = require("child_process");
|
|
60
60
|
const config_1 = require("../config");
|
|
61
61
|
const distress_1 = require("../distress");
|
|
62
|
+
const pollLoop_1 = require("../pollLoop");
|
|
62
63
|
const selfTest_1 = require("../selfTest");
|
|
63
64
|
const daemonForensics_1 = require("../daemonForensics");
|
|
64
65
|
const securityAgents_1 = require("../securityAgents");
|
|
@@ -107,6 +108,14 @@ const RESCAN_INTERVAL_MS = envMs('FCD_DAEMON_RESCAN_MS', 5 * 60_000);
|
|
|
107
108
|
const HEARTBEAT_INTERVAL_MS = envMs('FCD_DAEMON_HEARTBEAT_MS', 5 * 60_000);
|
|
108
109
|
/** Bundle (mode / suspension / policy version) poll cadence. */
|
|
109
110
|
const BUNDLE_POLL_MS = envMs('FCD_DAEMON_BUNDLE_POLL_MS', 60_000);
|
|
111
|
+
/**
|
|
112
|
+
* Hard ceilings for one poll attempt (see startPollLoop). Generous multiples of
|
|
113
|
+
* the work each poll actually does — a bundle poll is a 1.5s fetch plus an 8s
|
|
114
|
+
* snapshot refresh, a heartbeat is a spool flush plus a log upload — so these
|
|
115
|
+
* only ever fire on a genuinely hung socket, never on a merely slow network.
|
|
116
|
+
*/
|
|
117
|
+
const BUNDLE_POLL_DEADLINE_MS = envMs('FCD_DAEMON_BUNDLE_DEADLINE_MS', 45_000);
|
|
118
|
+
const HEARTBEAT_DEADLINE_MS = envMs('FCD_DAEMON_HEARTBEAT_DEADLINE_MS', 120_000);
|
|
110
119
|
/** Delay before the one-time initial discovery sweep on a fresh machine. */
|
|
111
120
|
const INITIAL_DISCOVER_DELAY_MS = envMs('FCD_DAEMON_INITIAL_DISCOVER_MS', 2 * 60_000);
|
|
112
121
|
/** A discovery upload older than this is stale — the daemon catches up itself. */
|
|
@@ -1287,33 +1296,35 @@ async function runDaemon(args, config) {
|
|
|
1287
1296
|
// The bundle poll additionally honors the server's pollIntervalMs, and the
|
|
1288
1297
|
// next tick is scheduled only AFTER the previous poll finishes — a slow
|
|
1289
1298
|
// gate can never pile up overlapping polls.
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1299
|
+
//
|
|
1300
|
+
// Both loops run through startPollLoop, which bounds every attempt with a
|
|
1301
|
+
// hard deadline. A poll that never settles (socket dead without a FIN after
|
|
1302
|
+
// sleep / VPN change / proxy drop — `AbortSignal.timeout` does not always
|
|
1303
|
+
// rescue that) used to kill its loop for the life of the process. When that
|
|
1304
|
+
// happened to the bundle poll, remote actions — whose ONLY delivery path is
|
|
1305
|
+
// this poll — silently expired unacknowledged while heartbeats kept the
|
|
1306
|
+
// machine looking healthy. Abandoning the hung attempt restores delivery on
|
|
1307
|
+
// the next tick and leaves a `poll_stalled` signal in the fleet.
|
|
1308
|
+
const bundleLoop = (0, pollLoop_1.startPollLoop)({
|
|
1309
|
+
run: pollBundle,
|
|
1310
|
+
intervalMs: () => bundlePollBaseMs,
|
|
1311
|
+
deadlineMs: BUNDLE_POLL_DEADLINE_MS,
|
|
1312
|
+
onStalled: deadlineMs => {
|
|
1313
|
+
log(`Bundle poll did not finish within ${Math.round(deadlineMs / 1000)}s — abandoned; remote actions and policy updates were delayed. Next poll continues normally.`);
|
|
1314
|
+
(0, distress_1.reportDistress)('bundle', distress_1.DISTRESS.POLL_STALLED, `bundle poll exceeded ${deadlineMs}ms`);
|
|
1315
|
+
},
|
|
1316
|
+
onError: error => log(`Bundle poll error: ${error instanceof Error ? error.message : String(error)}`),
|
|
1317
|
+
});
|
|
1318
|
+
const heartbeatLoop = (0, pollLoop_1.startPollLoop)({
|
|
1319
|
+
run: heartbeat,
|
|
1320
|
+
intervalMs: () => HEARTBEAT_INTERVAL_MS,
|
|
1321
|
+
deadlineMs: HEARTBEAT_DEADLINE_MS,
|
|
1322
|
+
onStalled: deadlineMs => {
|
|
1323
|
+
log(`Heartbeat did not finish within ${Math.round(deadlineMs / 1000)}s — abandoned; spooled events stay on disk for the next tick.`);
|
|
1324
|
+
(0, distress_1.reportDistress)('heartbeat', distress_1.DISTRESS.POLL_STALLED, `heartbeat exceeded ${deadlineMs}ms`);
|
|
1325
|
+
},
|
|
1326
|
+
onError: error => log(`Heartbeat error: ${error instanceof Error ? error.message : String(error)}`),
|
|
1327
|
+
});
|
|
1317
1328
|
// PowerShell transcript retention (Windows): the Transcription policy FCD
|
|
1318
1329
|
// enables writes a file per session forever — prune anything older than the
|
|
1319
1330
|
// retention window once a day (plus once shortly after boot, so laptops
|
|
@@ -1337,10 +1348,8 @@ async function runDaemon(args, config) {
|
|
|
1337
1348
|
stopped = true;
|
|
1338
1349
|
log(`Received ${signal} — shutting down.`);
|
|
1339
1350
|
clearInterval(rescanTimer);
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
if (heartbeatTimer)
|
|
1343
|
-
clearTimeout(heartbeatTimer);
|
|
1351
|
+
bundleLoop.stop();
|
|
1352
|
+
heartbeatLoop.stop();
|
|
1344
1353
|
clearTimeout(transcriptPruneBootTimer);
|
|
1345
1354
|
clearInterval(transcriptPruneTimer);
|
|
1346
1355
|
clearTimeout(discoverCatchUpBootTimer);
|
package/dist/commands/hook.js
CHANGED
|
@@ -684,6 +684,13 @@ async function evaluateHookRequest(args, stdinRaw, config) {
|
|
|
684
684
|
* through respond() inside and never reach this catch.
|
|
685
685
|
*/
|
|
686
686
|
async function hookCommand(args, config) {
|
|
687
|
+
// Start of the developer's wait. Taken once here and carried (in-memory) so
|
|
688
|
+
// every event this hook spools reports real latency, and the daemon can
|
|
689
|
+
// report the FULL wait rather than just its own slice. No probe, no timer.
|
|
690
|
+
const startedAt = Date.now();
|
|
691
|
+
return (0, telemetry_1.markVerdictTiming)({ startedAt, path: 'local' }, () => hookCommandOuter(args, config, startedAt));
|
|
692
|
+
}
|
|
693
|
+
async function hookCommandOuter(args, config, startedAt) {
|
|
687
694
|
try {
|
|
688
695
|
let raw = '';
|
|
689
696
|
let stdinErr = '';
|
|
@@ -700,7 +707,7 @@ async function hookCommand(args, config) {
|
|
|
700
707
|
// the full local evaluation below, so the daemon can never make a hook
|
|
701
708
|
// slower than it was before this feature. FCD_VERDICT_IPC=off opts out.
|
|
702
709
|
if (process.env.FCD_VERDICT_IPC !== 'off') {
|
|
703
|
-
const ipc = await (0, verdictIpc_1.requestDaemonVerdict)(args, raw);
|
|
710
|
+
const ipc = await (0, verdictIpc_1.requestDaemonVerdict)(args, raw, undefined, startedAt);
|
|
704
711
|
if (ipc.outcome === 'verdict') {
|
|
705
712
|
dbg({ phase: 'ipc_verdict', event: args.event, exitCode: ipc.exitCode });
|
|
706
713
|
if (ipc.stderr) {
|
|
@@ -744,6 +751,12 @@ async function hookCommand(args, config) {
|
|
|
744
751
|
*/
|
|
745
752
|
function failOpenOutcome(event, message) {
|
|
746
753
|
const toolName = event || 'unknown';
|
|
754
|
+
// Label the spooled event below as a crash-allow: a rising fail_open rate is
|
|
755
|
+
// the fleet's earliest signal that the guard itself is broken on a machine.
|
|
756
|
+
try {
|
|
757
|
+
(0, telemetry_1.setVerdictPath)('fail_open');
|
|
758
|
+
}
|
|
759
|
+
catch { /* never throw */ }
|
|
747
760
|
let suspended = false;
|
|
748
761
|
try {
|
|
749
762
|
suspended = (0, runtimeConfig_1.isSuspendedByCache)();
|
|
@@ -878,6 +878,13 @@ class McpGatewayServer {
|
|
|
878
878
|
(0, telemetry_1.triggerFlush)(decision === 'block');
|
|
879
879
|
}
|
|
880
880
|
async handleToolCall(params) {
|
|
881
|
+
// Passive perf stamp for every event this call spools: how long the agent
|
|
882
|
+
// waited for OUR decision (path 'gateway'). Pure in-memory context — no
|
|
883
|
+
// probe, no I/O; the clock is restarted after the downstream call so the
|
|
884
|
+
// tool's own runtime is never billed to FullCourtDefense.
|
|
885
|
+
return (0, telemetry_1.markVerdictTiming)({ startedAt: Date.now(), path: 'gateway' }, () => this.handleToolCallInner(params));
|
|
886
|
+
}
|
|
887
|
+
async handleToolCallInner(params) {
|
|
881
888
|
await this.ensureDownstream();
|
|
882
889
|
const toolName = String(params.name || '');
|
|
883
890
|
if (!toolName)
|
|
@@ -1248,6 +1255,9 @@ class McpGatewayServer {
|
|
|
1248
1255
|
let rawResult = await this.downstream.callTool(toolName, toolArgs);
|
|
1249
1256
|
downstreamRan = true;
|
|
1250
1257
|
downstreamResult = rawResult;
|
|
1258
|
+
// Response-side events must measure our scanning overhead, not the
|
|
1259
|
+
// tool's execution time (see restartVerdictTiming).
|
|
1260
|
+
(0, telemetry_1.restartVerdictTiming)();
|
|
1251
1261
|
const responseOutcome = (0, deterministicGuard_1.resolveDeterministicTextResponse)(contentToText(rawResult), (0, localSafetySnapshot_1.snapshotToScanOptions)(snapshot));
|
|
1252
1262
|
for (const warning of responseOutcome.warnings) {
|
|
1253
1263
|
this.spoolLocalFinding(warning, toolName, operation, 'warn');
|
package/dist/distress.d.ts
CHANGED
|
@@ -48,6 +48,12 @@ export declare const DISTRESS: {
|
|
|
48
48
|
readonly MACHINE_KEYFILE_ACL_FAILED: "machine_keyfile_acl_failed";
|
|
49
49
|
/** Fail-closed engaged: hook is BLOCKING user actions because the policy gate is persistently unreachable. */
|
|
50
50
|
readonly HOOK_FAIL_CLOSED: "hook_fail_closed";
|
|
51
|
+
/**
|
|
52
|
+
* A daemon poll never settled and was abandoned at its deadline. Usually a
|
|
53
|
+
* socket that died without a FIN (sleep / VPN / proxy). Self-heals on the
|
|
54
|
+
* next tick — but while it lasted, remote actions were undeliverable.
|
|
55
|
+
*/
|
|
56
|
+
readonly POLL_STALLED: "poll_stalled";
|
|
51
57
|
/** Anything nobody predicted — reported via reportUnexpected(). */
|
|
52
58
|
readonly UNEXPECTED: "unexpected_error";
|
|
53
59
|
};
|
package/dist/distress.js
CHANGED
|
@@ -93,6 +93,12 @@ exports.DISTRESS = {
|
|
|
93
93
|
MACHINE_KEYFILE_ACL_FAILED: 'machine_keyfile_acl_failed',
|
|
94
94
|
/** Fail-closed engaged: hook is BLOCKING user actions because the policy gate is persistently unreachable. */
|
|
95
95
|
HOOK_FAIL_CLOSED: 'hook_fail_closed',
|
|
96
|
+
/**
|
|
97
|
+
* A daemon poll never settled and was abandoned at its deadline. Usually a
|
|
98
|
+
* socket that died without a FIN (sleep / VPN / proxy). Self-heals on the
|
|
99
|
+
* next tick — but while it lasted, remote actions were undeliverable.
|
|
100
|
+
*/
|
|
101
|
+
POLL_STALLED: 'poll_stalled',
|
|
96
102
|
/** Anything nobody predicted — reported via reportUnexpected(). */
|
|
97
103
|
UNEXPECTED: 'unexpected_error',
|
|
98
104
|
};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Self-healing poll scheduler for the resident daemon.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS (a real production incident, 2026-08-22)
|
|
5
|
+
* The daemon's loops schedule the next tick only AFTER the current attempt
|
|
6
|
+
* settles — deliberate, so a slow control plane can never stack overlapping
|
|
7
|
+
* polls on a developer's laptop. The hidden cost: an attempt that NEVER
|
|
8
|
+
* settles kills the loop for the lifetime of the process.
|
|
9
|
+
*
|
|
10
|
+
* That is not theoretical. `fetch` can hang indefinitely despite its
|
|
11
|
+
* `AbortSignal.timeout` when the underlying socket dies without a FIN —
|
|
12
|
+
* laptop sleep, VPN/Wi-Fi change, or a corporate proxy silently dropping an
|
|
13
|
+
* idle connection. On the incident machine the bundle poll hung at 15:59 and
|
|
14
|
+
* never ran again, while heartbeats, hooks, and the watchdog all kept working
|
|
15
|
+
* for three hours. The machine therefore looked perfectly healthy in the
|
|
16
|
+
* console — but the bundle poll is the ONLY delivery path for remote actions,
|
|
17
|
+
* so every admin fix (upgrade, repair protection, collect diagnostics)
|
|
18
|
+
* silently expired unacknowledged. The worst possible failure mode: a machine
|
|
19
|
+
* we cannot reach that reports itself fine.
|
|
20
|
+
*
|
|
21
|
+
* The fix is a hard per-attempt deadline. When it expires we abandon the
|
|
22
|
+
* attempt (logging + distress so the fleet SEES it) and schedule the next
|
|
23
|
+
* tick anyway. The orphaned promise gets a permanent catch so a late
|
|
24
|
+
* rejection can never surface as an unhandled rejection and take the daemon
|
|
25
|
+
* down with it.
|
|
26
|
+
*
|
|
27
|
+
* Pure and injectable (timers + clock) so the "loop survives a poll that
|
|
28
|
+
* never settles" property is unit-tested without waiting on real minutes.
|
|
29
|
+
*/
|
|
30
|
+
export interface PollLoopHandle {
|
|
31
|
+
stop(): void;
|
|
32
|
+
/** Attempts abandoned at the deadline — exposed for tests/diagnostics. */
|
|
33
|
+
stalledCount(): number;
|
|
34
|
+
}
|
|
35
|
+
export interface PollLoopInput {
|
|
36
|
+
/** One attempt. May reject, and may never settle — both are survivable. */
|
|
37
|
+
run: () => Promise<unknown>;
|
|
38
|
+
/**
|
|
39
|
+
* Base cadence, read fresh before every tick so the server can retune a
|
|
40
|
+
* whole fleet (bundle `pollIntervalMs`) without a new CLI.
|
|
41
|
+
*/
|
|
42
|
+
intervalMs: () => number;
|
|
43
|
+
/**
|
|
44
|
+
* Hard ceiling for a single attempt. Keep it below the interval so an
|
|
45
|
+
* abandoned attempt cannot overlap the next one for long.
|
|
46
|
+
*/
|
|
47
|
+
deadlineMs: number;
|
|
48
|
+
/** Called once per abandoned attempt (log + distress at the call site). */
|
|
49
|
+
onStalled?: (deadlineMs: number) => void;
|
|
50
|
+
/** Called when an attempt rejects. Never rethrow from here. */
|
|
51
|
+
onError?: (error: unknown) => void;
|
|
52
|
+
/** Cadence jitter fraction (0..1, default 0.2) — a proxied fleet must not poll in lockstep. */
|
|
53
|
+
jitter?: number;
|
|
54
|
+
/** Injectable timers for tests. */
|
|
55
|
+
timers?: {
|
|
56
|
+
setTimeout: (fn: () => void, ms: number) => unknown;
|
|
57
|
+
clearTimeout: (handle: unknown) => void;
|
|
58
|
+
};
|
|
59
|
+
/** Injectable randomness for deterministic tests. */
|
|
60
|
+
random?: () => number;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Await `run()` but never longer than `deadlineMs`. Resolves 'settled' when the
|
|
64
|
+
* attempt finished (fulfilled or rejected) and 'abandoned' when the deadline
|
|
65
|
+
* won the race.
|
|
66
|
+
*/
|
|
67
|
+
export declare function runWithDeadline(input: Pick<PollLoopInput, 'run' | 'deadlineMs' | 'onError' | 'timers'>): Promise<'settled' | 'abandoned'>;
|
|
68
|
+
/**
|
|
69
|
+
* Start a loop that runs `run()` forever on a jittered cadence and CANNOT be
|
|
70
|
+
* killed by an attempt that hangs or throws.
|
|
71
|
+
*/
|
|
72
|
+
export declare function startPollLoop(input: PollLoopInput): PollLoopHandle;
|
package/dist/pollLoop.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Self-healing poll scheduler for the resident daemon.
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS EXISTS (a real production incident, 2026-08-22)
|
|
6
|
+
* The daemon's loops schedule the next tick only AFTER the current attempt
|
|
7
|
+
* settles — deliberate, so a slow control plane can never stack overlapping
|
|
8
|
+
* polls on a developer's laptop. The hidden cost: an attempt that NEVER
|
|
9
|
+
* settles kills the loop for the lifetime of the process.
|
|
10
|
+
*
|
|
11
|
+
* That is not theoretical. `fetch` can hang indefinitely despite its
|
|
12
|
+
* `AbortSignal.timeout` when the underlying socket dies without a FIN —
|
|
13
|
+
* laptop sleep, VPN/Wi-Fi change, or a corporate proxy silently dropping an
|
|
14
|
+
* idle connection. On the incident machine the bundle poll hung at 15:59 and
|
|
15
|
+
* never ran again, while heartbeats, hooks, and the watchdog all kept working
|
|
16
|
+
* for three hours. The machine therefore looked perfectly healthy in the
|
|
17
|
+
* console — but the bundle poll is the ONLY delivery path for remote actions,
|
|
18
|
+
* so every admin fix (upgrade, repair protection, collect diagnostics)
|
|
19
|
+
* silently expired unacknowledged. The worst possible failure mode: a machine
|
|
20
|
+
* we cannot reach that reports itself fine.
|
|
21
|
+
*
|
|
22
|
+
* The fix is a hard per-attempt deadline. When it expires we abandon the
|
|
23
|
+
* attempt (logging + distress so the fleet SEES it) and schedule the next
|
|
24
|
+
* tick anyway. The orphaned promise gets a permanent catch so a late
|
|
25
|
+
* rejection can never surface as an unhandled rejection and take the daemon
|
|
26
|
+
* down with it.
|
|
27
|
+
*
|
|
28
|
+
* Pure and injectable (timers + clock) so the "loop survives a poll that
|
|
29
|
+
* never settles" property is unit-tested without waiting on real minutes.
|
|
30
|
+
*/
|
|
31
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
32
|
+
exports.runWithDeadline = runWithDeadline;
|
|
33
|
+
exports.startPollLoop = startPollLoop;
|
|
34
|
+
const MIN_DELAY_MS = 5_000;
|
|
35
|
+
/**
|
|
36
|
+
* Await `run()` but never longer than `deadlineMs`. Resolves 'settled' when the
|
|
37
|
+
* attempt finished (fulfilled or rejected) and 'abandoned' when the deadline
|
|
38
|
+
* won the race.
|
|
39
|
+
*/
|
|
40
|
+
async function runWithDeadline(input) {
|
|
41
|
+
const timers = input.timers || { setTimeout: (fn, ms) => setTimeout(fn, ms), clearTimeout: handle => clearTimeout(handle) };
|
|
42
|
+
// The catch is attached NOW and stays attached: if this attempt is abandoned
|
|
43
|
+
// and rejects minutes later, the rejection is already handled.
|
|
44
|
+
let attemptSettled = false;
|
|
45
|
+
const attempt = (async () => {
|
|
46
|
+
try {
|
|
47
|
+
await input.run();
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
try {
|
|
51
|
+
input.onError?.(error);
|
|
52
|
+
}
|
|
53
|
+
catch { /* a reporter must never break the loop */ }
|
|
54
|
+
}
|
|
55
|
+
finally {
|
|
56
|
+
attemptSettled = true;
|
|
57
|
+
}
|
|
58
|
+
})();
|
|
59
|
+
let deadlineHandle;
|
|
60
|
+
const deadline = new Promise(resolve => {
|
|
61
|
+
deadlineHandle = timers.setTimeout(() => resolve(), input.deadlineMs);
|
|
62
|
+
});
|
|
63
|
+
await Promise.race([attempt, deadline]);
|
|
64
|
+
timers.clearTimeout(deadlineHandle);
|
|
65
|
+
return attemptSettled ? 'settled' : 'abandoned';
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Start a loop that runs `run()` forever on a jittered cadence and CANNOT be
|
|
69
|
+
* killed by an attempt that hangs or throws.
|
|
70
|
+
*/
|
|
71
|
+
function startPollLoop(input) {
|
|
72
|
+
const timers = input.timers || { setTimeout: (fn, ms) => setTimeout(fn, ms), clearTimeout: handle => clearTimeout(handle) };
|
|
73
|
+
const random = input.random || Math.random;
|
|
74
|
+
const jitter = Math.min(Math.max(input.jitter ?? 0.2, 0), 0.9);
|
|
75
|
+
let stopped = false;
|
|
76
|
+
let stalled = 0;
|
|
77
|
+
let tickHandle;
|
|
78
|
+
const nextDelay = () => {
|
|
79
|
+
const base = input.intervalMs();
|
|
80
|
+
const safeBase = Number.isFinite(base) && base > 0 ? base : MIN_DELAY_MS;
|
|
81
|
+
const factor = 1 - jitter + random() * jitter * 2;
|
|
82
|
+
return Math.max(MIN_DELAY_MS, Math.round(safeBase * factor));
|
|
83
|
+
};
|
|
84
|
+
const schedule = () => {
|
|
85
|
+
if (stopped)
|
|
86
|
+
return;
|
|
87
|
+
tickHandle = timers.setTimeout(async () => {
|
|
88
|
+
if (stopped)
|
|
89
|
+
return;
|
|
90
|
+
const outcome = await runWithDeadline({
|
|
91
|
+
run: input.run,
|
|
92
|
+
deadlineMs: input.deadlineMs,
|
|
93
|
+
onError: input.onError,
|
|
94
|
+
timers,
|
|
95
|
+
});
|
|
96
|
+
if (outcome === 'abandoned') {
|
|
97
|
+
stalled += 1;
|
|
98
|
+
try {
|
|
99
|
+
input.onStalled?.(input.deadlineMs);
|
|
100
|
+
}
|
|
101
|
+
catch { /* never break the loop */ }
|
|
102
|
+
}
|
|
103
|
+
schedule();
|
|
104
|
+
}, nextDelay());
|
|
105
|
+
};
|
|
106
|
+
schedule();
|
|
107
|
+
return {
|
|
108
|
+
stop() {
|
|
109
|
+
stopped = true;
|
|
110
|
+
timers.clearTimeout(tickHandle);
|
|
111
|
+
},
|
|
112
|
+
stalledCount() {
|
|
113
|
+
return stalled;
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
}
|
package/dist/telemetry.d.ts
CHANGED
|
@@ -16,8 +16,44 @@ export interface SpoolEvent {
|
|
|
16
16
|
policyHash?: string;
|
|
17
17
|
/** True when this decision was enforced locally while the backend was unreachable. */
|
|
18
18
|
offlineEnforced?: boolean;
|
|
19
|
+
/**
|
|
20
|
+
* Wall time the developer actually waited for this verdict, in ms — measured
|
|
21
|
+
* from the moment the hook process started to the moment the decision was
|
|
22
|
+
* spooled. Passive: computed from timestamps already taken on the hot path
|
|
23
|
+
* (see markVerdictTiming), never an extra probe, timer, or process.
|
|
24
|
+
*/
|
|
25
|
+
latencyMs?: number;
|
|
26
|
+
/** Which path produced the verdict (see VerdictPath). */
|
|
27
|
+
verdictPath?: VerdictPath;
|
|
19
28
|
occurredAt: string;
|
|
20
29
|
}
|
|
30
|
+
/**
|
|
31
|
+
* How a verdict was produced — the difference between a healthy machine and a
|
|
32
|
+
* degraded one:
|
|
33
|
+
* - `ipc` resident daemon answered the thin client (the fast path)
|
|
34
|
+
* - `local` full local evaluation in the hook process (daemon down/busy)
|
|
35
|
+
* - `gateway` resident MCP gateway decided in-process (its own fast path)
|
|
36
|
+
* - `fail_open` the hook crashed and allowed by contract
|
|
37
|
+
*/
|
|
38
|
+
export type VerdictPath = 'ipc' | 'local' | 'fail_open' | 'gateway';
|
|
39
|
+
interface VerdictTiming {
|
|
40
|
+
startedAt: number;
|
|
41
|
+
path: VerdictPath;
|
|
42
|
+
}
|
|
43
|
+
/** Run one hook evaluation with timing attached to everything it spools. */
|
|
44
|
+
export declare function markVerdictTiming<T>(timing: VerdictTiming, fn: () => T): T;
|
|
45
|
+
/**
|
|
46
|
+
* Re-label the current evaluation (e.g. it ended in the fail-open handler).
|
|
47
|
+
* Keeps the original start time so latency still covers the full wait.
|
|
48
|
+
*/
|
|
49
|
+
export declare function setVerdictPath(path: VerdictPath): void;
|
|
50
|
+
/**
|
|
51
|
+
* Restart the current evaluation's clock. Used by the MCP gateway right after
|
|
52
|
+
* the downstream tool returns: everything spooled from then on (response
|
|
53
|
+
* scanning, masking, bookkeeping) must report OUR overhead, not the tool's own
|
|
54
|
+
* runtime — a 30s build tool is not a 30s FullCourtDefense stall.
|
|
55
|
+
*/
|
|
56
|
+
export declare function restartVerdictTiming(): void;
|
|
21
57
|
/** Append one decision to the spool. Never throws (telemetry must not break the hook). */
|
|
22
58
|
export declare function spoolEvent(event: Omit<SpoolEvent, 'eventId' | 'occurredAt' | 'type'> & Partial<Pick<SpoolEvent, 'occurredAt' | 'type'>>): void;
|
|
23
59
|
export interface FlushInput {
|
|
@@ -76,3 +112,4 @@ export declare function flushSpool(input: FlushInput): Promise<{
|
|
|
76
112
|
* (used for critical blocks). The child reads creds from ~/.fullcourtdefense.yml.
|
|
77
113
|
*/
|
|
78
114
|
export declare function triggerFlush(immediate?: boolean): void;
|
|
115
|
+
export {};
|
package/dist/telemetry.js
CHANGED
|
@@ -33,9 +33,13 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.markVerdictTiming = markVerdictTiming;
|
|
37
|
+
exports.setVerdictPath = setVerdictPath;
|
|
38
|
+
exports.restartVerdictTiming = restartVerdictTiming;
|
|
36
39
|
exports.spoolEvent = spoolEvent;
|
|
37
40
|
exports.flushSpool = flushSpool;
|
|
38
41
|
exports.triggerFlush = triggerFlush;
|
|
42
|
+
const async_hooks_1 = require("async_hooks");
|
|
39
43
|
const child_process_1 = require("child_process");
|
|
40
44
|
const crypto = __importStar(require("crypto"));
|
|
41
45
|
const fs = __importStar(require("fs"));
|
|
@@ -63,9 +67,50 @@ const MAX_BATCH = 200;
|
|
|
63
67
|
const MAX_SPOOL_EVENTS = 2_000;
|
|
64
68
|
/** Cheap pre-check: skip the trim parse entirely until the file is plausibly over cap. */
|
|
65
69
|
const SPOOL_TRIM_BYTES = 2 * 1024 * 1024;
|
|
70
|
+
/**
|
|
71
|
+
* Per-evaluation timing context.
|
|
72
|
+
*
|
|
73
|
+
* AsyncLocalStorage (not a module variable) because the daemon evaluates
|
|
74
|
+
* several hook requests CONCURRENTLY in one process: a shared variable would
|
|
75
|
+
* attribute one hook's start time to another hook's event. The store follows
|
|
76
|
+
* each request's own async chain, so every spooled event gets its own numbers.
|
|
77
|
+
*
|
|
78
|
+
* Cost on the hot path: one object allocation per hook evaluation. No I/O, no
|
|
79
|
+
* timer, no network — the fields ride along on the verdict event the CLI was
|
|
80
|
+
* already spooling.
|
|
81
|
+
*/
|
|
82
|
+
const verdictTiming = new async_hooks_1.AsyncLocalStorage();
|
|
83
|
+
/** Run one hook evaluation with timing attached to everything it spools. */
|
|
84
|
+
function markVerdictTiming(timing, fn) {
|
|
85
|
+
return verdictTiming.run(timing, fn);
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Re-label the current evaluation (e.g. it ended in the fail-open handler).
|
|
89
|
+
* Keeps the original start time so latency still covers the full wait.
|
|
90
|
+
*/
|
|
91
|
+
function setVerdictPath(path) {
|
|
92
|
+
const store = verdictTiming.getStore();
|
|
93
|
+
if (store)
|
|
94
|
+
store.path = path;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Restart the current evaluation's clock. Used by the MCP gateway right after
|
|
98
|
+
* the downstream tool returns: everything spooled from then on (response
|
|
99
|
+
* scanning, masking, bookkeeping) must report OUR overhead, not the tool's own
|
|
100
|
+
* runtime — a 30s build tool is not a 30s FullCourtDefense stall.
|
|
101
|
+
*/
|
|
102
|
+
function restartVerdictTiming() {
|
|
103
|
+
const store = verdictTiming.getStore();
|
|
104
|
+
if (store)
|
|
105
|
+
store.startedAt = Date.now();
|
|
106
|
+
}
|
|
66
107
|
/** Append one decision to the spool. Never throws (telemetry must not break the hook). */
|
|
67
108
|
function spoolEvent(event) {
|
|
68
109
|
try {
|
|
110
|
+
// Passive perf stamp: pure arithmetic on a timestamp the hot path already
|
|
111
|
+
// took. Absent when a caller spools outside a hook evaluation (guards,
|
|
112
|
+
// onboard) — the backend treats missing fields as "no sample".
|
|
113
|
+
const timing = verdictTiming.getStore();
|
|
69
114
|
const full = {
|
|
70
115
|
eventId: crypto.randomUUID(),
|
|
71
116
|
occurredAt: event.occurredAt || new Date().toISOString(),
|
|
@@ -84,6 +129,8 @@ function spoolEvent(event) {
|
|
|
84
129
|
explanation: event.explanation,
|
|
85
130
|
policyHash: event.policyHash,
|
|
86
131
|
offlineEnforced: event.offlineEnforced,
|
|
132
|
+
latencyMs: timing ? Math.max(0, Date.now() - timing.startedAt) : undefined,
|
|
133
|
+
verdictPath: timing?.path,
|
|
87
134
|
};
|
|
88
135
|
fs.appendFileSync(SPOOL_PATH, JSON.stringify(full) + '\n', { encoding: 'utf-8', mode: 0o600 });
|
|
89
136
|
enforceSpoolCap();
|
package/dist/verdictIpc.d.ts
CHANGED
|
@@ -33,6 +33,14 @@ export interface VerdictRequest {
|
|
|
33
33
|
args: Record<string, string | undefined>;
|
|
34
34
|
/** Raw stdin payload exactly as the IDE piped it (BOM already stripped). */
|
|
35
35
|
stdin: string;
|
|
36
|
+
/**
|
|
37
|
+
* Client's `Date.now()` at hook start, so the daemon-written telemetry event
|
|
38
|
+
* carries the wall time the DEVELOPER waited (process spawn + pipe + verdict),
|
|
39
|
+
* not just the daemon's own evaluation slice. Optional on purpose: during a
|
|
40
|
+
* staged rollout an old client omits it (daemon falls back to its own clock)
|
|
41
|
+
* and an old daemon ignores it — same-machine clock, so no skew.
|
|
42
|
+
*/
|
|
43
|
+
t0?: number;
|
|
36
44
|
}
|
|
37
45
|
export interface VerdictResponse {
|
|
38
46
|
v: number;
|
|
@@ -68,7 +76,7 @@ export declare function verdictPipePath(): string;
|
|
|
68
76
|
* get ONE short jittered retry; hard failures (ENOENT stale pipe, refused)
|
|
69
77
|
* still fall back to local evaluation instantly.
|
|
70
78
|
*/
|
|
71
|
-
export declare function requestDaemonVerdict(args: Record<string, string | undefined>, stdin: string, responseTimeoutMs?: number): Promise<VerdictClientResult>;
|
|
79
|
+
export declare function requestDaemonVerdict(args: Record<string, string | undefined>, stdin: string, responseTimeoutMs?: number, clientStartedAt?: number): Promise<VerdictClientResult>;
|
|
72
80
|
export interface VerdictServerHandle {
|
|
73
81
|
close(): void;
|
|
74
82
|
}
|
package/dist/verdictIpc.js
CHANGED
|
@@ -43,6 +43,7 @@ const fs = __importStar(require("fs"));
|
|
|
43
43
|
const net = __importStar(require("net"));
|
|
44
44
|
const os = __importStar(require("os"));
|
|
45
45
|
const path = __importStar(require("path"));
|
|
46
|
+
const telemetry_1 = require("./telemetry");
|
|
46
47
|
/**
|
|
47
48
|
* Daemon verdict IPC — the resident-agent pattern real endpoint products use
|
|
48
49
|
* (one resident process; per-event interceptors are thin clients that ask it
|
|
@@ -118,15 +119,15 @@ const RESPONSE_TIMEOUT_MS = 2_500;
|
|
|
118
119
|
* get ONE short jittered retry; hard failures (ENOENT stale pipe, refused)
|
|
119
120
|
* still fall back to local evaluation instantly.
|
|
120
121
|
*/
|
|
121
|
-
async function requestDaemonVerdict(args, stdin, responseTimeoutMs = RESPONSE_TIMEOUT_MS) {
|
|
122
|
-
const first = await attemptDaemonVerdict(args, stdin, responseTimeoutMs);
|
|
122
|
+
async function requestDaemonVerdict(args, stdin, responseTimeoutMs = RESPONSE_TIMEOUT_MS, clientStartedAt) {
|
|
123
|
+
const first = await attemptDaemonVerdict(args, stdin, responseTimeoutMs, clientStartedAt);
|
|
123
124
|
if (first.outcome === 'unavailable' && /EBUSY|busy|EAGAIN|connect timeout/i.test(first.detail || '')) {
|
|
124
125
|
await new Promise((r) => setTimeout(r, 20 + Math.floor(Math.random() * 30)));
|
|
125
|
-
return attemptDaemonVerdict(args, stdin, responseTimeoutMs);
|
|
126
|
+
return attemptDaemonVerdict(args, stdin, responseTimeoutMs, clientStartedAt);
|
|
126
127
|
}
|
|
127
128
|
return first;
|
|
128
129
|
}
|
|
129
|
-
function attemptDaemonVerdict(args, stdin, responseTimeoutMs) {
|
|
130
|
+
function attemptDaemonVerdict(args, stdin, responseTimeoutMs, clientStartedAt) {
|
|
130
131
|
return new Promise((resolve) => {
|
|
131
132
|
const token = readToken();
|
|
132
133
|
if (!token) {
|
|
@@ -153,7 +154,7 @@ function attemptDaemonVerdict(args, stdin, responseTimeoutMs) {
|
|
|
153
154
|
socket.on('error', (err) => fail(err.message));
|
|
154
155
|
socket.on('connect', () => {
|
|
155
156
|
clearTimeout(connectTimer);
|
|
156
|
-
const request = { v: exports.VERDICT_IPC_PROTOCOL_VERSION, token, args, stdin };
|
|
157
|
+
const request = { v: exports.VERDICT_IPC_PROTOCOL_VERSION, token, args, stdin, t0: clientStartedAt };
|
|
157
158
|
try {
|
|
158
159
|
socket.write(JSON.stringify(request) + '\n');
|
|
159
160
|
}
|
|
@@ -258,7 +259,11 @@ function startVerdictServer(evaluate, log) {
|
|
|
258
259
|
return;
|
|
259
260
|
}
|
|
260
261
|
try {
|
|
261
|
-
|
|
262
|
+
// Attach the requesting hook's own start time to whatever this
|
|
263
|
+
// evaluation spools, so fleet telemetry reports the wall time the
|
|
264
|
+
// developer waited. Per-request (AsyncLocalStorage), so concurrent
|
|
265
|
+
// hooks in this one daemon never borrow each other's timings.
|
|
266
|
+
const result = await (0, telemetry_1.markVerdictTiming)({ startedAt: typeof request.t0 === 'number' ? request.t0 : Date.now(), path: 'ipc' }, () => evaluate(request.args || {}, String(request.stdin ?? '')));
|
|
262
267
|
reply({ v: exports.VERDICT_IPC_PROTOCOL_VERSION, ...result });
|
|
263
268
|
}
|
|
264
269
|
catch (err) {
|
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.4",
|
|
4
4
|
"description": "Full Court Defense CLI — security scanning for AI agents from your terminal",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -53,6 +53,7 @@
|
|
|
53
53
|
"test:scan-root-compaction": "npm run build && node scripts/test-scan-root-compaction.js",
|
|
54
54
|
"test:dpapi-config": "npm run build && node scripts/test-dpapi-config.js",
|
|
55
55
|
"test:distress-ledger": "npm run build && node scripts/test-distress-ledger.js",
|
|
56
|
+
"test:poll-loop": "npm run build && node scripts/test-poll-loop-resilience.js",
|
|
56
57
|
"test:deep-selftest": "npm run build && node scripts/test-deep-selftest.js",
|
|
57
58
|
"test:update-loop": "npm run build && node scripts/test-update-loop-detection.js",
|
|
58
59
|
"test:bricked-rescue": "npm run build && node scripts/test-bricked-machine-rescue.js",
|