fullcourtdefense-cli 1.27.2 → 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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)) {
|
package/dist/commands/hook.js
CHANGED
|
@@ -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
|
|
530
|
-
const
|
|
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:
|
|
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
|
|
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
|
|
571
|
+
offlineEnforced,
|
|
551
572
|
});
|
|
552
573
|
(0, telemetry_1.triggerFlush)(false);
|
|
553
|
-
|
|
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
|
|
577
|
+
event,
|
|
557
578
|
toolName: call.toolName,
|
|
558
579
|
operation,
|
|
559
580
|
argsDigest: (0, devConfirm_1.actionDigest)(call.toolArgs),
|
|
560
|
-
policyId:
|
|
561
|
-
policyName:
|
|
562
|
-
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
|
|
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}${
|
|
590
|
+
reason: `developer confirmation requested via IDE prompt${policyLabel}${source.reason ? `: ${source.reason}` : ''}`,
|
|
570
591
|
approvalScope: 'developer',
|
|
571
|
-
offlineEnforced
|
|
592
|
+
offlineEnforced,
|
|
572
593
|
});
|
|
573
594
|
(0, telemetry_1.triggerFlush)(false);
|
|
574
|
-
const reason =
|
|
575
|
-
|
|
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
|
|
@@ -1140,9 +1165,35 @@ async function hookCommandInner(args, config, io) {
|
|
|
1140
1165
|
pendingLocalWarnings.push({ finding, toolName, operation });
|
|
1141
1166
|
}
|
|
1142
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);
|
|
1143
1177
|
const respond = (blocked, userMsg, agentMsg) => {
|
|
1144
|
-
|
|
1178
|
+
let ask = blocked === 'ask' && event !== 'prompt';
|
|
1145
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
|
+
}
|
|
1146
1197
|
if (!denied && pendingLocalWarnings.length > 0) {
|
|
1147
1198
|
spoolLocalWarnings(pendingLocalWarnings.map(w => w.finding), pendingLocalWarnings[0].toolName, pendingLocalWarnings[0].operation);
|
|
1148
1199
|
}
|
|
@@ -1227,7 +1278,7 @@ async function hookCommandInner(args, config, io) {
|
|
|
1227
1278
|
if (event === 'shell' || event === 'mcp' || event === 'file' || event === 'read') {
|
|
1228
1279
|
await enforceActionPolicy({
|
|
1229
1280
|
event, payload, apiUrl: apiUrl, shieldId: shieldId, shieldKey,
|
|
1230
|
-
shadow, failClosed, timeoutMs, approvalMode, approvalTimeoutMs, approvalPollMs, respond, deferLocalWarnings,
|
|
1281
|
+
shadow, failClosed, timeoutMs, approvalMode, approvalTimeoutMs, approvalPollMs, respond, deferLocalWarnings, deferLocalAsk, hasPendingLocalAsk,
|
|
1231
1282
|
effectivePolicyHash, localPolicies, client, exitsProcess: io.exitsProcess,
|
|
1232
1283
|
});
|
|
1233
1284
|
return;
|
|
@@ -1252,7 +1303,9 @@ async function hookCommandInner(args, config, io) {
|
|
|
1252
1303
|
});
|
|
1253
1304
|
const promptOutcome = (0, deterministicGuard_1.resolveDeterministicOutcome)(opts => (0, deterministicGuard_1.scanDeterministicPrompt)(text, opts), (0, localSafetySnapshot_1.snapshotToScanOptions)(snapshot));
|
|
1254
1305
|
deferLocalWarnings(promptOutcome.warnings, 'prompt', 'prompt');
|
|
1255
|
-
|
|
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;
|
|
1256
1309
|
if (localBlock) {
|
|
1257
1310
|
(0, hookIo_1.dbg)({ phase: 'local_deterministic_prompt_block', event, ruleId: localBlock.ruleId, category: localBlock.category });
|
|
1258
1311
|
if (shadow) {
|
|
@@ -1356,7 +1409,7 @@ async function runTaintApproveOnce(ctx, event, call, sessId, taintFinding) {
|
|
|
1356
1409
|
return false;
|
|
1357
1410
|
}
|
|
1358
1411
|
async function enforceActionPolicy(ctx) {
|
|
1359
|
-
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;
|
|
1360
1413
|
const call = buildToolCall(event, payload);
|
|
1361
1414
|
if (!call) {
|
|
1362
1415
|
respond(false);
|
|
@@ -1390,6 +1443,26 @@ async function enforceActionPolicy(ctx) {
|
|
|
1390
1443
|
respond(true, localBlockUserMessage(localBlock), `FullCourtDefense blocked this ${event} locally (${localBlock.ruleId}). Do not retry.`);
|
|
1391
1444
|
return;
|
|
1392
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
|
+
}
|
|
1393
1466
|
// --- Taint-guard self-protection (always enforced before anything taint) ---
|
|
1394
1467
|
// The agent must never resolve approve-once requests or touch the taint
|
|
1395
1468
|
// state directory; otherwise a compromised agent could approve itself.
|
|
@@ -1476,13 +1549,26 @@ async function enforceActionPolicy(ctx) {
|
|
|
1476
1549
|
// (a human must resolve it in the console) — falls through to
|
|
1477
1550
|
// the synchronous gate call below.
|
|
1478
1551
|
// No cached policies (fresh install, bundle fetch failed) also falls through.
|
|
1479
|
-
|
|
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)) {
|
|
1480
1559
|
const { operation: localOp, context: localCtx } = (0, actionPolicyEngine_1.inferToolContext)(call.toolName, call.toolArgs);
|
|
1481
1560
|
localCtx.toolName = call.toolName;
|
|
1482
1561
|
(0, machineIdentity_1.stampMachineIdentityContext)(localCtx, developerId());
|
|
1483
1562
|
const localVerdict = (0, actionPolicyEngine_1.evaluateActionPolicies)(ctx.localPolicies, call.toolName, localOp, localCtx, (0, actionPolicyEngine_1.deriveToolOperations)(call.toolName));
|
|
1484
1563
|
(0, hookIo_1.dbg)({ phase: 'policy_local_first', event, tool: call.toolName, operation: localOp, verdict: localVerdict.verdict, policyName: localVerdict.policyName });
|
|
1485
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
|
+
}
|
|
1486
1572
|
if (ctx.exitsProcess) {
|
|
1487
1573
|
// Standalone hook (CI runner, container, daemon down): respond() ends
|
|
1488
1574
|
// 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
|
-
|
|
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
|
|
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
|
|
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fullcourtdefense-cli",
|
|
3
|
-
"version": "1.27.
|
|
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",
|