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.
Files changed (45) hide show
  1. package/dist/commands/daemon.d.ts +14 -0
  2. package/dist/commands/daemon.js +181 -28
  3. package/dist/commands/desktopChatGuard.js +5 -4
  4. package/dist/commands/deterministicGuard.d.ts +8 -0
  5. package/dist/commands/deterministicGuard.js +6 -0
  6. package/dist/commands/doctor.d.ts +4 -0
  7. package/dist/commands/doctor.js +99 -0
  8. package/dist/commands/hook.d.ts +1 -0
  9. package/dist/commands/hook.js +61 -104
  10. package/dist/commands/installClaudeHook.d.ts +2 -0
  11. package/dist/commands/installClaudeHook.js +21 -3
  12. package/dist/commands/installCursorHook.d.ts +8 -0
  13. package/dist/commands/installCursorHook.js +29 -4
  14. package/dist/commands/mcpGateway.js +234 -43
  15. package/dist/commands/onboard.d.ts +9 -0
  16. package/dist/commands/onboard.js +7 -0
  17. package/dist/commands/watchdog.d.ts +5 -2
  18. package/dist/commands/watchdog.js +34 -4
  19. package/dist/config.js +6 -1
  20. package/dist/daemonForensics.d.ts +27 -0
  21. package/dist/daemonForensics.js +27 -0
  22. package/dist/distress.d.ts +17 -0
  23. package/dist/distress.js +17 -0
  24. package/dist/hookIo.d.ts +43 -0
  25. package/dist/hookIo.js +189 -0
  26. package/dist/hookSlim.d.ts +32 -0
  27. package/dist/hookSlim.js +142 -0
  28. package/dist/index.js +10 -14
  29. package/dist/integrity.js +8 -0
  30. package/dist/localSafetySnapshot.d.ts +9 -0
  31. package/dist/localSafetySnapshot.js +58 -2
  32. package/dist/machineIdentity.js +70 -4
  33. package/dist/runtimeConfig.d.ts +2 -0
  34. package/dist/runtimeConfig.js +177 -5
  35. package/dist/selfTest.d.ts +10 -0
  36. package/dist/selfTest.js +19 -8
  37. package/dist/selfUpdate.d.ts +19 -4
  38. package/dist/selfUpdate.js +108 -12
  39. package/dist/telemetry.js +67 -2
  40. package/dist/verdictIpc.d.ts +12 -75
  41. package/dist/verdictIpc.js +104 -188
  42. package/dist/verdictIpcClient.d.ts +103 -0
  43. package/dist/verdictIpcClient.js +251 -0
  44. package/dist/version.json +1 -1
  45. 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 what a
377
- // client would actually read out of this result.
378
- const remaining = contentToText(masked);
379
- const fullyMasked = secrets.every(({ secret }) => !remaining.includes(secret));
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 = (0, child_process_1.spawn)(command, args, {
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
- reject(new Error(`MCP request timed out: ${method}`));
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
- if (typeof message.id !== 'number')
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
- if (!message.id && message.method?.startsWith('notifications/'))
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
- this.downstream = this.downstreamSpec.kind === 'http'
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.downstreamReady = this.downstream.initialize();
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: this.gatewayConfig.developerName,
963
- machineName: os.hostname(),
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
- const responseOutcome = (0, deterministicGuard_1.resolveDeterministicTextResponse)(contentToText(rawResult), (0, localSafetySnapshot_1.snapshotToScanOptions)(snapshot));
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
- const finalResult = this.gatewayConfig.scanResponse
1335
- ? await this.api.scanToolResponse({ toolName, operation, toolArgs, result: rawResult })
1336
- : rawResult;
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
- '--scan-response', String(gatewayConfig.scanResponse),
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
@@ -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 running? If not, detect + persist the unclean
8
- * death post-mortem (the daemon can no longer say how it died).
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
- if (!state.alive) {
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
- postmortem = (0, daemonForensics_1.recordUncleanDeathFromWatchdog)(daemon_1.pidIsAlive) || postmortem;
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
- fs.writeFileSync(target, `${lines.filter((line, idx, arr) => !(line === '' && arr[idx - 1] === '')).join('\n').trim()}\n`, 'utf-8');
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;
@@ -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();
@@ -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
  };