fullcourtdefense-cli 1.26.4 → 1.26.5

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.
@@ -36,9 +36,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.normalizeClaudePayload = normalizeClaudePayload;
37
37
  exports.evaluateHookRequest = evaluateHookRequest;
38
38
  exports.hookCommand = hookCommand;
39
- const fs = __importStar(require("fs"));
40
39
  const os = __importStar(require("os"));
41
- const path = __importStar(require("path"));
42
40
  const config_1 = require("../config");
43
41
  const runtimeConfig_1 = require("../runtimeConfig");
44
42
  const telemetry_1 = require("../telemetry");
@@ -51,73 +49,9 @@ const policyGateHealth_1 = require("../policyGateHealth");
51
49
  const verdictIpc_1 = require("../verdictIpc");
52
50
  const machineIdentity_1 = require("../machineIdentity");
53
51
  const actionPolicyEngine_1 = require("../actionPolicyEngine");
54
- const DEBUG_LOG = path.join(os.homedir(), '.fullcourtdefense-hook.log');
55
- /**
56
- * Verbose diagnostics (FCD_HOOK_DEBUG=1) additionally log content previews
57
- * (raw payload + prompt text snippets). Default logging keeps operational
58
- * metadata only — user prompt content must never sit in a plaintext file on
59
- * a customer laptop unless support explicitly opts in.
60
- */
61
- const HOOK_DEBUG_VERBOSE = process.env.FCD_HOOK_DEBUG === '1';
62
- /** Rotate the hook log when it grows past this size (same scheme as daemon.log). */
63
- const DEBUG_LOG_MAX_BYTES = 1_000_000;
64
- // Checked once per (short-lived) hook process: unbounded hook.log growth was
65
- // real customer disk churn — and every append to a huge file re-triggers AV
66
- // scanning. One stat per process is enough for a hook run; the resident
67
- // daemon (verdict IPC evaluation) re-checks every 500 writes so a long
68
- // uptime can't outgrow the cap either.
69
- let debugLogRotationChecked = false;
70
- let debugLogWritesSinceCheck = 0;
71
- /** Append a diagnostic line so we can see exactly what Cursor invoked + the verdict. */
72
- function dbg(obj) {
73
- try {
74
- debugLogWritesSinceCheck += 1;
75
- if (!debugLogRotationChecked || debugLogWritesSinceCheck >= 500) {
76
- debugLogRotationChecked = true;
77
- debugLogWritesSinceCheck = 0;
78
- try {
79
- if (fs.existsSync(DEBUG_LOG) && fs.statSync(DEBUG_LOG).size > DEBUG_LOG_MAX_BYTES) {
80
- fs.renameSync(DEBUG_LOG, `${DEBUG_LOG}.1`);
81
- }
82
- }
83
- catch { /* rotation is best-effort */ }
84
- }
85
- fs.appendFileSync(DEBUG_LOG, JSON.stringify({ t: new Date().toISOString(), ...obj }) + '\n');
86
- }
87
- catch { /* ignore */ }
88
- }
89
- function readStdin() {
90
- return new Promise((resolve) => {
91
- const stdin = process.stdin;
92
- if (stdin.isTTY) {
93
- resolve('');
94
- return;
95
- }
96
- // Read raw BYTES (no setEncoding) and decode the whole payload as UTF-8 once.
97
- // Per-chunk string decoding + concat corrupts multibyte chars (e.g. Hebrew),
98
- // turning them into Latin-1 mojibake before they ever reach the backend.
99
- const chunks = [];
100
- let settled = false;
101
- const finish = () => {
102
- if (settled)
103
- return;
104
- settled = true;
105
- let buf = Buffer.concat(chunks);
106
- // Strip UTF-8 BOM at the byte level (Cursor on Windows prepends EF BB BF).
107
- if (buf.length >= 3 && buf[0] === 0xEF && buf[1] === 0xBB && buf[2] === 0xBF) {
108
- buf = buf.subarray(3);
109
- }
110
- resolve(buf.toString('utf8'));
111
- };
112
- stdin.on('data', (chunk) => {
113
- chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
114
- });
115
- stdin.on('end', finish);
116
- stdin.on('error', finish);
117
- // Safety: if nothing arrives, don't hang forever.
118
- setTimeout(finish, 2000).unref?.();
119
- });
120
- }
52
+ // Shared with the slim hook entry (hookSlim.ts) so both entries log to the
53
+ // same rotated hook.log and read stdin identically.
54
+ const hookIo_1 = require("../hookIo");
121
55
  function inferEvent(explicit, payload) {
122
56
  const e = (explicit || '').toLowerCase();
123
57
  if (e) {
@@ -427,7 +361,7 @@ function respondDegraded(ctx, detail, toolName, authRejected = false, opts = {})
427
361
  // Grace window: transient failure on a fail-closed machine — allow this
428
362
  // action, but record distress so the episode is visible in the console.
429
363
  (0, distress_1.reportDistress)('hook', authRejected ? distress_1.DISTRESS.AUTH_BROKEN : distress_1.DISTRESS.NETWORK_DOWN, `Policy gate failure ${health.consecutiveFailures}/3 (grace window, still allowing): ${detail}`);
430
- dbg({ phase: 'policy_grace_allow', event: ctx.event, consecutiveFailures: health.consecutiveFailures, detail });
364
+ (0, hookIo_1.dbg)({ phase: 'policy_grace_allow', event: ctx.event, consecutiveFailures: health.consecutiveFailures, detail });
431
365
  (0, telemetry_1.spoolEvent)({ decision: 'allow', toolName, reason: `degraded (grace ${health.consecutiveFailures}/3): ${detail}`, offlineEnforced: true });
432
366
  (0, telemetry_1.triggerFlush)(false);
433
367
  ctx.respond(false, undefined, degradedAllowMessage(ctx.event, detail));
@@ -469,7 +403,7 @@ function tryLocalPolicyEnforcement(ctx, call, detail, opts = {}) {
469
403
  context.machineName = os.hostname();
470
404
  const declaredOperations = (0, actionPolicyEngine_1.deriveToolOperations)(call.toolName);
471
405
  const local = (0, actionPolicyEngine_1.evaluateActionPolicies)(policies, call.toolName, operation, context, declaredOperations);
472
- dbg({ phase: 'policy_local_verdict', event: ctx.event, tool: call.toolName, operation, verdict: local.verdict, policyName: local.policyName, detail });
406
+ (0, hookIo_1.dbg)({ phase: 'policy_local_verdict', event: ctx.event, tool: call.toolName, operation, verdict: local.verdict, policyName: local.policyName, detail });
473
407
  if (local.verdict === 'block' || local.verdict === 'require_approval') {
474
408
  const reason = local.reason || `${call.toolName}: local policy ${local.verdict}`;
475
409
  if (ctx.shadow) {
@@ -617,7 +551,7 @@ function spoolLocalFinding(input) {
617
551
  */
618
552
  function spoolLocalWarnings(warnings, toolName, operation) {
619
553
  for (const finding of warnings) {
620
- dbg({ phase: 'local_deterministic_warn', tool: toolName, ruleId: finding.ruleId, itemId: finding.itemId });
554
+ (0, hookIo_1.dbg)({ phase: 'local_deterministic_warn', tool: toolName, ruleId: finding.ruleId, itemId: finding.itemId });
621
555
  spoolLocalFinding({ finding, toolName, operation, decision: 'warn' });
622
556
  }
623
557
  }
@@ -695,7 +629,7 @@ async function hookCommandOuter(args, config, startedAt) {
695
629
  let raw = '';
696
630
  let stdinErr = '';
697
631
  try {
698
- raw = await readStdin();
632
+ raw = await (0, hookIo_1.readStdin)();
699
633
  }
700
634
  catch (e) {
701
635
  stdinErr = e instanceof Error ? e.message : String(e);
@@ -709,7 +643,7 @@ async function hookCommandOuter(args, config, startedAt) {
709
643
  if (process.env.FCD_VERDICT_IPC !== 'off') {
710
644
  const ipc = await (0, verdictIpc_1.requestDaemonVerdict)(args, raw, undefined, startedAt);
711
645
  if (ipc.outcome === 'verdict') {
712
- dbg({ phase: 'ipc_verdict', event: args.event, exitCode: ipc.exitCode });
646
+ (0, hookIo_1.dbg)({ phase: 'ipc_verdict', event: args.event, exitCode: ipc.exitCode });
713
647
  if (ipc.stderr) {
714
648
  try {
715
649
  process.stderr.write(ipc.stderr);
@@ -726,7 +660,7 @@ async function hookCommandOuter(args, config, startedAt) {
726
660
  }
727
661
  // Daemon down / busy / refused — evaluate locally as always. Logged so a
728
662
  // machine that silently stopped using the fast path is diagnosable.
729
- dbg({ phase: 'ipc_unavailable', event: args.event, detail: ipc.detail });
663
+ (0, hookIo_1.dbg)({ phase: 'ipc_unavailable', event: args.event, detail: ipc.detail });
730
664
  }
731
665
  await hookCommandInner(args, config, processIo(raw, stdinErr));
732
666
  }
@@ -763,7 +697,7 @@ function failOpenOutcome(event, message) {
763
697
  }
764
698
  catch { /* never throw */ }
765
699
  try {
766
- dbg({ phase: 'hook_fail_open', event, error: message, suspended });
700
+ (0, hookIo_1.dbg)({ phase: 'hook_fail_open', event, error: message, suspended });
767
701
  }
768
702
  catch { /* never throw */ }
769
703
  try {
@@ -855,7 +789,7 @@ async function hookCommandInner(args, config, io) {
855
789
  machineSuspended = true;
856
790
  shadow = false;
857
791
  }
858
- dbg({ phase: 'mode_resolved', mode: bundle.mode, source: bundle.source, shadow, failClosed, suspended: machineSuspended });
792
+ (0, hookIo_1.dbg)({ phase: 'mode_resolved', mode: bundle.mode, source: bundle.source, shadow, failClosed, suspended: machineSuspended });
859
793
  }
860
794
  }
861
795
  catch { /* keep the local default */ }
@@ -863,7 +797,7 @@ async function hookCommandInner(args, config, io) {
863
797
  // Monitor-first: no authoritative bundle (fresh/uncached/fetch failed) => report-only.
864
798
  if (!modeResolved && !forceEnforce) {
865
799
  shadow = true;
866
- dbg({ phase: 'mode_default_monitor_first', shadow });
800
+ (0, hookIo_1.dbg)({ phase: 'mode_default_monitor_first', shadow });
867
801
  }
868
802
  if (forceEnforce)
869
803
  shadow = false;
@@ -908,8 +842,8 @@ async function hookCommandInner(args, config, io) {
908
842
  else {
909
843
  event = inferEvent(args.event, payload);
910
844
  }
911
- dbg({ phase: 'invoke', event, argEvent: args.event, format: hookFormat, client, pid: process.pid,
912
- isTTY, rawLen: raw.length, ...(HOOK_DEBUG_VERBOSE ? { rawPreview: raw.slice(0, 300) } : {}), stdinErr, parseErr,
845
+ (0, hookIo_1.dbg)({ phase: 'invoke', event, argEvent: args.event, format: hookFormat, client, pid: process.pid,
846
+ isTTY, rawLen: raw.length, ...(hookIo_1.HOOK_DEBUG_VERBOSE ? { rawPreview: raw.slice(0, 300) } : {}), stdinErr, parseErr,
913
847
  argv: process.argv.slice(2), payloadKeys: Object.keys(payload) });
914
848
  // Emit the verdict in the shape THIS client expects, then exit.
915
849
  // Cursor: beforeSubmitPrompt blocks via { continue: false }; execution hooks
@@ -949,7 +883,7 @@ async function hookCommandInner(args, config, io) {
949
883
  if (agentMsg)
950
884
  decision.agent_message = agentMsg;
951
885
  }
952
- dbg({ phase: 'verdict', event, format: hookFormat, blocked, decision });
886
+ (0, hookIo_1.dbg)({ phase: 'verdict', event, format: hookFormat, blocked, decision });
953
887
  if (hookFormat === 'claude' && event === 'prompt' && blocked) {
954
888
  // Claude Desktop/Code variants do not all honor the structured
955
889
  // UserPromptSubmit decision consistently. Exit 2 is the documented hard
@@ -1001,7 +935,7 @@ async function hookCommandInner(args, config, io) {
1001
935
  // --- Shield text analysis for prompts (developer chat) ---
1002
936
  if (event === 'prompt') {
1003
937
  const text = extractPromptText(payload).trim();
1004
- dbg({ phase: 'prompt_text', textLen: text.length, ...(HOOK_DEBUG_VERBOSE ? { textPreview: text.slice(0, 300) } : {}) });
938
+ (0, hookIo_1.dbg)({ phase: 'prompt_text', textLen: text.length, ...(hookIo_1.HOOK_DEBUG_VERBOSE ? { textPreview: text.slice(0, 300) } : {}) });
1005
939
  if (!text)
1006
940
  respond(false);
1007
941
  // Record the developer's prompt so taint-tracking can later tell whether a
@@ -1020,7 +954,7 @@ async function hookCommandInner(args, config, io) {
1020
954
  spoolLocalWarnings(promptOutcome.warnings, 'prompt', 'prompt');
1021
955
  const localBlock = promptOutcome.blockingFinding;
1022
956
  if (localBlock) {
1023
- dbg({ phase: 'local_deterministic_prompt_block', event, ruleId: localBlock.ruleId, category: localBlock.category });
957
+ (0, hookIo_1.dbg)({ phase: 'local_deterministic_prompt_block', event, ruleId: localBlock.ruleId, category: localBlock.category });
1024
958
  if (shadow) {
1025
959
  // Truth-in-reporting: this prompt RAN (shadow/monitor) — 'warn', never 'block'.
1026
960
  spoolLocalFinding({ finding: localBlock, toolName: 'prompt', operation: 'prompt', decision: 'warn' });
@@ -1032,7 +966,7 @@ async function hookCommandInner(args, config, io) {
1032
966
  return;
1033
967
  }
1034
968
  if (localOnly) {
1035
- dbg({ phase: 'local_deterministic_prompt_allow', event });
969
+ (0, hookIo_1.dbg)({ phase: 'local_deterministic_prompt_allow', event });
1036
970
  respond(false);
1037
971
  return;
1038
972
  }
@@ -1094,9 +1028,9 @@ async function runTaintApproveOnce(ctx, event, call, sessId, taintFinding) {
1094
1028
  + `Deny: fullcourtdefense approve ${approval.id} --deny\n`
1095
1029
  + `(run "fullcourtdefense approve" to list; expires in ${Math.round(timeoutMs / 60000)} min)`,
1096
1030
  });
1097
- dbg({ phase: 'taint_approve_once_wait', event, tool: call.toolName, approvalId: approval.id, timeoutMs });
1031
+ (0, hookIo_1.dbg)({ phase: 'taint_approve_once_wait', event, tool: call.toolName, approvalId: approval.id, timeoutMs });
1098
1032
  const outcome = await (0, taintLedger_1.waitForTaintApproval)(approval.id, timeoutMs, Math.max(750, Math.min(ctx.approvalPollMs, 2000)));
1099
- dbg({ phase: 'taint_approve_once_outcome', approvalId: approval.id, outcome });
1033
+ (0, hookIo_1.dbg)({ phase: 'taint_approve_once_outcome', approvalId: approval.id, outcome });
1100
1034
  if (outcome === 'approved') {
1101
1035
  (0, telemetry_1.spoolEvent)({
1102
1036
  decision: 'allow',
@@ -1146,7 +1080,7 @@ async function enforceActionPolicy(ctx) {
1146
1080
  spoolLocalWarnings(toolOutcome.warnings, call.toolName, event);
1147
1081
  const localBlock = toolOutcome.blockingFinding;
1148
1082
  if (localBlock) {
1149
- dbg({ phase: 'local_deterministic_block', event, tool: call.toolName, ruleId: localBlock.ruleId, category: localBlock.category });
1083
+ (0, hookIo_1.dbg)({ phase: 'local_deterministic_block', event, tool: call.toolName, ruleId: localBlock.ruleId, category: localBlock.category });
1150
1084
  if (shadow) {
1151
1085
  // Truth-in-reporting: this action RAN (shadow/monitor) — 'warn', never 'block'.
1152
1086
  spoolLocalFinding({ finding: localBlock, toolName: call.toolName, operation: event, decision: 'warn' });
@@ -1162,7 +1096,7 @@ async function enforceActionPolicy(ctx) {
1162
1096
  // state directory; otherwise a compromised agent could approve itself.
1163
1097
  const selfApproval = (0, taintLedger_1.detectTaintSelfApproval)(event, call.toolName, call.toolArgs);
1164
1098
  if (selfApproval) {
1165
- dbg({ phase: 'taint_self_approval_block', event, tool: call.toolName });
1099
+ (0, hookIo_1.dbg)({ phase: 'taint_self_approval_block', event, tool: call.toolName });
1166
1100
  if (shadow) {
1167
1101
  respond(false, undefined, `[FullCourtDefense shadow] would block ${call.toolName}: ${selfApproval.reason}`);
1168
1102
  return;
@@ -1180,7 +1114,7 @@ async function enforceActionPolicy(ctx) {
1180
1114
  const sessId = sessionId(payload);
1181
1115
  const taintFinding = (0, taintLedger_1.checkTaintedSink)(sessId, event, call.toolName, call.toolArgs);
1182
1116
  if (taintFinding) {
1183
- dbg({ phase: 'local_taint_block', event, tool: call.toolName, ruleId: taintFinding.ruleId, sink: taintFinding.sink.kind, targets: taintFinding.sink.targets });
1117
+ (0, hookIo_1.dbg)({ phase: 'local_taint_block', event, tool: call.toolName, ruleId: taintFinding.ruleId, sink: taintFinding.sink.kind, targets: taintFinding.sink.targets });
1184
1118
  if (shadow) {
1185
1119
  respond(false, undefined, `[FullCourtDefense shadow] would block ${call.toolName}: ${taintFinding.reason}`);
1186
1120
  return;
@@ -1204,7 +1138,7 @@ async function enforceActionPolicy(ctx) {
1204
1138
  // (detached flush child). Enforce machines keep the full timeout + retry:
1205
1139
  // their verdict actually gates the action.
1206
1140
  if (shadow) {
1207
- dbg({ phase: 'policy_skip_monitor', event, tool: call.toolName });
1141
+ (0, hookIo_1.dbg)({ phase: 'policy_skip_monitor', event, tool: call.toolName });
1208
1142
  const detail = 'monitor mode — synchronous policy check skipped by design.';
1209
1143
  if (!tryLocalPolicyEnforcement(ctx, call, detail, { countAsGateFailure: false })) {
1210
1144
  (0, telemetry_1.spoolEvent)({ decision: 'allow', toolName: call.toolName, reason: `monitor: ${detail}`, offlineEnforced: true });
@@ -1246,7 +1180,7 @@ async function enforceActionPolicy(ctx) {
1246
1180
  localCtx.developerName = developerId();
1247
1181
  localCtx.machineName = os.hostname();
1248
1182
  const localVerdict = (0, actionPolicyEngine_1.evaluateActionPolicies)(ctx.localPolicies, call.toolName, localOp, localCtx, (0, actionPolicyEngine_1.deriveToolOperations)(call.toolName));
1249
- dbg({ phase: 'policy_local_first', event, tool: call.toolName, operation: localOp, verdict: localVerdict.verdict, policyName: localVerdict.policyName });
1183
+ (0, hookIo_1.dbg)({ phase: 'policy_local_first', event, tool: call.toolName, operation: localOp, verdict: localVerdict.verdict, policyName: localVerdict.policyName });
1250
1184
  if (localVerdict.verdict === 'allow') {
1251
1185
  // Audit rides async: server records the action + its own verdict; if it
1252
1186
  // is unreachable the spool keeps the trail (idempotent on eventId).
@@ -1275,7 +1209,7 @@ async function enforceActionPolicy(ctx) {
1275
1209
  // skip does NOT refresh the failure window, so the next call after 60s
1276
1210
  // probes the gate again (bounded), and a success resets the streak.
1277
1211
  if ((0, policyGateHealth_1.gateRecentlyDown)()) {
1278
- dbg({ phase: 'policy_skip_gate_down', event, tool: call.toolName });
1212
+ (0, hookIo_1.dbg)({ phase: 'policy_skip_gate_down', event, tool: call.toolName });
1279
1213
  const detail = 'policy service recently unreachable — synchronous wait skipped (fast-probe window).';
1280
1214
  if (!tryLocalPolicyEnforcement(ctx, call, detail, { countAsGateFailure: false })) {
1281
1215
  respondDegraded(ctx, detail, call.toolName, false, { countAsGateFailure: false });
@@ -1293,9 +1227,9 @@ async function enforceActionPolicy(ctx) {
1293
1227
  method: 'POST',
1294
1228
  headers,
1295
1229
  body: gateBody,
1296
- }, gateTimeoutMs, (attempt, err) => dbg({ phase: 'policy_retry', event, attempt, error: err }), gateAttempts);
1230
+ }, gateTimeoutMs, (attempt, err) => (0, hookIo_1.dbg)({ phase: 'policy_retry', event, attempt, error: err }), gateAttempts);
1297
1231
  if (!resp.ok) {
1298
- dbg({ phase: 'policy_http_error', event, status: resp.status, failClosed: ctx.failClosed });
1232
+ (0, hookIo_1.dbg)({ phase: 'policy_http_error', event, status: resp.status, failClosed: ctx.failClosed });
1299
1233
  const authRejected = resp.status === 401 || resp.status === 403;
1300
1234
  const detail = authRejected
1301
1235
  ? `The policy service rejected this machine's credentials (HTTP ${resp.status}) — the shield key saved here is broken or revoked, not a network problem.`
@@ -1314,7 +1248,7 @@ async function enforceActionPolicy(ctx) {
1314
1248
  }
1315
1249
  const body = await resp.json().catch(() => ({}));
1316
1250
  if (!body.success || !body.data) {
1317
- dbg({ phase: 'policy_no_data', event, error: body.error, failClosed: ctx.failClosed });
1251
+ (0, hookIo_1.dbg)({ phase: 'policy_no_data', event, error: body.error, failClosed: ctx.failClosed });
1318
1252
  const detail = body.error ? `Backend error: ${body.error}` : 'Backend returned no policy data.';
1319
1253
  if (!tryLocalPolicyEnforcement(ctx, call, detail)) {
1320
1254
  respondDegraded(ctx, detail, call.toolName);
@@ -1326,7 +1260,7 @@ async function enforceActionPolicy(ctx) {
1326
1260
  (0, policyGateHealth_1.recordGateSuccess)();
1327
1261
  }
1328
1262
  catch (err) {
1329
- dbg({ phase: 'policy_exception', event, error: err instanceof Error ? err.message : String(err), failClosed: ctx.failClosed });
1263
+ (0, hookIo_1.dbg)({ phase: 'policy_exception', event, error: err instanceof Error ? err.message : String(err), failClosed: ctx.failClosed });
1330
1264
  const detail = `Hook error: ${err instanceof Error ? err.message : String(err)}.`;
1331
1265
  if (!tryLocalPolicyEnforcement(ctx, call, detail)) {
1332
1266
  respondDegraded(ctx, detail, call.toolName);
@@ -1337,7 +1271,7 @@ async function enforceActionPolicy(ctx) {
1337
1271
  || result.intentEvaluation?.reasons?.[0]
1338
1272
  || `${call.toolName} ${result.decision}ed by Action Policy.`;
1339
1273
  const policyName = result.actionPolicy?.policyName;
1340
- dbg({ phase: 'policy_verdict', event, tool: call.toolName, decision: result.decision, allowed: result.allowed, policyName });
1274
+ (0, hookIo_1.dbg)({ phase: 'policy_verdict', event, tool: call.toolName, decision: result.decision, allowed: result.allowed, policyName });
1341
1275
  if (result.allowed && result.decision === 'allow') {
1342
1276
  respond(false);
1343
1277
  return;
@@ -1396,7 +1330,7 @@ async function waitForApproval(input) {
1396
1330
  headers['x-shield-key'] = input.shieldKey;
1397
1331
  const url = `${input.apiUrl}/api/agent-security/runtime/approvals/${encodeURIComponent(input.actionId)}?shieldId=${encodeURIComponent(input.shieldId)}`;
1398
1332
  const deadline = Date.now() + input.timeoutMs;
1399
- dbg({ phase: 'approval_wait_start', actionId: input.actionId, timeoutMs: input.timeoutMs });
1333
+ (0, hookIo_1.dbg)({ phase: 'approval_wait_start', actionId: input.actionId, timeoutMs: input.timeoutMs });
1400
1334
  while (Date.now() <= deadline) {
1401
1335
  try {
1402
1336
  const resp = await fetch(url, { method: 'GET', headers, signal: AbortSignal.timeout(15000) });
@@ -1415,7 +1349,7 @@ async function waitForApproval(input) {
1415
1349
  }
1416
1350
  }
1417
1351
  catch (err) {
1418
- dbg({ phase: 'approval_poll_error', actionId: input.actionId, error: err instanceof Error ? err.message : String(err) });
1352
+ (0, hookIo_1.dbg)({ phase: 'approval_poll_error', actionId: input.actionId, error: err instanceof Error ? err.message : String(err) });
1419
1353
  }
1420
1354
  await new Promise((resolve) => setTimeout(resolve, Math.min(input.pollMs, Math.max(0, deadline - Date.now()))));
1421
1355
  }
@@ -1431,7 +1365,7 @@ async function enforceShieldText(ctx) {
1431
1365
  // deterministic rules already ran before this point; the audit trail rides
1432
1366
  // the async spool. Enforce machines keep the full window: their verdict gates.
1433
1367
  if (shadow) {
1434
- dbg({ phase: 'shield_skip_monitor', event });
1368
+ (0, hookIo_1.dbg)({ phase: 'shield_skip_monitor', event });
1435
1369
  (0, telemetry_1.spoolEvent)({ decision: 'allow', toolName: 'prompt', reason: 'monitor: synchronous Shield scan skipped by design.', offlineEnforced: true });
1436
1370
  (0, telemetry_1.triggerFlush)(false);
1437
1371
  respond(false);
@@ -1440,7 +1374,7 @@ async function enforceShieldText(ctx) {
1440
1374
  // recently-failed gate is not re-probed on every prompt; the degraded stance
1441
1375
  // resolves instantly instead of paying the full timeout per event.
1442
1376
  if ((0, policyGateHealth_1.gateRecentlyDown)()) {
1443
- dbg({ phase: 'shield_skip_gate_down', event });
1377
+ (0, hookIo_1.dbg)({ phase: 'shield_skip_gate_down', event });
1444
1378
  respondDegraded(ctx, 'Shield service recently unreachable — synchronous wait skipped (fast-probe window).', 'prompt', false, { countAsGateFailure: false });
1445
1379
  return;
1446
1380
  }
@@ -1460,7 +1394,7 @@ async function enforceShieldText(ctx) {
1460
1394
  method: 'POST',
1461
1395
  headers,
1462
1396
  body: JSON.stringify({ message: text }),
1463
- }, gateTimeoutMs, (attempt, error) => dbg({ phase: 'shield_retry', event, attempt, error }), gateAttempts);
1397
+ }, gateTimeoutMs, (attempt, error) => (0, hookIo_1.dbg)({ phase: 'shield_retry', event, attempt, error }), gateAttempts);
1464
1398
  if (!resp.ok) {
1465
1399
  respondDegraded(ctx, `Backend returned HTTP ${resp.status}.`, 'prompt');
1466
1400
  return;
@@ -41,6 +41,7 @@ const fs = __importStar(require("fs"));
41
41
  const os = __importStar(require("os"));
42
42
  const path = __importStar(require("path"));
43
43
  const config_1 = require("../config");
44
+ const hookIo_1 = require("../hookIo");
44
45
  const restartNotice_1 = require("./restartNotice");
45
46
  const COLOR = {
46
47
  reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m',
@@ -77,9 +78,23 @@ function isManagedCommand(cmd) {
77
78
  return typeof cmd?.command === 'string'
78
79
  && (cmd.command.includes(MANAGED_MARKER) || cmd.command.includes(MANAGED_TAG));
79
80
  }
81
+ /**
82
+ * Prefer the SLIM hook entry (dist/hookSlim.js — Node-builtins-only module
83
+ * graph, near-spawn-floor cold start) and fall back to the full CLI entry on
84
+ * older dist layouts. Repair rewrites stale entries through this prefix too,
85
+ * which is how an enrolled fleet migrates to the slim hook on upgrade.
86
+ */
87
+ function hookScriptPath() {
88
+ const cliScript = process.argv[1] || path.join(__dirname, '..', 'index.js');
89
+ // argv[1] first (normal CLI invocation), then this module's own dist
90
+ // location — argv[1] can be an npm shim or a daemon-driven repair helper.
91
+ return (0, hookIo_1.slimHookEntryPath)(cliScript)
92
+ || (0, hookIo_1.slimHookEntryPath)(path.join(__dirname, '..', 'index.js'))
93
+ || cliScript;
94
+ }
80
95
  function hookInvocationPrefix() {
81
96
  const q = (s) => (/\s/.test(s) ? `"${s}"` : s);
82
- return `${q(process.execPath)} ${q(process.argv[1] || path.join(__dirname, '..', 'index.js'))}`;
97
+ return `${q(process.execPath)} ${q(hookScriptPath())}`;
83
98
  }
84
99
  function repairManagedCommandPath(command) {
85
100
  const marker = command.indexOf(' hook ');
@@ -189,12 +204,11 @@ function stripManaged(hooks) {
189
204
  /** Build the absolute, shell-agnostic command that invokes this CLI's `hook`. */
190
205
  function buildHookCommand(opts) {
191
206
  const nodeExe = process.execPath;
192
- const scriptPath = process.argv[1] || path.join(__dirname, '..', 'index.js');
193
207
  const q = (s) => (/\s/.test(s) ? `"${s}"` : s);
194
208
  // No --event flag: the bridge detects the Claude payload (hook_event_name) itself.
195
209
  // local-only affects prompt events only; tool events still use the complete
196
210
  // Action Policy/approval path. This keeps prompt text on the endpoint.
197
- let cmd = `${q(nodeExe)} ${q(scriptPath)} hook --approval-mode wait --local-only true`;
211
+ let cmd = `${q(nodeExe)} ${q(hookScriptPath())} hook --approval-mode wait --local-only true`;
198
212
  if (opts.shadow)
199
213
  cmd += ' --shadow true';
200
214
  if (opts.failClosed)
@@ -41,6 +41,7 @@ const fs = __importStar(require("fs"));
41
41
  const os = __importStar(require("os"));
42
42
  const path = __importStar(require("path"));
43
43
  const config_1 = require("../config");
44
+ const hookIo_1 = require("../hookIo");
44
45
  const restartNotice_1 = require("./restartNotice");
45
46
  const COLOR = {
46
47
  reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m',
@@ -70,12 +71,29 @@ const EVENT_MAP = {
70
71
  file: { hookKey: 'afterFileEdit', flag: 'file', timeoutSec: APPROVAL_CAPABLE_TIMEOUT_SEC },
71
72
  read: { hookKey: 'beforeReadFile', flag: 'read', timeoutSec: APPROVAL_CAPABLE_TIMEOUT_SEC },
72
73
  };
74
+ /**
75
+ * The script a hook command invokes: the SLIM entry (dist/hookSlim.js) when it
76
+ * exists — a Node-builtins-only module graph, so per-event cold start is the
77
+ * node spawn plus three small files instead of the whole CLI (the difference
78
+ * an EDR-heavy corporate machine feels on every docker command). Falls back
79
+ * to the full CLI entry when the slim file is absent (older dist layouts).
80
+ * The literal ` hook ` subcommand is kept in the command either way — it is
81
+ * the marker managed-entry repair keys on, and the slim entry ignores it.
82
+ */
83
+ function hookScriptPath() {
84
+ const cliScript = process.argv[1] || path.join(__dirname, '..', 'index.js');
85
+ // Resolve the slim entry against argv[1] first (normal CLI invocation), then
86
+ // against this module's own dist location — argv[1] can be an npm shim or a
87
+ // helper script (daemon-driven repair) rather than dist/index.js.
88
+ return (0, hookIo_1.slimHookEntryPath)(cliScript)
89
+ || (0, hookIo_1.slimHookEntryPath)(path.join(__dirname, '..', 'index.js'))
90
+ || cliScript;
91
+ }
73
92
  /** Build the absolute, shell-agnostic command that invokes this CLI's `hook`. */
74
93
  function buildHookCommand(flag, opts) {
75
94
  const nodeExe = process.execPath; // real node binary (handles Windows/Linux/mac)
76
- const scriptPath = process.argv[1] || path.join(__dirname, '..', 'index.js');
77
95
  const q = (s) => (/\s/.test(s) ? `"${s}"` : s);
78
- let cmd = `${q(nodeExe)} ${q(scriptPath)} hook --event ${flag}`;
96
+ let cmd = `${q(nodeExe)} ${q(hookScriptPath())} hook --event ${flag}`;
79
97
  if (opts.shadow)
80
98
  cmd += ' --shadow true';
81
99
  if (opts.failClosed)
@@ -113,9 +131,11 @@ function isManaged(entry) {
113
131
  }
114
132
  function hookInvocationPrefix() {
115
133
  const nodeExe = process.execPath;
116
- const scriptPath = process.argv[1] || path.join(__dirname, '..', 'index.js');
117
134
  const q = (s) => (/\s/.test(s) ? `"${s}"` : s);
118
- return `${q(nodeExe)} ${q(scriptPath)}`;
135
+ // Repair rewrites stale managed entries to the slim entry too — this is how
136
+ // an already-enrolled fleet migrates to the slim hook on upgrade, without a
137
+ // reinstall (protection repair runs from the daemon after every update).
138
+ return `${q(nodeExe)} ${q(hookScriptPath())}`;
119
139
  }
120
140
  function repairManagedCommandPath(command) {
121
141
  const marker = command.indexOf(' hook ');
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Hook I/O leaf — the pieces of the hook runtime that BOTH entries share:
3
+ *
4
+ * - `dist/hookSlim.js` (the slim hook entry installed into IDE hook configs)
5
+ * - `dist/index.js hook` (the full CLI's hook command, and the daemon's
6
+ * in-process evaluator)
7
+ *
8
+ * COLD-START CONTRACT: this module is on the slim entry's module graph, which
9
+ * is loaded on EVERY IDE hook event. It may import Node built-ins ONLY —
10
+ * every additional module here is bytes the customer's EDR re-scans per
11
+ * docker command. `scripts/test-slim-hook.js` enforces the graph budget.
12
+ */
13
+ export declare const HOOK_DEBUG_LOG: string;
14
+ /**
15
+ * Verbose diagnostics (FCD_HOOK_DEBUG=1) additionally log content previews
16
+ * (raw payload + prompt text snippets). Default logging keeps operational
17
+ * metadata only — user prompt content must never sit in a plaintext file on
18
+ * a customer laptop unless support explicitly opts in.
19
+ */
20
+ export declare const HOOK_DEBUG_VERBOSE: boolean;
21
+ /** Append a diagnostic line so we can see exactly what the IDE invoked + the verdict. */
22
+ export declare function dbg(obj: Record<string, unknown>): void;
23
+ export declare function readStdin(): Promise<string>;
24
+ /**
25
+ * kebab-case CLI flags -> the camelCase HookArgs shape. ONE mapping shared by
26
+ * the full CLI entry (`index.ts` case 'hook') and the slim entry, so the
27
+ * daemon's IPC evaluator always receives identical argument shapes no matter
28
+ * which entry the IDE invoked.
29
+ */
30
+ export declare function mapHookFlags(flags: Record<string, string>): Record<string, string | undefined>;
31
+ /**
32
+ * Minimal `--flag value` / `--flag=value` parser with the same semantics as
33
+ * the full CLI's parseArgs, restricted to what a hook command line carries.
34
+ * Positionals (the literal `hook` subcommand kept in installed commands for
35
+ * the managed-entry repair marker) are ignored.
36
+ */
37
+ export declare function parseHookFlags(argv: string[]): Record<string, string>;
38
+ /**
39
+ * Absolute path of the slim hook entry that sits next to a given CLI script
40
+ * (`dist/index.js` -> `dist/hookSlim.js`). Installers prefer the slim entry
41
+ * when it exists; older installs (pre-slim dist) keep pointing at index.js.
42
+ */
43
+ export declare function slimHookEntryPath(cliScriptPath: string): string | undefined;