fullcourtdefense-cli 1.26.4 → 1.26.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/daemon.d.ts +14 -0
- package/dist/commands/daemon.js +181 -28
- package/dist/commands/desktopChatGuard.js +5 -4
- package/dist/commands/deterministicGuard.d.ts +8 -0
- package/dist/commands/deterministicGuard.js +6 -0
- package/dist/commands/doctor.d.ts +4 -0
- package/dist/commands/doctor.js +99 -0
- package/dist/commands/hook.d.ts +1 -0
- package/dist/commands/hook.js +61 -104
- package/dist/commands/installClaudeHook.d.ts +2 -0
- package/dist/commands/installClaudeHook.js +21 -3
- package/dist/commands/installCursorHook.d.ts +8 -0
- package/dist/commands/installCursorHook.js +29 -4
- package/dist/commands/mcpGateway.js +234 -43
- package/dist/commands/onboard.d.ts +9 -0
- package/dist/commands/onboard.js +7 -0
- package/dist/commands/watchdog.d.ts +5 -2
- package/dist/commands/watchdog.js +34 -4
- package/dist/config.js +6 -1
- package/dist/daemonForensics.d.ts +27 -0
- package/dist/daemonForensics.js +27 -0
- package/dist/distress.d.ts +17 -0
- package/dist/distress.js +17 -0
- package/dist/hookIo.d.ts +43 -0
- package/dist/hookIo.js +189 -0
- package/dist/hookSlim.d.ts +32 -0
- package/dist/hookSlim.js +142 -0
- package/dist/index.js +10 -14
- package/dist/integrity.js +8 -0
- package/dist/localSafetySnapshot.d.ts +9 -0
- package/dist/localSafetySnapshot.js +58 -2
- package/dist/machineIdentity.js +70 -4
- package/dist/runtimeConfig.d.ts +2 -0
- package/dist/runtimeConfig.js +177 -5
- package/dist/selfTest.d.ts +10 -0
- package/dist/selfTest.js +19 -8
- package/dist/selfUpdate.d.ts +19 -4
- package/dist/selfUpdate.js +108 -12
- package/dist/telemetry.js +67 -2
- package/dist/verdictIpc.d.ts +12 -75
- package/dist/verdictIpc.js +104 -188
- package/dist/verdictIpcClient.d.ts +103 -0
- package/dist/verdictIpcClient.js +251 -0
- package/dist/version.json +1 -1
- package/package.json +6 -1
package/dist/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,24 @@ 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' });
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
// Liveness ping (watchdog hung-daemon check / startup pid-reuse
|
|
132
|
+
// check): answer immediately, never evaluate.
|
|
133
|
+
if (request.ping === true) {
|
|
134
|
+
reply({ v: verdictIpcClient_1.VERDICT_IPC_PROTOCOL_VERSION, pong: true });
|
|
259
135
|
return;
|
|
260
136
|
}
|
|
261
137
|
try {
|
|
@@ -264,52 +140,92 @@ function startVerdictServer(evaluate, log) {
|
|
|
264
140
|
// developer waited. Per-request (AsyncLocalStorage), so concurrent
|
|
265
141
|
// hooks in this one daemon never borrow each other's timings.
|
|
266
142
|
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:
|
|
143
|
+
reply({ v: verdictIpcClient_1.VERDICT_IPC_PROTOCOL_VERSION, ...result });
|
|
268
144
|
}
|
|
269
145
|
catch (err) {
|
|
270
146
|
// Evaluator bug — tell the client to fall back locally (which will
|
|
271
147
|
// itself fail open if the same bug reproduces there).
|
|
272
|
-
reply({ v:
|
|
148
|
+
reply({ v: verdictIpcClient_1.VERDICT_IPC_PROTOCOL_VERSION, error: err instanceof Error ? err.message : String(err) });
|
|
273
149
|
}
|
|
274
150
|
})();
|
|
275
151
|
});
|
|
276
152
|
});
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
catch { /* best-effort */ }
|
|
153
|
+
// EADDRINUSE recovery: a wedged predecessor (or a stale unix socket) can
|
|
154
|
+
// hold the endpoint at boot. Giving up permanently downgraded every hook on
|
|
155
|
+
// the machine to the slow local path for the daemon's whole life. Instead,
|
|
156
|
+
// retry on a timer — the kernel frees a Windows named pipe the moment its
|
|
157
|
+
// owner dies (e.g. the watchdog recycles it), and the retry takes over.
|
|
158
|
+
const LISTEN_RETRY_MS = Math.max(100, Number(process.env.FCD_IPC_LISTEN_RETRY_MS) || 60_000);
|
|
159
|
+
let closedByUs = false;
|
|
160
|
+
let retryTimer;
|
|
161
|
+
let retries = 0;
|
|
162
|
+
const onListening = () => {
|
|
163
|
+
if (process.platform !== 'win32') {
|
|
164
|
+
try {
|
|
165
|
+
fs.chmodSync(pipePath, 0o600);
|
|
291
166
|
}
|
|
292
|
-
|
|
167
|
+
catch { /* best-effort */ }
|
|
168
|
+
}
|
|
169
|
+
if (retries > 0)
|
|
170
|
+
log(`Verdict IPC: endpoint freed after ${retries} retr${retries === 1 ? 'y' : 'ies'} — taking over.`);
|
|
171
|
+
retries = 0;
|
|
172
|
+
// Publish the token only now that the pipe is actually owned by us.
|
|
173
|
+
try {
|
|
174
|
+
fs.mkdirSync((0, verdictIpcClient_1.verdictStateDir)(), { recursive: true });
|
|
175
|
+
fs.writeFileSync((0, verdictIpcClient_1.verdictTokenFile)(), token, { encoding: 'utf8', mode: 0o600 });
|
|
176
|
+
log(`Verdict IPC: serving hook verdicts on ${pipePath} (thin-client hooks, ms verdicts).`);
|
|
177
|
+
}
|
|
178
|
+
catch (err) {
|
|
179
|
+
log(`Verdict IPC: could not write token file (${err instanceof Error ? err.message : String(err)}) — hooks will evaluate locally.`);
|
|
293
180
|
try {
|
|
294
|
-
|
|
295
|
-
fs.writeFileSync(verdictTokenFile(), token, { encoding: 'utf8', mode: 0o600 });
|
|
296
|
-
log(`Verdict IPC: serving hook verdicts on ${pipePath} (thin-client hooks, ms verdicts).`);
|
|
181
|
+
server.close();
|
|
297
182
|
}
|
|
298
|
-
catch
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
183
|
+
catch { /* best-effort */ }
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
const tryListen = () => {
|
|
187
|
+
try {
|
|
188
|
+
server.listen(pipePath, onListening);
|
|
189
|
+
}
|
|
190
|
+
catch (err) {
|
|
191
|
+
log(`Verdict IPC: listen failed (${err instanceof Error ? err.message : String(err)}) — hooks will evaluate locally.`);
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
server.on('error', (err) => {
|
|
195
|
+
// removeTokenIfOurs is a no-op unless we had already published our own
|
|
196
|
+
// token (listen succeeded earlier) — a live daemon's token is untouched.
|
|
197
|
+
removeTokenIfOurs();
|
|
198
|
+
const code = err.code;
|
|
199
|
+
if (!closedByUs && code === 'EADDRINUSE') {
|
|
200
|
+
if (retries === 0) {
|
|
201
|
+
log(`Verdict IPC: endpoint busy (${err.message}) — retrying every ${Math.round(LISTEN_RETRY_MS / 1000)}s until it frees; hooks evaluate locally meanwhile.`);
|
|
304
202
|
}
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
203
|
+
retries += 1;
|
|
204
|
+
retryTimer = setTimeout(() => {
|
|
205
|
+
if (closedByUs)
|
|
206
|
+
return;
|
|
207
|
+
if (process.platform !== 'win32') {
|
|
208
|
+
// A crashed owner leaves the socket FILE behind; unlink so the
|
|
209
|
+
// retry can bind. The pid lock guarantees no healthy daemon owns it.
|
|
210
|
+
try {
|
|
211
|
+
fs.unlinkSync(pipePath);
|
|
212
|
+
}
|
|
213
|
+
catch { /* not there — fine */ }
|
|
214
|
+
}
|
|
215
|
+
tryListen();
|
|
216
|
+
}, LISTEN_RETRY_MS);
|
|
217
|
+
if (typeof retryTimer.unref === 'function')
|
|
218
|
+
retryTimer.unref();
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
log(`Verdict IPC: server error (${err.message}) — hooks will evaluate locally.`);
|
|
222
|
+
});
|
|
223
|
+
tryListen();
|
|
311
224
|
return {
|
|
312
225
|
close() {
|
|
226
|
+
closedByUs = true;
|
|
227
|
+
if (retryTimer)
|
|
228
|
+
clearTimeout(retryTimer);
|
|
313
229
|
// Remove the token FIRST so new hook processes stop trying the pipe —
|
|
314
230
|
// but only if it is still ours (a newer daemon may have taken over).
|
|
315
231
|
removeTokenIfOurs();
|
|
@@ -319,7 +235,7 @@ function startVerdictServer(evaluate, log) {
|
|
|
319
235
|
catch { /* best-effort */ }
|
|
320
236
|
if (process.platform !== 'win32') {
|
|
321
237
|
try {
|
|
322
|
-
fs.unlinkSync(verdictPipePath());
|
|
238
|
+
fs.unlinkSync((0, verdictIpcClient_1.verdictPipePath)());
|
|
323
239
|
}
|
|
324
240
|
catch { /* best-effort */ }
|
|
325
241
|
}
|
|
@@ -0,0 +1,103 @@
|
|
|
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
|
+
* Liveness ping (watchdog / startup pid-reuse check): the server replies
|
|
42
|
+
* `{ pong: true }` without evaluating anything. Watchdog and daemon always
|
|
43
|
+
* ship in the same build (one install, one scheduled task target), so no
|
|
44
|
+
* cross-version compatibility concern.
|
|
45
|
+
*/
|
|
46
|
+
ping?: boolean;
|
|
47
|
+
}
|
|
48
|
+
export interface VerdictResponse {
|
|
49
|
+
v: number;
|
|
50
|
+
/** Present on evaluation success. */
|
|
51
|
+
stdout?: string;
|
|
52
|
+
stderr?: string;
|
|
53
|
+
exitCode?: number;
|
|
54
|
+
/** Present when the daemon could not evaluate — client falls back locally. */
|
|
55
|
+
error?: string;
|
|
56
|
+
/** Reply to a liveness ping. */
|
|
57
|
+
pong?: boolean;
|
|
58
|
+
}
|
|
59
|
+
/** Result of a client attempt. Anything but 'verdict' → evaluate locally. */
|
|
60
|
+
export interface VerdictClientResult {
|
|
61
|
+
outcome: 'verdict' | 'unavailable';
|
|
62
|
+
stdout?: string;
|
|
63
|
+
stderr?: string;
|
|
64
|
+
exitCode?: number;
|
|
65
|
+
detail?: string;
|
|
66
|
+
}
|
|
67
|
+
export declare function verdictStateDir(): string;
|
|
68
|
+
export declare function verdictTokenFile(): string;
|
|
69
|
+
export declare function verdictPipePath(): string;
|
|
70
|
+
/**
|
|
71
|
+
* Ask the resident daemon for a verdict. Bounded hard: connect budget ~250ms
|
|
72
|
+
* (a missing pipe fails in <1ms with ENOENT — the common daemon-down case
|
|
73
|
+
* costs nothing), response capped at ~2.5s. A verdict that legitimately needs
|
|
74
|
+
* longer than that (synchronous approval wait, no-cache gate call) is exactly
|
|
75
|
+
* the case where the IPC saves nothing — the local evaluation path handles it
|
|
76
|
+
* with its own bounded waits. Never throws.
|
|
77
|
+
*
|
|
78
|
+
* Burst behavior: an agent firing many commands at once (docker compose +
|
|
79
|
+
* exec + build in flight together) means many hooks connecting to the ONE
|
|
80
|
+
* pipe simultaneously — some connects lose the race (EBUSY / connect
|
|
81
|
+
* timeout) while the daemon is demonstrably alive (token present). Those
|
|
82
|
+
* get ONE short jittered retry; hard failures (ENOENT stale pipe, refused)
|
|
83
|
+
* still fall back to local evaluation instantly.
|
|
84
|
+
*/
|
|
85
|
+
export declare function requestDaemonVerdict(args: Record<string, string | undefined>, stdin: string, responseTimeoutMs?: number, clientStartedAt?: number): Promise<VerdictClientResult>;
|
|
86
|
+
/**
|
|
87
|
+
* Liveness probe result for the verdict endpoint:
|
|
88
|
+
* - 'responsive' — something answered on the pipe (any parseable reply,
|
|
89
|
+
* including 'unauthorized': the server is alive).
|
|
90
|
+
* - 'unresponsive' — the endpoint EXISTS (connect succeeded, or the pipe is
|
|
91
|
+
* busy) but nothing answered within the budget. On
|
|
92
|
+
* Windows a named pipe is a kernel object freed when its
|
|
93
|
+
* owner dies, so an existing-but-silent endpoint means a
|
|
94
|
+
* live-but-wedged owner.
|
|
95
|
+
* - 'no-endpoint' — nothing is serving the pipe/socket at all.
|
|
96
|
+
*/
|
|
97
|
+
export type VerdictServerProbe = 'responsive' | 'unresponsive' | 'no-endpoint';
|
|
98
|
+
/**
|
|
99
|
+
* Probe the verdict server without evaluating anything. Used by the watchdog
|
|
100
|
+
* (hung-daemon detection) and by daemon startup (pid-reuse detection). Never
|
|
101
|
+
* throws; bounded by `timeoutMs`.
|
|
102
|
+
*/
|
|
103
|
+
export declare function probeVerdictServer(timeoutMs?: number): Promise<VerdictServerProbe>;
|