fullcourtdefense-cli 1.27.1 → 1.27.3

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.
@@ -8,8 +8,14 @@ export type DeterministicCategory = 'sensitive_file' | 'metadata_ssrf' | 'creden
8
8
  * - 'warn' — let the action proceed; surface + report the finding as a warning.
9
9
  * - 'mask' — redact the matched secret where the surface can rewrite content
10
10
  * (MCP tool responses); surfaces that cannot rewrite treat it as block.
11
+ * - 'ask' — the developer's IDE shows its Allow / Deny prompt (the same
12
+ * self-confirmation flow as an Action Policy "Ask the developer"). Where
13
+ * nobody can answer — CI / ephemeral machines, the MCP gateway, prompt
14
+ * events, shell wrappers — it is block. Never a silent allow.
11
15
  */
12
- export type LocalSafetyRuleAction = 'block' | 'warn' | 'mask';
16
+ export type LocalSafetyRuleAction = 'block' | 'warn' | 'mask' | 'ask';
17
+ /** Whitelist for values that arrive from a snapshot; anything else is unknown → caller defaults to block. */
18
+ export declare function toLocalSafetyRuleAction(value: unknown): LocalSafetyRuleAction | undefined;
13
19
  export interface DeterministicFinding {
14
20
  blocked: true;
15
21
  ruleId: string;
@@ -70,6 +76,13 @@ export declare function scanDeterministicPrompt(text: string, options?: LocalSaf
70
76
  export interface DeterministicScanOutcome {
71
77
  /** The finding that stops the action, if any. */
72
78
  blockingFinding?: DeterministicFinding;
79
+ /**
80
+ * The first ask-action finding, when NO block rule also matched. The surface
81
+ * decides what "ask" means for it: the IDE hook raises the developer's own
82
+ * Allow / Deny prompt; every surface that cannot ask (MCP gateway, prompt
83
+ * events, shell wrappers, no human present) treats it exactly as a block.
84
+ */
85
+ askFinding?: DeterministicFinding;
73
86
  /** warn-action findings that matched — the action proceeds; report these. */
74
87
  warnings: DeterministicFinding[];
75
88
  }
@@ -89,6 +102,10 @@ export interface DeterministicTextResponseOutcome extends DeterministicScanOutco
89
102
  *
90
103
  * 'mask' degrades to block here: these surfaces (tool calls, prompts, shell
91
104
  * commands) cannot rewrite content, so mask can only fail strict.
105
+ *
106
+ * 'ask' is likewise suppressed and re-scanned, so a block rule hiding behind
107
+ * an ask rule still wins; only when nothing blocks is the first ask finding
108
+ * returned for the surface to raise (IDE) or fail strict (everything else).
92
109
  */
93
110
  export declare function resolveDeterministicOutcome(scan: (options?: LocalSafetyScanOptions) => DeterministicFinding | undefined, options?: LocalSafetyScanOptions): DeterministicScanOutcome;
94
111
  /**
@@ -33,6 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.toLocalSafetyRuleAction = toLocalSafetyRuleAction;
36
37
  exports.stripInertDataSegments = stripInertDataSegments;
37
38
  exports.scanDeterministicToolCall = scanDeterministicToolCall;
38
39
  exports.scanDeterministicTextResponse = scanDeterministicTextResponse;
@@ -42,6 +43,10 @@ exports.resolveDeterministicTextResponse = resolveDeterministicTextResponse;
42
43
  const fs = __importStar(require("fs"));
43
44
  const os = __importStar(require("os"));
44
45
  const path = __importStar(require("path"));
46
+ /** Whitelist for values that arrive from a snapshot; anything else is unknown → caller defaults to block. */
47
+ function toLocalSafetyRuleAction(value) {
48
+ return value === 'block' || value === 'warn' || value === 'mask' || value === 'ask' ? value : undefined;
49
+ }
45
50
  const SECRET_PATTERNS = [
46
51
  { itemId: 'private_key_material', label: 'private key material', re: /-----BEGIN (?:RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----/i },
47
52
  { itemId: 'aws_access_key', label: 'AWS access key', re: /\bAKIA[0-9A-Z]{16}\b/ },
@@ -421,8 +426,8 @@ function isEnabled(itemId, options) {
421
426
  */
422
427
  const DEFAULT_WARN_BUILT_INS = new Set(['env_workspace']);
423
428
  function actionFor(itemId, options) {
424
- const action = options?.itemActions?.[itemId];
425
- if (action === 'warn' || action === 'mask' || action === 'block')
429
+ const action = toLocalSafetyRuleAction(options?.itemActions?.[itemId]);
430
+ if (action)
426
431
  return action;
427
432
  // No snapshot resolved at all: never hard-block on the catalog default —
428
433
  // an unarmed / offline machine mirrors the server's warn-first gate rather
@@ -510,7 +515,7 @@ function customBlock(value, options) {
510
515
  evidence: evidence(value),
511
516
  explanation: block.explanation,
512
517
  policyHash: options?.policyHash,
513
- action: block.action === 'warn' || block.action === 'mask' ? block.action : 'block',
518
+ action: toLocalSafetyRuleAction(block.action) || 'block',
514
519
  };
515
520
  }
516
521
  return undefined;
@@ -1211,7 +1216,7 @@ function withRuleSuppressed(options, finding) {
1211
1216
  return { ...(options || {}), disabledBuiltInItemIds: [...baseDisabled, finding.itemId] };
1212
1217
  }
1213
1218
  function findingAction(finding) {
1214
- return finding.action === 'warn' || finding.action === 'mask' ? finding.action : 'block';
1219
+ return toLocalSafetyRuleAction(finding.action) || 'block';
1215
1220
  }
1216
1221
  /**
1217
1222
  * Resolve a scan under per-rule actions WITHOUT letting a warn rule shadow a
@@ -1223,22 +1228,33 @@ function findingAction(finding) {
1223
1228
  *
1224
1229
  * 'mask' degrades to block here: these surfaces (tool calls, prompts, shell
1225
1230
  * commands) cannot rewrite content, so mask can only fail strict.
1231
+ *
1232
+ * 'ask' is likewise suppressed and re-scanned, so a block rule hiding behind
1233
+ * an ask rule still wins; only when nothing blocks is the first ask finding
1234
+ * returned for the surface to raise (IDE) or fail strict (everything else).
1226
1235
  */
1227
1236
  function resolveDeterministicOutcome(scan, options) {
1228
1237
  const warnings = [];
1238
+ let askFinding;
1229
1239
  let current = options;
1230
1240
  for (let pass = 0; pass < MAX_ACTION_PASSES; pass++) {
1231
1241
  const finding = scan(current);
1232
1242
  if (!finding)
1233
- return { warnings };
1234
- if (findingAction(finding) !== 'warn')
1243
+ return askFinding ? { askFinding, warnings } : { warnings };
1244
+ const action = findingAction(finding);
1245
+ if (action === 'warn')
1246
+ warnings.push(finding);
1247
+ else if (action === 'ask')
1248
+ askFinding = askFinding || finding;
1249
+ else
1235
1250
  return { blockingFinding: finding, warnings };
1236
- warnings.push(finding);
1237
1251
  current = withRuleSuppressed(current, finding);
1238
1252
  }
1239
1253
  // Pass budget exhausted with matches still appearing — fail strict.
1240
1254
  const last = scan(current);
1241
- return last ? { blockingFinding: last, warnings } : { warnings };
1255
+ if (last)
1256
+ return { blockingFinding: last, warnings };
1257
+ return askFinding ? { askFinding, warnings } : { warnings };
1242
1258
  }
1243
1259
  /**
1244
1260
  * Resolve a TOOL RESPONSE under per-rule actions. The response is the one
@@ -1258,7 +1274,8 @@ function resolveDeterministicTextResponse(text, options) {
1258
1274
  if (!finding)
1259
1275
  return { warnings, maskFindings, maskedText: currentText };
1260
1276
  const action = findingAction(finding);
1261
- if (action === 'block')
1277
+ // A tool response has no developer to ask — 'ask' fails strict here.
1278
+ if (action === 'block' || action === 'ask')
1262
1279
  return { blockingFinding: finding, warnings, maskFindings, maskedText: currentText };
1263
1280
  if (action === 'mask') {
1264
1281
  if (!finding.matchedSecret || !currentText.includes(finding.matchedSecret)) {
@@ -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);
@@ -498,10 +524,31 @@ function tryDeveloperConfirmation(ctx, call, verdict, payload, where) {
498
524
  }
499
525
  if (ctx.shadow)
500
526
  return false; // monitor machines never prompt — the caller records would-approve
527
+ const prepared = prepareDeveloperConfirmation(ctx.event, call, payload, {
528
+ policyId: verdict.policyId,
529
+ policyName: verdict.policyName,
530
+ matchedRule: verdict.matchedRule,
531
+ remember: verdict.approval?.remember ?? 'none',
532
+ reason: verdict.reason,
533
+ where,
534
+ });
535
+ if (prepared.outcome === 'allow')
536
+ ctx.respond(false, undefined, prepared.agentMsg);
537
+ ctx.respond('ask', prepared.userMsg, prepared.agentMsg);
538
+ }
539
+ /**
540
+ * The developer-confirmation core, independent of WHAT asked for it: an Action
541
+ * Policy rule routed to the developer, or a Local Safety rule set to "Ask".
542
+ * Sweeps unanswered markers (recorded as not approved), honours a remembered
543
+ * answer (→ allow, recorded as developer-approved), otherwise raises the
544
+ * marker the post-execution hook settles and records the pending approval.
545
+ * Returns what to answer; the caller emits it (respond() ends the process).
546
+ */
547
+ function prepareDeveloperConfirmation(event, call, payload, source) {
501
548
  const { operation } = (0, actionPolicyEngine_1.inferToolContext)(call.toolName, call.toolArgs);
502
549
  const sessId = sessionId(payload);
503
- const remember = verdict.approval?.remember ?? 'none';
504
- const policyLabel = verdict.policyName ? ` (${verdict.policyName})` : '';
550
+ const policyLabel = source.policyName ? ` (${source.policyName})` : '';
551
+ const offlineEnforced = source.where !== 'local-first';
505
552
  // Sweep first: markers the human never answered are recorded as not approved.
506
553
  for (const expired of (0, devConfirm_1.sweepExpiredConfirmations)()) {
507
554
  (0, telemetry_1.spoolEvent)({
@@ -513,40 +560,44 @@ function tryDeveloperConfirmation(ctx, call, verdict, payload, where) {
513
560
  offlineEnforced: true,
514
561
  });
515
562
  }
516
- const remembered = (0, devConfirm_1.isConfirmationRemembered)({ toolName: call.toolName, operation, policyId: verdict.policyId, matchedRule: verdict.matchedRule }, sessId);
563
+ const remembered = (0, devConfirm_1.isConfirmationRemembered)({ toolName: call.toolName, operation, policyId: source.policyId, matchedRule: source.matchedRule }, sessId);
517
564
  if (remembered) {
518
- (0, hookIo_1.dbg)({ phase: 'dev_confirm_remembered', event: ctx.event, tool: call.toolName, operation, remember: remembered.remember, where });
565
+ (0, hookIo_1.dbg)({ phase: 'dev_confirm_remembered', event, tool: call.toolName, operation, remember: remembered.remember, where: source.where });
519
566
  spoolCallEvent(call, {
520
567
  decision: 'allow',
521
568
  reason: `developer confirmed earlier (${remembered.remember} memory)${policyLabel}`,
522
569
  approvalScope: 'developer',
523
570
  approvedBy: 'developer',
524
- offlineEnforced: where === 'offline',
571
+ offlineEnforced,
525
572
  });
526
573
  (0, telemetry_1.triggerFlush)(false);
527
- ctx.respond(false, undefined, `FullCourtDefense: ${call.toolName} was already confirmed by the developer in this ${remembered.remember === '1h' ? 'hour' : 'session'}.`);
574
+ return { outcome: 'allow', agentMsg: `FullCourtDefense: ${call.toolName} was already confirmed by the developer in this ${remembered.remember === '1h' ? 'hour' : 'session'}.` };
528
575
  }
529
576
  const marker = (0, devConfirm_1.raiseDeveloperConfirmation)({
530
- event: ctx.event,
577
+ event,
531
578
  toolName: call.toolName,
532
579
  operation,
533
580
  argsDigest: (0, devConfirm_1.actionDigest)(call.toolArgs),
534
- policyId: verdict.policyId,
535
- policyName: verdict.policyName,
536
- matchedRule: verdict.matchedRule,
581
+ policyId: source.policyId,
582
+ policyName: source.policyName,
583
+ matchedRule: source.matchedRule,
537
584
  sessionId: sessId,
538
- remember,
585
+ remember: source.remember,
539
586
  });
540
- (0, hookIo_1.dbg)({ phase: 'dev_confirm_ask', event: ctx.event, tool: call.toolName, operation, key: marker.key, remember, where });
587
+ (0, hookIo_1.dbg)({ phase: 'dev_confirm_ask', event, tool: call.toolName, operation, key: marker.key, remember: source.remember, where: source.where });
541
588
  spoolCallEvent(call, {
542
589
  decision: 'approval',
543
- reason: `developer confirmation requested via IDE prompt${policyLabel}${verdict.reason ? `: ${verdict.reason}` : ''}`,
590
+ reason: `developer confirmation requested via IDE prompt${policyLabel}${source.reason ? `: ${source.reason}` : ''}`,
544
591
  approvalScope: 'developer',
545
- offlineEnforced: where === 'offline',
592
+ offlineEnforced,
546
593
  });
547
594
  (0, telemetry_1.triggerFlush)(false);
548
- const reason = verdict.reason || `${call.toolName} needs your confirmation`;
549
- ctx.respond('ask', `FullCourtDefense${policyLabel}: ${reason}. Allow this ${ctx.event}?`, `FullCourtDefense paused this ${ctx.event} for the developer's confirmation${policyLabel}: ${reason}. Wait for the developer's answer in the IDE prompt — do not retry or work around it.`);
595
+ const reason = source.reason || `${call.toolName} needs your confirmation`;
596
+ return {
597
+ outcome: 'ask',
598
+ userMsg: `FullCourtDefense${policyLabel}: ${reason}. Allow this ${event}?`,
599
+ agentMsg: `FullCourtDefense paused this ${event} for the developer's confirmation${policyLabel}: ${reason}. Wait for the developer's answer in the IDE prompt — do not retry or work around it.`,
600
+ };
550
601
  }
551
602
  /**
552
603
  * Post-execution hook: the action ran, so if we asked the developer about exactly this
@@ -631,6 +682,11 @@ function tryLocalPolicyEnforcement(ctx, call, detail, opts = {}) {
631
682
  (0, telemetry_1.triggerFlush)(true);
632
683
  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
684
  }
685
+ if (local.verdict === 'log') {
686
+ // Warn-only rule, offline: still a real warning + Warned in the fleet.
687
+ const warning = warnByPolicy(call, local, ctx.event, ctx.exitsProcess, 'local-first');
688
+ ctx.respond(false, warning.userMsg, `${warning.agentMsg} (policy service unreachable — evaluated from the locally cached org policies).`);
689
+ }
634
690
  spoolCallEvent(call, { decision: 'allow', reason: `local policy allow (offline): ${detail}`, offlineEnforced: true });
635
691
  (0, telemetry_1.triggerFlush)(false);
636
692
  ctx.respond(false, undefined, `FullCourtDefense: policy service unreachable — this ${ctx.event} was checked against the locally cached org policies and allowed.`);
@@ -1109,9 +1165,35 @@ async function hookCommandInner(args, config, io) {
1109
1165
  pendingLocalWarnings.push({ finding, toolName, operation });
1110
1166
  }
1111
1167
  };
1168
+ // A Local Safety rule set to "Ask" is held the same way: the IDE prompt is
1169
+ // raised only if the action is not blocked by anything evaluated later
1170
+ // (taint, org Action Policies) — a block always wins over an ask, and the
1171
+ // developer is never asked about an action that would be denied anyway.
1172
+ let pendingLocalAsk;
1173
+ const deferLocalAsk = (finding, call) => {
1174
+ pendingLocalAsk = pendingLocalAsk || { finding, call };
1175
+ };
1176
+ const hasPendingLocalAsk = () => Boolean(pendingLocalAsk);
1112
1177
  const respond = (blocked, userMsg, agentMsg) => {
1113
- const ask = blocked === 'ask' && event !== 'prompt';
1178
+ let ask = blocked === 'ask' && event !== 'prompt';
1114
1179
  const denied = blocked === true;
1180
+ if (!denied && !ask && pendingLocalAsk && event !== 'prompt') {
1181
+ const { finding, call } = pendingLocalAsk;
1182
+ pendingLocalAsk = undefined;
1183
+ const prepared = prepareDeveloperConfirmation(event, call, payload, {
1184
+ policyId: `local-safety:${finding.itemId}`,
1185
+ policyName: `Local Safety · ${finding.itemId}`,
1186
+ matchedRule: finding.itemId,
1187
+ remember: 'session',
1188
+ reason: finding.reason.replace(/[.\s]+$/, ''),
1189
+ where: 'local-safety',
1190
+ });
1191
+ if (prepared.outcome === 'ask') {
1192
+ ask = true;
1193
+ userMsg = prepared.userMsg;
1194
+ }
1195
+ agentMsg = [prepared.agentMsg, agentMsg].filter(Boolean).join(' ');
1196
+ }
1115
1197
  if (!denied && pendingLocalWarnings.length > 0) {
1116
1198
  spoolLocalWarnings(pendingLocalWarnings.map(w => w.finding), pendingLocalWarnings[0].toolName, pendingLocalWarnings[0].operation);
1117
1199
  }
@@ -1196,7 +1278,7 @@ async function hookCommandInner(args, config, io) {
1196
1278
  if (event === 'shell' || event === 'mcp' || event === 'file' || event === 'read') {
1197
1279
  await enforceActionPolicy({
1198
1280
  event, payload, apiUrl: apiUrl, shieldId: shieldId, shieldKey,
1199
- shadow, failClosed, timeoutMs, approvalMode, approvalTimeoutMs, approvalPollMs, respond, deferLocalWarnings,
1281
+ shadow, failClosed, timeoutMs, approvalMode, approvalTimeoutMs, approvalPollMs, respond, deferLocalWarnings, deferLocalAsk, hasPendingLocalAsk,
1200
1282
  effectivePolicyHash, localPolicies, client, exitsProcess: io.exitsProcess,
1201
1283
  });
1202
1284
  return;
@@ -1221,7 +1303,9 @@ async function hookCommandInner(args, config, io) {
1221
1303
  });
1222
1304
  const promptOutcome = (0, deterministicGuard_1.resolveDeterministicOutcome)(opts => (0, deterministicGuard_1.scanDeterministicPrompt)(text, opts), (0, localSafetySnapshot_1.snapshotToScanOptions)(snapshot));
1223
1305
  deferLocalWarnings(promptOutcome.warnings, 'prompt', 'prompt');
1224
- const localBlock = promptOutcome.blockingFinding;
1306
+ // Prompt events have no IDE permission prompt to hand an "ask" to — an
1307
+ // ask-action rule fails strict here (block), never silently allows.
1308
+ const localBlock = promptOutcome.blockingFinding || promptOutcome.askFinding;
1225
1309
  if (localBlock) {
1226
1310
  (0, hookIo_1.dbg)({ phase: 'local_deterministic_prompt_block', event, ruleId: localBlock.ruleId, category: localBlock.category });
1227
1311
  if (shadow) {
@@ -1325,7 +1409,7 @@ async function runTaintApproveOnce(ctx, event, call, sessId, taintFinding) {
1325
1409
  return false;
1326
1410
  }
1327
1411
  async function enforceActionPolicy(ctx) {
1328
- const { event, payload, apiUrl, shieldId, shieldKey, shadow, timeoutMs, respond, deferLocalWarnings, effectivePolicyHash } = ctx;
1412
+ const { event, payload, apiUrl, shieldId, shieldKey, shadow, timeoutMs, respond, deferLocalWarnings, deferLocalAsk, hasPendingLocalAsk, effectivePolicyHash } = ctx;
1329
1413
  const call = buildToolCall(event, payload);
1330
1414
  if (!call) {
1331
1415
  respond(false);
@@ -1359,6 +1443,26 @@ async function enforceActionPolicy(ctx) {
1359
1443
  respond(true, localBlockUserMessage(localBlock), `FullCourtDefense blocked this ${event} locally (${localBlock.ruleId}). Do not retry.`);
1360
1444
  return;
1361
1445
  }
1446
+ // Local Safety rule set to "Ask the developer" (no block rule matched).
1447
+ // human at the keyboard -> held until the verdict; the IDE's own Allow / Deny
1448
+ // prompt is raised unless something later blocks.
1449
+ // monitor / shadow -> recorded as a would-ask warning, the action runs.
1450
+ // no human (CI, ephemeral, FCD_HUMAN_PRESENT=0) -> block, never a silent allow.
1451
+ const localAsk = toolOutcome.askFinding;
1452
+ if (localAsk) {
1453
+ (0, hookIo_1.dbg)({ phase: 'local_deterministic_ask', event, tool: call.toolName, ruleId: localAsk.ruleId, humanPresent: (0, devConfirm_1.isHumanPresent)(), shadow });
1454
+ if (shadow) {
1455
+ spoolLocalFinding({ finding: localAsk, toolName: call.toolName, operation: event, decision: 'warn' });
1456
+ }
1457
+ else if (!(0, devConfirm_1.isHumanPresent)()) {
1458
+ spoolLocalFinding({ finding: localAsk, toolName: call.toolName, operation: event });
1459
+ respond(true, `${localBlockUserMessage(localAsk)} This rule asks the developer to confirm, but no developer is present on this machine (CI / ephemeral), so it is blocked.`, `FullCourtDefense blocked this ${event} locally (${localAsk.ruleId}): the rule requires the developer's confirmation and no human is present here. Do not retry.`);
1460
+ return;
1461
+ }
1462
+ else {
1463
+ deferLocalAsk?.(localAsk, call);
1464
+ }
1465
+ }
1362
1466
  // --- Taint-guard self-protection (always enforced before anything taint) ---
1363
1467
  // The agent must never resolve approve-once requests or touch the taint
1364
1468
  // state directory; otherwise a compromised agent could approve itself.
@@ -1445,13 +1549,26 @@ async function enforceActionPolicy(ctx) {
1445
1549
  // (a human must resolve it in the console) — falls through to
1446
1550
  // the synchronous gate call below.
1447
1551
  // No cached policies (fresh install, bundle fetch failed) also falls through.
1448
- if (ctx.localPolicies && ctx.localPolicies.length > 0) {
1552
+ // A pending Local Safety "Ask the developer" also resolves here when the
1553
+ // bundle is present but the org has no Action Policies yet: the cached
1554
+ // (hash-covered) policy set is the same one the gate would evaluate, so
1555
+ // "nothing matched" is final and the IDE prompt is raised without a gate
1556
+ // round trip — the same trust the local-first allow below already extends.
1557
+ const localAskPending = Boolean(hasPendingLocalAsk?.());
1558
+ if (ctx.localPolicies && (ctx.localPolicies.length > 0 || localAskPending)) {
1449
1559
  const { operation: localOp, context: localCtx } = (0, actionPolicyEngine_1.inferToolContext)(call.toolName, call.toolArgs);
1450
1560
  localCtx.toolName = call.toolName;
1451
1561
  (0, machineIdentity_1.stampMachineIdentityContext)(localCtx, developerId());
1452
1562
  const localVerdict = (0, actionPolicyEngine_1.evaluateActionPolicies)(ctx.localPolicies, call.toolName, localOp, localCtx, (0, actionPolicyEngine_1.deriveToolOperations)(call.toolName));
1453
1563
  (0, hookIo_1.dbg)({ phase: 'policy_local_first', event, tool: call.toolName, operation: localOp, verdict: localVerdict.verdict, policyName: localVerdict.policyName });
1454
1564
  if (localVerdict.verdict === 'allow') {
1565
+ if (localAskPending) {
1566
+ // No "Allowed" row for an action that has not run yet: respond() turns
1567
+ // this into the IDE ask and records the pending developer approval; the
1568
+ // post-execution hook records the Allow, the sweep records a Deny.
1569
+ respond(false);
1570
+ return;
1571
+ }
1455
1572
  if (ctx.exitsProcess) {
1456
1573
  // Standalone hook (CI runner, container, daemon down): respond() ends
1457
1574
  // the process, so a fire-and-forget gate report would die in flight and
@@ -1483,6 +1600,13 @@ async function enforceActionPolicy(ctx) {
1483
1600
  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.`);
1484
1601
  return;
1485
1602
  }
1603
+ if (localVerdict.verdict === 'log') {
1604
+ // "Warn only": runs, developer told why, fleet records Warned. No gate
1605
+ // round trip — the verdict is final and the audit rides the spool.
1606
+ const warning = warnByPolicy(call, localVerdict, event, ctx.exitsProcess, 'local-first');
1607
+ respond(false, warning.userMsg, warning.agentMsg);
1608
+ return;
1609
+ }
1486
1610
  // require_approval, developer scope + human present → IDE-native ask (no org queue).
1487
1611
  if (tryDeveloperConfirmation({ event, respond, shadow }, call, localVerdict, payload, 'local-first'))
1488
1612
  return;
@@ -1564,6 +1688,13 @@ async function enforceActionPolicy(ctx) {
1564
1688
  const policyName = result.actionPolicy?.policyName;
1565
1689
  (0, hookIo_1.dbg)({ phase: 'policy_verdict', event, tool: call.toolName, decision: result.decision, allowed: result.allowed, policyName });
1566
1690
  if (result.allowed && result.decision === 'allow') {
1691
+ if (result.actionPolicy?.verdict === 'log') {
1692
+ // The server allowed it under a warn-only rule; surface the warning and
1693
+ // record Warned (the server only records blocks/approvals itself).
1694
+ const warning = warnByPolicy(call, result.actionPolicy, event, ctx.exitsProcess, 'gate');
1695
+ respond(false, warning.userMsg, warning.agentMsg);
1696
+ return;
1697
+ }
1567
1698
  respond(false);
1568
1699
  return;
1569
1700
  }
@@ -1099,9 +1099,12 @@ class McpGatewayServer {
1099
1099
  this.spoolLocalFinding(warning, toolName, operation, 'warn');
1100
1100
  process.stderr.write(`FullCourtDefense warning (${warning.itemId}): ${warning.reason}\n`);
1101
1101
  }
1102
- const localBlock = requestOutcome.blockingFinding;
1102
+ // The gateway is a stdio proxy with no developer prompt of its own — an
1103
+ // "Ask the developer" rule fails strict here (block), never a silent allow.
1104
+ const localBlock = requestOutcome.blockingFinding || requestOutcome.askFinding;
1103
1105
  if (localBlock) {
1104
1106
  const origin = localBlock.source === 'custom' ? 'org custom rule' : 'built-in Local Safety rule';
1107
+ const askNote = !requestOutcome.blockingFinding ? ' This rule asks the developer to confirm; the MCP gateway cannot prompt, so it blocks here (the IDE hook can ask).' : '';
1105
1108
  if (reportOnlyMode) {
1106
1109
  // MONITOR CONTRACT: a monitor/shadow machine (or one with no bundle
1107
1110
  // signal at all) must NEVER hard-block. Online, the server already
@@ -1114,7 +1117,7 @@ class McpGatewayServer {
1114
1117
  }
1115
1118
  else {
1116
1119
  this.spoolLocalFinding(localBlock, toolName, operation);
1117
- throw blockError(`${localBlock.reason} (${origin} "${localBlock.itemId}", ${localBlock.ruleId}: ${localBlock.evidence}) — logged to your org's console; admins manage rules under Shield → Local Safety. This is a security policy decision, not a transient error — do not retry.`);
1120
+ throw blockError(`${localBlock.reason} (${origin} "${localBlock.itemId}", ${localBlock.ruleId}: ${localBlock.evidence}) — logged to your org's console; admins manage rules under Shield → Local Safety.${askNote} This is a security policy decision, not a transient error — do not retry.`);
1118
1121
  }
1119
1122
  }
1120
1123
  // --- Machine-role session amount limits (pre-call: operation counts) ---
@@ -1213,7 +1216,15 @@ class McpGatewayServer {
1213
1216
  (0, notify_1.notifyOs)({ title: 'FullCourtDefense — blocked by policy', message: `${toolName} blocked${local.policyName ? ` (${local.policyName})` : ''}.` });
1214
1217
  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
1218
  }
1216
- if (local.verdict !== 'require_approval') {
1219
+ if (local.verdict === 'log') {
1220
+ // "Warn only": the call runs, the fleet records Warned (not a silent
1221
+ // allow). Final verdict — no gate round trip; the audit rides the spool.
1222
+ const reason = local.reason || `${toolName}: flagged by org Action Policy`;
1223
+ (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 });
1224
+ (0, telemetry_1.triggerFlush)(false);
1225
+ preflight = { allowed: true, decision: 'allow', operation: localOperation };
1226
+ }
1227
+ else if (local.verdict !== 'require_approval') {
1217
1228
  // Local allow: the server still records the action asynchronously; a
1218
1229
  // failed report spools (idempotent on eventId) for the next flush.
1219
1230
  // During a recently-down gate window, spool directly (no per-call
@@ -42,6 +42,7 @@ const crypto = __importStar(require("crypto"));
42
42
  const fs = __importStar(require("fs"));
43
43
  const os = __importStar(require("os"));
44
44
  const path = __importStar(require("path"));
45
+ const deterministicGuard_1 = require("./commands/deterministicGuard");
45
46
  const honeypot_1 = require("./honeypot");
46
47
  const CACHE_TTL_MS = 60_000;
47
48
  const CACHE_DIR = path.join(os.homedir(), '.fullcourtdefense');
@@ -103,7 +104,7 @@ function toRuleAction(value) {
103
104
  // Explicit 'block' is preserved (not treated as absent): warn-by-default
104
105
  // items (e.g. env_workspace) need an org's explicit block override to
105
106
  // survive parsing, otherwise the CLI's catalog default would resurrect warn.
106
- return value === 'warn' || value === 'mask' || value === 'block' ? value : undefined;
107
+ return (0, deterministicGuard_1.toLocalSafetyRuleAction)(value);
107
108
  }
108
109
  function toSnapshot(data) {
109
110
  if (!data || typeof data !== 'object')
package/dist/version.json CHANGED
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "1.27.1"
2
+ "version": "1.27.3"
3
3
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullcourtdefense-cli",
3
- "version": "1.27.1",
3
+ "version": "1.27.3",
4
4
  "description": "Full Court Defense CLI — security scanning for AI agents from your terminal",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -77,6 +77,7 @@
77
77
  "test:enroll-cache-reset": "npm run build && node scripts/test-enroll-cache-reset.js",
78
78
  "test:offline-policy": "npm run build && node scripts/test-offline-policy-enforcement.js",
79
79
  "test:developer-confirmation": "npm run build && node scripts/test-developer-confirmation.js",
80
+ "test:local-safety-ask": "npm run build && node scripts/test-local-safety-ask.js",
80
81
  "test:demo-actions": "npm run build && node scripts/test-demo-actions.js",
81
82
  "test:real-scenario": "npm run build && node scripts/test-real-scenario-drill.js",
82
83
  "test:blocking-approval": "npm run build && node scripts/test-blocking-approval-drill.js",