fullcourtdefense-cli 1.36.4 → 1.37.0

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 (90) hide show
  1. package/dist/askDialog.d.ts +1 -0
  2. package/dist/askDialog.js +20 -3
  3. package/dist/callerEnv.d.ts +29 -2
  4. package/dist/callerEnv.js +48 -2
  5. package/dist/commands/daemon.d.ts +10 -0
  6. package/dist/commands/daemon.js +348 -170
  7. package/dist/commands/desktopChatGuard.js +2 -0
  8. package/dist/commands/discoverPaths.d.ts +12 -9
  9. package/dist/commands/discoverPaths.js +30 -0
  10. package/dist/commands/ephemeralStack.js +7 -0
  11. package/dist/commands/flushSpool.d.ts +2 -0
  12. package/dist/commands/flushSpool.js +9 -2
  13. package/dist/commands/hook.d.ts +14 -0
  14. package/dist/commands/hook.js +302 -101
  15. package/dist/commands/httpGatewayCommand.d.ts +1 -0
  16. package/dist/commands/httpGatewayCommand.js +7 -1
  17. package/dist/commands/installClaudeHook.js +32 -10
  18. package/dist/commands/installCursorHook.js +20 -9
  19. package/dist/commands/login.js +8 -1
  20. package/dist/commands/mcpGateway.d.ts +21 -0
  21. package/dist/commands/mcpGateway.js +90 -32
  22. package/dist/commands/refreshPolicyCache.d.ts +28 -0
  23. package/dist/commands/refreshPolicyCache.js +59 -0
  24. package/dist/commands/watchdog.d.ts +2 -0
  25. package/dist/commands/watchdog.js +30 -5
  26. package/dist/config.d.ts +36 -0
  27. package/dist/config.js +78 -12
  28. package/dist/daemonDetectorBoot.d.ts +1 -0
  29. package/dist/daemonDetectorBoot.js +12 -0
  30. package/dist/daemonForensics.d.ts +14 -1
  31. package/dist/daemonForensics.js +32 -0
  32. package/dist/detectorInProcess.d.ts +18 -0
  33. package/dist/detectorInProcess.js +234 -0
  34. package/dist/detectorInThread.d.ts +15 -0
  35. package/dist/detectorInThread.js +129 -0
  36. package/dist/distress.d.ts +2 -0
  37. package/dist/distress.js +2 -0
  38. package/dist/fileWriteCanon.d.ts +17 -0
  39. package/dist/fileWriteCanon.js +141 -0
  40. package/dist/gateReports.d.ts +144 -0
  41. package/dist/gateReports.js +488 -0
  42. package/dist/hookIo.d.ts +59 -0
  43. package/dist/hookIo.js +319 -9
  44. package/dist/hookSlim.d.ts +15 -8
  45. package/dist/hookSlim.js +187 -25
  46. package/dist/housekeeping.d.ts +64 -0
  47. package/dist/housekeeping.js +384 -0
  48. package/dist/housekeepingOps.d.ts +150 -0
  49. package/dist/housekeepingOps.js +218 -0
  50. package/dist/housekeepingWorker.d.ts +1 -0
  51. package/dist/housekeepingWorker.js +113 -0
  52. package/dist/index.d.ts +1 -1
  53. package/dist/index.js +19 -0
  54. package/dist/localDetectionData.d.ts +10 -0
  55. package/dist/localDetectionData.js +48 -0
  56. package/dist/localDetectionUpdates.d.ts +2 -2
  57. package/dist/localDetectionUpdates.js +4 -4
  58. package/dist/localSafetySnapshot.d.ts +48 -2
  59. package/dist/localSafetySnapshot.js +241 -25
  60. package/dist/machineIdentity.d.ts +7 -0
  61. package/dist/machineIdentity.js +20 -2
  62. package/dist/monitorFastPath.d.ts +191 -0
  63. package/dist/monitorFastPath.js +1198 -0
  64. package/dist/nativeHook.js +9 -2
  65. package/dist/pendingDrain.d.ts +82 -0
  66. package/dist/pendingDrain.js +215 -0
  67. package/dist/pendingDrainer.d.ts +30 -0
  68. package/dist/pendingDrainer.js +217 -0
  69. package/dist/perfSnapshot.d.ts +1 -1
  70. package/dist/perfSnapshot.js +28 -4
  71. package/dist/policyCacheFiles.d.ts +73 -0
  72. package/dist/policyCacheFiles.js +320 -0
  73. package/dist/policyGateHealth.d.ts +8 -0
  74. package/dist/policyGateHealth.js +17 -0
  75. package/dist/policyRefresh.d.ts +28 -0
  76. package/dist/policyRefresh.js +69 -0
  77. package/dist/protectionMaintenance.d.ts +15 -0
  78. package/dist/protectionMaintenance.js +43 -1
  79. package/dist/runtimeConfig.d.ts +77 -20
  80. package/dist/runtimeConfig.js +278 -57
  81. package/dist/selfTest.d.ts +6 -1
  82. package/dist/selfTest.js +11 -4
  83. package/dist/telemetry.d.ts +136 -4
  84. package/dist/telemetry.js +493 -96
  85. package/dist/verdictIpc.d.ts +60 -0
  86. package/dist/verdictIpc.js +504 -19
  87. package/dist/verdictIpcClient.d.ts +175 -18
  88. package/dist/verdictIpcClient.js +277 -59
  89. package/dist/version.json +1 -1
  90. package/package.json +2 -2
@@ -60,22 +60,30 @@ const callerEnv_1 = require("../callerEnv");
60
60
  const deterministicGuard_1 = require("./deterministicGuard");
61
61
  const localSafetySnapshot_1 = require("../localSafetySnapshot");
62
62
  const taintLedger_1 = require("./taintLedger");
63
- const notify_1 = require("../notify");
64
- const blockExplanation_1 = require("../blockExplanation");
65
63
  const distress_1 = require("../distress");
66
64
  const describeError_1 = require("../describeError");
67
65
  const policyGateHealth_1 = require("../policyGateHealth");
68
- const verdictIpc_1 = require("../verdictIpc");
69
66
  const machineIdentity_1 = require("../machineIdentity");
70
67
  const actionPolicyEngine_1 = require("../actionPolicyEngine");
71
- const devConfirm_1 = require("../devConfirm");
72
- const askDialog_1 = require("../askDialog");
73
68
  // Shared with the slim hook entry (hookSlim.ts) so both entries log to the
74
69
  // same rotated hook.log and read stdin identically.
75
70
  const hookIo_1 = require("../hookIo");
76
71
  const fileWriteCanon_1 = require("../fileWriteCanon");
77
- const discoverProxy_1 = require("./discoverProxy");
78
72
  const shellAstLoader_1 = require("../shellAstLoader");
73
+ const detectorInProcess_1 = require("../detectorInProcess");
74
+ // ENFORCE CORE: a daemon-down verdict loads only what decides it — the engine,
75
+ // the deterministic guard, the cached bundle + Local Safety snapshot and the
76
+ // taint ledger. Everything below loads on the path that uses it: developer
77
+ // confirmations and grants (asks), the dialog envelope, OS notifications
78
+ // (approvals), block explanations, the MCP gateway lookup and the IPC client.
79
+ /* eslint-disable @typescript-eslint/no-var-requires */
80
+ const devConfirm = () => require('../devConfirm');
81
+ const askDialog = () => require('../askDialog');
82
+ const notify = () => require('../notify');
83
+ const blockExplanation = () => require('../blockExplanation');
84
+ const discoverProxy = () => require('./discoverProxy');
85
+ const verdictIpc = () => require('../verdictIpc');
86
+ const gateReports = () => require('../gateReports');
79
87
  function inferEvent(explicit, payload) {
80
88
  const e = (explicit || '').toLowerCase();
81
89
  if (e) {
@@ -439,7 +447,7 @@ function isGatewayWrappedMcpServer(payload, client, cwd = process.cwd()) {
439
447
  return false;
440
448
  const clientKey = client ? client.toLowerCase().replace(/-/g, '_') : undefined;
441
449
  try {
442
- return (0, discoverProxy_1.isServerBehindFcdGateway)(server, cwd, clientKey);
450
+ return discoverProxy().isServerBehindFcdGateway(server, cwd, clientKey);
443
451
  }
444
452
  catch {
445
453
  return false;
@@ -595,14 +603,17 @@ function summarizeToolCall(toolName, toolArgs) {
595
603
  return parts.join(' ').slice(0, 500);
596
604
  }
597
605
  function spoolCallEvent(call, rest) {
606
+ (0, telemetry_1.spoolEvent)(callEvent(call, rest));
607
+ }
608
+ function callEvent(call, rest) {
598
609
  const { operation } = (0, actionPolicyEngine_1.inferToolContext)(call.toolName, call.toolArgs);
599
- (0, telemetry_1.spoolEvent)({
610
+ return {
600
611
  ...rest,
601
612
  toolName: call.toolName,
602
613
  operation: rest.operation || operation,
603
614
  evidence: rest.evidence || (0, telemetry_1.evidenceFromToolArgs)(call.toolArgs),
604
615
  shellAst: rest.shellAst || (0, shellAstLoader_1.collectShellAstFacts)(),
605
- });
616
+ };
606
617
  }
607
618
  /**
608
619
  * A `log` verdict ("Warn only" in the console) is a real warning, not a silent
@@ -706,6 +717,11 @@ async function readBackendError(resp, maxLength = 200) {
706
717
  * One immediate retry on network failure/timeout: ~1% of gate calls in the
707
718
  * field hit the timeout on transient jitter, and a single retry converts
708
719
  * nearly all of them into normal verdicts instead of degraded decisions.
720
+ *
721
+ * ONE deadline per attempt covers the headers AND the body: a backend that
722
+ * answers headers and then stalls the body (305 s measured) is a failed
723
+ * attempt — a gate failure — never an unbounded wait that ends in the IDE
724
+ * killing the hook and running the action.
709
725
  */
710
726
  async function fetchWithOneRetry(url, init, timeoutMs, onFailure, attempts = 2) {
711
727
  let lastError;
@@ -713,7 +729,14 @@ async function fetchWithOneRetry(url, init, timeoutMs, onFailure, attempts = 2)
713
729
  const controller = new AbortController();
714
730
  const timer = setTimeout(() => controller.abort(), timeoutMs);
715
731
  try {
716
- return await fetch(url, { ...init, signal: controller.signal });
732
+ const resp = await fetch(url, { ...init, signal: controller.signal });
733
+ const body = await resp.text();
734
+ return {
735
+ ok: resp.ok,
736
+ status: resp.status,
737
+ text: async () => body,
738
+ json: async () => JSON.parse(body),
739
+ };
717
740
  }
718
741
  catch (err) {
719
742
  lastError = err;
@@ -800,7 +823,7 @@ function respondDegraded(ctx, detail, toolName, kind = 'unreachable', opts = {})
800
823
  function tryDeveloperConfirmation(ctx, call, verdict, payload, where) {
801
824
  if (verdict.verdict !== 'require_approval')
802
825
  return false;
803
- const humanPresent = (0, devConfirm_1.isHumanPresent)();
826
+ const humanPresent = (0, callerEnv_1.isHumanPresent)();
804
827
  if ((0, actionPolicyEngine_1.effectiveApprovalScope)(verdict.approval, { humanPresent }) !== 'developer') {
805
828
  (0, hookIo_1.dbg)({ phase: 'dev_confirm_not_applicable', event: ctx.event, tool: call.toolName, humanPresent, scope: verdict.approval?.scope });
806
829
  return false;
@@ -813,7 +836,7 @@ function tryDeveloperConfirmation(ctx, call, verdict, payload, where) {
813
836
  policyName: verdict.policyName,
814
837
  matchedRule: verdict.matchedRule,
815
838
  // Memory / grants bind to the policy as written, not to the bundle ETag.
816
- ruleHash: (0, devConfirm_1.actionPolicyRuleHash)(ctx.localPolicies?.find(policy => policy.id === verdict.policyId)),
839
+ ruleHash: devConfirm().actionPolicyRuleHash(ctx.localPolicies?.find(policy => policy.id === verdict.policyId)),
817
840
  remember: verdict.approval?.remember ?? 'none',
818
841
  grantCaps: { maxDays: verdict.approval?.maxDays, maxUses: verdict.approval?.maxUses },
819
842
  reason: verdict.reason,
@@ -841,7 +864,7 @@ function prepareDeveloperConfirmation(event, call, payload, source) {
841
864
  const policyHash = source.policyHash || source.localFinding?.policyHash;
842
865
  const memoryHash = source.ruleHash || policyHash;
843
866
  // Sweep first: markers the human never answered are recorded as not approved.
844
- for (const expired of (0, devConfirm_1.sweepExpiredConfirmations)()) {
867
+ for (const expired of devConfirm().sweepExpiredConfirmations()) {
845
868
  (0, telemetry_1.spoolEvent)({
846
869
  decision: 'block',
847
870
  toolName: expired.toolName,
@@ -854,7 +877,7 @@ function prepareDeveloperConfirmation(event, call, payload, source) {
854
877
  // A remote-control bridge hands the machine to an outside AI: "Allow once" is once,
855
878
  // with no session memory. Only an explicit "Always allow" (a bridge-scoped grant) lasts.
856
879
  const sessionMemory = source.localFinding?.remoteControl || context['toolArgs.remoteControl'] ? 'none' : source.remember;
857
- const remembered = sessionMemory === 'none' ? undefined : (0, devConfirm_1.isConfirmationRemembered)({ toolName: call.toolName, operation, argsDigest: (0, devConfirm_1.actionDigest)(call.toolArgs), policyId: source.policyId, matchedRule: source.matchedRule, policyHash: memoryHash }, sessId);
880
+ const remembered = sessionMemory === 'none' ? undefined : devConfirm().isConfirmationRemembered({ toolName: call.toolName, operation, argsDigest: devConfirm().actionDigest(call.toolArgs), policyId: source.policyId, matchedRule: source.matchedRule, policyHash: memoryHash }, sessId);
858
881
  if (remembered) {
859
882
  (0, hookIo_1.dbg)({ phase: 'dev_confirm_remembered', event, tool: call.toolName, operation, remember: remembered.remember, where: source.where });
860
883
  spoolCallEvent(call, {
@@ -875,11 +898,11 @@ function prepareDeveloperConfirmation(event, call, payload, source) {
875
898
  // an org that switched "Always allow" off (`null`) honours no grant at all.
876
899
  // The grant binds the facts THIS rule fired on: a credential-command rule is
877
900
  // about the program, an egress / SSRF / exfil rule is about the host too.
878
- const grantScope = (0, devConfirm_1.grantScopeFor)(event, call.toolName, call.toolArgs, operation, context, (0, devConfirm_1.grantRuleFactsFor)(source));
879
- const grantLimits = (0, devConfirm_1.capGrantLimitsForScope)((0, devConfirm_1.resolveGrantLimits)(source.grantCaps, (0, runtimeConfig_1.getCachedDeveloperGrantPolicy)()), grantScope);
880
- const grant = source.remember === 'none' ? undefined : (0, devConfirm_1.findDeveloperGrant)(grantScope, { policyId: source.policyId, matchedRule: source.matchedRule, policyHash: memoryHash }, grantLimits);
901
+ const grantScope = devConfirm().grantScopeFor(event, call.toolName, call.toolArgs, operation, context, devConfirm().grantRuleFactsFor(source));
902
+ const grantLimits = devConfirm().capGrantLimitsForScope(devConfirm().resolveGrantLimits(source.grantCaps, (0, runtimeConfig_1.getCachedDeveloperGrantPolicy)()), grantScope);
903
+ const grant = source.remember === 'none' ? undefined : devConfirm().findDeveloperGrant(grantScope, { policyId: source.policyId, matchedRule: source.matchedRule, policyHash: memoryHash }, grantLimits);
881
904
  if (grant) {
882
- const covers = (0, devConfirm_1.describeGrantScope)(grant.scope, grant);
905
+ const covers = devConfirm().describeGrantScope(grant.scope, grant);
883
906
  (0, hookIo_1.dbg)({ phase: 'dev_confirm_granted', event, tool: call.toolName, operation, grant: grant.key, where: source.where });
884
907
  spoolCallEvent(call, {
885
908
  decision: 'allow',
@@ -893,11 +916,11 @@ function prepareDeveloperConfirmation(event, call, payload, source) {
893
916
  (0, telemetry_1.triggerFlush)(false);
894
917
  return { outcome: 'allow', agentMsg: `FullCourtDefense: the developer has a standing "Always allow" for ${covers}.` };
895
918
  }
896
- const marker = (0, devConfirm_1.raiseDeveloperConfirmation)({
919
+ const marker = devConfirm().raiseDeveloperConfirmation({
897
920
  event,
898
921
  toolName: call.toolName,
899
922
  operation,
900
- argsDigest: (0, devConfirm_1.actionDigest)(call.toolArgs),
923
+ argsDigest: devConfirm().actionDigest(call.toolArgs),
901
924
  policyId: source.policyId,
902
925
  policyName: source.policyName,
903
926
  policyHash: memoryHash,
@@ -973,7 +996,7 @@ function settlePostExecution(explicitEvent, payload) {
973
996
  const call = buildToolCall(inner, payload);
974
997
  if (!call)
975
998
  return;
976
- const settled = (0, devConfirm_1.settleDeveloperConfirmation)(inner, call.toolName, (0, devConfirm_1.actionDigest)(call.toolArgs));
999
+ const settled = devConfirm().settleDeveloperConfirmation(inner, call.toolName, devConfirm().actionDigest(call.toolArgs));
977
1000
  if (!settled)
978
1001
  return;
979
1002
  (0, hookIo_1.dbg)({ phase: 'dev_confirm_settled', event: inner, tool: call.toolName, confirmLatencyMs: settled.confirmLatencyMs, remember: settled.marker.remember });
@@ -1134,7 +1157,7 @@ function sessionId(p) {
1134
1157
  return fromPayload;
1135
1158
  return `cursor-${os.hostname()}`;
1136
1159
  }
1137
- function machineMetadata(client = 'cursor') {
1160
+ function machineMetadata(client = 'cursor', opts = {}) {
1138
1161
  let username = 'unknown';
1139
1162
  try {
1140
1163
  username = os.userInfo().username;
@@ -1143,9 +1166,11 @@ function machineMetadata(client = 'cursor') {
1143
1166
  // Stable fleet machine identity: lets the backend attribute this decision to
1144
1167
  // THIS machine exactly (timeline + behavior baseline), instead of matching
1145
1168
  // by hostname. Best-effort — identity must never break the hook.
1169
+ // `noNativeRead` (a report queued before the verdict): cached or pinned id
1170
+ // only, never a reg.exe / ioreg read; its deliverer adds a missing id.
1146
1171
  let machineId;
1147
1172
  try {
1148
- machineId = (0, machineIdentity_1.getMachineIdentity)().machineId;
1173
+ machineId = opts.noNativeRead ? (0, machineIdentity_1.machineIdForDisplay)() : (0, machineIdentity_1.getMachineIdentity)().machineId;
1149
1174
  }
1150
1175
  catch { /* ignore */ }
1151
1176
  return {
@@ -1170,10 +1195,10 @@ function machineMetadata(client = 'cursor') {
1170
1195
  function localBlockUserMessage(finding, event = 'shell', call, note) {
1171
1196
  return explainLocalSafetyStop('block', event, call || { toolName: 'prompt', toolArgs: {} }, finding, note).userMessage;
1172
1197
  }
1173
- /** Fleet machine id for deep links — best-effort, never throws. */
1198
+ /** Fleet machine id for deep links — best-effort, never throws, never a reg.exe read on the verdict path. */
1174
1199
  function explanationMachineId() {
1175
1200
  try {
1176
- return (0, machineIdentity_1.getMachineIdentity)().machineId;
1201
+ return (0, machineIdentity_1.machineIdForDisplay)();
1177
1202
  }
1178
1203
  catch {
1179
1204
  return undefined;
@@ -1194,7 +1219,7 @@ function explainPolicyStop(outcome, event, call, verdict, policies, note) {
1194
1219
  operation = inferred.operation;
1195
1220
  }
1196
1221
  catch { /* explanation must never fail the hook */ }
1197
- return (0, blockExplanation_1.buildBlockExplanation)({
1222
+ return blockExplanation().buildBlockExplanation({
1198
1223
  outcome,
1199
1224
  event,
1200
1225
  toolName: call.toolName,
@@ -1209,14 +1234,14 @@ function explainPolicyStop(outcome, event, call, verdict, policies, note) {
1209
1234
  operation,
1210
1235
  reason: verdict.reason,
1211
1236
  },
1212
- consoleUrl: (0, notify_1.consoleUrl)(),
1237
+ consoleUrl: notify().consoleUrl(),
1213
1238
  machineId: explanationMachineId(),
1214
1239
  developerName: developerId(),
1215
1240
  note,
1216
1241
  });
1217
1242
  }
1218
1243
  function explainLocalSafetyStop(outcome, event, call, finding, note) {
1219
- return (0, blockExplanation_1.buildBlockExplanation)({
1244
+ return blockExplanation().buildBlockExplanation({
1220
1245
  outcome,
1221
1246
  event,
1222
1247
  toolName: call.toolName,
@@ -1229,20 +1254,20 @@ function explainLocalSafetyStop(outcome, event, call, finding, note) {
1229
1254
  explanation: finding.explanation,
1230
1255
  reason: finding.reason,
1231
1256
  },
1232
- consoleUrl: (0, notify_1.consoleUrl)(),
1257
+ consoleUrl: notify().consoleUrl(),
1233
1258
  machineId: explanationMachineId(),
1234
1259
  developerName: developerId(),
1235
1260
  note,
1236
1261
  });
1237
1262
  }
1238
1263
  function explainGuardStop(kind, event, call, reason, ruleId) {
1239
- return (0, blockExplanation_1.buildBlockExplanation)({
1264
+ return blockExplanation().buildBlockExplanation({
1240
1265
  outcome: 'block',
1241
1266
  event,
1242
1267
  toolName: call.toolName,
1243
1268
  toolArgs: call.toolArgs,
1244
1269
  source: { kind, ruleId, reason },
1245
- consoleUrl: (0, notify_1.consoleUrl)(),
1270
+ consoleUrl: notify().consoleUrl(),
1246
1271
  machineId: explanationMachineId(),
1247
1272
  developerName: developerId(),
1248
1273
  });
@@ -1254,7 +1279,7 @@ function spoolLocalFinding(input) {
1254
1279
  decision: input.decision || 'block',
1255
1280
  toolName: input.toolName,
1256
1281
  operation: input.operation,
1257
- reason: recorded ? (0, blockExplanation_1.recordedReason)(input.finding.reason, recorded) : input.finding.reason,
1282
+ reason: recorded ? blockExplanation().recordedReason(input.finding.reason, recorded) : input.finding.reason,
1258
1283
  ruleId: input.finding.ruleId,
1259
1284
  category: input.finding.category,
1260
1285
  categoryId: input.finding.categoryId,
@@ -1337,12 +1362,20 @@ function processIo(stdin, stdinErr) {
1337
1362
  * snapshot, policy engine) is warm here — verdicts resolve in milliseconds.
1338
1363
  * Mirrors hookCommand's fail-open contract: any unexpected exception yields
1339
1364
  * an allow verdict, never an error the thin client would surface.
1365
+ * `exitsProcess`: the caller is a one-shot hook process that ends with this
1366
+ * verdict (the slim entry's fallback, native hooks) — see HookIo.exitsProcess.
1367
+ * `deferGateReports`: native hooks — see HookIo.deferGateReports.
1340
1368
  */
1341
1369
  async function evaluateHookRequest(args, stdinRaw, config, opts = {}) {
1342
1370
  // Structural shell facts (shadow by default). A missing/slow parser means
1343
1371
  // "no facts" and the regex floor decides alone — never a wait, never fail-open.
1344
- await (0, shellAstLoader_1.ensureShellAst)();
1345
- return evaluateBuffered(args, stdinRaw, '', config, { isTTY: false, exitsProcess: false, viaDaemon: opts.viaDaemon });
1372
+ // A one-shot process (`exitsProcess`) starts the parser only where it may
1373
+ // decide (primary mode, see hookCommandInner).
1374
+ if (!opts.exitsProcess)
1375
+ await (0, shellAstLoader_1.ensureShellAst)();
1376
+ return evaluateBuffered(args, stdinRaw, '', config, {
1377
+ isTTY: false, exitsProcess: opts.exitsProcess === true, deferGateReports: opts.exitsProcess === true && opts.deferGateReports === true, viaDaemon: opts.viaDaemon,
1378
+ });
1346
1379
  }
1347
1380
  /**
1348
1381
  * Run the hook against a buffered io and return the verdict instead of writing
@@ -1361,6 +1394,7 @@ async function evaluateBuffered(args, stdinRaw, stdinErr, config, mode) {
1361
1394
  writeErr: (text) => { errOut += text; },
1362
1395
  exit: (code) => { throw new HookExitSignal(code); },
1363
1396
  exitsProcess: mode.exitsProcess,
1397
+ deferGateReports: mode.deferGateReports,
1364
1398
  viaDaemon: mode.viaDaemon,
1365
1399
  };
1366
1400
  try {
@@ -1393,10 +1427,14 @@ async function evaluateBuffered(args, stdinRaw, stdinErr, config, mode) {
1393
1427
  * through respond() inside and never reach this catch.
1394
1428
  */
1395
1429
  async function hookCommand(args, config) {
1396
- // Start of the developer's wait. Taken once here and carried (in-memory) so
1397
- // every event this hook spools reports real latency, and the daemon can
1398
- // report the FULL wait rather than just its own slice. No probe, no timer.
1399
- const startedAt = Date.now();
1430
+ // Start of the developer's wait: when the IDE spawned this process (boot and
1431
+ // module load included). Carried (in-memory) so every event this hook spools
1432
+ // reports real latency, and the daemon can report the FULL wait rather than
1433
+ // just its own slice. No probe, no timer.
1434
+ const startedAt = (0, hookIo_1.processStartedAt)();
1435
+ (0, callerEnv_1.applyIdeTimeoutArg)(args.ideTimeout);
1436
+ // Uploads requested during the evaluation start after the verdict is out.
1437
+ (0, telemetry_1.deferFlushUntilExit)();
1400
1438
  return (0, telemetry_1.markVerdictTiming)({ startedAt, path: 'local' }, () => hookCommandOuter(args, config, startedAt));
1401
1439
  }
1402
1440
  /**
@@ -1432,29 +1470,31 @@ async function hookCommandOuter(args, config, startedAt) {
1432
1470
  // THIN-CLIENT FAST PATH: when the resident daemon is up, it computes the
1433
1471
  // verdict with everything warm (ms, no per-event config/bundle/snapshot
1434
1472
  // loading here). Bounded hard: a missing pipe fails in <1ms, a daemon that
1435
- // does not accept the call within ~2.5s is cut off; an accepted call is
1473
+ // does not accept the call within PRE_ACK_BUDGET_MS is cut off (it has
1474
+ // started nothing; it never starts the call afterwards); an accepted call is
1436
1475
  // the daemon's to decide (one evaluation per call, #1517) up to the IDE
1437
1476
  // wait cutoff. ANY non-verdict outcome falls through to the full local
1438
1477
  // evaluation below. FCD_VERDICT_IPC=off opts out.
1439
1478
  if (process.env.FCD_VERDICT_IPC !== 'off') {
1440
- const ipc = await (0, verdictIpc_1.requestDaemonVerdict)(args, raw, undefined, startedAt);
1479
+ const ipc = await verdictIpc().requestDaemonVerdict(args, raw, undefined, startedAt);
1441
1480
  if (ipc.outcome === 'verdict') {
1442
1481
  (0, hookIo_1.dbg)({ phase: 'ipc_verdict', event: args.event, exitCode: ipc.exitCode });
1443
- await (0, askDialog_1.emitVerdictForIde)(args, { stdout: ipc.stdout || '', stderr: ipc.stderr || '', exitCode: ipc.exitCode ?? 0 }, 'ipc');
1482
+ await askDialog().emitVerdictForIde(args, { stdout: ipc.stdout || '', stderr: ipc.stderr || '', exitCode: ipc.exitCode ?? 0 }, 'ipc');
1444
1483
  }
1445
1484
  // Daemon down / busy / refused — evaluate locally as always. Logged so a
1446
- // machine that silently stopped using the fast path is diagnosable.
1447
- (0, hookIo_1.dbg)({ phase: 'ipc_unavailable', event: args.event, detail: ipc.detail, ...(ipc.accepted ? { accepted: true } : {}) });
1448
- }
1449
- // Structural shell facts for the local evaluation (shadow by default; the
1450
- // daemon path above initialises its own copy once). Bounded: a slow or
1451
- // missing parser resolves to "no facts" and the regex floor decides alone.
1452
- const astReady = await (0, shellAstLoader_1.ensureShellAst)();
1453
- (0, hookIo_1.dbg)({ phase: 'shell_ast', ready: astReady, status: (0, shellAstLoader_1.shellAstStatus)() });
1485
+ // machine that silently stopped using the fast path is diagnosable, and
1486
+ // the reason rides every event this evaluation spools.
1487
+ (0, hookIo_1.dbg)({ phase: 'ipc_unavailable', event: args.event, detail: ipc.detail, ipcMiss: ipc.miss, ...(ipc.accepted ? { accepted: true } : {}) });
1488
+ (0, telemetry_1.setVerdictIpcMiss)(ipc.miss);
1489
+ }
1490
+ // Structural shell facts: this one-shot process starts the parser only in
1491
+ // primary mode (hookCommandInner). In shadow the parser never changes a
1492
+ // verdict, so its WASM start-up would only cost CPU; the regex floor decides.
1493
+ (0, hookIo_1.dbg)({ phase: 'shell_ast', ready: (0, shellAstLoader_1.shellAstStatus)().ready, status: (0, shellAstLoader_1.shellAstStatus)() });
1454
1494
  // Buffered so an `ask` can be put to the developer in OUR dialog before the
1455
1495
  // IDE reads the verdict; exitsProcess stays true — this process ends right after.
1456
1496
  const result = await evaluateBuffered(args, raw, stdinErr, config, { isTTY: !!process.stdin.isTTY, exitsProcess: true });
1457
- await (0, askDialog_1.emitVerdictForIde)(args, result, 'local');
1497
+ await askDialog().emitVerdictForIde(args, result, 'local');
1458
1498
  }
1459
1499
  catch (err) {
1460
1500
  const message = err instanceof Error ? err.message : String(err);
@@ -1490,7 +1530,7 @@ function failOpenOutcome(event, message, args, config, pendingStop) {
1490
1530
  }
1491
1531
  catch { /* never throw */ }
1492
1532
  try {
1493
- (0, telemetry_1.spoolEvent)({ decision: 'block', toolName, operation: `${toolName}:hook_error`, reason: `Hook error while ${pendingStop} — denied: ${message.slice(0, 200)}`, offlineEnforced: true });
1533
+ (0, telemetry_1.spoolEvent)({ verdictPath: 'fail_open', decision: 'block', toolName, operation: `${toolName}:hook_error`, reason: `Hook error while ${pendingStop} — denied: ${message.slice(0, 200)}`, offlineEnforced: true });
1494
1534
  (0, telemetry_1.triggerFlush)(true);
1495
1535
  }
1496
1536
  catch { /* never throw */ }
@@ -1518,6 +1558,9 @@ function failOpenOutcome(event, message, args, config, pendingStop) {
1518
1558
  catch { /* never throw */ }
1519
1559
  try {
1520
1560
  (0, telemetry_1.spoolEvent)({
1561
+ // Explicit as well: a process-level crash handler runs outside the
1562
+ // evaluation's timing store, where setVerdictPath has nothing to label.
1563
+ verdictPath: 'fail_open',
1521
1564
  decision: suspended || denyForError ? 'block' : 'allow',
1522
1565
  toolName,
1523
1566
  operation: `${toolName}:hook_error`,
@@ -1588,31 +1631,46 @@ async function hookCommandInner(args, config, io) {
1588
1631
  // the full window, and the action can simply be re-run once approved.
1589
1632
  const approvalTimeoutMs = Number(args.approvalTimeoutMs) > 0 ? Number(args.approvalTimeoutMs) : 60_000;
1590
1633
  const approvalPollMs = Math.max(1000, Number(args.approvalPollMs) > 0 ? Number(args.approvalPollMs) : 3000);
1634
+ // The signed detector, compiled in-process on the engine's first inference
1635
+ // (no-op where the daemon's Worker loader owns it).
1636
+ (0, detectorInProcess_1.ensureEvaluationDetector)();
1637
+ // A one-shot process starts the shell parser only where it may decide; an
1638
+ // env-forced primary mode starts it as early as before.
1639
+ if (io.exitsProcess && (0, shellAstLoader_1.resolveShellAstMode)(process.env.FCD_SHELL_AST_MODE, undefined) === 'primary')
1640
+ void (0, shellAstLoader_1.ensureShellAst)();
1641
+ // A one-shot process starts a policy refresher only after its verdict is
1642
+ // written (at exit), never before it.
1643
+ if (io.exitsProcess && !io.viaDaemon)
1644
+ (0, runtimeConfig_1.deferBackgroundRefreshUntilExit)();
1591
1645
  // HOT PATH: never spawn powershell.exe for a DPAPI decrypt from a hook —
1592
1646
  // that is up to 15s per shell, per IDE event, and EDRs flag the spawn. The
1593
1647
  // key resolves from the native Credential Manager / machine key file /
1594
1648
  // config / env only. Machines whose key exists solely as a DPAPI blob are
1595
1649
  // self-healed by the daemon: its (non-hot-path) resolution migrates the key
1596
1650
  // into the native store within one cycle, and hooks read it from there.
1597
- const creds = (0, config_1.resolveCliCredentials)(config, {
1598
- shieldId: args.shieldId,
1599
- shieldKey: args.shieldKey,
1600
- apiUrl: args.apiUrl,
1601
- }, { skipDpapi: true });
1651
+ // The key only authenticates network calls, so it is resolved on first use:
1652
+ // a verdict decided from the offline cache never opens a credential store.
1653
+ const credOverrides = { shieldId: args.shieldId, shieldKey: args.shieldKey, apiUrl: args.apiUrl };
1654
+ const creds = (0, config_1.resolveCliCredentialsWithoutKey)(config, credOverrides);
1602
1655
  const shieldId = creds.shieldId;
1603
- const shieldKey = creds.shieldKey;
1656
+ let resolvedKey;
1657
+ const shieldKey = () => (resolvedKey ??= { value: (0, config_1.resolveCliCredentials)(config, credOverrides, { skipDpapi: true }).shieldKey }).value;
1604
1658
  const apiUrl = creds.apiUrl;
1605
1659
  let effectivePolicyHash;
1606
1660
  let localPolicies;
1607
1661
  // Server-authoritative enforcement mode: the console flips shield.mode and the
1608
- // machine applies it on the next poll. Cached locally (stale-while-error), so
1609
- // this is instant when fresh and bounded (~1.5s) only when stale.
1662
+ // machine applies it on the next poll. Served from the local cache at any
1663
+ // age with no network; only a machine with NO cache makes one bounded
1664
+ // (~1.5s) attempt, single-flight machine-wide.
1610
1665
  let machineSuspended = false;
1611
1666
  let modeResolved = false;
1612
1667
  let runtimeBundleFlags = {};
1613
1668
  if (shieldId) {
1614
1669
  try {
1615
- const bundle = await (0, runtimeConfig_1.getRuntimeBundle)({ apiUrl, shieldId, shieldKey, developerName: developerId(), machineName: os.hostname(), hotPath: true });
1670
+ const bundle = await (0, runtimeConfig_1.getRuntimeBundle)({
1671
+ apiUrl, shieldId, get shieldKey() { return shieldKey(); }, developerName: developerId(), machineName: os.hostname(), hotPath: true, viaDaemon: io.viaDaemon,
1672
+ beforeNetwork: () => beforeVerdictNetworkWait('runtime policy refresh'),
1673
+ });
1616
1674
  runtimeBundleFlags = bundle;
1617
1675
  // Kill-switch holds even on a 'default' bundle: with the cache missing
1618
1676
  // but a signed last-known-suspended marker standing, getRuntimeBundle
@@ -1639,12 +1697,19 @@ async function hookCommandInner(args, config, io) {
1639
1697
  (0, hookIo_1.dbg)({ phase: 'mode_resolved', mode: bundle.mode, source: bundle.source, shadow, failClosed, suspended: machineSuspended });
1640
1698
  }
1641
1699
  }
1642
- catch { /* keep the local default */ }
1700
+ catch (err) {
1701
+ // The thin client took this call over (see beforeVerdictNetworkWait).
1702
+ if (err instanceof DaemonDeclined)
1703
+ throw err;
1704
+ /* keep the local default */
1705
+ }
1643
1706
  }
1644
1707
  // Shell AST mode: FCD_SHELL_AST_MODE env, else the bundle's `shellAstMode`
1645
1708
  // (server-controlled rollout), else shadow — facts recorded, verdicts unchanged.
1646
1709
  (0, shellAstLoader_1.applyShellAstMode)((0, shellAstLoader_1.resolveShellAstMode)(process.env.FCD_SHELL_AST_MODE, runtimeBundleFlags.shellAstMode));
1647
1710
  (0, hookIo_1.dbg)({ phase: 'shell_ast_mode', mode: (0, actionPolicyEngine_1.getShellAstMode)(), fromEnv: process.env.FCD_SHELL_AST_MODE, fromBundle: runtimeBundleFlags.shellAstMode });
1711
+ if (io.exitsProcess && (0, actionPolicyEngine_1.getShellAstMode)() === 'primary')
1712
+ void (0, shellAstLoader_1.ensureShellAst)();
1648
1713
  // Monitor-first: no authoritative bundle (fresh/uncached/fetch failed) => report-only.
1649
1714
  if (!modeResolved && !forceEnforce) {
1650
1715
  shadow = true;
@@ -1652,6 +1717,8 @@ async function hookCommandInner(args, config, io) {
1652
1717
  }
1653
1718
  if (forceEnforce && !modeResolved)
1654
1719
  shadow = false;
1720
+ // Nothing recorded yet: a daemon whose client stopped waiting stops here.
1721
+ await beforeVerdictSideEffects('verdict', shadow);
1655
1722
  // Keep device liveness fresh during active use (throttled, detached — never blocks).
1656
1723
  if (shieldId)
1657
1724
  (0, telemetry_1.triggerFlush)(false);
@@ -1758,7 +1825,7 @@ async function hookCommandInner(args, config, io) {
1758
1825
  }
1759
1826
  agentMsg = [prepared.agentMsg, agentMsg].filter(Boolean).join(' ');
1760
1827
  }
1761
- if (ask && !(0, devConfirm_1.isHumanPresent)()) {
1828
+ if (ask && !(0, callerEnv_1.isHumanPresent)()) {
1762
1829
  // Nobody can answer an IDE ask here, and an agent in an auto-approve mode
1763
1830
  // (Claude Code bypass, Gemini CLI yolo) runs the action on an ask.
1764
1831
  (0, hookIo_1.dbg)({ phase: 'ask_without_human_denied', event, policyId: askEnvelope?.policyId });
@@ -1815,8 +1882,8 @@ async function hookCommandInner(args, config, io) {
1815
1882
  // developer's session (askDialog.ts); it is removed before the IDE reads the verdict.
1816
1883
  // The same client the runtime event records as `agentClient`: the dialog names the agent the console shows.
1817
1884
  if (ask && askEnvelope)
1818
- decision = (0, askDialog_1.attachAskEnvelope)(decision, { ...askEnvelope, agentClient: client });
1819
- (0, hookIo_1.dbg)({ phase: 'verdict', event, format: hookFormat, blocked, decision: ask && askEnvelope ? { ...decision, [askDialog_1.ASK_ENVELOPE_FIELD]: '(envelope)' } : decision });
1885
+ decision = askDialog().attachAskEnvelope(decision, { ...askEnvelope, agentClient: client });
1886
+ (0, hookIo_1.dbg)({ phase: 'verdict', event, format: hookFormat, blocked, decision: ask && askEnvelope ? { ...decision, [askDialog().ASK_ENVELOPE_FIELD]: '(envelope)' } : decision });
1820
1887
  if (hookFormat === 'claude' && event === 'prompt' && denied) {
1821
1888
  // Claude Desktop/Code variants do not all honor the structured
1822
1889
  // UserPromptSubmit decision consistently. Exit 2 is the documented hard
@@ -1877,7 +1944,7 @@ async function hookCommandInner(args, config, io) {
1877
1944
  await enforceActionPolicy({
1878
1945
  event, payload, apiUrl: apiUrl, shieldId: shieldId, shieldKey,
1879
1946
  shadow, failClosed, timeoutMs, approvalMode, approvalTimeoutMs, approvalPollMs, respond, deferLocalWarnings, deferLocalAsk, hasPendingLocalAsk,
1880
- effectivePolicyHash, localPolicies, client, exitsProcess: io.exitsProcess, viaDaemon: Boolean(io.viaDaemon),
1947
+ effectivePolicyHash, localPolicies, client, exitsProcess: io.exitsProcess, deferGateReports: io.deferGateReports === true, viaDaemon: Boolean(io.viaDaemon),
1881
1948
  notePendingStop: (what) => {
1882
1949
  io.pendingStop = what;
1883
1950
  if (!io.viaDaemon)
@@ -1898,12 +1965,16 @@ async function hookCommandInner(args, config, io) {
1898
1965
  const snapshot = await (0, localSafetySnapshot_1.loadLocalSafetySnapshot)({
1899
1966
  apiUrl: apiUrl,
1900
1967
  shieldId: shieldId,
1901
- shieldKey,
1968
+ get shieldKey() { return shieldKey(); },
1902
1969
  developerName: developerId(),
1903
1970
  machineName: os.hostname(),
1904
1971
  expectedPolicyHash: effectivePolicyHash,
1905
1972
  preferCached: shadow,
1973
+ hotPath: true,
1974
+ viaDaemon: io.viaDaemon,
1975
+ beforeNetwork: () => beforeVerdictNetworkWait('Local Safety refresh'),
1906
1976
  });
1977
+ await beforeVerdictSideEffects('prompt verdict', shadow);
1907
1978
  const promptOutcome = (0, deterministicGuard_1.resolveDeterministicOutcome)(opts => (0, deterministicGuard_1.scanDeterministicPrompt)(text, opts), (0, localSafetySnapshot_1.snapshotToScanOptions)(snapshot));
1908
1979
  deferLocalWarnings(promptOutcome.warnings, 'prompt', 'prompt');
1909
1980
  // The prompt path only judges what is literally IN the text — a real secret
@@ -1918,7 +1989,7 @@ async function hookCommandInner(args, config, io) {
1918
1989
  if (shadow) {
1919
1990
  // Truth-in-reporting: this prompt RAN (shadow/monitor) — 'warn', never 'block'.
1920
1991
  spoolLocalFinding({ finding: localBlock, toolName: 'prompt', operation: 'prompt', decision: 'warn', recorded: 'would_block' });
1921
- respond(false, undefined, `[FullCourtDefense shadow] ${(0, blockExplanation_1.recordedReason)(localBlock.reason, 'would_block')}`);
1992
+ respond(false, undefined, `[FullCourtDefense shadow] ${blockExplanation().recordedReason(localBlock.reason, 'would_block')}`);
1922
1993
  return;
1923
1994
  }
1924
1995
  spoolLocalFinding({ finding: localBlock, toolName: 'prompt', operation: 'prompt' });
@@ -1947,6 +2018,42 @@ function declineInDaemon(ctx, what) {
1947
2018
  (0, hookIo_1.dbg)({ phase: 'daemon_declined', what });
1948
2019
  throw new DaemonDeclined(`${what}: runs in the hook process`);
1949
2020
  }
2021
+ /**
2022
+ * The verdict is about to wait on the network (the synchronous gate, which is
2023
+ * also what opens an org approval; the Shield text scan; an inline refresh of
2024
+ * the policy bundle or the Local Safety snapshot). The client is told
2025
+ * (announceNetworkWait). A client that already gave up is evaluating this call
2026
+ * itself: hand it back rather than make the same call — or open the same
2027
+ * approval — twice.
2028
+ */
2029
+ function beforeVerdictNetworkWait(what) {
2030
+ // Only a verdict-server request has a client to tell; a one-shot hook (the
2031
+ // enforce core) never loads the server module for it.
2032
+ if ((0, telemetry_1.verdictPathNow)() !== 'ipc' || verdictIpc().announceNetworkWait())
2033
+ return;
2034
+ (0, hookIo_1.dbg)({ phase: 'daemon_declined', what, reason: 'client_gone' });
2035
+ throw new DaemonDeclined(`${what}: the hook took the call over`);
2036
+ }
2037
+ /**
2038
+ * A phase boundary before the evaluation records anything or consumes
2039
+ * anything (spool, taint, session memory, a maxUses developer grant). In the
2040
+ * daemon, a thin client that stopped waiting is evaluating — and recording —
2041
+ * the call itself: hand it back rather than decide it twice. A client stops
2042
+ * waiting on an accepted call at its IDE wait cutoff, or — on a monitor
2043
+ * machine whose hook can answer the call itself (the same allow) — at the
2044
+ * give-up time it sent (MONITOR_IPC_BUDGET_MS after it asked; see
2045
+ * clientStillWaiting). Elsewhere (a hook's own process, a replay of the local
2046
+ * queue) it returns at once.
2047
+ */
2048
+ async function beforeVerdictSideEffects(what, monitor) {
2049
+ if ((0, telemetry_1.verdictPathNow)() !== 'ipc')
2050
+ return;
2051
+ const startedAt = (0, telemetry_1.verdictStartedAt)() ?? Date.now();
2052
+ if (await verdictIpc().clientStillWaiting((0, callerEnv_1.ideWaitCutoff)(startedAt, (0, callerEnv_1.ideHookTimeoutMs)()), monitor))
2053
+ return;
2054
+ (0, hookIo_1.dbg)({ phase: 'daemon_declined', what, reason: 'client_gone' });
2055
+ throw new DaemonDeclined(`${what}: the hook took the call over`);
2056
+ }
1950
2057
  /**
1951
2058
  * Approve-once for a taint-guard block: hold the action, alert the human at
1952
2059
  * the keyboard, and poll for their one-time decision from their OWN terminal
@@ -1976,7 +2083,7 @@ async function runTaintApproveOnce(ctx, event, call, sessId, taintFinding) {
1976
2083
  return hardBlock();
1977
2084
  // Approve-once is answered from the developer's own terminal: with nobody
1978
2085
  // there, the wait could only end in the IDE's timeout.
1979
- if (!(0, devConfirm_1.isHumanPresent)())
2086
+ if (!(0, callerEnv_1.isHumanPresent)())
1980
2087
  return hardBlock();
1981
2088
  declineInDaemon(ctx, 'taint approve-once');
1982
2089
  const approval = (0, taintLedger_1.createTaintApproval)({
@@ -1994,7 +2101,7 @@ async function runTaintApproveOnce(ctx, event, call, sessId, taintFinding) {
1994
2101
  : 120000; // local developer decision — short window, they are at the keyboard
1995
2102
  const targetText = taintFinding.sink.targets.join(', ') || 'an external destination';
1996
2103
  // forceWindow: a toast can be swallowed by Focus Assist; this decision must be seen.
1997
- (0, notify_1.notifyOs)({
2104
+ notify().notifyOs({
1998
2105
  forceWindow: true,
1999
2106
  title: 'FullCourtDefense — approve once?',
2000
2107
  message: `Agent action held: ${call.toolName} -> ${targetText}\n`
@@ -2017,7 +2124,7 @@ async function runTaintApproveOnce(ctx, event, call, sessId, taintFinding) {
2017
2124
  offlineEnforced: true,
2018
2125
  });
2019
2126
  (0, telemetry_1.triggerFlush)(true);
2020
- (0, notify_1.notifyOs)({ title: 'FullCourtDefense — approved once', message: `${call.toolName} -> ${targetText} will continue.` });
2127
+ notify().notifyOs({ title: 'FullCourtDefense — approved once', message: `${call.toolName} -> ${targetText} will continue.` });
2021
2128
  return true;
2022
2129
  }
2023
2130
  spoolCallEvent(call, {
@@ -2034,6 +2141,59 @@ async function runTaintApproveOnce(ctx, event, call, sessId, taintFinding) {
2034
2141
  respond(true, explained.userMessage, `FullCourtDefense blocked this ${event} locally (${taintFinding.ruleId}): ${taintFinding.reason} The developer was asked and did not approve. Do not retry. ${explained.agentMessage}`);
2035
2142
  return false;
2036
2143
  }
2144
+ /**
2145
+ * Native hooks: queue a block-mode local-first allow's check-tool-call report
2146
+ * (gateReports.ts) instead of sending it from a process that exits with this
2147
+ * verdict. The record spooled if it cannot be delivered is stamped now, as the
2148
+ * failed immediate report's was. Inside the fast-probe window (a gate failure
2149
+ * under 60 s old) the upload starts at this process's exit — the report may
2150
+ * end that window for every process on the machine — instead of on the 30 s
2151
+ * routine cadence. Evaluations that read gate health settle queued reports
2152
+ * first either way (settleGateReports). The queue holds the body (its shield)
2153
+ * and the fallback record only: whoever sends it uses its own configured API
2154
+ * URL and key for that shield. False when nothing was queued (the caller
2155
+ * spools).
2156
+ */
2157
+ function queueAllowReport(ctx, call, ruleId, gateBody) {
2158
+ try {
2159
+ const decidedAt = Date.now();
2160
+ const waitStartedAt = (0, telemetry_1.verdictStartedAt)();
2161
+ const fallback = (0, telemetry_1.buildSpoolEvent)(callEvent(call, {
2162
+ decision: 'allow', policyHash: ctx.effectivePolicyHash, ruleId,
2163
+ reason: 'local policy allow (block mode, local-first); gate report failed — spooled', offlineEnforced: true,
2164
+ occurredAt: new Date(decidedAt).toISOString(),
2165
+ latencyMs: waitStartedAt === undefined ? undefined : Math.max(0, decidedAt - waitStartedAt),
2166
+ }));
2167
+ if (!gateReports().queueGateReport({ decidedAt, body: gateBody({ noNativeRead: true }), fallback }))
2168
+ return false;
2169
+ (0, telemetry_1.triggerFlush)((0, policyGateHealth_1.gateRecentlyDown)());
2170
+ (0, hookIo_1.dbg)({ phase: 'gate_report_queued', event: ctx.event, tool: call.toolName });
2171
+ return true;
2172
+ }
2173
+ catch {
2174
+ return false;
2175
+ }
2176
+ }
2177
+ /**
2178
+ * Native allow reports queued on this machine land before this evaluation
2179
+ * reads gate health or calls the gate (gateReports.ts settleGateReports).
2180
+ */
2181
+ async function settleGateReports(ctx, forGateCall) {
2182
+ try {
2183
+ await gateReports().settleGateReports({
2184
+ apiUrl: ctx.apiUrl,
2185
+ shieldId: ctx.shieldId,
2186
+ shieldKey: ctx.shieldKey,
2187
+ forGateCall,
2188
+ machineId: () => (0, machineIdentity_1.getMachineIdentity)().machineId,
2189
+ spool: (fallback) => { (0, telemetry_1.appendSpoolEvent)(fallback); (0, telemetry_1.triggerFlush)(false); },
2190
+ onSettled: (scope, outcome) => (0, hookIo_1.dbg)({
2191
+ phase: 'gate_reports_settled', event: ctx.event, scope, ...outcome, ...(scope === 'streak' ? { gateDown: (0, policyGateHealth_1.gateRecentlyDown)() } : {}),
2192
+ }),
2193
+ });
2194
+ }
2195
+ catch { /* best-effort: the evaluation continues as it would have */ }
2196
+ }
2037
2197
  async function enforceActionPolicy(ctx) {
2038
2198
  const { event, payload, apiUrl, shieldId, shieldKey, shadow, timeoutMs, respond, deferLocalWarnings, deferLocalAsk, hasPendingLocalAsk, effectivePolicyHash } = ctx;
2039
2199
  const call = buildToolCall(event, payload);
@@ -2045,12 +2205,18 @@ async function enforceActionPolicy(ctx) {
2045
2205
  const snapshot = await (0, localSafetySnapshot_1.loadLocalSafetySnapshot)({
2046
2206
  apiUrl,
2047
2207
  shieldId,
2048
- shieldKey,
2208
+ get shieldKey() { return shieldKey(); },
2049
2209
  developerName: developerId(),
2050
2210
  machineName: os.hostname(),
2051
2211
  expectedPolicyHash: effectivePolicyHash,
2052
2212
  preferCached: shadow,
2213
+ hotPath: true,
2214
+ viaDaemon: ctx.viaDaemon,
2215
+ beforeNetwork: () => beforeVerdictNetworkWait('Local Safety refresh'),
2053
2216
  });
2217
+ // From here the evaluation records (spool, taint, session memory) and may
2218
+ // consume a developer grant: a daemon whose client stopped waiting stops.
2219
+ await beforeVerdictSideEffects('action verdict', shadow);
2054
2220
  const toolOutcome = (0, deterministicGuard_1.resolveDeterministicOutcome)(opts => (0, deterministicGuard_1.scanDeterministicToolCall)(call.toolName, call.toolArgs, opts), (0, localSafetySnapshot_1.snapshotToScanOptions)(snapshot, {
2055
2221
  cwd: typeof call.toolArgs.cwd === 'string' ? call.toolArgs.cwd : process.cwd(),
2056
2222
  inspectScripts: event === 'shell',
@@ -2063,7 +2229,7 @@ async function enforceActionPolicy(ctx) {
2063
2229
  if (shadow) {
2064
2230
  // Truth-in-reporting: this action RAN (shadow/monitor) — 'warn', never 'block'.
2065
2231
  spoolLocalFinding({ finding: localBlock, toolName: call.toolName, operation: event, decision: 'warn', recorded: 'would_block' });
2066
- respond(false, undefined, `[FullCourtDefense shadow] ${call.toolName}: ${(0, blockExplanation_1.recordedReason)(localBlock.reason, 'would_block')}`);
2232
+ respond(false, undefined, `[FullCourtDefense shadow] ${call.toolName}: ${blockExplanation().recordedReason(localBlock.reason, 'would_block')}`);
2067
2233
  return;
2068
2234
  }
2069
2235
  spoolLocalFinding({ finding: localBlock, toolName: call.toolName, operation: event });
@@ -2078,11 +2244,11 @@ async function enforceActionPolicy(ctx) {
2078
2244
  // no human (CI, ephemeral, FCD_HUMAN_PRESENT=0) -> block, never a silent allow.
2079
2245
  const localAsk = toolOutcome.askFinding;
2080
2246
  if (localAsk) {
2081
- (0, hookIo_1.dbg)({ phase: 'local_deterministic_ask', event, tool: call.toolName, ruleId: localAsk.ruleId, humanPresent: (0, devConfirm_1.isHumanPresent)(), shadow });
2247
+ (0, hookIo_1.dbg)({ phase: 'local_deterministic_ask', event, tool: call.toolName, ruleId: localAsk.ruleId, humanPresent: (0, callerEnv_1.isHumanPresent)(), shadow });
2082
2248
  if (shadow) {
2083
2249
  spoolLocalFinding({ finding: localAsk, toolName: call.toolName, operation: event, decision: 'warn', recorded: 'would_ask' });
2084
2250
  }
2085
- else if (!(0, devConfirm_1.isHumanPresent)()) {
2251
+ else if (!(0, callerEnv_1.isHumanPresent)()) {
2086
2252
  spoolLocalFinding({ finding: localAsk, toolName: call.toolName, operation: event });
2087
2253
  const explained = explainLocalSafetyStop('block', event, call, localAsk, 'This rule asks the developer to confirm, but no developer is present on this machine (CI / ephemeral), so it is blocked.');
2088
2254
  respond(true, explained.userMessage, `FullCourtDefense blocked this ${event} locally (${localAsk.ruleId}): the rule requires the developer's confirmation and no human is present here. Do not retry. ${explained.agentMessage}`);
@@ -2091,7 +2257,7 @@ async function enforceActionPolicy(ctx) {
2091
2257
  else {
2092
2258
  // Memory and grants bind to THIS rule (item + configured action), not to the
2093
2259
  // bundle hash the snapshot carries — that changes with any org edit or enrollment.
2094
- deferLocalAsk?.(localAsk, call, (0, devConfirm_1.localSafetyRuleHash)(localAsk, snapshot?.customBlocks));
2260
+ deferLocalAsk?.(localAsk, call, devConfirm().localSafetyRuleHash(localAsk, snapshot?.customBlocks));
2095
2261
  }
2096
2262
  }
2097
2263
  // --- Taint-guard self-protection (always enforced before anything taint) ---
@@ -2142,9 +2308,15 @@ async function enforceActionPolicy(ctx) {
2142
2308
  respond(false);
2143
2309
  return;
2144
2310
  }
2145
- const headers = { 'Content-Type': 'application/json' };
2146
- if (shieldKey)
2147
- headers['x-shield-key'] = shieldKey;
2311
+ // Built only when a request is actually sent: the key store and the machine
2312
+ // id are not touched by a verdict decided locally.
2313
+ const gateHeaders = () => {
2314
+ const headers = { 'Content-Type': 'application/json' };
2315
+ const key = shieldKey();
2316
+ if (key)
2317
+ headers['x-shield-key'] = key;
2318
+ return headers;
2319
+ };
2148
2320
  // MONITOR CONTRACT (latency half): a monitor/shadow machine makes ZERO
2149
2321
  // synchronous network calls — the developer never waits on the gate, not
2150
2322
  // even for the first event after a cache expiry. Cached org policies are
@@ -2164,7 +2336,7 @@ async function enforceActionPolicy(ctx) {
2164
2336
  }
2165
2337
  // The gate request body is shared by the synchronous check (approvals /
2166
2338
  // no-cache fallback) and the fire-and-forget audit report (local-first allow).
2167
- const gateBody = JSON.stringify({
2339
+ const gateBody = (opts = {}) => JSON.stringify({
2168
2340
  shieldId,
2169
2341
  // For MCP calls this mirrors the gateway's runtime-agnostic per-server identity
2170
2342
  // (`<developer>-<server>`), so ONE per-agent policy matches the same server on the
@@ -2177,9 +2349,9 @@ async function enforceActionPolicy(ctx) {
2177
2349
  source: 'cursor_hook',
2178
2350
  // Lets the server record the effective approval scope (developer-scoped rules degrade
2179
2351
  // to org where nobody can answer an IDE prompt: CI, ephemeral workloads).
2180
- humanPresent: (0, devConfirm_1.isHumanPresent)(),
2352
+ humanPresent: (0, callerEnv_1.isHumanPresent)(),
2181
2353
  ...(event === 'mcp' && mcpServerName(payload) ? { mcpServer: mcpServerName(payload) } : {}),
2182
- ...machineMetadata(ctx.client),
2354
+ ...machineMetadata(ctx.client, opts),
2183
2355
  });
2184
2356
  const gateUrl = `${apiUrl}/api/agent-security/runtime/check-tool-call`;
2185
2357
  // BLOCK MODE, LOCAL-FIRST: the bundle's cached org policies (refreshed by
@@ -2215,24 +2387,44 @@ async function enforceActionPolicy(ctx) {
2215
2387
  return;
2216
2388
  }
2217
2389
  if (ctx.exitsProcess) {
2390
+ if (ctx.deferGateReports && queueAllowReport(ctx, call, localVerdict.policyId, gateBody)) {
2391
+ respond(false);
2392
+ return;
2393
+ }
2218
2394
  // Standalone hook (CI runner, container, daemon down): respond() ends
2219
2395
  // the process, so a fire-and-forget gate report would die in flight and
2220
2396
  // the allowed action would never reach the fleet — the machine would
2221
- // read "N blocked of N recent" with every allow missing. Same delivery
2222
- // as a block: spool (sync file append) + detached flusher that outlives
2223
- // this process. The IDE is not kept waiting on the network.
2397
+ // read "N blocked of N recent" with every allow missing. The audit goes
2398
+ // to the spool (sync file append) and rides the routine upload: at
2399
+ // most one detached flusher per 30 s, started after the verdict — not
2400
+ // one per allowed action. Blocks and findings still request theirs at
2401
+ // once. The IDE is not kept waiting on the network.
2224
2402
  spoolCallEvent(call, { decision: 'allow', policyHash: effectivePolicyHash, ruleId: localVerdict.policyId, reason: 'local policy allow (block mode, local-first)' });
2225
- (0, telemetry_1.triggerFlush)(true);
2403
+ (0, telemetry_1.triggerFlush)(false);
2226
2404
  respond(false);
2227
2405
  return;
2228
2406
  }
2229
2407
  // Daemon-evaluated: the process lives on, so the audit rides async — the
2230
2408
  // server records the action + its own verdict; if it is unreachable the
2231
- // spool keeps the trail (idempotent on eventId).
2232
- void fetch(gateUrl, { method: 'POST', headers, body: gateBody, signal: AbortSignal.timeout(4000) })
2409
+ // spool keeps the trail (idempotent on eventId). A failed report records
2410
+ // the wait for THIS verdict, decided now — not the report's own 4 s
2411
+ // timeout (or a sleep gap), which read as fake slow hooks in fleet health.
2412
+ const decidedAt = Date.now();
2413
+ const waitStartedAt = (0, telemetry_1.verdictStartedAt)();
2414
+ const verdictLatencyMs = waitStartedAt === undefined ? undefined : Math.max(0, decidedAt - waitStartedAt);
2415
+ // Off the verdict path, allow reports native hooks queued land first:
2416
+ // they used to reach the server before this one (settleGateReports).
2417
+ const body = gateBody();
2418
+ void settleGateReports(ctx, true)
2419
+ .then(() => fetch(gateUrl, { method: 'POST', headers: gateHeaders(), body, signal: AbortSignal.timeout(4000) }))
2420
+ // Success only once the body arrived (same 4s signal covers it).
2421
+ .then(resp => resp.text())
2233
2422
  .then(() => (0, policyGateHealth_1.recordGateSuccess)())
2234
2423
  .catch(() => {
2235
- spoolCallEvent(call, { decision: 'allow', policyHash: effectivePolicyHash, ruleId: localVerdict.policyId, reason: 'local policy allow (block mode, local-first); gate report failed — spooled', offlineEnforced: true });
2424
+ spoolCallEvent(call, {
2425
+ decision: 'allow', policyHash: effectivePolicyHash, ruleId: localVerdict.policyId, reason: 'local policy allow (block mode, local-first); gate report failed — spooled', offlineEnforced: true,
2426
+ occurredAt: new Date(decidedAt).toISOString(), latencyMs: verdictLatencyMs,
2427
+ });
2236
2428
  (0, telemetry_1.triggerFlush)(false);
2237
2429
  });
2238
2430
  respond(false);
@@ -2261,6 +2453,10 @@ async function enforceActionPolicy(ctx) {
2261
2453
  declineInDaemon(ctx, 'org approval');
2262
2454
  }
2263
2455
  }
2456
+ // Allow reports native hooks queued land first (they used to be sent before
2457
+ // those processes exited): they may end a failure streak, and the server's
2458
+ // verdict below reads the baseline they feed.
2459
+ await settleGateReports(ctx, true);
2264
2460
  // PROXY RESILIENCE (fast probe): when the gate failed within the last 60s
2265
2461
  // (bank proxy without the api hostname allowlisted, backend outage), skip
2266
2462
  // the synchronous call entirely — the offline stance is decided in ~1ms from
@@ -2280,12 +2476,13 @@ async function enforceActionPolicy(ctx) {
2280
2476
  // 8s x 2 (~16s) — local-first above means almost no call ever waits here.
2281
2477
  const gateTimeoutMs = Math.min(timeoutMs, 4000);
2282
2478
  const gateAttempts = 2;
2479
+ beforeVerdictNetworkWait('policy gate');
2283
2480
  let result;
2284
2481
  try {
2285
2482
  const resp = await fetchWithOneRetry(gateUrl, {
2286
2483
  method: 'POST',
2287
- headers,
2288
- body: gateBody,
2484
+ headers: gateHeaders(),
2485
+ body: gateBody(),
2289
2486
  }, gateTimeoutMs, (attempt, err) => (0, hookIo_1.dbg)({ phase: 'policy_retry', event, attempt, error: err }), gateAttempts);
2290
2487
  if (!resp.ok) {
2291
2488
  (0, hookIo_1.dbg)({ phase: 'policy_http_error', event, status: resp.status, failClosed: ctx.failClosed });
@@ -2299,7 +2496,7 @@ async function enforceActionPolicy(ctx) {
2299
2496
  // (below) remain the best enforcement stance, but the distress code
2300
2497
  // must say auth_broken so the org-admin alert email names the real
2301
2498
  // fix (re-enroll) instead of "check connectivity".
2302
- (0, distress_1.reportDistress)('hook', distress_1.DISTRESS.AUTH_BROKEN, `check-tool-call HTTP ${resp.status}${shieldKey ? '' : ' (no shield key resolved)'}`);
2499
+ (0, distress_1.reportDistress)('hook', distress_1.DISTRESS.AUTH_BROKEN, `check-tool-call HTTP ${resp.status}${shieldKey() ? '' : ' (no shield key resolved)'}`);
2303
2500
  }
2304
2501
  else if (failure === 'rejected') {
2305
2502
  (0, distress_1.reportDistress)('hook', distress_1.DISTRESS.BACKEND_REJECTED, `check-tool-call HTTP ${resp.status}${backendReason ? `: ${backendReason}` : ''}`);
@@ -2359,9 +2556,9 @@ async function enforceActionPolicy(ctx) {
2359
2556
  respond(false, undefined, `[FullCourtDefense shadow] would require approval for ${call.toolName}${policyName ? ` (${policyName})` : ''}: ${reason}`);
2360
2557
  return;
2361
2558
  }
2362
- const queueUrl = (0, notify_1.approvalsConsoleUrl)();
2559
+ const queueUrl = notify().approvalsConsoleUrl();
2363
2560
  if (ctx.approvalMode === 'block' || !result.approvalActionId) {
2364
- (0, notify_1.notifyOs)({
2561
+ notify().notifyOs({
2365
2562
  title: 'FullCourtDefense — approval required',
2366
2563
  message: `${call.toolName} needs human approval${policyName ? ` (${policyName})` : ''}.`,
2367
2564
  url: queueUrl,
@@ -2378,22 +2575,22 @@ async function enforceActionPolicy(ctx) {
2378
2575
  // Make the wait visible on the machine: the hook process has no UI and its
2379
2576
  // stderr is invisible inside IDEs, so a native toast is the only reliable
2380
2577
  // signal that a human needs to act NOW (the approval window is minutes).
2381
- (0, notify_1.notifyOs)({
2578
+ notify().notifyOs({
2382
2579
  title: 'FullCourtDefense — approval required',
2383
2580
  message: `${call.toolName} is paused waiting for human approval${policyName ? ` (${policyName})` : ''}. Approve or reject in the console.`,
2384
2581
  url: queueUrl,
2385
2582
  });
2386
2583
  const outcome = await waitForApproval({
2387
- apiUrl, shieldId, shieldKey, actionId: result.approvalActionId,
2584
+ apiUrl, shieldId, shieldKey: shieldKey(), actionId: result.approvalActionId,
2388
2585
  deadline: humanWaitDeadline(ctx.approvalTimeoutMs), pollMs: ctx.approvalPollMs,
2389
2586
  });
2390
2587
  if (outcome.status === 'approved') {
2391
2588
  ctx.notePendingStop('');
2392
- (0, notify_1.notifyOs)({ title: 'FullCourtDefense — approved', message: `${call.toolName} was approved and will continue.` });
2589
+ notify().notifyOs({ title: 'FullCourtDefense — approved', message: `${call.toolName} was approved and will continue.` });
2393
2590
  respond(false, undefined, `FullCourtDefense: human approved this ${event}.`);
2394
2591
  return;
2395
2592
  }
2396
- (0, notify_1.notifyOs)({
2593
+ notify().notifyOs({
2397
2594
  title: 'FullCourtDefense — not approved',
2398
2595
  message: `${call.toolName}: ${outcome.message}`,
2399
2596
  url: outcome.status === 'timeout' ? queueUrl : undefined,
@@ -2460,6 +2657,8 @@ async function enforceShieldText(ctx) {
2460
2657
  (0, telemetry_1.triggerFlush)(false);
2461
2658
  respond(false);
2462
2659
  }
2660
+ // Queued native allow reports that may end a failure streak land first.
2661
+ await settleGateReports(ctx, false);
2463
2662
  // PROXY RESILIENCE (fast probe): same rule as the action-policy gate — a
2464
2663
  // recently-failed gate is not re-probed on every prompt; the degraded stance
2465
2664
  // resolves instantly instead of paying the full timeout per event.
@@ -2470,6 +2669,7 @@ async function enforceShieldText(ctx) {
2470
2669
  }
2471
2670
  const gateTimeoutMs = timeoutMs;
2472
2671
  const gateAttempts = 2;
2672
+ beforeVerdictNetworkWait('Shield text scan');
2473
2673
  try {
2474
2674
  const headers = {
2475
2675
  'Content-Type': 'application/json',
@@ -2478,8 +2678,9 @@ async function enforceShieldText(ctx) {
2478
2678
  'x-session-id': sessionId(payload),
2479
2679
  ...attributionFor(event, payload),
2480
2680
  };
2481
- if (shieldKey)
2482
- headers['x-shield-key'] = shieldKey;
2681
+ const key = shieldKey();
2682
+ if (key)
2683
+ headers['x-shield-key'] = key;
2483
2684
  const resp = await fetchWithOneRetry(`${apiUrl}/api/shield/proxy/${shieldId}`, {
2484
2685
  method: 'POST',
2485
2686
  headers,
@@ -2491,9 +2692,9 @@ async function enforceShieldText(ctx) {
2491
2692
  respondDegraded(ctx, gateHttpFailureDetail(failure, resp.status, backendReason, 'Shield service'), 'prompt', failure);
2492
2693
  return;
2493
2694
  }
2494
- // A clean round-trip ends any failure streak (grace-window state).
2495
- (0, policyGateHealth_1.recordGateSuccess)();
2496
2695
  const data = await resp.json().catch(() => ({}));
2696
+ // A clean round-trip — body included — ends any failure streak.
2697
+ (0, policyGateHealth_1.recordGateSuccess)();
2497
2698
  const sh = (data._shield || {});
2498
2699
  const action = String(sh.action || '');
2499
2700
  const isBlocked = action.startsWith('blocked');