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
|
|
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
|
@@ -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
|
|
504
|
-
const
|
|
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:
|
|
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
|
|
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
|
|
571
|
+
offlineEnforced,
|
|
525
572
|
});
|
|
526
573
|
(0, telemetry_1.triggerFlush)(false);
|
|
527
|
-
|
|
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
|
|
577
|
+
event,
|
|
531
578
|
toolName: call.toolName,
|
|
532
579
|
operation,
|
|
533
580
|
argsDigest: (0, devConfirm_1.actionDigest)(call.toolArgs),
|
|
534
|
-
policyId:
|
|
535
|
-
policyName:
|
|
536
|
-
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
|
|
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}${
|
|
590
|
+
reason: `developer confirmation requested via IDE prompt${policyLabel}${source.reason ? `: ${source.reason}` : ''}`,
|
|
544
591
|
approvalScope: 'developer',
|
|
545
|
-
offlineEnforced
|
|
592
|
+
offlineEnforced,
|
|
546
593
|
});
|
|
547
594
|
(0, telemetry_1.triggerFlush)(false);
|
|
548
|
-
const reason =
|
|
549
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) ---
|
|
@@ -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
|
|
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
|
|
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",
|