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.
- package/dist/commands/daemon.js +40 -31
- package/dist/commands/hook.js +36 -102
- package/dist/commands/installClaudeHook.js +17 -3
- package/dist/commands/installCursorHook.js +24 -4
- package/dist/distress.d.ts +6 -0
- package/dist/distress.js +6 -0
- package/dist/hookIo.d.ts +43 -0
- package/dist/hookIo.js +188 -0
- package/dist/hookSlim.d.ts +32 -0
- package/dist/hookSlim.js +142 -0
- package/dist/index.js +4 -14
- package/dist/pollLoop.d.ts +72 -0
- package/dist/pollLoop.js +116 -0
- package/dist/verdictIpc.d.ts +12 -75
- package/dist/verdictIpc.js +30 -160
- package/dist/verdictIpcClient.d.ts +76 -0
- package/dist/verdictIpcClient.js +183 -0
- package/dist/version.json +1 -1
- package/package.json +3 -1
package/dist/verdictIpc.d.ts
CHANGED
|
@@ -1,82 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Daemon verdict IPC —
|
|
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
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
|
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
|
}
|
package/dist/verdictIpc.js
CHANGED
|
@@ -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 —
|
|
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
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
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
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
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
|
-
|
|
75
|
-
function
|
|
76
|
-
|
|
77
|
-
}
|
|
78
|
-
function
|
|
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:
|
|
117
|
+
reply({ v: verdictIpcClient_1.VERDICT_IPC_PROTOCOL_VERSION, error: 'malformed request' });
|
|
248
118
|
return;
|
|
249
119
|
}
|
|
250
|
-
if (request.v !==
|
|
251
|
-
reply({ 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:
|
|
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:
|
|
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:
|
|
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(
|
|
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fullcourtdefense-cli",
|
|
3
|
-
"version": "1.26.
|
|
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",
|