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.
@@ -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
@@ -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
- // Audit rides async: server records the action + its own verdict; if it
1454
- // is unreachable the spool keeps the trail (idempotent on eventId).
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 !== 'require_approval') {
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
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "1.27.0"
2
+ "version": "1.27.2"
3
3
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullcourtdefense-cli",
3
- "version": "1.27.0",
3
+ "version": "1.27.2",
4
4
  "description": "Full Court Defense CLI — security scanning for AI agents from your terminal",
5
5
  "main": "dist/index.js",
6
6
  "bin": {