fullcourtdefense-cli 1.26.3 → 1.26.5

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.
@@ -1,82 +1,19 @@
1
1
  /**
2
- * Daemon verdict IPC — the resident-agent pattern real endpoint products use
3
- * (one resident process; per-event interceptors are thin clients that ask it
4
- * over local IPC), applied to the IDE hook path.
2
+ * Daemon verdict IPC — SERVER side (runs inside the resident daemon).
5
3
  *
6
- * Why: every IDE hook event spawns a fresh Node process. The spawn itself is
7
- * unavoidable (the IDE invokes a command), but everything AFTER it — config
8
- * parse, credential resolution, bundle read, snapshot load, policy evaluation,
9
- * possible network — used to repeat per event. The daemon is already resident
10
- * with all of that warm in memory, so the spawned hook becomes a THIN CLIENT:
11
- * it forwards `{args, stdin}` over a named pipe (Windows) / Unix domain socket
12
- * and prints the verdict the daemon computed in single-digit milliseconds.
4
+ * The resident-agent pattern real endpoint products use: one resident process;
5
+ * per-event interceptors are thin clients that ask it over local IPC. The
6
+ * daemon is already resident with config, credentials, bundle, and snapshot
7
+ * warm in memory, so a spawned hook becomes a THIN CLIENT that forwards
8
+ * `{args, stdin}` over a named pipe (Windows) / Unix domain socket and prints
9
+ * the verdict the daemon computed in single-digit milliseconds.
13
10
  *
14
- * Fallback contract: ANY failure — pipe missing, handshake error, slow or
15
- * busy daemon (response cap ~2.5s) — makes the hook silently fall back to its
16
- * full local evaluation, identical to before this feature existed. Local
17
- * re-evaluation after a partial daemon evaluation is safe: verdict
18
- * computation is read-mostly (worst case a duplicate telemetry event), and
19
- * enforcement is never weakened by evaluating twice. The cap guarantees a
20
- * busy daemon (blocking schtasks call, GC pause) can never make a hook
21
- * SLOWER than the pre-IPC status quo.
22
- *
23
- * Security: named pipes are reachable by other local users by default, so the
24
- * server requires a per-boot random token stored under ~/.fullcourtdefense
25
- * (user-profile ACL). A client that cannot read the token cannot get verdicts.
26
- * The protocol is line-delimited JSON — one request line, one response line.
27
- */
28
- export declare const VERDICT_IPC_PROTOCOL_VERSION = 1;
29
- export interface VerdictRequest {
30
- v: number;
31
- token: string;
32
- /** HookArgs as parsed by the CLI entry (all-string values). */
33
- args: Record<string, string | undefined>;
34
- /** Raw stdin payload exactly as the IDE piped it (BOM already stripped). */
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;
44
- }
45
- export interface VerdictResponse {
46
- v: number;
47
- /** Present on evaluation success. */
48
- stdout?: string;
49
- stderr?: string;
50
- exitCode?: number;
51
- /** Present when the daemon could not evaluate — client falls back locally. */
52
- error?: string;
53
- }
54
- /** Result of a client attempt. Anything but 'verdict' → evaluate locally. */
55
- export interface VerdictClientResult {
56
- outcome: 'verdict' | 'unavailable';
57
- stdout?: string;
58
- stderr?: string;
59
- exitCode?: number;
60
- detail?: string;
61
- }
62
- export declare function verdictTokenFile(): string;
63
- export declare function verdictPipePath(): string;
64
- /**
65
- * Ask the resident daemon for a verdict. Bounded hard: connect budget ~250ms
66
- * (a missing pipe fails in <1ms with ENOENT — the common daemon-down case
67
- * costs nothing), response capped at ~2.5s. A verdict that legitimately needs
68
- * longer than that (synchronous approval wait, no-cache gate call) is exactly
69
- * the case where the IPC saves nothing — the local evaluation path handles it
70
- * with its own bounded waits. Never throws.
71
- *
72
- * Burst behavior: an agent firing many commands at once (docker compose +
73
- * exec + build in flight together) means many hooks connecting to the ONE
74
- * pipe simultaneously — some connects lose the race (EBUSY / connect
75
- * timeout) while the daemon is demonstrably alive (token present). Those
76
- * get ONE short jittered retry; hard failures (ENOENT stale pipe, refused)
77
- * still fall back to local evaluation instantly.
11
+ * The client half lives in `verdictIpcClient.ts` — a Node-builtins-only leaf
12
+ * shared with the slim hook entry (`hookSlim.ts`), whose module graph must
13
+ * stay tiny because it loads on every IDE hook event. This file re-exports
14
+ * the client API so existing imports keep working.
78
15
  */
79
- export declare function requestDaemonVerdict(args: Record<string, string | undefined>, stdin: string, responseTimeoutMs?: number, clientStartedAt?: number): Promise<VerdictClientResult>;
16
+ export { VERDICT_IPC_PROTOCOL_VERSION, VerdictRequest, VerdictResponse, VerdictClientResult, requestDaemonVerdict, verdictPipePath, verdictTokenFile, } from './verdictIpcClient';
80
17
  export interface VerdictServerHandle {
81
18
  close(): void;
82
19
  }
@@ -33,163 +33,33 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.VERDICT_IPC_PROTOCOL_VERSION = void 0;
37
- exports.verdictTokenFile = verdictTokenFile;
38
- exports.verdictPipePath = verdictPipePath;
39
- exports.requestDaemonVerdict = requestDaemonVerdict;
36
+ exports.verdictTokenFile = exports.verdictPipePath = exports.requestDaemonVerdict = exports.VERDICT_IPC_PROTOCOL_VERSION = void 0;
40
37
  exports.startVerdictServer = startVerdictServer;
41
38
  const crypto = __importStar(require("crypto"));
42
39
  const fs = __importStar(require("fs"));
43
40
  const net = __importStar(require("net"));
44
- const os = __importStar(require("os"));
45
- const path = __importStar(require("path"));
46
41
  const telemetry_1 = require("./telemetry");
42
+ const verdictIpcClient_1 = require("./verdictIpcClient");
47
43
  /**
48
- * Daemon verdict IPC — the resident-agent pattern real endpoint products use
49
- * (one resident process; per-event interceptors are thin clients that ask it
50
- * over local IPC), applied to the IDE hook path.
44
+ * Daemon verdict IPC — SERVER side (runs inside the resident daemon).
51
45
  *
52
- * Why: every IDE hook event spawns a fresh Node process. The spawn itself is
53
- * unavoidable (the IDE invokes a command), but everything AFTER it — config
54
- * parse, credential resolution, bundle read, snapshot load, policy evaluation,
55
- * possible network — used to repeat per event. The daemon is already resident
56
- * with all of that warm in memory, so the spawned hook becomes a THIN CLIENT:
57
- * it forwards `{args, stdin}` over a named pipe (Windows) / Unix domain socket
58
- * and prints the verdict the daemon computed in single-digit milliseconds.
46
+ * The resident-agent pattern real endpoint products use: one resident process;
47
+ * per-event interceptors are thin clients that ask it over local IPC. The
48
+ * daemon is already resident with config, credentials, bundle, and snapshot
49
+ * warm in memory, so a spawned hook becomes a THIN CLIENT that forwards
50
+ * `{args, stdin}` over a named pipe (Windows) / Unix domain socket and prints
51
+ * the verdict the daemon computed in single-digit milliseconds.
59
52
  *
60
- * Fallback contract: ANY failure — pipe missing, handshake error, slow or
61
- * busy daemon (response cap ~2.5s) — makes the hook silently fall back to its
62
- * full local evaluation, identical to before this feature existed. Local
63
- * re-evaluation after a partial daemon evaluation is safe: verdict
64
- * computation is read-mostly (worst case a duplicate telemetry event), and
65
- * enforcement is never weakened by evaluating twice. The cap guarantees a
66
- * busy daemon (blocking schtasks call, GC pause) can never make a hook
67
- * SLOWER than the pre-IPC status quo.
68
- *
69
- * Security: named pipes are reachable by other local users by default, so the
70
- * server requires a per-boot random token stored under ~/.fullcourtdefense
71
- * (user-profile ACL). A client that cannot read the token cannot get verdicts.
72
- * The protocol is line-delimited JSON — one request line, one response line.
53
+ * The client half lives in `verdictIpcClient.ts` — a Node-builtins-only leaf
54
+ * shared with the slim hook entry (`hookSlim.ts`), whose module graph must
55
+ * stay tiny because it loads on every IDE hook event. This file re-exports
56
+ * the client API so existing imports keep working.
73
57
  */
74
- exports.VERDICT_IPC_PROTOCOL_VERSION = 1;
75
- function stateDir() {
76
- return path.join(os.homedir(), '.fullcourtdefense');
77
- }
78
- function verdictTokenFile() {
79
- return path.join(stateDir(), 'verdict-ipc-token');
80
- }
81
- function verdictPipePath() {
82
- // Test isolation / unusual environments: explicit endpoint override.
83
- const override = process.env.FCD_VERDICT_PIPE;
84
- if (override)
85
- return override;
86
- if (process.platform === 'win32') {
87
- // Per-user pipe name: two users on one machine each get their own daemon.
88
- const user = (os.userInfo().username || 'user').replace(/[^a-zA-Z0-9_-]/g, '_');
89
- return `\\\\.\\pipe\\fullcourtdefense-verdict-${user}`;
90
- }
91
- return path.join(stateDir(), 'verdict.sock');
92
- }
93
- function readToken() {
94
- try {
95
- const token = fs.readFileSync(verdictTokenFile(), 'utf8').trim();
96
- return token.length >= 16 ? token : undefined;
97
- }
98
- catch {
99
- return undefined;
100
- }
101
- }
102
- // ---------------------------------------------------------------------------
103
- // Client (thin hook side)
104
- // ---------------------------------------------------------------------------
105
- const CONNECT_TIMEOUT_MS = 250;
106
- const RESPONSE_TIMEOUT_MS = 2_500;
107
- /**
108
- * Ask the resident daemon for a verdict. Bounded hard: connect budget ~250ms
109
- * (a missing pipe fails in <1ms with ENOENT — the common daemon-down case
110
- * costs nothing), response capped at ~2.5s. A verdict that legitimately needs
111
- * longer than that (synchronous approval wait, no-cache gate call) is exactly
112
- * the case where the IPC saves nothing — the local evaluation path handles it
113
- * with its own bounded waits. Never throws.
114
- *
115
- * Burst behavior: an agent firing many commands at once (docker compose +
116
- * exec + build in flight together) means many hooks connecting to the ONE
117
- * pipe simultaneously — some connects lose the race (EBUSY / connect
118
- * timeout) while the daemon is demonstrably alive (token present). Those
119
- * get ONE short jittered retry; hard failures (ENOENT stale pipe, refused)
120
- * still fall back to local evaluation instantly.
121
- */
122
- async function requestDaemonVerdict(args, stdin, responseTimeoutMs = RESPONSE_TIMEOUT_MS, clientStartedAt) {
123
- const first = await attemptDaemonVerdict(args, stdin, responseTimeoutMs, clientStartedAt);
124
- if (first.outcome === 'unavailable' && /EBUSY|busy|EAGAIN|connect timeout/i.test(first.detail || '')) {
125
- await new Promise((r) => setTimeout(r, 20 + Math.floor(Math.random() * 30)));
126
- return attemptDaemonVerdict(args, stdin, responseTimeoutMs, clientStartedAt);
127
- }
128
- return first;
129
- }
130
- function attemptDaemonVerdict(args, stdin, responseTimeoutMs, clientStartedAt) {
131
- return new Promise((resolve) => {
132
- const token = readToken();
133
- if (!token) {
134
- resolve({ outcome: 'unavailable', detail: 'no ipc token (daemon not running or old version)' });
135
- return;
136
- }
137
- let settled = false;
138
- const finish = (result) => {
139
- if (settled)
140
- return;
141
- settled = true;
142
- clearTimeout(connectTimer);
143
- clearTimeout(responseTimer);
144
- try {
145
- socket.destroy();
146
- }
147
- catch { /* already gone */ }
148
- resolve(result);
149
- };
150
- const fail = (detail) => finish({ outcome: 'unavailable', detail });
151
- const socket = net.connect({ path: verdictPipePath() });
152
- const connectTimer = setTimeout(() => fail('connect timeout'), CONNECT_TIMEOUT_MS);
153
- const responseTimer = setTimeout(() => fail('response timeout'), responseTimeoutMs);
154
- socket.on('error', (err) => fail(err.message));
155
- socket.on('connect', () => {
156
- clearTimeout(connectTimer);
157
- const request = { v: exports.VERDICT_IPC_PROTOCOL_VERSION, token, args, stdin, t0: clientStartedAt };
158
- try {
159
- socket.write(JSON.stringify(request) + '\n');
160
- }
161
- catch (err) {
162
- fail(err instanceof Error ? err.message : String(err));
163
- }
164
- });
165
- let buffer = '';
166
- socket.on('data', (chunk) => {
167
- buffer += chunk.toString('utf8');
168
- const nl = buffer.indexOf('\n');
169
- if (nl === -1)
170
- return;
171
- try {
172
- const resp = JSON.parse(buffer.slice(0, nl));
173
- if (resp.error || typeof resp.stdout !== 'string') {
174
- // The daemon declined (protocol mismatch, internal error). Nothing
175
- // was decided — treat as unavailable so the hook evaluates locally.
176
- finish({ outcome: 'unavailable', detail: resp.error || 'malformed response' });
177
- return;
178
- }
179
- finish({
180
- outcome: 'verdict',
181
- stdout: resp.stdout,
182
- stderr: resp.stderr || '',
183
- exitCode: typeof resp.exitCode === 'number' ? resp.exitCode : 0,
184
- });
185
- }
186
- catch (err) {
187
- fail(err instanceof Error ? err.message : String(err));
188
- }
189
- });
190
- socket.on('close', () => fail('connection closed'));
191
- });
192
- }
58
+ var verdictIpcClient_2 = require("./verdictIpcClient");
59
+ Object.defineProperty(exports, "VERDICT_IPC_PROTOCOL_VERSION", { enumerable: true, get: function () { return verdictIpcClient_2.VERDICT_IPC_PROTOCOL_VERSION; } });
60
+ Object.defineProperty(exports, "requestDaemonVerdict", { enumerable: true, get: function () { return verdictIpcClient_2.requestDaemonVerdict; } });
61
+ Object.defineProperty(exports, "verdictPipePath", { enumerable: true, get: function () { return verdictIpcClient_2.verdictPipePath; } });
62
+ Object.defineProperty(exports, "verdictTokenFile", { enumerable: true, get: function () { return verdictIpcClient_2.verdictTokenFile; } });
193
63
  /**
194
64
  * Start the verdict server inside the daemon. Returns undefined when the
195
65
  * endpoint cannot be created (another daemon holds the pipe, filesystem
@@ -204,13 +74,13 @@ function startVerdictServer(evaluate, log) {
204
74
  // path until the next daemon restart.
205
75
  const removeTokenIfOurs = () => {
206
76
  try {
207
- if (fs.readFileSync(verdictTokenFile(), 'utf8') === token) {
208
- fs.unlinkSync(verdictTokenFile());
77
+ if (fs.readFileSync((0, verdictIpcClient_1.verdictTokenFile)(), 'utf8') === token) {
78
+ fs.unlinkSync((0, verdictIpcClient_1.verdictTokenFile)());
209
79
  }
210
80
  }
211
81
  catch { /* absent or unreadable — nothing of ours to clean */ }
212
82
  };
213
- const pipePath = verdictPipePath();
83
+ const pipePath = (0, verdictIpcClient_1.verdictPipePath)();
214
84
  if (process.platform !== 'win32') {
215
85
  // A stale socket file from a crashed daemon blocks listen() with EADDRINUSE.
216
86
  try {
@@ -244,18 +114,18 @@ function startVerdictServer(evaluate, log) {
244
114
  request = JSON.parse(line);
245
115
  }
246
116
  catch {
247
- reply({ v: exports.VERDICT_IPC_PROTOCOL_VERSION, error: 'malformed request' });
117
+ reply({ v: verdictIpcClient_1.VERDICT_IPC_PROTOCOL_VERSION, error: 'malformed request' });
248
118
  return;
249
119
  }
250
- if (request.v !== exports.VERDICT_IPC_PROTOCOL_VERSION) {
251
- reply({ v: exports.VERDICT_IPC_PROTOCOL_VERSION, error: `unsupported protocol version ${request.v}` });
120
+ if (request.v !== verdictIpcClient_1.VERDICT_IPC_PROTOCOL_VERSION) {
121
+ reply({ v: verdictIpcClient_1.VERDICT_IPC_PROTOCOL_VERSION, error: `unsupported protocol version ${request.v}` });
252
122
  return;
253
123
  }
254
124
  // Timing-safe token check — the pipe is reachable by other local users.
255
125
  const expected = Buffer.from(token);
256
126
  const got = Buffer.from(String(request.token || ''));
257
127
  if (got.length !== expected.length || !crypto.timingSafeEqual(expected, got)) {
258
- reply({ v: exports.VERDICT_IPC_PROTOCOL_VERSION, error: 'unauthorized' });
128
+ reply({ v: verdictIpcClient_1.VERDICT_IPC_PROTOCOL_VERSION, error: 'unauthorized' });
259
129
  return;
260
130
  }
261
131
  try {
@@ -264,12 +134,12 @@ function startVerdictServer(evaluate, log) {
264
134
  // developer waited. Per-request (AsyncLocalStorage), so concurrent
265
135
  // hooks in this one daemon never borrow each other's timings.
266
136
  const result = await (0, telemetry_1.markVerdictTiming)({ startedAt: typeof request.t0 === 'number' ? request.t0 : Date.now(), path: 'ipc' }, () => evaluate(request.args || {}, String(request.stdin ?? '')));
267
- reply({ v: exports.VERDICT_IPC_PROTOCOL_VERSION, ...result });
137
+ reply({ v: verdictIpcClient_1.VERDICT_IPC_PROTOCOL_VERSION, ...result });
268
138
  }
269
139
  catch (err) {
270
140
  // Evaluator bug — tell the client to fall back locally (which will
271
141
  // itself fail open if the same bug reproduces there).
272
- reply({ v: exports.VERDICT_IPC_PROTOCOL_VERSION, error: err instanceof Error ? err.message : String(err) });
142
+ reply({ v: verdictIpcClient_1.VERDICT_IPC_PROTOCOL_VERSION, error: err instanceof Error ? err.message : String(err) });
273
143
  }
274
144
  })();
275
145
  });
@@ -291,8 +161,8 @@ function startVerdictServer(evaluate, log) {
291
161
  }
292
162
  // Publish the token only now that the pipe is actually owned by us.
293
163
  try {
294
- fs.mkdirSync(stateDir(), { recursive: true });
295
- fs.writeFileSync(verdictTokenFile(), token, { encoding: 'utf8', mode: 0o600 });
164
+ fs.mkdirSync((0, verdictIpcClient_1.verdictStateDir)(), { recursive: true });
165
+ fs.writeFileSync((0, verdictIpcClient_1.verdictTokenFile)(), token, { encoding: 'utf8', mode: 0o600 });
296
166
  log(`Verdict IPC: serving hook verdicts on ${pipePath} (thin-client hooks, ms verdicts).`);
297
167
  }
298
168
  catch (err) {
@@ -319,7 +189,7 @@ function startVerdictServer(evaluate, log) {
319
189
  catch { /* best-effort */ }
320
190
  if (process.platform !== 'win32') {
321
191
  try {
322
- fs.unlinkSync(verdictPipePath());
192
+ fs.unlinkSync((0, verdictIpcClient_1.verdictPipePath)());
323
193
  }
324
194
  catch { /* best-effort */ }
325
195
  }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Verdict IPC — CLIENT side (thin hook -> resident daemon).
3
+ *
4
+ * Split from the server half (`verdictIpc.ts`) on purpose: the client is on
5
+ * the slim hook entry's module graph, loaded on EVERY IDE hook event, so it
6
+ * may import Node built-ins ONLY. The server half runs inside the resident
7
+ * daemon where module weight is irrelevant. `scripts/test-slim-hook.js`
8
+ * enforces the graph budget.
9
+ *
10
+ * Fallback contract: ANY failure — pipe missing, handshake error, slow or
11
+ * busy daemon (response cap ~2.5s) — makes the hook silently fall back to its
12
+ * full local evaluation, identical to before this feature existed. Local
13
+ * re-evaluation after a partial daemon evaluation is safe: verdict
14
+ * computation is read-mostly (worst case a duplicate telemetry event), and
15
+ * enforcement is never weakened by evaluating twice. The cap guarantees a
16
+ * busy daemon (blocking schtasks call, GC pause) can never make a hook
17
+ * SLOWER than the pre-IPC status quo.
18
+ *
19
+ * Security: named pipes are reachable by other local users by default, so the
20
+ * server requires a per-boot random token stored under ~/.fullcourtdefense
21
+ * (user-profile ACL). A client that cannot read the token cannot get verdicts.
22
+ * The protocol is line-delimited JSON — one request line, one response line.
23
+ */
24
+ export declare const VERDICT_IPC_PROTOCOL_VERSION = 1;
25
+ export interface VerdictRequest {
26
+ v: number;
27
+ token: string;
28
+ /** HookArgs as parsed by the CLI entry (all-string values). */
29
+ args: Record<string, string | undefined>;
30
+ /** Raw stdin payload exactly as the IDE piped it (BOM already stripped). */
31
+ stdin: string;
32
+ /**
33
+ * Client's `Date.now()` at hook start, so the daemon-written telemetry event
34
+ * carries the wall time the DEVELOPER waited (process spawn + pipe + verdict),
35
+ * not just the daemon's own evaluation slice. Optional on purpose: during a
36
+ * staged rollout an old client omits it (daemon falls back to its own clock)
37
+ * and an old daemon ignores it — same-machine clock, so no skew.
38
+ */
39
+ t0?: number;
40
+ }
41
+ export interface VerdictResponse {
42
+ v: number;
43
+ /** Present on evaluation success. */
44
+ stdout?: string;
45
+ stderr?: string;
46
+ exitCode?: number;
47
+ /** Present when the daemon could not evaluate — client falls back locally. */
48
+ error?: string;
49
+ }
50
+ /** Result of a client attempt. Anything but 'verdict' → evaluate locally. */
51
+ export interface VerdictClientResult {
52
+ outcome: 'verdict' | 'unavailable';
53
+ stdout?: string;
54
+ stderr?: string;
55
+ exitCode?: number;
56
+ detail?: string;
57
+ }
58
+ export declare function verdictStateDir(): string;
59
+ export declare function verdictTokenFile(): string;
60
+ export declare function verdictPipePath(): string;
61
+ /**
62
+ * Ask the resident daemon for a verdict. Bounded hard: connect budget ~250ms
63
+ * (a missing pipe fails in <1ms with ENOENT — the common daemon-down case
64
+ * costs nothing), response capped at ~2.5s. A verdict that legitimately needs
65
+ * longer than that (synchronous approval wait, no-cache gate call) is exactly
66
+ * the case where the IPC saves nothing — the local evaluation path handles it
67
+ * with its own bounded waits. Never throws.
68
+ *
69
+ * Burst behavior: an agent firing many commands at once (docker compose +
70
+ * exec + build in flight together) means many hooks connecting to the ONE
71
+ * pipe simultaneously — some connects lose the race (EBUSY / connect
72
+ * timeout) while the daemon is demonstrably alive (token present). Those
73
+ * get ONE short jittered retry; hard failures (ENOENT stale pipe, refused)
74
+ * still fall back to local evaluation instantly.
75
+ */
76
+ export declare function requestDaemonVerdict(args: Record<string, string | undefined>, stdin: string, responseTimeoutMs?: number, clientStartedAt?: number): Promise<VerdictClientResult>;
@@ -0,0 +1,183 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.VERDICT_IPC_PROTOCOL_VERSION = void 0;
37
+ exports.verdictStateDir = verdictStateDir;
38
+ exports.verdictTokenFile = verdictTokenFile;
39
+ exports.verdictPipePath = verdictPipePath;
40
+ exports.requestDaemonVerdict = requestDaemonVerdict;
41
+ const fs = __importStar(require("fs"));
42
+ const net = __importStar(require("net"));
43
+ const os = __importStar(require("os"));
44
+ const path = __importStar(require("path"));
45
+ /**
46
+ * Verdict IPC — CLIENT side (thin hook -> resident daemon).
47
+ *
48
+ * Split from the server half (`verdictIpc.ts`) on purpose: the client is on
49
+ * the slim hook entry's module graph, loaded on EVERY IDE hook event, so it
50
+ * may import Node built-ins ONLY. The server half runs inside the resident
51
+ * daemon where module weight is irrelevant. `scripts/test-slim-hook.js`
52
+ * enforces the graph budget.
53
+ *
54
+ * Fallback contract: ANY failure — pipe missing, handshake error, slow or
55
+ * busy daemon (response cap ~2.5s) — makes the hook silently fall back to its
56
+ * full local evaluation, identical to before this feature existed. Local
57
+ * re-evaluation after a partial daemon evaluation is safe: verdict
58
+ * computation is read-mostly (worst case a duplicate telemetry event), and
59
+ * enforcement is never weakened by evaluating twice. The cap guarantees a
60
+ * busy daemon (blocking schtasks call, GC pause) can never make a hook
61
+ * SLOWER than the pre-IPC status quo.
62
+ *
63
+ * Security: named pipes are reachable by other local users by default, so the
64
+ * server requires a per-boot random token stored under ~/.fullcourtdefense
65
+ * (user-profile ACL). A client that cannot read the token cannot get verdicts.
66
+ * The protocol is line-delimited JSON — one request line, one response line.
67
+ */
68
+ exports.VERDICT_IPC_PROTOCOL_VERSION = 1;
69
+ function verdictStateDir() {
70
+ return path.join(os.homedir(), '.fullcourtdefense');
71
+ }
72
+ function verdictTokenFile() {
73
+ return path.join(verdictStateDir(), 'verdict-ipc-token');
74
+ }
75
+ function verdictPipePath() {
76
+ // Test isolation / unusual environments: explicit endpoint override.
77
+ const override = process.env.FCD_VERDICT_PIPE;
78
+ if (override)
79
+ return override;
80
+ if (process.platform === 'win32') {
81
+ // Per-user pipe name: two users on one machine each get their own daemon.
82
+ const user = (os.userInfo().username || 'user').replace(/[^a-zA-Z0-9_-]/g, '_');
83
+ return `\\\\.\\pipe\\fullcourtdefense-verdict-${user}`;
84
+ }
85
+ return path.join(verdictStateDir(), 'verdict.sock');
86
+ }
87
+ function readToken() {
88
+ try {
89
+ const token = fs.readFileSync(verdictTokenFile(), 'utf8').trim();
90
+ return token.length >= 16 ? token : undefined;
91
+ }
92
+ catch {
93
+ return undefined;
94
+ }
95
+ }
96
+ const CONNECT_TIMEOUT_MS = 250;
97
+ const RESPONSE_TIMEOUT_MS = 2_500;
98
+ /**
99
+ * Ask the resident daemon for a verdict. Bounded hard: connect budget ~250ms
100
+ * (a missing pipe fails in <1ms with ENOENT — the common daemon-down case
101
+ * costs nothing), response capped at ~2.5s. A verdict that legitimately needs
102
+ * longer than that (synchronous approval wait, no-cache gate call) is exactly
103
+ * the case where the IPC saves nothing — the local evaluation path handles it
104
+ * with its own bounded waits. Never throws.
105
+ *
106
+ * Burst behavior: an agent firing many commands at once (docker compose +
107
+ * exec + build in flight together) means many hooks connecting to the ONE
108
+ * pipe simultaneously — some connects lose the race (EBUSY / connect
109
+ * timeout) while the daemon is demonstrably alive (token present). Those
110
+ * get ONE short jittered retry; hard failures (ENOENT stale pipe, refused)
111
+ * still fall back to local evaluation instantly.
112
+ */
113
+ async function requestDaemonVerdict(args, stdin, responseTimeoutMs = RESPONSE_TIMEOUT_MS, clientStartedAt) {
114
+ const first = await attemptDaemonVerdict(args, stdin, responseTimeoutMs, clientStartedAt);
115
+ if (first.outcome === 'unavailable' && /EBUSY|busy|EAGAIN|connect timeout/i.test(first.detail || '')) {
116
+ await new Promise((r) => setTimeout(r, 20 + Math.floor(Math.random() * 30)));
117
+ return attemptDaemonVerdict(args, stdin, responseTimeoutMs, clientStartedAt);
118
+ }
119
+ return first;
120
+ }
121
+ function attemptDaemonVerdict(args, stdin, responseTimeoutMs, clientStartedAt) {
122
+ return new Promise((resolve) => {
123
+ const token = readToken();
124
+ if (!token) {
125
+ resolve({ outcome: 'unavailable', detail: 'no ipc token (daemon not running or old version)' });
126
+ return;
127
+ }
128
+ let settled = false;
129
+ const finish = (result) => {
130
+ if (settled)
131
+ return;
132
+ settled = true;
133
+ clearTimeout(connectTimer);
134
+ clearTimeout(responseTimer);
135
+ try {
136
+ socket.destroy();
137
+ }
138
+ catch { /* already gone */ }
139
+ resolve(result);
140
+ };
141
+ const fail = (detail) => finish({ outcome: 'unavailable', detail });
142
+ const socket = net.connect({ path: verdictPipePath() });
143
+ const connectTimer = setTimeout(() => fail('connect timeout'), CONNECT_TIMEOUT_MS);
144
+ const responseTimer = setTimeout(() => fail('response timeout'), responseTimeoutMs);
145
+ socket.on('error', (err) => fail(err.message));
146
+ socket.on('connect', () => {
147
+ clearTimeout(connectTimer);
148
+ const request = { v: exports.VERDICT_IPC_PROTOCOL_VERSION, token, args, stdin, t0: clientStartedAt };
149
+ try {
150
+ socket.write(JSON.stringify(request) + '\n');
151
+ }
152
+ catch (err) {
153
+ fail(err instanceof Error ? err.message : String(err));
154
+ }
155
+ });
156
+ let buffer = '';
157
+ socket.on('data', (chunk) => {
158
+ buffer += chunk.toString('utf8');
159
+ const nl = buffer.indexOf('\n');
160
+ if (nl === -1)
161
+ return;
162
+ try {
163
+ const resp = JSON.parse(buffer.slice(0, nl));
164
+ if (resp.error || typeof resp.stdout !== 'string') {
165
+ // The daemon declined (protocol mismatch, internal error). Nothing
166
+ // was decided — treat as unavailable so the hook evaluates locally.
167
+ finish({ outcome: 'unavailable', detail: resp.error || 'malformed response' });
168
+ return;
169
+ }
170
+ finish({
171
+ outcome: 'verdict',
172
+ stdout: resp.stdout,
173
+ stderr: resp.stderr || '',
174
+ exitCode: typeof resp.exitCode === 'number' ? resp.exitCode : 0,
175
+ });
176
+ }
177
+ catch (err) {
178
+ fail(err instanceof Error ? err.message : String(err));
179
+ }
180
+ });
181
+ socket.on('close', () => fail('connection closed'));
182
+ });
183
+ }
package/dist/version.json CHANGED
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "1.26.3"
2
+ "version": "1.26.5"
3
3
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullcourtdefense-cli",
3
- "version": "1.26.3",
3
+ "version": "1.26.5",
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,8 @@
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",
57
+ "test:slim-hook": "npm run build && node scripts/test-slim-hook.js",
56
58
  "test:deep-selftest": "npm run build && node scripts/test-deep-selftest.js",
57
59
  "test:update-loop": "npm run build && node scripts/test-update-loop-detection.js",
58
60
  "test:bricked-rescue": "npm run build && node scripts/test-bricked-machine-rescue.js",