fullcourtdefense-cli 1.26.5 → 1.26.7
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 +25 -2
- package/dist/commands/installClaudeHook.d.ts +2 -0
- package/dist/commands/installClaudeHook.js +4 -0
- package/dist/commands/installCursorHook.d.ts +8 -0
- package/dist/commands/installCursorHook.js +5 -0
- 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.js +1 -0
- package/dist/index.js +6 -0
- 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 +12 -0
- package/dist/runtimeConfig.js +181 -7
- package/dist/selfTest.d.ts +10 -0
- package/dist/selfTest.js +21 -8
- package/dist/selfUpdate.d.ts +19 -4
- package/dist/selfUpdate.js +108 -12
- package/dist/telemetry.js +67 -2
- package/dist/verdictIpc.js +76 -30
- package/dist/verdictIpcClient.d.ts +27 -0
- package/dist/verdictIpcClient.js +68 -0
- package/dist/version.json +1 -1
- package/package.json +5 -1
package/dist/commands/hook.js
CHANGED
|
@@ -733,8 +733,23 @@ function failOpenOutcome(event, message) {
|
|
|
733
733
|
async function hookCommandInner(args, config, io) {
|
|
734
734
|
// Mode is server-authoritative (pulled from the cached runtime bundle), with
|
|
735
735
|
// local flags as explicit overrides. Resolved after creds below.
|
|
736
|
-
|
|
737
|
-
|
|
736
|
+
//
|
|
737
|
+
// On INSTALLER-MANAGED invocations (`--fcd-managed true`, the hooks.json we
|
|
738
|
+
// write) a `--shadow` flag is IGNORED: our installer never writes it on the
|
|
739
|
+
// standard path, so its presence means someone hand-edited the managed entry
|
|
740
|
+
// to silently downgrade enforcement. Mode for managed hooks comes from the
|
|
741
|
+
// server bundle only — an org that wants shadow sets it in the console.
|
|
742
|
+
// Ad-hoc/manual `fullcourtdefense hook --shadow true` keeps working.
|
|
743
|
+
const managedInvocation = args.fcdManaged === 'true';
|
|
744
|
+
const shadowFlagIgnored = managedInvocation && args.shadow === 'true';
|
|
745
|
+
let shadow = args.shadow === 'true' && !managedInvocation;
|
|
746
|
+
const forceMonitor = shadow;
|
|
747
|
+
if (shadowFlagIgnored) {
|
|
748
|
+
try {
|
|
749
|
+
(0, hookIo_1.dbg)({ phase: 'managed_shadow_flag_ignored', event: args.event });
|
|
750
|
+
}
|
|
751
|
+
catch { /* never throw */ }
|
|
752
|
+
}
|
|
738
753
|
const forceEnforce = args.enforce === 'true';
|
|
739
754
|
// Offline stance: an explicit --fail-closed flag wins; otherwise the
|
|
740
755
|
// server-authoritative machine stance from the runtime bundle applies
|
|
@@ -775,6 +790,14 @@ async function hookCommandInner(args, config, io) {
|
|
|
775
790
|
if (!forceMonitor && !forceEnforce && shieldId) {
|
|
776
791
|
try {
|
|
777
792
|
const bundle = await (0, runtimeConfig_1.getRuntimeBundle)({ apiUrl, shieldId, shieldKey, developerName: developerId(), machineName: os.hostname(), hotPath: true });
|
|
793
|
+
// Kill-switch holds even on a 'default' bundle: with the cache missing
|
|
794
|
+
// but a signed last-known-suspended marker standing, getRuntimeBundle
|
|
795
|
+
// stamps suspended on the default — deleting the cache file must not
|
|
796
|
+
// unlock a suspended machine.
|
|
797
|
+
if (bundle.suspended === true) {
|
|
798
|
+
machineSuspended = true;
|
|
799
|
+
shadow = false;
|
|
800
|
+
}
|
|
778
801
|
if (bundle.source !== 'default') {
|
|
779
802
|
shadow = bundle.mode !== 'block'; // 'monitor'/'shadow' => report-only
|
|
780
803
|
modeResolved = true; // add this line
|
|
@@ -28,6 +28,8 @@ export interface ClaudeManagedHookRepairResult {
|
|
|
28
28
|
existed: boolean;
|
|
29
29
|
managedEntries: number;
|
|
30
30
|
staleEntries: number;
|
|
31
|
+
/** Managed entries with a hand-added `--shadow true` (enforcement downgrade tamper). */
|
|
32
|
+
weakenedEntries?: number;
|
|
31
33
|
changed: boolean;
|
|
32
34
|
}
|
|
33
35
|
export declare function inspectClaudeManagedHooks(): ClaudeManagedHookRepairResult;
|
|
@@ -129,6 +129,10 @@ function inspectClaudeManagedHooks() {
|
|
|
129
129
|
existed: true,
|
|
130
130
|
managedEntries: commands.length,
|
|
131
131
|
staleEntries: commands.filter(command => repairManagedCommandPath(command.command).stale).length,
|
|
132
|
+
// Hand-added `--shadow true` on a managed command = enforcement downgrade
|
|
133
|
+
// tamper (the installer never writes it). The hook ignores the flag on
|
|
134
|
+
// managed invocations; this count surfaces the edit in integrity reports.
|
|
135
|
+
weakenedEntries: commands.filter(command => /--shadow(?:[ =])true\b/.test(command.command)).length,
|
|
132
136
|
changed: false,
|
|
133
137
|
};
|
|
134
138
|
}
|
|
@@ -13,6 +13,14 @@ export interface ManagedHookRepairResult {
|
|
|
13
13
|
existed: boolean;
|
|
14
14
|
managedEntries: number;
|
|
15
15
|
staleEntries: number;
|
|
16
|
+
/**
|
|
17
|
+
* Managed entries whose command carries a hand-added `--shadow true` — the
|
|
18
|
+
* standard installer/repair path never writes it, so its presence means the
|
|
19
|
+
* managed entry was edited to silently downgrade enforcement. The hook
|
|
20
|
+
* itself ignores the flag on managed invocations; this count makes the
|
|
21
|
+
* tamper VISIBLE (integrity report -> fleet console).
|
|
22
|
+
*/
|
|
23
|
+
weakenedEntries?: number;
|
|
16
24
|
changed: boolean;
|
|
17
25
|
}
|
|
18
26
|
export declare function inspectCursorManagedHooks(): ManagedHookRepairResult;
|
|
@@ -144,6 +144,10 @@ function repairManagedCommandPath(command) {
|
|
|
144
144
|
const expected = `${hookInvocationPrefix()}${command.slice(marker)}`;
|
|
145
145
|
return { command: expected, stale: expected !== command };
|
|
146
146
|
}
|
|
147
|
+
/** A managed command mutated to weaken enforcement (`--shadow true` injected). */
|
|
148
|
+
function isWeakenedManagedCommand(command) {
|
|
149
|
+
return /--shadow(?:[ =])true\b/.test(command);
|
|
150
|
+
}
|
|
147
151
|
function inspectCursorManagedHooks() {
|
|
148
152
|
const file = hooksJsonPath(false);
|
|
149
153
|
if (!fs.existsSync(file))
|
|
@@ -155,6 +159,7 @@ function inspectCursorManagedHooks() {
|
|
|
155
159
|
existed: true,
|
|
156
160
|
managedEntries: managed.length,
|
|
157
161
|
staleEntries: managed.filter(entry => repairManagedCommandPath(entry.command).stale).length,
|
|
162
|
+
weakenedEntries: managed.filter(entry => isWeakenedManagedCommand(entry.command)).length,
|
|
158
163
|
changed: false,
|
|
159
164
|
};
|
|
160
165
|
}
|
|
@@ -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) {
|