fullcourtdefense-cli 1.27.2 → 1.27.4

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)) {
@@ -524,10 +524,31 @@ function tryDeveloperConfirmation(ctx, call, verdict, payload, where) {
524
524
  }
525
525
  if (ctx.shadow)
526
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) {
527
548
  const { operation } = (0, actionPolicyEngine_1.inferToolContext)(call.toolName, call.toolArgs);
528
549
  const sessId = sessionId(payload);
529
- const remember = verdict.approval?.remember ?? 'none';
530
- const policyLabel = verdict.policyName ? ` (${verdict.policyName})` : '';
550
+ const policyLabel = source.policyName ? ` (${source.policyName})` : '';
551
+ const offlineEnforced = source.where !== 'local-first';
531
552
  // Sweep first: markers the human never answered are recorded as not approved.
532
553
  for (const expired of (0, devConfirm_1.sweepExpiredConfirmations)()) {
533
554
  (0, telemetry_1.spoolEvent)({
@@ -539,40 +560,44 @@ function tryDeveloperConfirmation(ctx, call, verdict, payload, where) {
539
560
  offlineEnforced: true,
540
561
  });
541
562
  }
542
- 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);
543
564
  if (remembered) {
544
- (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 });
545
566
  spoolCallEvent(call, {
546
567
  decision: 'allow',
547
568
  reason: `developer confirmed earlier (${remembered.remember} memory)${policyLabel}`,
548
569
  approvalScope: 'developer',
549
570
  approvedBy: 'developer',
550
- offlineEnforced: where === 'offline',
571
+ offlineEnforced,
551
572
  });
552
573
  (0, telemetry_1.triggerFlush)(false);
553
- 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'}.` };
554
575
  }
555
576
  const marker = (0, devConfirm_1.raiseDeveloperConfirmation)({
556
- event: ctx.event,
577
+ event,
557
578
  toolName: call.toolName,
558
579
  operation,
559
580
  argsDigest: (0, devConfirm_1.actionDigest)(call.toolArgs),
560
- policyId: verdict.policyId,
561
- policyName: verdict.policyName,
562
- matchedRule: verdict.matchedRule,
581
+ policyId: source.policyId,
582
+ policyName: source.policyName,
583
+ matchedRule: source.matchedRule,
563
584
  sessionId: sessId,
564
- remember,
585
+ remember: source.remember,
565
586
  });
566
- (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 });
567
588
  spoolCallEvent(call, {
568
589
  decision: 'approval',
569
- 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}` : ''}`,
570
591
  approvalScope: 'developer',
571
- offlineEnforced: where === 'offline',
592
+ offlineEnforced,
572
593
  });
573
594
  (0, telemetry_1.triggerFlush)(false);
574
- const reason = verdict.reason || `${call.toolName} needs your confirmation`;
575
- 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
+ };
576
601
  }
577
602
  /**
578
603
  * Post-execution hook: the action ran, so if we asked the developer about exactly this
@@ -657,14 +682,22 @@ function tryLocalPolicyEnforcement(ctx, call, detail, opts = {}) {
657
682
  (0, telemetry_1.triggerFlush)(true);
658
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.`);
659
684
  }
685
+ // Monitor machines land here by design (zero synchronous network calls), not
686
+ // because anything is down — never tell the developer the service is
687
+ // unreachable when it is simply not consulted.
688
+ const monitorPath = opts.countAsGateFailure === false;
660
689
  if (local.verdict === 'log') {
661
690
  // Warn-only rule, offline: still a real warning + Warned in the fleet.
662
691
  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).`);
692
+ ctx.respond(false, warning.userMsg, monitorPath
693
+ ? warning.agentMsg
694
+ : `${warning.agentMsg} (policy service unreachable — evaluated from the locally cached org policies).`);
664
695
  }
665
- spoolCallEvent(call, { decision: 'allow', reason: `local policy allow (offline): ${detail}`, offlineEnforced: true });
696
+ spoolCallEvent(call, { decision: 'allow', reason: `local policy allow (${monitorPath ? 'monitor' : 'offline'}): ${detail}`, offlineEnforced: true });
666
697
  (0, telemetry_1.triggerFlush)(false);
667
- ctx.respond(false, undefined, `FullCourtDefense: policy service unreachable — this ${ctx.event} was checked against the locally cached org policies and allowed.`);
698
+ ctx.respond(false, undefined, monitorPath
699
+ ? undefined
700
+ : `FullCourtDefense: policy service unreachable — this ${ctx.event} was checked against the locally cached org policies and allowed.`);
668
701
  }
669
702
  /** Per-event attribution that maps onto the existing Shield runtime headers (prompt path). */
670
703
  function attributionFor(event, p) {
@@ -1140,9 +1173,35 @@ async function hookCommandInner(args, config, io) {
1140
1173
  pendingLocalWarnings.push({ finding, toolName, operation });
1141
1174
  }
1142
1175
  };
1176
+ // A Local Safety rule set to "Ask" is held the same way: the IDE prompt is
1177
+ // raised only if the action is not blocked by anything evaluated later
1178
+ // (taint, org Action Policies) — a block always wins over an ask, and the
1179
+ // developer is never asked about an action that would be denied anyway.
1180
+ let pendingLocalAsk;
1181
+ const deferLocalAsk = (finding, call) => {
1182
+ pendingLocalAsk = pendingLocalAsk || { finding, call };
1183
+ };
1184
+ const hasPendingLocalAsk = () => Boolean(pendingLocalAsk);
1143
1185
  const respond = (blocked, userMsg, agentMsg) => {
1144
- const ask = blocked === 'ask' && event !== 'prompt';
1186
+ let ask = blocked === 'ask' && event !== 'prompt';
1145
1187
  const denied = blocked === true;
1188
+ if (!denied && !ask && pendingLocalAsk && event !== 'prompt') {
1189
+ const { finding, call } = pendingLocalAsk;
1190
+ pendingLocalAsk = undefined;
1191
+ const prepared = prepareDeveloperConfirmation(event, call, payload, {
1192
+ policyId: `local-safety:${finding.itemId}`,
1193
+ policyName: `Local Safety · ${finding.itemId}`,
1194
+ matchedRule: finding.itemId,
1195
+ remember: 'session',
1196
+ reason: finding.reason.replace(/[.\s]+$/, ''),
1197
+ where: 'local-safety',
1198
+ });
1199
+ if (prepared.outcome === 'ask') {
1200
+ ask = true;
1201
+ userMsg = prepared.userMsg;
1202
+ }
1203
+ agentMsg = [prepared.agentMsg, agentMsg].filter(Boolean).join(' ');
1204
+ }
1146
1205
  if (!denied && pendingLocalWarnings.length > 0) {
1147
1206
  spoolLocalWarnings(pendingLocalWarnings.map(w => w.finding), pendingLocalWarnings[0].toolName, pendingLocalWarnings[0].operation);
1148
1207
  }
@@ -1227,7 +1286,7 @@ async function hookCommandInner(args, config, io) {
1227
1286
  if (event === 'shell' || event === 'mcp' || event === 'file' || event === 'read') {
1228
1287
  await enforceActionPolicy({
1229
1288
  event, payload, apiUrl: apiUrl, shieldId: shieldId, shieldKey,
1230
- shadow, failClosed, timeoutMs, approvalMode, approvalTimeoutMs, approvalPollMs, respond, deferLocalWarnings,
1289
+ shadow, failClosed, timeoutMs, approvalMode, approvalTimeoutMs, approvalPollMs, respond, deferLocalWarnings, deferLocalAsk, hasPendingLocalAsk,
1231
1290
  effectivePolicyHash, localPolicies, client, exitsProcess: io.exitsProcess,
1232
1291
  });
1233
1292
  return;
@@ -1252,7 +1311,9 @@ async function hookCommandInner(args, config, io) {
1252
1311
  });
1253
1312
  const promptOutcome = (0, deterministicGuard_1.resolveDeterministicOutcome)(opts => (0, deterministicGuard_1.scanDeterministicPrompt)(text, opts), (0, localSafetySnapshot_1.snapshotToScanOptions)(snapshot));
1254
1313
  deferLocalWarnings(promptOutcome.warnings, 'prompt', 'prompt');
1255
- const localBlock = promptOutcome.blockingFinding;
1314
+ // Prompt events have no IDE permission prompt to hand an "ask" to — an
1315
+ // ask-action rule fails strict here (block), never silently allows.
1316
+ const localBlock = promptOutcome.blockingFinding || promptOutcome.askFinding;
1256
1317
  if (localBlock) {
1257
1318
  (0, hookIo_1.dbg)({ phase: 'local_deterministic_prompt_block', event, ruleId: localBlock.ruleId, category: localBlock.category });
1258
1319
  if (shadow) {
@@ -1356,7 +1417,7 @@ async function runTaintApproveOnce(ctx, event, call, sessId, taintFinding) {
1356
1417
  return false;
1357
1418
  }
1358
1419
  async function enforceActionPolicy(ctx) {
1359
- const { event, payload, apiUrl, shieldId, shieldKey, shadow, timeoutMs, respond, deferLocalWarnings, effectivePolicyHash } = ctx;
1420
+ const { event, payload, apiUrl, shieldId, shieldKey, shadow, timeoutMs, respond, deferLocalWarnings, deferLocalAsk, hasPendingLocalAsk, effectivePolicyHash } = ctx;
1360
1421
  const call = buildToolCall(event, payload);
1361
1422
  if (!call) {
1362
1423
  respond(false);
@@ -1390,6 +1451,26 @@ async function enforceActionPolicy(ctx) {
1390
1451
  respond(true, localBlockUserMessage(localBlock), `FullCourtDefense blocked this ${event} locally (${localBlock.ruleId}). Do not retry.`);
1391
1452
  return;
1392
1453
  }
1454
+ // Local Safety rule set to "Ask the developer" (no block rule matched).
1455
+ // human at the keyboard -> held until the verdict; the IDE's own Allow / Deny
1456
+ // prompt is raised unless something later blocks.
1457
+ // monitor / shadow -> recorded as a would-ask warning, the action runs.
1458
+ // no human (CI, ephemeral, FCD_HUMAN_PRESENT=0) -> block, never a silent allow.
1459
+ const localAsk = toolOutcome.askFinding;
1460
+ if (localAsk) {
1461
+ (0, hookIo_1.dbg)({ phase: 'local_deterministic_ask', event, tool: call.toolName, ruleId: localAsk.ruleId, humanPresent: (0, devConfirm_1.isHumanPresent)(), shadow });
1462
+ if (shadow) {
1463
+ spoolLocalFinding({ finding: localAsk, toolName: call.toolName, operation: event, decision: 'warn' });
1464
+ }
1465
+ else if (!(0, devConfirm_1.isHumanPresent)()) {
1466
+ spoolLocalFinding({ finding: localAsk, toolName: call.toolName, operation: event });
1467
+ 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.`);
1468
+ return;
1469
+ }
1470
+ else {
1471
+ deferLocalAsk?.(localAsk, call);
1472
+ }
1473
+ }
1393
1474
  // --- Taint-guard self-protection (always enforced before anything taint) ---
1394
1475
  // The agent must never resolve approve-once requests or touch the taint
1395
1476
  // state directory; otherwise a compromised agent could approve itself.
@@ -1476,13 +1557,26 @@ async function enforceActionPolicy(ctx) {
1476
1557
  // (a human must resolve it in the console) — falls through to
1477
1558
  // the synchronous gate call below.
1478
1559
  // No cached policies (fresh install, bundle fetch failed) also falls through.
1479
- if (ctx.localPolicies && ctx.localPolicies.length > 0) {
1560
+ // A pending Local Safety "Ask the developer" also resolves here when the
1561
+ // bundle is present but the org has no Action Policies yet: the cached
1562
+ // (hash-covered) policy set is the same one the gate would evaluate, so
1563
+ // "nothing matched" is final and the IDE prompt is raised without a gate
1564
+ // round trip — the same trust the local-first allow below already extends.
1565
+ const localAskPending = Boolean(hasPendingLocalAsk?.());
1566
+ if (ctx.localPolicies && (ctx.localPolicies.length > 0 || localAskPending)) {
1480
1567
  const { operation: localOp, context: localCtx } = (0, actionPolicyEngine_1.inferToolContext)(call.toolName, call.toolArgs);
1481
1568
  localCtx.toolName = call.toolName;
1482
1569
  (0, machineIdentity_1.stampMachineIdentityContext)(localCtx, developerId());
1483
1570
  const localVerdict = (0, actionPolicyEngine_1.evaluateActionPolicies)(ctx.localPolicies, call.toolName, localOp, localCtx, (0, actionPolicyEngine_1.deriveToolOperations)(call.toolName));
1484
1571
  (0, hookIo_1.dbg)({ phase: 'policy_local_first', event, tool: call.toolName, operation: localOp, verdict: localVerdict.verdict, policyName: localVerdict.policyName });
1485
1572
  if (localVerdict.verdict === 'allow') {
1573
+ if (localAskPending) {
1574
+ // No "Allowed" row for an action that has not run yet: respond() turns
1575
+ // this into the IDE ask and records the pending developer approval; the
1576
+ // post-execution hook records the Allow, the sweep records a Deny.
1577
+ respond(false);
1578
+ return;
1579
+ }
1486
1580
  if (ctx.exitsProcess) {
1487
1581
  // Standalone hook (CI runner, container, daemon down): respond() ends
1488
1582
  // the process, so a fire-and-forget gate report would die in flight and
@@ -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) ---
@@ -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.2"
2
+ "version": "1.27.4"
3
3
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullcourtdefense-cli",
3
- "version": "1.27.2",
3
+ "version": "1.27.4",
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",