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
|
@@ -236,6 +236,7 @@ function resolveGatewayConfig(args, config, defaults = {}) {
|
|
|
236
236
|
approvalTimeoutMs: Number(args.approvalTimeoutMs) > 0 ? Number(args.approvalTimeoutMs) : 60_000,
|
|
237
237
|
approvalPollMs: Math.max(1000, Number(args.approvalPollMs) > 0 ? Number(args.approvalPollMs) : 3000),
|
|
238
238
|
scanResponse: args.scanResponse === 'true',
|
|
239
|
+
scanResponseExplicit: args.scanResponse !== undefined,
|
|
239
240
|
failClosed: args.failClosed !== 'false',
|
|
240
241
|
failClosedExplicit: args.failClosed !== undefined,
|
|
241
242
|
};
|
|
@@ -342,6 +343,33 @@ function contentToText(result) {
|
|
|
342
343
|
return String(result);
|
|
343
344
|
}
|
|
344
345
|
}
|
|
346
|
+
function safeStringify(value) {
|
|
347
|
+
try {
|
|
348
|
+
return JSON.stringify(value) ?? '';
|
|
349
|
+
}
|
|
350
|
+
catch {
|
|
351
|
+
return '';
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Text a response-side deterministic scan must see. contentToText() only reads
|
|
356
|
+
* `type:'text'` items — a secret riding a non-text shape (resource blob,
|
|
357
|
+
* structuredContent, extra result fields) would bypass the scan entirely. The
|
|
358
|
+
* scan input is the readable text PLUS the full JSON serialization when the
|
|
359
|
+
* result carries more than plain text (the text stays first and verbatim so
|
|
360
|
+
* multi-line patterns keep matching).
|
|
361
|
+
*/
|
|
362
|
+
function responseScanText(result) {
|
|
363
|
+
const text = contentToText(result);
|
|
364
|
+
if (typeof result === 'string')
|
|
365
|
+
return text;
|
|
366
|
+
const serialized = safeStringify(result);
|
|
367
|
+
// All-text results serialize to roughly the text plus JSON scaffolding; only
|
|
368
|
+
// append when the serialization carries meaningfully more payload.
|
|
369
|
+
if (serialized && serialized.length > text.length + 64)
|
|
370
|
+
return `${text}\n${serialized}`;
|
|
371
|
+
return text;
|
|
372
|
+
}
|
|
345
373
|
/**
|
|
346
374
|
* Redact mask-action secrets from an MCP tool result IN PLACE of blocking it.
|
|
347
375
|
* Only the shapes contentToText() reads are rewritable (plain string, text
|
|
@@ -373,29 +401,86 @@ function maskToolResult(result, maskFindings) {
|
|
|
373
401
|
};
|
|
374
402
|
}
|
|
375
403
|
}
|
|
376
|
-
// Fail-strict verification: every masked secret must be gone from
|
|
377
|
-
//
|
|
378
|
-
|
|
379
|
-
|
|
404
|
+
// Fail-strict verification: every masked secret must be gone from the WHOLE
|
|
405
|
+
// result — not just the text items a client usually reads. A secret hiding
|
|
406
|
+
// in a non-text shape (resource blob, structuredContent, extra fields) must
|
|
407
|
+
// flip fullyMasked=false so enforce mode blocks instead of leaking it. The
|
|
408
|
+
// JSON-escaped form is checked too (quotes/backslashes in the secret would
|
|
409
|
+
// otherwise dodge the serialized comparison).
|
|
410
|
+
const remaining = `${contentToText(masked)}\n${safeStringify(masked)}`;
|
|
411
|
+
const fullyMasked = secrets.every(({ secret }) => !remaining.includes(secret) && !remaining.includes(JSON.stringify(secret).slice(1, -1)));
|
|
380
412
|
return { masked, fullyMasked };
|
|
381
413
|
}
|
|
414
|
+
/** Line-buffer hard cap: a downstream emitting one endless line must not OOM the proxy. */
|
|
415
|
+
const MAX_LINE_BUFFER_BYTES = 32 * 1024 * 1024;
|
|
416
|
+
/** cmd.exe metacharacters that would change meaning inside a shell:true spawn. */
|
|
417
|
+
const CMD_SHELL_METACHARS = /[&|<>^%!"\r\n]/;
|
|
418
|
+
/**
|
|
419
|
+
* Resolve a bare Windows command through PATH + PATHEXT (what a shell would
|
|
420
|
+
* do) so the gateway can spawn the real target with shell:false. Returns
|
|
421
|
+
* undefined when nothing resolves.
|
|
422
|
+
*/
|
|
423
|
+
function resolveWindowsCommand(command) {
|
|
424
|
+
if (path.isAbsolute(command) || command.includes('/') || command.includes('\\')) {
|
|
425
|
+
return fs.existsSync(command) ? command : undefined;
|
|
426
|
+
}
|
|
427
|
+
const dirs = (process.env.PATH || '').split(path.delimiter).filter(Boolean);
|
|
428
|
+
const exts = path.extname(command)
|
|
429
|
+
? ['']
|
|
430
|
+
: (process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean);
|
|
431
|
+
for (const dir of dirs) {
|
|
432
|
+
for (const ext of exts) {
|
|
433
|
+
const candidate = path.join(dir, command + ext.toLowerCase());
|
|
434
|
+
try {
|
|
435
|
+
if (fs.existsSync(candidate))
|
|
436
|
+
return candidate;
|
|
437
|
+
}
|
|
438
|
+
catch { /* unreadable PATH entry */ }
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
return undefined;
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* Spawn the downstream WITHOUT an interpreting shell whenever possible.
|
|
445
|
+
* shell:true on every Windows spawn (the old behavior) let metacharacters in
|
|
446
|
+
* a workspace-supplied command/args run arbitrary cmd.exe syntax. Now:
|
|
447
|
+
* - real executables (.exe/.com, or anything non-Windows) spawn shell:false;
|
|
448
|
+
* - .cmd/.bat shims genuinely need cmd.exe — those spawn shell:true only
|
|
449
|
+
* after the command AND args are checked for shell metacharacters.
|
|
450
|
+
*/
|
|
451
|
+
function spawnDownstream(command, args) {
|
|
452
|
+
if (process.platform !== 'win32') {
|
|
453
|
+
return (0, child_process_1.spawn)(command, args, { stdio: ['pipe', 'pipe', 'pipe'], shell: false });
|
|
454
|
+
}
|
|
455
|
+
const resolved = resolveWindowsCommand(command);
|
|
456
|
+
const target = resolved || command;
|
|
457
|
+
const needsShell = /\.(cmd|bat)$/i.test(target) || !resolved;
|
|
458
|
+
if (!needsShell) {
|
|
459
|
+
return (0, child_process_1.spawn)(target, args, { stdio: ['pipe', 'pipe', 'pipe'], shell: false });
|
|
460
|
+
}
|
|
461
|
+
for (const part of [command, ...args]) {
|
|
462
|
+
if (CMD_SHELL_METACHARS.test(part)) {
|
|
463
|
+
throw new Error(`Downstream MCP command contains shell metacharacters and cannot be run safely through cmd.exe: ${part.slice(0, 120)}`);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
return (0, child_process_1.spawn)(target, args, { stdio: ['pipe', 'pipe', 'pipe'], shell: true });
|
|
467
|
+
}
|
|
382
468
|
class StdioMcpClient {
|
|
469
|
+
onClosed;
|
|
383
470
|
child;
|
|
384
471
|
buffer = '';
|
|
385
472
|
nextId = 1;
|
|
386
473
|
pending = new Map();
|
|
387
|
-
constructor(command, args) {
|
|
474
|
+
constructor(command, args, onClosed) {
|
|
475
|
+
this.onClosed = onClosed;
|
|
388
476
|
if (!command)
|
|
389
477
|
throw new Error('Missing downstream MCP command. Pass --mcp-command.');
|
|
390
|
-
this.child = (
|
|
391
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
392
|
-
shell: process.platform === 'win32',
|
|
393
|
-
});
|
|
478
|
+
this.child = spawnDownstream(command, args);
|
|
394
479
|
this.child.stdout.setEncoding('utf8');
|
|
395
480
|
this.child.stdout.on('data', chunk => this.onData(chunk));
|
|
396
481
|
this.child.stderr.on('data', chunk => process.stderr.write(String(chunk)));
|
|
397
|
-
this.child.on('error', error => this.rejectAll(error));
|
|
398
|
-
this.child.on('exit', code => this.rejectAll(new Error(`Downstream MCP server exited (${code ?? 'signal'}).`)));
|
|
482
|
+
this.child.on('error', error => { this.rejectAll(error); this.onClosed?.(); });
|
|
483
|
+
this.child.on('exit', code => { this.rejectAll(new Error(`Downstream MCP server exited (${code ?? 'signal'}).`)); this.onClosed?.(); });
|
|
399
484
|
}
|
|
400
485
|
async initialize() {
|
|
401
486
|
await this.request('initialize', {
|
|
@@ -431,10 +516,17 @@ class StdioMcpClient {
|
|
|
431
516
|
this.write({ jsonrpc: '2.0', id, method, params });
|
|
432
517
|
return new Promise((resolve, reject) => {
|
|
433
518
|
const timeout = setTimeout(() => {
|
|
434
|
-
this.pending.delete(id);
|
|
435
|
-
|
|
519
|
+
this.pending.delete(String(id));
|
|
520
|
+
// A downstream that ignores a request for a full minute is hung, not
|
|
521
|
+
// slow — kill it so the exit handler rejects everything in flight and
|
|
522
|
+
// the gateway respawns a fresh downstream on the next call.
|
|
523
|
+
try {
|
|
524
|
+
this.child.kill();
|
|
525
|
+
}
|
|
526
|
+
catch { /* already gone */ }
|
|
527
|
+
reject(new Error(`MCP request timed out: ${method} — downstream server killed; it restarts on the next call.`));
|
|
436
528
|
}, 60000);
|
|
437
|
-
this.pending.set(id, { resolve, reject, timeout });
|
|
529
|
+
this.pending.set(String(id), { resolve, reject, timeout });
|
|
438
530
|
});
|
|
439
531
|
}
|
|
440
532
|
notify(method, params) {
|
|
@@ -447,8 +539,14 @@ class StdioMcpClient {
|
|
|
447
539
|
this.buffer += chunk;
|
|
448
540
|
while (true) {
|
|
449
541
|
const lineEnd = this.buffer.indexOf('\n');
|
|
450
|
-
if (lineEnd === -1)
|
|
542
|
+
if (lineEnd === -1) {
|
|
543
|
+
// Cap the partial-line buffer: one endless line must not OOM the proxy.
|
|
544
|
+
if (this.buffer.length > MAX_LINE_BUFFER_BYTES) {
|
|
545
|
+
this.buffer = '';
|
|
546
|
+
process.stderr.write('[downstream] dropped an oversized partial line (buffer cap)\n');
|
|
547
|
+
}
|
|
451
548
|
return;
|
|
549
|
+
}
|
|
452
550
|
const raw = this.buffer.slice(0, lineEnd).trim();
|
|
453
551
|
this.buffer = this.buffer.slice(lineEnd + 1);
|
|
454
552
|
if (!raw)
|
|
@@ -465,13 +563,14 @@ class StdioMcpClient {
|
|
|
465
563
|
process.stderr.write(`[downstream] ${raw}\n`);
|
|
466
564
|
continue;
|
|
467
565
|
}
|
|
468
|
-
|
|
566
|
+
// JSON-RPC ids may legally be strings — some servers echo "1" for 1.
|
|
567
|
+
if (message.id === undefined || message.id === null)
|
|
469
568
|
continue;
|
|
470
|
-
const pending = this.pending.get(message.id);
|
|
569
|
+
const pending = this.pending.get(String(message.id));
|
|
471
570
|
if (!pending)
|
|
472
571
|
continue;
|
|
473
572
|
clearTimeout(pending.timeout);
|
|
474
|
-
this.pending.delete(message.id);
|
|
573
|
+
this.pending.delete(String(message.id));
|
|
475
574
|
if (message.error)
|
|
476
575
|
pending.reject(new Error(message.error.message || 'MCP request failed'));
|
|
477
576
|
else
|
|
@@ -797,8 +896,13 @@ class McpGatewayServer {
|
|
|
797
896
|
this.buffer += chunk;
|
|
798
897
|
while (true) {
|
|
799
898
|
const lineEnd = this.buffer.indexOf('\n');
|
|
800
|
-
if (lineEnd === -1)
|
|
899
|
+
if (lineEnd === -1) {
|
|
900
|
+
if (this.buffer.length > MAX_LINE_BUFFER_BYTES) {
|
|
901
|
+
this.buffer = '';
|
|
902
|
+
process.stderr.write('FullCourtDefense gateway: dropped an oversized partial line from the client (buffer cap)\n');
|
|
903
|
+
}
|
|
801
904
|
return;
|
|
905
|
+
}
|
|
802
906
|
const raw = this.buffer.slice(0, lineEnd).trim();
|
|
803
907
|
this.buffer = this.buffer.slice(lineEnd + 1);
|
|
804
908
|
if (!raw)
|
|
@@ -816,7 +920,8 @@ class McpGatewayServer {
|
|
|
816
920
|
}
|
|
817
921
|
}
|
|
818
922
|
async handleMessage(message) {
|
|
819
|
-
|
|
923
|
+
// id may legally be 0 or "" — only a genuinely ABSENT id marks a notification.
|
|
924
|
+
if ((message.id === undefined || message.id === null) && message.method?.startsWith('notifications/'))
|
|
820
925
|
return;
|
|
821
926
|
try {
|
|
822
927
|
if (message.method === 'initialize') {
|
|
@@ -851,13 +956,27 @@ class McpGatewayServer {
|
|
|
851
956
|
}
|
|
852
957
|
async ensureDownstream() {
|
|
853
958
|
if (!this.downstreamReady) {
|
|
854
|
-
|
|
959
|
+
const client = this.downstreamSpec.kind === 'http'
|
|
855
960
|
? new HttpMcpClient(this.downstreamSpec.url, this.downstreamSpec.headers)
|
|
856
|
-
: new StdioMcpClient(this.downstreamSpec.command, this.downstreamSpec.args);
|
|
857
|
-
this.
|
|
961
|
+
: new StdioMcpClient(this.downstreamSpec.command, this.downstreamSpec.args, () => this.resetDownstream(client));
|
|
962
|
+
this.downstream = client;
|
|
963
|
+
// A failed initialize must NOT poison the cached promise forever (every
|
|
964
|
+
// later call would rethrow the same startup error) — reset so the next
|
|
965
|
+
// call spawns a fresh downstream.
|
|
966
|
+
this.downstreamReady = client.initialize().catch(err => {
|
|
967
|
+
this.resetDownstream(client);
|
|
968
|
+
throw err;
|
|
969
|
+
});
|
|
858
970
|
}
|
|
859
971
|
await this.downstreamReady;
|
|
860
972
|
}
|
|
973
|
+
/** Forget a dead/failed downstream so the next call respawns it. */
|
|
974
|
+
resetDownstream(client) {
|
|
975
|
+
if (this.downstream === client) {
|
|
976
|
+
this.downstream = undefined;
|
|
977
|
+
this.downstreamReady = undefined;
|
|
978
|
+
}
|
|
979
|
+
}
|
|
861
980
|
/** Spool a locally-enforced deterministic block so it reaches Live Enforcement even offline. */
|
|
862
981
|
spoolLocalFinding(finding, toolName, operation, decision = 'block') {
|
|
863
982
|
(0, telemetry_1.spoolEvent)({
|
|
@@ -924,6 +1043,22 @@ class McpGatewayServer {
|
|
|
924
1043
|
machineName: os.hostname(),
|
|
925
1044
|
hotPath: true,
|
|
926
1045
|
});
|
|
1046
|
+
// Admin kill-switch: a suspended machine denies every tool call. This
|
|
1047
|
+
// check sits OUTSIDE the source gate below — a 'default' bundle can
|
|
1048
|
+
// carry suspended:true from the signed last-known-suspended marker
|
|
1049
|
+
// (cache file deleted), and that must still deny.
|
|
1050
|
+
if (bundle.suspended === true) {
|
|
1051
|
+
(0, telemetry_1.spoolEvent)({
|
|
1052
|
+
decision: 'block',
|
|
1053
|
+
toolName,
|
|
1054
|
+
operation: 'mcp:suspended',
|
|
1055
|
+
reason: 'Machine suspended by an administrator (kill-switch).',
|
|
1056
|
+
source: 'backend',
|
|
1057
|
+
severity: 'critical',
|
|
1058
|
+
});
|
|
1059
|
+
(0, telemetry_1.triggerFlush)(true);
|
|
1060
|
+
throw blockError('This machine is suspended by your security team — all AI agent actions are blocked. Contact your admin to resume it.');
|
|
1061
|
+
}
|
|
927
1062
|
if (bundle.source !== 'default') {
|
|
928
1063
|
expectedPolicyHash = bundle.policyHash || bundle.version;
|
|
929
1064
|
cachedPolicies = bundle.actionPolicies;
|
|
@@ -935,19 +1070,6 @@ class McpGatewayServer {
|
|
|
935
1070
|
if (!this.gatewayConfig.failClosedExplicit && typeof bundle.failClosed === 'boolean') {
|
|
936
1071
|
this.gatewayConfig.failClosed = bundle.failClosed;
|
|
937
1072
|
}
|
|
938
|
-
// Admin kill-switch: a suspended machine denies every tool call.
|
|
939
|
-
if (bundle.suspended === true) {
|
|
940
|
-
(0, telemetry_1.spoolEvent)({
|
|
941
|
-
decision: 'block',
|
|
942
|
-
toolName,
|
|
943
|
-
operation: 'mcp:suspended',
|
|
944
|
-
reason: 'Machine suspended by an administrator (kill-switch).',
|
|
945
|
-
source: 'backend',
|
|
946
|
-
severity: 'critical',
|
|
947
|
-
});
|
|
948
|
-
(0, telemetry_1.triggerFlush)(true);
|
|
949
|
-
throw blockError('This machine is suspended by your security team — all AI agent actions are blocked. Contact your admin to resume it.');
|
|
950
|
-
}
|
|
951
1073
|
}
|
|
952
1074
|
}
|
|
953
1075
|
catch (err) {
|
|
@@ -955,12 +1077,18 @@ class McpGatewayServer {
|
|
|
955
1077
|
throw err;
|
|
956
1078
|
/* cached/default snapshot still applies */
|
|
957
1079
|
}
|
|
1080
|
+
// Snapshot cache key uses the SHARED hook identity (not the gateway's
|
|
1081
|
+
// configurable developerName): the daemon warms exactly one snapshot
|
|
1082
|
+
// file per machine under hookSnapshotIdentity() — keying by the gateway
|
|
1083
|
+
// identity produced a second, never-warmed cache file, so every gateway
|
|
1084
|
+
// call risked paying the fetch timeout the daemon exists to absorb.
|
|
1085
|
+
const snapshotIdentity = (0, localSafetySnapshot_1.hookSnapshotIdentity)();
|
|
958
1086
|
const snapshot = await (0, localSafetySnapshot_1.loadLocalSafetySnapshot)({
|
|
959
1087
|
apiUrl: this.gatewayConfig.apiUrl,
|
|
960
1088
|
shieldId: this.gatewayConfig.shieldId,
|
|
961
1089
|
shieldKey: this.gatewayConfig.shieldKey,
|
|
962
|
-
developerName:
|
|
963
|
-
machineName:
|
|
1090
|
+
developerName: snapshotIdentity.developerName,
|
|
1091
|
+
machineName: snapshotIdentity.machineName,
|
|
964
1092
|
expectedPolicyHash,
|
|
965
1093
|
preferCached: reportOnlyMode,
|
|
966
1094
|
});
|
|
@@ -1258,7 +1386,10 @@ class McpGatewayServer {
|
|
|
1258
1386
|
// Response-side events must measure our scanning overhead, not the
|
|
1259
1387
|
// tool's execution time (see restartVerdictTiming).
|
|
1260
1388
|
(0, telemetry_1.restartVerdictTiming)();
|
|
1261
|
-
|
|
1389
|
+
// responseScanText: readable text PLUS the JSON serialization when the
|
|
1390
|
+
// result carries non-text shapes — a secret in a resource blob or
|
|
1391
|
+
// structured field must not bypass the scan.
|
|
1392
|
+
const responseOutcome = (0, deterministicGuard_1.resolveDeterministicTextResponse)(responseScanText(rawResult), (0, localSafetySnapshot_1.snapshotToScanOptions)(snapshot));
|
|
1262
1393
|
for (const warning of responseOutcome.warnings) {
|
|
1263
1394
|
this.spoolLocalFinding(warning, toolName, operation, 'warn');
|
|
1264
1395
|
process.stderr.write(`FullCourtDefense warning (${warning.itemId}): ${warning.reason}\n`);
|
|
@@ -1297,6 +1428,11 @@ class McpGatewayServer {
|
|
|
1297
1428
|
}
|
|
1298
1429
|
}
|
|
1299
1430
|
rawResult = masked;
|
|
1431
|
+
// Keep the fail-open snapshot in sync: if a LATER step (role limits,
|
|
1432
|
+
// cloud scan) hits an unexpected bug, the top-level catch returns
|
|
1433
|
+
// downstreamResult — which must be the MASKED payload, never the
|
|
1434
|
+
// pre-mask original with the secret still in it.
|
|
1435
|
+
downstreamResult = masked;
|
|
1300
1436
|
for (const finding of responseOutcome.maskFindings) {
|
|
1301
1437
|
this.spoolLocalFinding(finding, toolName, operation, 'mask');
|
|
1302
1438
|
process.stderr.write(`FullCourtDefense masked a ${finding.itemId} in the ${toolName} response.\n`);
|
|
@@ -1331,9 +1467,34 @@ class McpGatewayServer {
|
|
|
1331
1467
|
}
|
|
1332
1468
|
}
|
|
1333
1469
|
}
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1470
|
+
// Effective cloud response scanning: explicit flag wins; otherwise the
|
|
1471
|
+
// machine's mode decides — ON for block machines, OFF for monitor.
|
|
1472
|
+
const effectiveScanResponse = this.gatewayConfig.scanResponseExplicit
|
|
1473
|
+
? this.gatewayConfig.scanResponse
|
|
1474
|
+
: !reportOnlyMode;
|
|
1475
|
+
let finalResult = rawResult;
|
|
1476
|
+
if (effectiveScanResponse) {
|
|
1477
|
+
try {
|
|
1478
|
+
finalResult = await this.api.scanToolResponse({ toolName, operation, toolArgs, result: rawResult });
|
|
1479
|
+
}
|
|
1480
|
+
catch (scanErr) {
|
|
1481
|
+
if (isBlockError(scanErr))
|
|
1482
|
+
throw scanErr; // Shield verdict — a real block
|
|
1483
|
+
// Scan API/infra failure. On an ENFORCING machine, returning the raw
|
|
1484
|
+
// response would silently skip the org's exfil scan — that is a
|
|
1485
|
+
// block, not a fail-open. Monitor machines record and pass through.
|
|
1486
|
+
const detail = scanErr instanceof Error ? scanErr.message : String(scanErr);
|
|
1487
|
+
if (!reportOnlyMode) {
|
|
1488
|
+
(0, telemetry_1.spoolEvent)({ decision: 'block', toolName, operation, reason: `response scan unavailable (block mode): ${detail.slice(0, 200)}`, offlineEnforced: true });
|
|
1489
|
+
(0, telemetry_1.triggerFlush)(true);
|
|
1490
|
+
throw blockError(`FullCourtDefense could not scan the ${toolName} response (${detail}) and this machine enforces response scanning — response withheld. Retry once the scan service is reachable.`);
|
|
1491
|
+
}
|
|
1492
|
+
(0, telemetry_1.spoolEvent)({ decision: 'warn', toolName, operation, reason: `[monitor] response scan unavailable: ${detail.slice(0, 200)}`, offlineEnforced: true });
|
|
1493
|
+
(0, telemetry_1.triggerFlush)(false);
|
|
1494
|
+
process.stderr.write(`FullCourtDefense (monitor): response scan unavailable for ${toolName} (${detail}) — response passed through.\n`);
|
|
1495
|
+
finalResult = rawResult;
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1337
1498
|
// Post-call audit is fire-and-forget: the verdict is already made and the
|
|
1338
1499
|
// tool already ran. Awaiting the report here held the IDE's tool response
|
|
1339
1500
|
// hostage for the full network timeout (up to 10s) — pure perceived lag.
|
|
@@ -1358,6 +1519,33 @@ class McpGatewayServer {
|
|
|
1358
1519
|
// verdicts, not the tool's own failure): a bug in our preflight or
|
|
1359
1520
|
// response-scanning code must never break the developer's tool call.
|
|
1360
1521
|
// One silent stderr line + one spooled event, then the real result.
|
|
1522
|
+
//
|
|
1523
|
+
// EXCEPT the kill-switch: "exception = allow" must not bypass an admin
|
|
1524
|
+
// suspension. Mirrors the hook's failOpenOutcome contract — a suspended
|
|
1525
|
+
// machine denies even when our own code crashed.
|
|
1526
|
+
let suspendedOnFailOpen = false;
|
|
1527
|
+
if (!isBlockError(error)) {
|
|
1528
|
+
try {
|
|
1529
|
+
suspendedOnFailOpen = (0, runtimeConfig_1.isSuspendedByCache)();
|
|
1530
|
+
}
|
|
1531
|
+
catch { /* never throw */ }
|
|
1532
|
+
}
|
|
1533
|
+
if (!isBlockError(error) && suspendedOnFailOpen) {
|
|
1534
|
+
try {
|
|
1535
|
+
(0, telemetry_1.spoolEvent)({
|
|
1536
|
+
decision: 'block', toolName, operation,
|
|
1537
|
+
reason: `Gateway error on a SUSPENDED machine — denied (kill-switch holds): ${message.slice(0, 200)}`,
|
|
1538
|
+
offlineEnforced: true, severity: 'critical', source: 'backend',
|
|
1539
|
+
});
|
|
1540
|
+
(0, telemetry_1.triggerFlush)(true);
|
|
1541
|
+
}
|
|
1542
|
+
catch { /* never throw */ }
|
|
1543
|
+
return {
|
|
1544
|
+
isError: true,
|
|
1545
|
+
blocked: true,
|
|
1546
|
+
content: [{ type: 'text', text: 'This machine is suspended by your security team — all AI agent actions are blocked. Contact your admin to resume it.' }],
|
|
1547
|
+
};
|
|
1548
|
+
}
|
|
1361
1549
|
if (!isBlockError(error) && downstreamRan) {
|
|
1362
1550
|
// Post-call scan bug: the tool already succeeded — return its result.
|
|
1363
1551
|
try {
|
|
@@ -1508,7 +1696,10 @@ function buildGatewayCommandArgs(gatewayConfig, downstream, options = {}) {
|
|
|
1508
1696
|
'--approval-mode', gatewayConfig.approvalMode,
|
|
1509
1697
|
'--approval-timeout-ms', String(gatewayConfig.approvalTimeoutMs),
|
|
1510
1698
|
'--approval-poll-ms', String(gatewayConfig.approvalPollMs),
|
|
1511
|
-
|
|
1699
|
+
// Only bake --scan-response when the installer was given it explicitly.
|
|
1700
|
+
// Otherwise the runtime decides per machine mode (block → scan on,
|
|
1701
|
+
// monitor → off) instead of pinning install-time state forever.
|
|
1702
|
+
...(gatewayConfig.scanResponseExplicit ? ['--scan-response', String(gatewayConfig.scanResponse)] : []),
|
|
1512
1703
|
// Only bake --fail-closed into the installed config when the installer was
|
|
1513
1704
|
// given it explicitly. Otherwise the runtime gateway follows the machine's
|
|
1514
1705
|
// server-authoritative stance from /api/cli/bundle (same as the IDE hooks).
|
|
@@ -17,6 +17,15 @@ export interface OnboardArgs extends InstallAllArgs {
|
|
|
17
17
|
noDaemon?: string;
|
|
18
18
|
/** 'true' => skip the interactive trust disclosure (automation/MDM; the MSI consents via its own dialog). */
|
|
19
19
|
acceptDisclosure?: string;
|
|
20
|
+
/**
|
|
21
|
+
* Requested enforcement mode (`--mode` / MSI `FCD_INSTALL_MODE`). The
|
|
22
|
+
* control plane deliberately owns the machine's mode — every (re-)enrollment
|
|
23
|
+
* starts in monitor and an admin promotes from the console. This flag is
|
|
24
|
+
* accepted for compatibility but only ACKNOWLEDGED: a non-monitor request
|
|
25
|
+
* prints an explicit note instead of being silently dropped, so an MDM
|
|
26
|
+
* operator who set FCD_INSTALL_MODE=block learns where mode is actually set.
|
|
27
|
+
*/
|
|
28
|
+
mode?: string;
|
|
20
29
|
}
|
|
21
30
|
/**
|
|
22
31
|
* `fullcourtdefense onboard` — the one-command path from a fresh machine to a
|
package/dist/commands/onboard.js
CHANGED
|
@@ -310,6 +310,13 @@ async function onboardCommand(args, config) {
|
|
|
310
310
|
// mode so nothing in the developer's workflow breaks while the fleet baselines
|
|
311
311
|
// this machine. Admins promote it to blocking mode from the fleet console.
|
|
312
312
|
console.log(`${DIM}New machines start in ${RESET}${BOLD}monitor mode${RESET}${DIM} — every agent action is recorded and analyzed, nothing is blocked yet. Your admin promotes the machine to blocking mode from the fleet console once its workflow is baselined.${RESET}\n`);
|
|
313
|
+
const requestedMode = (args.mode || process.env.FCD_INSTALL_MODE || '').trim().toLowerCase();
|
|
314
|
+
if (requestedMode && requestedMode !== 'monitor') {
|
|
315
|
+
// Honest acknowledgement instead of a silently-dropped flag: enrollment is
|
|
316
|
+
// monitor-first by design (control plane owns the mode), so a requested
|
|
317
|
+
// 'block'/'shadow' does not take effect at install time.
|
|
318
|
+
console.log(`${YELLOW}Note: --mode ${requestedMode} was requested, but enrollment always starts in monitor mode — the machine's mode is managed centrally. Promote it from the fleet console after enrollment.${RESET}\n`);
|
|
319
|
+
}
|
|
313
320
|
// Trust disclosure first — nothing touches the machine before consent.
|
|
314
321
|
// Dry runs report the plan without changing anything, so no prompt there.
|
|
315
322
|
if (!dryRun && !(await confirmTrustDisclosure(args))) {
|
|
@@ -4,8 +4,11 @@ import { BotGuardConfig } from '../config';
|
|
|
4
4
|
* 5 minutes) so machine health stays observable even when the daemon itself
|
|
5
5
|
* is dead. Three jobs, all best-effort, never throws, exits 0:
|
|
6
6
|
*
|
|
7
|
-
* 1. Liveness: is the daemon
|
|
8
|
-
*
|
|
7
|
+
* 1. Liveness: is the daemon RESPONSIVE — not merely "a pid exists"? A
|
|
8
|
+
* wedged daemon (stale alive-marker + silent verdict pipe) is recycled;
|
|
9
|
+
* a recycled pid (marker stale, no pipe endpoint at all) is treated as
|
|
10
|
+
* dead WITHOUT touching the unrelated process now holding that pid.
|
|
11
|
+
* Either way, detect + persist the unclean death post-mortem.
|
|
9
12
|
* 2. Revival: relaunch a dead daemon (plain detached node — no shells).
|
|
10
13
|
* 3. Beacon: report daemon state to the console so the dashboard can tell
|
|
11
14
|
* "daemon dead since 11:02" apart from "machine off".
|
|
@@ -37,6 +37,8 @@ exports.watchdogCommand = watchdogCommand;
|
|
|
37
37
|
const config_1 = require("../config");
|
|
38
38
|
const daemon_1 = require("./daemon");
|
|
39
39
|
const daemonForensics_1 = require("../daemonForensics");
|
|
40
|
+
const verdictIpcClient_1 = require("../verdictIpcClient");
|
|
41
|
+
const distress_1 = require("../distress");
|
|
40
42
|
const machineIdentity_1 = require("../machineIdentity");
|
|
41
43
|
const fs = __importStar(require("fs"));
|
|
42
44
|
const path = __importStar(require("path"));
|
|
@@ -64,10 +66,37 @@ async function watchdogCommand(args, config) {
|
|
|
64
66
|
const state = (0, daemon_1.daemonRuntimeState)();
|
|
65
67
|
let relaunched = false;
|
|
66
68
|
let postmortem = (0, daemonForensics_1.readPostmortem)();
|
|
67
|
-
|
|
69
|
+
// Beyond "the pid exists": a wedged daemon or a recycled pid both pass a
|
|
70
|
+
// plain pid check while the machine has no working control plane. Classify
|
|
71
|
+
// with the alive-marker freshness + a verdict-pipe probe.
|
|
72
|
+
let health = state.alive ? 'healthy' : 'dead';
|
|
73
|
+
if (state.alive && state.pid) {
|
|
74
|
+
health = (0, daemonForensics_1.classifyDaemonHealth)({
|
|
75
|
+
pidAlive: true,
|
|
76
|
+
pid: state.pid,
|
|
77
|
+
marker: (0, daemonForensics_1.readAliveMarker)(),
|
|
78
|
+
staleAfterMs: (0, daemonForensics_1.daemonHungAfterMs)(),
|
|
79
|
+
pipeProbe: await (0, verdictIpcClient_1.probeVerdictServer)(1_500),
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
if (health === 'hung' && state.pid && process.env.FCD_WATCHDOG_NO_RELAUNCH !== '1') {
|
|
83
|
+
// Positive evidence this pid IS our daemon (it still owns the verdict
|
|
84
|
+
// endpoint) and it is wedged — recycle it so the relaunch below starts
|
|
85
|
+
// clean. The distress ledger survives; the fresh daemon's first heartbeat
|
|
86
|
+
// ships the story to the fleet.
|
|
87
|
+
(0, distress_1.reportDistress)('watchdog', distress_1.DISTRESS.DAEMON_HUNG, `daemon pid ${state.pid} unresponsive (alive-marker stale + verdict pipe silent) — recycled by watchdog`);
|
|
88
|
+
(0, daemon_1.forceStopPid)(state.pid);
|
|
89
|
+
}
|
|
90
|
+
// NOTE for 'pid_reused': the pid belongs to an UNRELATED process — never
|
|
91
|
+
// kill it. The daemon is simply gone; fall through to the dead path. The
|
|
92
|
+
// relaunched daemon's own startup detects the recycled pid-file owner and
|
|
93
|
+
// takes over the lock without touching that process.
|
|
94
|
+
const effectivelyDead = !state.alive || health === 'hung' || health === 'pid_reused';
|
|
95
|
+
if (effectivelyDead) {
|
|
68
96
|
// The daemon is down — capture how it died BEFORE reviving it (the fresh
|
|
69
|
-
// daemon rewrites the alive marker on boot).
|
|
70
|
-
|
|
97
|
+
// daemon rewrites the alive marker on boot). For a recycled pid the
|
|
98
|
+
// marker's pid is alive but is not the daemon, so force the detection.
|
|
99
|
+
postmortem = (0, daemonForensics_1.recordUncleanDeathFromWatchdog)(health === 'pid_reused' ? () => false : daemon_1.pidIsAlive) || postmortem;
|
|
71
100
|
// Revival can be disabled for tests/diagnostics; default is on.
|
|
72
101
|
if (process.env.FCD_WATCHDOG_NO_RELAUNCH !== '1') {
|
|
73
102
|
relaunched = (0, daemon_1.spawnDetachedDaemon)();
|
|
@@ -87,11 +116,12 @@ async function watchdogCommand(args, config) {
|
|
|
87
116
|
shieldId: creds.shieldId,
|
|
88
117
|
machineId: identity.machineId,
|
|
89
118
|
agentVersion: cliVersion(),
|
|
90
|
-
daemonAlive: state.alive,
|
|
119
|
+
daemonAlive: state.alive && !effectivelyDead,
|
|
91
120
|
daemonPid: state.pid,
|
|
92
121
|
daemonVersion: state.version,
|
|
93
122
|
daemonStartedAt: state.startedAt,
|
|
94
123
|
relaunched: relaunched || undefined,
|
|
124
|
+
daemonHealth: health === 'hung' || health === 'pid_reused' ? health : undefined,
|
|
95
125
|
lastCrash: crashSummary(postmortem),
|
|
96
126
|
};
|
|
97
127
|
const headers = { 'Content-Type': 'application/json' };
|
package/dist/config.js
CHANGED
|
@@ -564,7 +564,12 @@ function writeSetupConfig(target, input) {
|
|
|
564
564
|
}
|
|
565
565
|
setTopLevel('apiUrl', input.apiUrl);
|
|
566
566
|
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
567
|
-
|
|
567
|
+
// Atomic write-temp-rename: this file carries the machine's enrollment
|
|
568
|
+
// (shieldId + key location). A crash/kill mid-write must never leave a torn
|
|
569
|
+
// config — a half-written yml makes every hook 401 until manual re-enroll.
|
|
570
|
+
const tmp = `${target}.tmp-${process.pid}`;
|
|
571
|
+
fs.writeFileSync(tmp, `${lines.filter((line, idx, arr) => !(line === '' && arr[idx - 1] === '')).join('\n').trim()}\n`, 'utf-8');
|
|
572
|
+
fs.renameSync(tmp, target);
|
|
568
573
|
return target;
|
|
569
574
|
}
|
|
570
575
|
function saveSetupConfig(input, targetPath) {
|
|
@@ -73,5 +73,32 @@ export declare function recordUncleanDeathFromWatchdog(isPidAlive: (pid: number)
|
|
|
73
73
|
export declare function touchDaemonAlive(): void;
|
|
74
74
|
/** Record an intentional shutdown so the next boot knows this was NOT a kill. */
|
|
75
75
|
export declare function recordCleanExit(reason: string): void;
|
|
76
|
+
/**
|
|
77
|
+
* A pid can lie in two ways: the daemon process exists but its event loop is
|
|
78
|
+
* WEDGED (never heartbeats, never serves the pipe, never polls the control
|
|
79
|
+
* plane), or the daemon died long ago and the OS RECYCLED its pid for an
|
|
80
|
+
* unrelated process. Both look "alive" to a pid check; both mean the machine
|
|
81
|
+
* has no working control plane. The alive-marker freshness plus a verdict-pipe
|
|
82
|
+
* probe tells them apart:
|
|
83
|
+
*
|
|
84
|
+
* marker fresh → healthy (heartbeats are running)
|
|
85
|
+
* marker stale + pipe answers → healthy (only the marker write fails)
|
|
86
|
+
* marker stale + pipe exists, silent → hung (kernel frees a named pipe when
|
|
87
|
+
* its owner dies, so a silent endpoint
|
|
88
|
+
* means a live-but-wedged owner)
|
|
89
|
+
* marker stale + no pipe endpoint → pid recycled (daemon is gone; do NOT
|
|
90
|
+
* kill the unrelated process)
|
|
91
|
+
*/
|
|
92
|
+
export type DaemonHealthClass = 'dead' | 'healthy' | 'hung' | 'pid_reused' | 'indeterminate';
|
|
93
|
+
/** Marker older than this is stale — 4 missed 5-minute heartbeats. */
|
|
94
|
+
export declare function daemonHungAfterMs(): number;
|
|
95
|
+
export declare function classifyDaemonHealth(input: {
|
|
96
|
+
pidAlive: boolean;
|
|
97
|
+
pid: number | undefined;
|
|
98
|
+
marker: DaemonAliveMarker | undefined;
|
|
99
|
+
staleAfterMs: number;
|
|
100
|
+
pipeProbe: 'responsive' | 'unresponsive' | 'no-endpoint' | 'not-probed';
|
|
101
|
+
nowMs?: number;
|
|
102
|
+
}): DaemonHealthClass;
|
|
76
103
|
/** Stamp the post-mortem as delivered so heartbeats stop re-sending it. */
|
|
77
104
|
export declare function markPostmortemReported(): void;
|
package/dist/daemonForensics.js
CHANGED
|
@@ -43,6 +43,8 @@ exports.recordDaemonStart = recordDaemonStart;
|
|
|
43
43
|
exports.recordUncleanDeathFromWatchdog = recordUncleanDeathFromWatchdog;
|
|
44
44
|
exports.touchDaemonAlive = touchDaemonAlive;
|
|
45
45
|
exports.recordCleanExit = recordCleanExit;
|
|
46
|
+
exports.daemonHungAfterMs = daemonHungAfterMs;
|
|
47
|
+
exports.classifyDaemonHealth = classifyDaemonHealth;
|
|
46
48
|
exports.markPostmortemReported = markPostmortemReported;
|
|
47
49
|
const fs = __importStar(require("fs"));
|
|
48
50
|
const os = __importStar(require("os"));
|
|
@@ -178,6 +180,31 @@ function recordCleanExit(reason) {
|
|
|
178
180
|
marker.cleanExit = { at: new Date().toISOString(), reason: reason.slice(0, 120) };
|
|
179
181
|
writeJson(aliveMarkerFile(), marker);
|
|
180
182
|
}
|
|
183
|
+
/** Marker older than this is stale — 4 missed 5-minute heartbeats. */
|
|
184
|
+
function daemonHungAfterMs() {
|
|
185
|
+
const env = Number(process.env.FCD_DAEMON_HUNG_AFTER_MS);
|
|
186
|
+
return Number.isFinite(env) && env > 0 ? env : 20 * 60_000;
|
|
187
|
+
}
|
|
188
|
+
function classifyDaemonHealth(input) {
|
|
189
|
+
if (!input.pidAlive || !input.pid)
|
|
190
|
+
return 'dead';
|
|
191
|
+
const marker = input.marker;
|
|
192
|
+
// No marker for this pid (marker deleted, or owned by a different pid):
|
|
193
|
+
// freshness cannot be judged — stay conservative, never kill on a guess.
|
|
194
|
+
if (!marker || marker.pid !== input.pid)
|
|
195
|
+
return 'indeterminate';
|
|
196
|
+
const last = Date.parse(marker.lastAliveAt || marker.startedAt || '');
|
|
197
|
+
const age = (input.nowMs ?? Date.now()) - last;
|
|
198
|
+
if (!Number.isFinite(age) || age <= input.staleAfterMs)
|
|
199
|
+
return 'healthy';
|
|
200
|
+
if (input.pipeProbe === 'responsive')
|
|
201
|
+
return 'healthy';
|
|
202
|
+
if (input.pipeProbe === 'unresponsive')
|
|
203
|
+
return 'hung';
|
|
204
|
+
if (input.pipeProbe === 'no-endpoint')
|
|
205
|
+
return 'pid_reused';
|
|
206
|
+
return 'indeterminate';
|
|
207
|
+
}
|
|
181
208
|
/** Stamp the post-mortem as delivered so heartbeats stop re-sending it. */
|
|
182
209
|
function markPostmortemReported() {
|
|
183
210
|
const pm = readPostmortem();
|
package/dist/distress.d.ts
CHANGED
|
@@ -54,6 +54,23 @@ export declare const DISTRESS: {
|
|
|
54
54
|
* next tick — but while it lasted, remote actions were undeliverable.
|
|
55
55
|
*/
|
|
56
56
|
readonly POLL_STALLED: "poll_stalled";
|
|
57
|
+
/**
|
|
58
|
+
* The watchdog found the daemon process alive but WEDGED — alive-marker
|
|
59
|
+
* stale past the bound and the verdict pipe silent — and recycled it.
|
|
60
|
+
*/
|
|
61
|
+
readonly DAEMON_HUNG: "daemon_hung";
|
|
62
|
+
/**
|
|
63
|
+
* The daemon exited itself after repeated uncaught exceptions in a short
|
|
64
|
+
* window (process state suspect); the supervisor restarts it clean.
|
|
65
|
+
*/
|
|
66
|
+
readonly UNCAUGHT_BURST: "uncaught_burst";
|
|
67
|
+
/**
|
|
68
|
+
* The telemetry spool could not be written (disk full, ACL, AV lock) — the
|
|
69
|
+
* machine is still enforcing, but its decisions are being LOST locally and
|
|
70
|
+
* will never reach the console. Deserves an admin's attention because a
|
|
71
|
+
* quiet machine and a machine that cannot record look identical otherwise.
|
|
72
|
+
*/
|
|
73
|
+
readonly SPOOL_WRITE_FAILED: "spool_write_failed";
|
|
57
74
|
/** Anything nobody predicted — reported via reportUnexpected(). */
|
|
58
75
|
readonly UNEXPECTED: "unexpected_error";
|
|
59
76
|
};
|