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.
- package/dist/commands/daemon.d.ts +14 -0
- package/dist/commands/daemon.js +181 -28
- package/dist/commands/desktopChatGuard.js +5 -4
- package/dist/commands/deterministicGuard.d.ts +8 -0
- package/dist/commands/deterministicGuard.js +6 -0
- package/dist/commands/doctor.d.ts +4 -0
- package/dist/commands/doctor.js +99 -0
- package/dist/commands/hook.d.ts +1 -0
- package/dist/commands/hook.js +61 -104
- package/dist/commands/installClaudeHook.d.ts +2 -0
- package/dist/commands/installClaudeHook.js +21 -3
- package/dist/commands/installCursorHook.d.ts +8 -0
- package/dist/commands/installCursorHook.js +29 -4
- package/dist/commands/mcpGateway.js +234 -43
- package/dist/commands/onboard.d.ts +9 -0
- package/dist/commands/onboard.js +7 -0
- package/dist/commands/watchdog.d.ts +5 -2
- package/dist/commands/watchdog.js +34 -4
- package/dist/config.js +6 -1
- package/dist/daemonForensics.d.ts +27 -0
- package/dist/daemonForensics.js +27 -0
- package/dist/distress.d.ts +17 -0
- package/dist/distress.js +17 -0
- package/dist/hookIo.d.ts +43 -0
- package/dist/hookIo.js +189 -0
- package/dist/hookSlim.d.ts +32 -0
- package/dist/hookSlim.js +142 -0
- package/dist/index.js +10 -14
- package/dist/integrity.js +8 -0
- package/dist/localSafetySnapshot.d.ts +9 -0
- package/dist/localSafetySnapshot.js +58 -2
- package/dist/machineIdentity.js +70 -4
- package/dist/runtimeConfig.d.ts +2 -0
- package/dist/runtimeConfig.js +177 -5
- package/dist/selfTest.d.ts +10 -0
- package/dist/selfTest.js +19 -8
- package/dist/selfUpdate.d.ts +19 -4
- package/dist/selfUpdate.js +108 -12
- package/dist/telemetry.js +67 -2
- package/dist/verdictIpc.d.ts +12 -75
- package/dist/verdictIpc.js +104 -188
- package/dist/verdictIpcClient.d.ts +103 -0
- package/dist/verdictIpcClient.js +251 -0
- package/dist/version.json +1 -1
- package/package.json +6 -1
package/dist/commands/hook.js
CHANGED
|
@@ -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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
803
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
}
|