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