fullcourtdefense-cli 1.27.0 → 1.27.2
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/hook.d.ts +8 -0
- package/dist/commands/hook.js +63 -3
- package/dist/commands/mcpGateway.js +9 -1
- package/dist/version.json +1 -1
- package/package.json +1 -1
package/dist/commands/hook.d.ts
CHANGED
|
@@ -114,6 +114,14 @@ export interface HookIo {
|
|
|
114
114
|
write(text: string): void;
|
|
115
115
|
writeErr(text: string): void;
|
|
116
116
|
exit(code: number): never;
|
|
117
|
+
/**
|
|
118
|
+
* True when `exit` really ends the process (standalone hook: CI runners,
|
|
119
|
+
* containers, a workstation whose daemon is down). Fire-and-forget network
|
|
120
|
+
* work started before `respond()` dies with the process, so audit reports
|
|
121
|
+
* must go through the spool + detached flusher instead. False in daemon
|
|
122
|
+
* mode, where `exit` only unwinds the evaluation and the process lives on.
|
|
123
|
+
*/
|
|
124
|
+
exitsProcess: boolean;
|
|
117
125
|
}
|
|
118
126
|
/**
|
|
119
127
|
* Daemon-side entry: evaluate one hook request in-process and return the
|
package/dist/commands/hook.js
CHANGED
|
@@ -338,6 +338,32 @@ function spoolCallEvent(call, rest) {
|
|
|
338
338
|
evidence: rest.evidence || (0, telemetry_1.evidenceFromToolArgs)(call.toolArgs),
|
|
339
339
|
});
|
|
340
340
|
}
|
|
341
|
+
/**
|
|
342
|
+
* A `log` verdict ("Warn only" in the console) is a real warning, not a silent
|
|
343
|
+
* allow: the action runs, the developer sees why it was flagged, and the fleet
|
|
344
|
+
* records it as Warned. Same delivery as a block — spool synchronously and let
|
|
345
|
+
* the detached flusher outlive a standalone hook process — so the warning is
|
|
346
|
+
* never lost on CI runners / containers where respond() ends the process.
|
|
347
|
+
*/
|
|
348
|
+
function warnByPolicy(call, verdict, event, exitsProcess, source) {
|
|
349
|
+
const reason = verdict.reason || `${call.toolName}: flagged by org Action Policy`;
|
|
350
|
+
const named = verdict.policyName ? ` (${verdict.policyName})` : '';
|
|
351
|
+
if (source === 'local-first') {
|
|
352
|
+
// The gate path is recorded as Warned by the server itself (it saw the
|
|
353
|
+
// call); only the local verdict has nobody else to write the audit.
|
|
354
|
+
spoolCallEvent(call, {
|
|
355
|
+
decision: 'warn',
|
|
356
|
+
reason: `policy warn (local-first): ${reason}`,
|
|
357
|
+
ruleId: verdict.policyName ? `action-policy:${verdict.policyName}` : undefined,
|
|
358
|
+
offlineEnforced: true,
|
|
359
|
+
});
|
|
360
|
+
(0, telemetry_1.triggerFlush)(exitsProcess);
|
|
361
|
+
}
|
|
362
|
+
return {
|
|
363
|
+
userMsg: `FullCourtDefense warning${named} — ${reason}. The ${event} was allowed and recorded as Warned.`,
|
|
364
|
+
agentMsg: `FullCourtDefense warning${named}: ${reason}. This ${event} was allowed by a warn-only Action Policy and recorded as Warned — proceed, but mention the warning to the developer.`,
|
|
365
|
+
};
|
|
366
|
+
}
|
|
341
367
|
function safeJson(v) {
|
|
342
368
|
try {
|
|
343
369
|
return JSON.stringify(v);
|
|
@@ -631,6 +657,11 @@ function tryLocalPolicyEnforcement(ctx, call, detail, opts = {}) {
|
|
|
631
657
|
(0, telemetry_1.triggerFlush)(true);
|
|
632
658
|
ctx.respond(true, `Blocked by FullCourtDefense${local.policyName ? ` (${local.policyName})` : ''} — enforced locally while the policy service is unreachable.${approvalNote}`, `FullCourtDefense blocked this ${ctx.event} using the locally cached org policy${local.policyName ? ` "${local.policyName}"` : ''} (${detail}).${approvalNote} Do not retry until the connection is restored or the policy allows it.`);
|
|
633
659
|
}
|
|
660
|
+
if (local.verdict === 'log') {
|
|
661
|
+
// Warn-only rule, offline: still a real warning + Warned in the fleet.
|
|
662
|
+
const warning = warnByPolicy(call, local, ctx.event, ctx.exitsProcess, 'local-first');
|
|
663
|
+
ctx.respond(false, warning.userMsg, `${warning.agentMsg} (policy service unreachable — evaluated from the locally cached org policies).`);
|
|
664
|
+
}
|
|
634
665
|
spoolCallEvent(call, { decision: 'allow', reason: `local policy allow (offline): ${detail}`, offlineEnforced: true });
|
|
635
666
|
(0, telemetry_1.triggerFlush)(false);
|
|
636
667
|
ctx.respond(false, undefined, `FullCourtDefense: policy service unreachable — this ${ctx.event} was checked against the locally cached org policies and allowed.`);
|
|
@@ -785,6 +816,7 @@ function processIo(stdin, stdinErr) {
|
|
|
785
816
|
write: (text) => { process.stdout.write(text); },
|
|
786
817
|
writeErr: (text) => { process.stderr.write(text); },
|
|
787
818
|
exit: (code) => process.exit(code),
|
|
819
|
+
exitsProcess: true,
|
|
788
820
|
};
|
|
789
821
|
}
|
|
790
822
|
/**
|
|
@@ -804,6 +836,7 @@ async function evaluateHookRequest(args, stdinRaw, config) {
|
|
|
804
836
|
write: (text) => { out += text; },
|
|
805
837
|
writeErr: (text) => { errOut += text; },
|
|
806
838
|
exit: (code) => { throw new HookExitSignal(code); },
|
|
839
|
+
exitsProcess: false,
|
|
807
840
|
};
|
|
808
841
|
try {
|
|
809
842
|
await hookCommandInner(args, config, io);
|
|
@@ -1195,7 +1228,7 @@ async function hookCommandInner(args, config, io) {
|
|
|
1195
1228
|
await enforceActionPolicy({
|
|
1196
1229
|
event, payload, apiUrl: apiUrl, shieldId: shieldId, shieldKey,
|
|
1197
1230
|
shadow, failClosed, timeoutMs, approvalMode, approvalTimeoutMs, approvalPollMs, respond, deferLocalWarnings,
|
|
1198
|
-
effectivePolicyHash, localPolicies, client,
|
|
1231
|
+
effectivePolicyHash, localPolicies, client, exitsProcess: io.exitsProcess,
|
|
1199
1232
|
});
|
|
1200
1233
|
return;
|
|
1201
1234
|
}
|
|
@@ -1450,8 +1483,21 @@ async function enforceActionPolicy(ctx) {
|
|
|
1450
1483
|
const localVerdict = (0, actionPolicyEngine_1.evaluateActionPolicies)(ctx.localPolicies, call.toolName, localOp, localCtx, (0, actionPolicyEngine_1.deriveToolOperations)(call.toolName));
|
|
1451
1484
|
(0, hookIo_1.dbg)({ phase: 'policy_local_first', event, tool: call.toolName, operation: localOp, verdict: localVerdict.verdict, policyName: localVerdict.policyName });
|
|
1452
1485
|
if (localVerdict.verdict === 'allow') {
|
|
1453
|
-
|
|
1454
|
-
|
|
1486
|
+
if (ctx.exitsProcess) {
|
|
1487
|
+
// Standalone hook (CI runner, container, daemon down): respond() ends
|
|
1488
|
+
// the process, so a fire-and-forget gate report would die in flight and
|
|
1489
|
+
// the allowed action would never reach the fleet — the machine would
|
|
1490
|
+
// read "N blocked of N recent" with every allow missing. Same delivery
|
|
1491
|
+
// as a block: spool (sync file append) + detached flusher that outlives
|
|
1492
|
+
// this process. The IDE is not kept waiting on the network.
|
|
1493
|
+
spoolCallEvent(call, { decision: 'allow', reason: 'local policy allow (block mode, local-first)' });
|
|
1494
|
+
(0, telemetry_1.triggerFlush)(true);
|
|
1495
|
+
respond(false);
|
|
1496
|
+
return;
|
|
1497
|
+
}
|
|
1498
|
+
// Daemon-evaluated: the process lives on, so the audit rides async — the
|
|
1499
|
+
// server records the action + its own verdict; if it is unreachable the
|
|
1500
|
+
// spool keeps the trail (idempotent on eventId).
|
|
1455
1501
|
void fetch(gateUrl, { method: 'POST', headers, body: gateBody, signal: AbortSignal.timeout(4000) })
|
|
1456
1502
|
.then(() => (0, policyGateHealth_1.recordGateSuccess)())
|
|
1457
1503
|
.catch(() => {
|
|
@@ -1468,6 +1514,13 @@ async function enforceActionPolicy(ctx) {
|
|
|
1468
1514
|
respond(true, `Blocked by FullCourtDefense${localVerdict.policyName ? ` (${localVerdict.policyName})` : ''} — ${reason}`, `FullCourtDefense blocked this ${event} by org Action Policy${localVerdict.policyName ? ` "${localVerdict.policyName}"` : ''}: ${reason}. This is a policy decision, not an error — do not retry.`);
|
|
1469
1515
|
return;
|
|
1470
1516
|
}
|
|
1517
|
+
if (localVerdict.verdict === 'log') {
|
|
1518
|
+
// "Warn only": runs, developer told why, fleet records Warned. No gate
|
|
1519
|
+
// round trip — the verdict is final and the audit rides the spool.
|
|
1520
|
+
const warning = warnByPolicy(call, localVerdict, event, ctx.exitsProcess, 'local-first');
|
|
1521
|
+
respond(false, warning.userMsg, warning.agentMsg);
|
|
1522
|
+
return;
|
|
1523
|
+
}
|
|
1471
1524
|
// require_approval, developer scope + human present → IDE-native ask (no org queue).
|
|
1472
1525
|
if (tryDeveloperConfirmation({ event, respond, shadow }, call, localVerdict, payload, 'local-first'))
|
|
1473
1526
|
return;
|
|
@@ -1549,6 +1602,13 @@ async function enforceActionPolicy(ctx) {
|
|
|
1549
1602
|
const policyName = result.actionPolicy?.policyName;
|
|
1550
1603
|
(0, hookIo_1.dbg)({ phase: 'policy_verdict', event, tool: call.toolName, decision: result.decision, allowed: result.allowed, policyName });
|
|
1551
1604
|
if (result.allowed && result.decision === 'allow') {
|
|
1605
|
+
if (result.actionPolicy?.verdict === 'log') {
|
|
1606
|
+
// The server allowed it under a warn-only rule; surface the warning and
|
|
1607
|
+
// record Warned (the server only records blocks/approvals itself).
|
|
1608
|
+
const warning = warnByPolicy(call, result.actionPolicy, event, ctx.exitsProcess, 'gate');
|
|
1609
|
+
respond(false, warning.userMsg, warning.agentMsg);
|
|
1610
|
+
return;
|
|
1611
|
+
}
|
|
1552
1612
|
respond(false);
|
|
1553
1613
|
return;
|
|
1554
1614
|
}
|
|
@@ -1213,7 +1213,15 @@ class McpGatewayServer {
|
|
|
1213
1213
|
(0, notify_1.notifyOs)({ title: 'FullCourtDefense — blocked by policy', message: `${toolName} blocked${local.policyName ? ` (${local.policyName})` : ''}.` });
|
|
1214
1214
|
throw blockError(`FullCourtDefense blocked ${toolName} by org Action Policy${local.policyName ? ` "${local.policyName}"` : ''}: ${reason}. This is a policy decision, not an error — do not retry.`);
|
|
1215
1215
|
}
|
|
1216
|
-
if (local.verdict
|
|
1216
|
+
if (local.verdict === 'log') {
|
|
1217
|
+
// "Warn only": the call runs, the fleet records Warned (not a silent
|
|
1218
|
+
// allow). Final verdict — no gate round trip; the audit rides the spool.
|
|
1219
|
+
const reason = local.reason || `${toolName}: flagged by org Action Policy`;
|
|
1220
|
+
(0, telemetry_1.spoolEvent)({ decision: 'warn', toolName, operation: localOperation, reason: `policy warn (local-first): ${reason}`, ruleId: local.policyName ? `action-policy:${local.policyName}` : undefined, offlineEnforced: true });
|
|
1221
|
+
(0, telemetry_1.triggerFlush)(false);
|
|
1222
|
+
preflight = { allowed: true, decision: 'allow', operation: localOperation };
|
|
1223
|
+
}
|
|
1224
|
+
else if (local.verdict !== 'require_approval') {
|
|
1217
1225
|
// Local allow: the server still records the action asynchronously; a
|
|
1218
1226
|
// failed report spools (idempotent on eventId) for the next flush.
|
|
1219
1227
|
// During a recently-down gate window, spool directly (no per-call
|
package/dist/version.json
CHANGED