fullcourtdefense-cli 1.26.4 → 1.26.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/dist/commands/daemon.d.ts +14 -0
  2. package/dist/commands/daemon.js +181 -28
  3. package/dist/commands/desktopChatGuard.js +5 -4
  4. package/dist/commands/deterministicGuard.d.ts +8 -0
  5. package/dist/commands/deterministicGuard.js +6 -0
  6. package/dist/commands/doctor.d.ts +4 -0
  7. package/dist/commands/doctor.js +99 -0
  8. package/dist/commands/hook.d.ts +1 -0
  9. package/dist/commands/hook.js +61 -104
  10. package/dist/commands/installClaudeHook.d.ts +2 -0
  11. package/dist/commands/installClaudeHook.js +21 -3
  12. package/dist/commands/installCursorHook.d.ts +8 -0
  13. package/dist/commands/installCursorHook.js +29 -4
  14. package/dist/commands/mcpGateway.js +234 -43
  15. package/dist/commands/onboard.d.ts +9 -0
  16. package/dist/commands/onboard.js +7 -0
  17. package/dist/commands/watchdog.d.ts +5 -2
  18. package/dist/commands/watchdog.js +34 -4
  19. package/dist/config.js +6 -1
  20. package/dist/daemonForensics.d.ts +27 -0
  21. package/dist/daemonForensics.js +27 -0
  22. package/dist/distress.d.ts +17 -0
  23. package/dist/distress.js +17 -0
  24. package/dist/hookIo.d.ts +43 -0
  25. package/dist/hookIo.js +189 -0
  26. package/dist/hookSlim.d.ts +32 -0
  27. package/dist/hookSlim.js +142 -0
  28. package/dist/index.js +10 -14
  29. package/dist/integrity.js +8 -0
  30. package/dist/localSafetySnapshot.d.ts +9 -0
  31. package/dist/localSafetySnapshot.js +58 -2
  32. package/dist/machineIdentity.js +70 -4
  33. package/dist/runtimeConfig.d.ts +2 -0
  34. package/dist/runtimeConfig.js +177 -5
  35. package/dist/selfTest.d.ts +10 -0
  36. package/dist/selfTest.js +19 -8
  37. package/dist/selfUpdate.d.ts +19 -4
  38. package/dist/selfUpdate.js +108 -12
  39. package/dist/telemetry.js +67 -2
  40. package/dist/verdictIpc.d.ts +12 -75
  41. package/dist/verdictIpc.js +104 -188
  42. package/dist/verdictIpcClient.d.ts +103 -0
  43. package/dist/verdictIpcClient.js +251 -0
  44. package/dist/version.json +1 -1
  45. package/package.json +6 -1
@@ -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 {
@@ -799,8 +733,23 @@ function failOpenOutcome(event, message) {
799
733
  async function hookCommandInner(args, config, io) {
800
734
  // Mode is server-authoritative (pulled from the cached runtime bundle), with
801
735
  // local flags as explicit overrides. Resolved after creds below.
802
- let shadow = args.shadow === 'true';
803
- const forceMonitor = args.shadow === 'true';
736
+ //
737
+ // On INSTALLER-MANAGED invocations (`--fcd-managed true`, the hooks.json we
738
+ // write) a `--shadow` flag is IGNORED: our installer never writes it on the
739
+ // standard path, so its presence means someone hand-edited the managed entry
740
+ // to silently downgrade enforcement. Mode for managed hooks comes from the
741
+ // server bundle only — an org that wants shadow sets it in the console.
742
+ // Ad-hoc/manual `fullcourtdefense hook --shadow true` keeps working.
743
+ const managedInvocation = args.fcdManaged === 'true';
744
+ const shadowFlagIgnored = managedInvocation && args.shadow === 'true';
745
+ let shadow = args.shadow === 'true' && !managedInvocation;
746
+ const forceMonitor = shadow;
747
+ if (shadowFlagIgnored) {
748
+ try {
749
+ (0, hookIo_1.dbg)({ phase: 'managed_shadow_flag_ignored', event: args.event });
750
+ }
751
+ catch { /* never throw */ }
752
+ }
804
753
  const forceEnforce = args.enforce === 'true';
805
754
  // Offline stance: an explicit --fail-closed flag wins; otherwise the
806
755
  // server-authoritative machine stance from the runtime bundle applies
@@ -841,6 +790,14 @@ async function hookCommandInner(args, config, io) {
841
790
  if (!forceMonitor && !forceEnforce && shieldId) {
842
791
  try {
843
792
  const bundle = await (0, runtimeConfig_1.getRuntimeBundle)({ apiUrl, shieldId, shieldKey, developerName: developerId(), machineName: os.hostname(), hotPath: true });
793
+ // Kill-switch holds even on a 'default' bundle: with the cache missing
794
+ // but a signed last-known-suspended marker standing, getRuntimeBundle
795
+ // stamps suspended on the default — deleting the cache file must not
796
+ // unlock a suspended machine.
797
+ if (bundle.suspended === true) {
798
+ machineSuspended = true;
799
+ shadow = false;
800
+ }
844
801
  if (bundle.source !== 'default') {
845
802
  shadow = bundle.mode !== 'block'; // 'monitor'/'shadow' => report-only
846
803
  modeResolved = true; // add this line
@@ -855,7 +812,7 @@ async function hookCommandInner(args, config, io) {
855
812
  machineSuspended = true;
856
813
  shadow = false;
857
814
  }
858
- dbg({ phase: 'mode_resolved', mode: bundle.mode, source: bundle.source, shadow, failClosed, suspended: machineSuspended });
815
+ (0, hookIo_1.dbg)({ phase: 'mode_resolved', mode: bundle.mode, source: bundle.source, shadow, failClosed, suspended: machineSuspended });
859
816
  }
860
817
  }
861
818
  catch { /* keep the local default */ }
@@ -863,7 +820,7 @@ async function hookCommandInner(args, config, io) {
863
820
  // Monitor-first: no authoritative bundle (fresh/uncached/fetch failed) => report-only.
864
821
  if (!modeResolved && !forceEnforce) {
865
822
  shadow = true;
866
- dbg({ phase: 'mode_default_monitor_first', shadow });
823
+ (0, hookIo_1.dbg)({ phase: 'mode_default_monitor_first', shadow });
867
824
  }
868
825
  if (forceEnforce)
869
826
  shadow = false;
@@ -908,8 +865,8 @@ async function hookCommandInner(args, config, io) {
908
865
  else {
909
866
  event = inferEvent(args.event, payload);
910
867
  }
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,
868
+ (0, hookIo_1.dbg)({ phase: 'invoke', event, argEvent: args.event, format: hookFormat, client, pid: process.pid,
869
+ isTTY, rawLen: raw.length, ...(hookIo_1.HOOK_DEBUG_VERBOSE ? { rawPreview: raw.slice(0, 300) } : {}), stdinErr, parseErr,
913
870
  argv: process.argv.slice(2), payloadKeys: Object.keys(payload) });
914
871
  // Emit the verdict in the shape THIS client expects, then exit.
915
872
  // Cursor: beforeSubmitPrompt blocks via { continue: false }; execution hooks
@@ -949,7 +906,7 @@ async function hookCommandInner(args, config, io) {
949
906
  if (agentMsg)
950
907
  decision.agent_message = agentMsg;
951
908
  }
952
- dbg({ phase: 'verdict', event, format: hookFormat, blocked, decision });
909
+ (0, hookIo_1.dbg)({ phase: 'verdict', event, format: hookFormat, blocked, decision });
953
910
  if (hookFormat === 'claude' && event === 'prompt' && blocked) {
954
911
  // Claude Desktop/Code variants do not all honor the structured
955
912
  // UserPromptSubmit decision consistently. Exit 2 is the documented hard
@@ -1001,7 +958,7 @@ async function hookCommandInner(args, config, io) {
1001
958
  // --- Shield text analysis for prompts (developer chat) ---
1002
959
  if (event === 'prompt') {
1003
960
  const text = extractPromptText(payload).trim();
1004
- dbg({ phase: 'prompt_text', textLen: text.length, ...(HOOK_DEBUG_VERBOSE ? { textPreview: text.slice(0, 300) } : {}) });
961
+ (0, hookIo_1.dbg)({ phase: 'prompt_text', textLen: text.length, ...(hookIo_1.HOOK_DEBUG_VERBOSE ? { textPreview: text.slice(0, 300) } : {}) });
1005
962
  if (!text)
1006
963
  respond(false);
1007
964
  // Record the developer's prompt so taint-tracking can later tell whether a
@@ -1020,7 +977,7 @@ async function hookCommandInner(args, config, io) {
1020
977
  spoolLocalWarnings(promptOutcome.warnings, 'prompt', 'prompt');
1021
978
  const localBlock = promptOutcome.blockingFinding;
1022
979
  if (localBlock) {
1023
- dbg({ phase: 'local_deterministic_prompt_block', event, ruleId: localBlock.ruleId, category: localBlock.category });
980
+ (0, hookIo_1.dbg)({ phase: 'local_deterministic_prompt_block', event, ruleId: localBlock.ruleId, category: localBlock.category });
1024
981
  if (shadow) {
1025
982
  // Truth-in-reporting: this prompt RAN (shadow/monitor) — 'warn', never 'block'.
1026
983
  spoolLocalFinding({ finding: localBlock, toolName: 'prompt', operation: 'prompt', decision: 'warn' });
@@ -1032,7 +989,7 @@ async function hookCommandInner(args, config, io) {
1032
989
  return;
1033
990
  }
1034
991
  if (localOnly) {
1035
- dbg({ phase: 'local_deterministic_prompt_allow', event });
992
+ (0, hookIo_1.dbg)({ phase: 'local_deterministic_prompt_allow', event });
1036
993
  respond(false);
1037
994
  return;
1038
995
  }
@@ -1094,9 +1051,9 @@ async function runTaintApproveOnce(ctx, event, call, sessId, taintFinding) {
1094
1051
  + `Deny: fullcourtdefense approve ${approval.id} --deny\n`
1095
1052
  + `(run "fullcourtdefense approve" to list; expires in ${Math.round(timeoutMs / 60000)} min)`,
1096
1053
  });
1097
- dbg({ phase: 'taint_approve_once_wait', event, tool: call.toolName, approvalId: approval.id, timeoutMs });
1054
+ (0, hookIo_1.dbg)({ phase: 'taint_approve_once_wait', event, tool: call.toolName, approvalId: approval.id, timeoutMs });
1098
1055
  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 });
1056
+ (0, hookIo_1.dbg)({ phase: 'taint_approve_once_outcome', approvalId: approval.id, outcome });
1100
1057
  if (outcome === 'approved') {
1101
1058
  (0, telemetry_1.spoolEvent)({
1102
1059
  decision: 'allow',
@@ -1146,7 +1103,7 @@ async function enforceActionPolicy(ctx) {
1146
1103
  spoolLocalWarnings(toolOutcome.warnings, call.toolName, event);
1147
1104
  const localBlock = toolOutcome.blockingFinding;
1148
1105
  if (localBlock) {
1149
- dbg({ phase: 'local_deterministic_block', event, tool: call.toolName, ruleId: localBlock.ruleId, category: localBlock.category });
1106
+ (0, hookIo_1.dbg)({ phase: 'local_deterministic_block', event, tool: call.toolName, ruleId: localBlock.ruleId, category: localBlock.category });
1150
1107
  if (shadow) {
1151
1108
  // Truth-in-reporting: this action RAN (shadow/monitor) — 'warn', never 'block'.
1152
1109
  spoolLocalFinding({ finding: localBlock, toolName: call.toolName, operation: event, decision: 'warn' });
@@ -1162,7 +1119,7 @@ async function enforceActionPolicy(ctx) {
1162
1119
  // state directory; otherwise a compromised agent could approve itself.
1163
1120
  const selfApproval = (0, taintLedger_1.detectTaintSelfApproval)(event, call.toolName, call.toolArgs);
1164
1121
  if (selfApproval) {
1165
- dbg({ phase: 'taint_self_approval_block', event, tool: call.toolName });
1122
+ (0, hookIo_1.dbg)({ phase: 'taint_self_approval_block', event, tool: call.toolName });
1166
1123
  if (shadow) {
1167
1124
  respond(false, undefined, `[FullCourtDefense shadow] would block ${call.toolName}: ${selfApproval.reason}`);
1168
1125
  return;
@@ -1180,7 +1137,7 @@ async function enforceActionPolicy(ctx) {
1180
1137
  const sessId = sessionId(payload);
1181
1138
  const taintFinding = (0, taintLedger_1.checkTaintedSink)(sessId, event, call.toolName, call.toolArgs);
1182
1139
  if (taintFinding) {
1183
- dbg({ phase: 'local_taint_block', event, tool: call.toolName, ruleId: taintFinding.ruleId, sink: taintFinding.sink.kind, targets: taintFinding.sink.targets });
1140
+ (0, hookIo_1.dbg)({ phase: 'local_taint_block', event, tool: call.toolName, ruleId: taintFinding.ruleId, sink: taintFinding.sink.kind, targets: taintFinding.sink.targets });
1184
1141
  if (shadow) {
1185
1142
  respond(false, undefined, `[FullCourtDefense shadow] would block ${call.toolName}: ${taintFinding.reason}`);
1186
1143
  return;
@@ -1204,7 +1161,7 @@ async function enforceActionPolicy(ctx) {
1204
1161
  // (detached flush child). Enforce machines keep the full timeout + retry:
1205
1162
  // their verdict actually gates the action.
1206
1163
  if (shadow) {
1207
- dbg({ phase: 'policy_skip_monitor', event, tool: call.toolName });
1164
+ (0, hookIo_1.dbg)({ phase: 'policy_skip_monitor', event, tool: call.toolName });
1208
1165
  const detail = 'monitor mode — synchronous policy check skipped by design.';
1209
1166
  if (!tryLocalPolicyEnforcement(ctx, call, detail, { countAsGateFailure: false })) {
1210
1167
  (0, telemetry_1.spoolEvent)({ decision: 'allow', toolName: call.toolName, reason: `monitor: ${detail}`, offlineEnforced: true });
@@ -1246,7 +1203,7 @@ async function enforceActionPolicy(ctx) {
1246
1203
  localCtx.developerName = developerId();
1247
1204
  localCtx.machineName = os.hostname();
1248
1205
  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 });
1206
+ (0, hookIo_1.dbg)({ phase: 'policy_local_first', event, tool: call.toolName, operation: localOp, verdict: localVerdict.verdict, policyName: localVerdict.policyName });
1250
1207
  if (localVerdict.verdict === 'allow') {
1251
1208
  // Audit rides async: server records the action + its own verdict; if it
1252
1209
  // is unreachable the spool keeps the trail (idempotent on eventId).
@@ -1275,7 +1232,7 @@ async function enforceActionPolicy(ctx) {
1275
1232
  // skip does NOT refresh the failure window, so the next call after 60s
1276
1233
  // probes the gate again (bounded), and a success resets the streak.
1277
1234
  if ((0, policyGateHealth_1.gateRecentlyDown)()) {
1278
- dbg({ phase: 'policy_skip_gate_down', event, tool: call.toolName });
1235
+ (0, hookIo_1.dbg)({ phase: 'policy_skip_gate_down', event, tool: call.toolName });
1279
1236
  const detail = 'policy service recently unreachable — synchronous wait skipped (fast-probe window).';
1280
1237
  if (!tryLocalPolicyEnforcement(ctx, call, detail, { countAsGateFailure: false })) {
1281
1238
  respondDegraded(ctx, detail, call.toolName, false, { countAsGateFailure: false });
@@ -1293,9 +1250,9 @@ async function enforceActionPolicy(ctx) {
1293
1250
  method: 'POST',
1294
1251
  headers,
1295
1252
  body: gateBody,
1296
- }, gateTimeoutMs, (attempt, err) => dbg({ phase: 'policy_retry', event, attempt, error: err }), gateAttempts);
1253
+ }, gateTimeoutMs, (attempt, err) => (0, hookIo_1.dbg)({ phase: 'policy_retry', event, attempt, error: err }), gateAttempts);
1297
1254
  if (!resp.ok) {
1298
- dbg({ phase: 'policy_http_error', event, status: resp.status, failClosed: ctx.failClosed });
1255
+ (0, hookIo_1.dbg)({ phase: 'policy_http_error', event, status: resp.status, failClosed: ctx.failClosed });
1299
1256
  const authRejected = resp.status === 401 || resp.status === 403;
1300
1257
  const detail = authRejected
1301
1258
  ? `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 +1271,7 @@ async function enforceActionPolicy(ctx) {
1314
1271
  }
1315
1272
  const body = await resp.json().catch(() => ({}));
1316
1273
  if (!body.success || !body.data) {
1317
- dbg({ phase: 'policy_no_data', event, error: body.error, failClosed: ctx.failClosed });
1274
+ (0, hookIo_1.dbg)({ phase: 'policy_no_data', event, error: body.error, failClosed: ctx.failClosed });
1318
1275
  const detail = body.error ? `Backend error: ${body.error}` : 'Backend returned no policy data.';
1319
1276
  if (!tryLocalPolicyEnforcement(ctx, call, detail)) {
1320
1277
  respondDegraded(ctx, detail, call.toolName);
@@ -1326,7 +1283,7 @@ async function enforceActionPolicy(ctx) {
1326
1283
  (0, policyGateHealth_1.recordGateSuccess)();
1327
1284
  }
1328
1285
  catch (err) {
1329
- dbg({ phase: 'policy_exception', event, error: err instanceof Error ? err.message : String(err), failClosed: ctx.failClosed });
1286
+ (0, hookIo_1.dbg)({ phase: 'policy_exception', event, error: err instanceof Error ? err.message : String(err), failClosed: ctx.failClosed });
1330
1287
  const detail = `Hook error: ${err instanceof Error ? err.message : String(err)}.`;
1331
1288
  if (!tryLocalPolicyEnforcement(ctx, call, detail)) {
1332
1289
  respondDegraded(ctx, detail, call.toolName);
@@ -1337,7 +1294,7 @@ async function enforceActionPolicy(ctx) {
1337
1294
  || result.intentEvaluation?.reasons?.[0]
1338
1295
  || `${call.toolName} ${result.decision}ed by Action Policy.`;
1339
1296
  const policyName = result.actionPolicy?.policyName;
1340
- dbg({ phase: 'policy_verdict', event, tool: call.toolName, decision: result.decision, allowed: result.allowed, policyName });
1297
+ (0, hookIo_1.dbg)({ phase: 'policy_verdict', event, tool: call.toolName, decision: result.decision, allowed: result.allowed, policyName });
1341
1298
  if (result.allowed && result.decision === 'allow') {
1342
1299
  respond(false);
1343
1300
  return;
@@ -1396,7 +1353,7 @@ async function waitForApproval(input) {
1396
1353
  headers['x-shield-key'] = input.shieldKey;
1397
1354
  const url = `${input.apiUrl}/api/agent-security/runtime/approvals/${encodeURIComponent(input.actionId)}?shieldId=${encodeURIComponent(input.shieldId)}`;
1398
1355
  const deadline = Date.now() + input.timeoutMs;
1399
- dbg({ phase: 'approval_wait_start', actionId: input.actionId, timeoutMs: input.timeoutMs });
1356
+ (0, hookIo_1.dbg)({ phase: 'approval_wait_start', actionId: input.actionId, timeoutMs: input.timeoutMs });
1400
1357
  while (Date.now() <= deadline) {
1401
1358
  try {
1402
1359
  const resp = await fetch(url, { method: 'GET', headers, signal: AbortSignal.timeout(15000) });
@@ -1415,7 +1372,7 @@ async function waitForApproval(input) {
1415
1372
  }
1416
1373
  }
1417
1374
  catch (err) {
1418
- dbg({ phase: 'approval_poll_error', actionId: input.actionId, error: err instanceof Error ? err.message : String(err) });
1375
+ (0, hookIo_1.dbg)({ phase: 'approval_poll_error', actionId: input.actionId, error: err instanceof Error ? err.message : String(err) });
1419
1376
  }
1420
1377
  await new Promise((resolve) => setTimeout(resolve, Math.min(input.pollMs, Math.max(0, deadline - Date.now()))));
1421
1378
  }
@@ -1431,7 +1388,7 @@ async function enforceShieldText(ctx) {
1431
1388
  // deterministic rules already ran before this point; the audit trail rides
1432
1389
  // the async spool. Enforce machines keep the full window: their verdict gates.
1433
1390
  if (shadow) {
1434
- dbg({ phase: 'shield_skip_monitor', event });
1391
+ (0, hookIo_1.dbg)({ phase: 'shield_skip_monitor', event });
1435
1392
  (0, telemetry_1.spoolEvent)({ decision: 'allow', toolName: 'prompt', reason: 'monitor: synchronous Shield scan skipped by design.', offlineEnforced: true });
1436
1393
  (0, telemetry_1.triggerFlush)(false);
1437
1394
  respond(false);
@@ -1440,7 +1397,7 @@ async function enforceShieldText(ctx) {
1440
1397
  // recently-failed gate is not re-probed on every prompt; the degraded stance
1441
1398
  // resolves instantly instead of paying the full timeout per event.
1442
1399
  if ((0, policyGateHealth_1.gateRecentlyDown)()) {
1443
- dbg({ phase: 'shield_skip_gate_down', event });
1400
+ (0, hookIo_1.dbg)({ phase: 'shield_skip_gate_down', event });
1444
1401
  respondDegraded(ctx, 'Shield service recently unreachable — synchronous wait skipped (fast-probe window).', 'prompt', false, { countAsGateFailure: false });
1445
1402
  return;
1446
1403
  }
@@ -1460,7 +1417,7 @@ async function enforceShieldText(ctx) {
1460
1417
  method: 'POST',
1461
1418
  headers,
1462
1419
  body: JSON.stringify({ message: text }),
1463
- }, gateTimeoutMs, (attempt, error) => dbg({ phase: 'shield_retry', event, attempt, error }), gateAttempts);
1420
+ }, gateTimeoutMs, (attempt, error) => (0, hookIo_1.dbg)({ phase: 'shield_retry', event, attempt, error }), gateAttempts);
1464
1421
  if (!resp.ok) {
1465
1422
  respondDegraded(ctx, `Backend returned HTTP ${resp.status}.`, 'prompt');
1466
1423
  return;
@@ -28,6 +28,8 @@ export interface ClaudeManagedHookRepairResult {
28
28
  existed: boolean;
29
29
  managedEntries: number;
30
30
  staleEntries: number;
31
+ /** Managed entries with a hand-added `--shadow true` (enforcement downgrade tamper). */
32
+ weakenedEntries?: number;
31
33
  changed: boolean;
32
34
  }
33
35
  export declare function inspectClaudeManagedHooks(): ClaudeManagedHookRepairResult;
@@ -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 ');
@@ -114,6 +129,10 @@ function inspectClaudeManagedHooks() {
114
129
  existed: true,
115
130
  managedEntries: commands.length,
116
131
  staleEntries: commands.filter(command => repairManagedCommandPath(command.command).stale).length,
132
+ // Hand-added `--shadow true` on a managed command = enforcement downgrade
133
+ // tamper (the installer never writes it). The hook ignores the flag on
134
+ // managed invocations; this count surfaces the edit in integrity reports.
135
+ weakenedEntries: commands.filter(command => /--shadow(?:[ =])true\b/.test(command.command)).length,
117
136
  changed: false,
118
137
  };
119
138
  }
@@ -189,12 +208,11 @@ function stripManaged(hooks) {
189
208
  /** Build the absolute, shell-agnostic command that invokes this CLI's `hook`. */
190
209
  function buildHookCommand(opts) {
191
210
  const nodeExe = process.execPath;
192
- const scriptPath = process.argv[1] || path.join(__dirname, '..', 'index.js');
193
211
  const q = (s) => (/\s/.test(s) ? `"${s}"` : s);
194
212
  // No --event flag: the bridge detects the Claude payload (hook_event_name) itself.
195
213
  // local-only affects prompt events only; tool events still use the complete
196
214
  // 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`;
215
+ let cmd = `${q(nodeExe)} ${q(hookScriptPath())} hook --approval-mode wait --local-only true`;
198
216
  if (opts.shadow)
199
217
  cmd += ' --shadow true';
200
218
  if (opts.failClosed)
@@ -13,6 +13,14 @@ export interface ManagedHookRepairResult {
13
13
  existed: boolean;
14
14
  managedEntries: number;
15
15
  staleEntries: number;
16
+ /**
17
+ * Managed entries whose command carries a hand-added `--shadow true` — the
18
+ * standard installer/repair path never writes it, so its presence means the
19
+ * managed entry was edited to silently downgrade enforcement. The hook
20
+ * itself ignores the flag on managed invocations; this count makes the
21
+ * tamper VISIBLE (integrity report -> fleet console).
22
+ */
23
+ weakenedEntries?: number;
16
24
  changed: boolean;
17
25
  }
18
26
  export declare function inspectCursorManagedHooks(): ManagedHookRepairResult;
@@ -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 ');
@@ -124,6 +144,10 @@ function repairManagedCommandPath(command) {
124
144
  const expected = `${hookInvocationPrefix()}${command.slice(marker)}`;
125
145
  return { command: expected, stale: expected !== command };
126
146
  }
147
+ /** A managed command mutated to weaken enforcement (`--shadow true` injected). */
148
+ function isWeakenedManagedCommand(command) {
149
+ return /--shadow(?:[ =])true\b/.test(command);
150
+ }
127
151
  function inspectCursorManagedHooks() {
128
152
  const file = hooksJsonPath(false);
129
153
  if (!fs.existsSync(file))
@@ -135,6 +159,7 @@ function inspectCursorManagedHooks() {
135
159
  existed: true,
136
160
  managedEntries: managed.length,
137
161
  staleEntries: managed.filter(entry => repairManagedCommandPath(entry.command).stale).length,
162
+ weakenedEntries: managed.filter(entry => isWeakenedManagedCommand(entry.command)).length,
138
163
  changed: false,
139
164
  };
140
165
  }