fullcourtdefense-cli 1.28.0 → 1.30.0
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.
- package/dist/actionPolicyEngine.d.ts +22 -0
- package/dist/actionPolicyEngine.js +446 -22
- package/dist/blockExplanation.d.ts +95 -0
- package/dist/blockExplanation.js +240 -0
- package/dist/commands/cmdGuard.d.ts +9 -0
- package/dist/commands/cmdGuard.js +27 -3
- package/dist/commands/deterministicGuard.d.ts +35 -0
- package/dist/commands/deterministicGuard.js +49 -35
- package/dist/commands/hook.js +116 -21
- package/dist/commands/mcpGateway.js +53 -14
- package/dist/commands/posixShellGuard.d.ts +12 -0
- package/dist/commands/posixShellGuard.js +8 -0
- package/dist/commands/shellGuard.d.ts +12 -0
- package/dist/commands/shellGuard.js +102 -13
- package/dist/notify.d.ts +2 -0
- package/dist/notify.js +5 -0
- package/dist/version.json +1 -1
- package/package.json +6 -2
package/dist/commands/hook.js
CHANGED
|
@@ -48,6 +48,7 @@ const deterministicGuard_1 = require("./deterministicGuard");
|
|
|
48
48
|
const localSafetySnapshot_1 = require("../localSafetySnapshot");
|
|
49
49
|
const taintLedger_1 = require("./taintLedger");
|
|
50
50
|
const notify_1 = require("../notify");
|
|
51
|
+
const blockExplanation_1 = require("../blockExplanation");
|
|
51
52
|
const distress_1 = require("../distress");
|
|
52
53
|
const describeError_1 = require("../describeError");
|
|
53
54
|
const policyGateHealth_1 = require("../policyGateHealth");
|
|
@@ -486,7 +487,9 @@ function respondDegraded(ctx, detail, toolName, kind = 'unreachable', opts = {})
|
|
|
486
487
|
(0, distress_1.reportDistress)('hook', authRejected ? distress_1.DISTRESS.AUTH_BROKEN : failure === 'rejected' ? distress_1.DISTRESS.BACKEND_REJECTED : distress_1.DISTRESS.HOOK_FAIL_CLOSED, `Blocking ${ctx.event} actions: ${cause} ${health.consecutiveFailures} times in a row since ${health.firstFailureAt || 'now'} (${detail})`);
|
|
487
488
|
(0, telemetry_1.spoolEvent)({ decision: 'block', toolName, reason: `fail-closed: ${detail}`, offlineEnforced: true });
|
|
488
489
|
(0, telemetry_1.triggerFlush)(true);
|
|
489
|
-
|
|
490
|
+
const failClosedWhy = `${cause} (${health.consecutiveFailures}x in a row) and this machine is set to fail-closed. ${detail} ${advice}`;
|
|
491
|
+
const explained = explainGuardStop('fail_closed', ctx.event, { toolName: toolName || 'unknown', toolArgs: {} }, failClosedWhy);
|
|
492
|
+
ctx.respond(true, explained.userMessage, `FullCourtDefense could not verify this ${ctx.event} against your org policies (${detail}) and the failure is persistent, so fail-closed mode is blocking. ${advice} ${explained.agentMessage}`);
|
|
490
493
|
}
|
|
491
494
|
// Grace window: transient failure on a fail-closed machine — allow this
|
|
492
495
|
// action, but record distress so the episode is visible in the console.
|
|
@@ -531,6 +534,7 @@ function tryDeveloperConfirmation(ctx, call, verdict, payload, where) {
|
|
|
531
534
|
remember: verdict.approval?.remember ?? 'none',
|
|
532
535
|
reason: verdict.reason,
|
|
533
536
|
where,
|
|
537
|
+
policies: ctx.localPolicies,
|
|
534
538
|
});
|
|
535
539
|
if (prepared.outcome === 'allow')
|
|
536
540
|
ctx.respond(false, undefined, prepared.agentMsg);
|
|
@@ -593,10 +597,15 @@ function prepareDeveloperConfirmation(event, call, payload, source) {
|
|
|
593
597
|
});
|
|
594
598
|
(0, telemetry_1.triggerFlush)(false);
|
|
595
599
|
const reason = source.reason || `${call.toolName} needs your confirmation`;
|
|
600
|
+
// Name the rule in the prompt itself: a developer deciding y/n must see WHICH
|
|
601
|
+
// rule is asking and where to change it — same builder as every block.
|
|
602
|
+
const explained = source.where === 'local-safety' && source.localFinding
|
|
603
|
+
? explainLocalSafetyStop('ask', event, call, source.localFinding)
|
|
604
|
+
: explainPolicyStop('ask', event, call, source, source.policies);
|
|
596
605
|
return {
|
|
597
606
|
outcome: 'ask',
|
|
598
|
-
userMsg:
|
|
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
|
|
607
|
+
userMsg: `${explained.userMessage}\nAllow this ${event}?`,
|
|
608
|
+
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. ${explained.agentMessage}`,
|
|
600
609
|
};
|
|
601
610
|
}
|
|
602
611
|
/**
|
|
@@ -680,7 +689,8 @@ function tryLocalPolicyEnforcement(ctx, call, detail, opts = {}) {
|
|
|
680
689
|
: '';
|
|
681
690
|
spoolCallEvent(call, { decision: 'block', reason: `local policy (offline): ${reason}`, offlineEnforced: true });
|
|
682
691
|
(0, telemetry_1.triggerFlush)(true);
|
|
683
|
-
ctx.
|
|
692
|
+
const explained = explainPolicyStop('block', ctx.event, call, local, policies, `Enforced from the locally cached policy while the policy service is unreachable (${detail}).${approvalNote}`);
|
|
693
|
+
ctx.respond(true, explained.userMessage, `${explained.agentMessage} Do not retry until the connection is restored or the policy allows it.`);
|
|
684
694
|
}
|
|
685
695
|
// Monitor machines land here by design (zero synchronous network calls), not
|
|
686
696
|
// because anything is down — never tell the developer the service is
|
|
@@ -800,10 +810,85 @@ function machineMetadata(client = 'cursor') {
|
|
|
800
810
|
* see/manage it" in one glance — a bare reason string left developers unable
|
|
801
811
|
* to tell a built-in rule from an org policy or to find the console entry.
|
|
802
812
|
*/
|
|
803
|
-
function localBlockUserMessage(finding) {
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
813
|
+
function localBlockUserMessage(finding, event = 'shell', call, note) {
|
|
814
|
+
return explainLocalSafetyStop('block', event, call || { toolName: 'prompt', toolArgs: {} }, finding, note).userMessage;
|
|
815
|
+
}
|
|
816
|
+
/** Fleet machine id for deep links — best-effort, never throws. */
|
|
817
|
+
function explanationMachineId() {
|
|
818
|
+
try {
|
|
819
|
+
return (0, machineIdentity_1.getMachineIdentity)().machineId;
|
|
820
|
+
}
|
|
821
|
+
catch {
|
|
822
|
+
return undefined;
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
/**
|
|
826
|
+
* Every stop the hook emits is explained through ONE builder (blockExplanation.ts):
|
|
827
|
+
* what was stopped, which rule BY NAME and where it came from, why it matched,
|
|
828
|
+
* and the exact console page that removes the block. Fixed templates, no prose.
|
|
829
|
+
*/
|
|
830
|
+
function explainPolicyStop(outcome, event, call, verdict, policies, note) {
|
|
831
|
+
const policy = verdict.policyId ? policies?.find(p => p.id === verdict.policyId) : undefined;
|
|
832
|
+
let facts = {};
|
|
833
|
+
let operation;
|
|
834
|
+
try {
|
|
835
|
+
const inferred = (0, actionPolicyEngine_1.inferToolContext)(call.toolName, call.toolArgs);
|
|
836
|
+
facts = inferred.context;
|
|
837
|
+
operation = inferred.operation;
|
|
838
|
+
}
|
|
839
|
+
catch { /* explanation must never fail the hook */ }
|
|
840
|
+
return (0, blockExplanation_1.buildBlockExplanation)({
|
|
841
|
+
outcome,
|
|
842
|
+
event,
|
|
843
|
+
toolName: call.toolName,
|
|
844
|
+
toolArgs: call.toolArgs,
|
|
845
|
+
source: {
|
|
846
|
+
kind: 'policy',
|
|
847
|
+
policyName: verdict.policyName,
|
|
848
|
+
policyId: verdict.policyId,
|
|
849
|
+
matchedRule: verdict.matchedRule,
|
|
850
|
+
scope: policy?.scope,
|
|
851
|
+
facts,
|
|
852
|
+
operation,
|
|
853
|
+
reason: verdict.reason,
|
|
854
|
+
},
|
|
855
|
+
consoleUrl: (0, notify_1.consoleUrl)(),
|
|
856
|
+
machineId: explanationMachineId(),
|
|
857
|
+
developerName: developerId(),
|
|
858
|
+
note,
|
|
859
|
+
});
|
|
860
|
+
}
|
|
861
|
+
function explainLocalSafetyStop(outcome, event, call, finding, note) {
|
|
862
|
+
return (0, blockExplanation_1.buildBlockExplanation)({
|
|
863
|
+
outcome,
|
|
864
|
+
event,
|
|
865
|
+
toolName: call.toolName,
|
|
866
|
+
toolArgs: call.toolArgs,
|
|
867
|
+
source: {
|
|
868
|
+
kind: 'local_safety',
|
|
869
|
+
itemId: finding.itemId,
|
|
870
|
+
category: finding.category,
|
|
871
|
+
source: finding.source,
|
|
872
|
+
explanation: finding.explanation,
|
|
873
|
+
reason: finding.reason,
|
|
874
|
+
},
|
|
875
|
+
consoleUrl: (0, notify_1.consoleUrl)(),
|
|
876
|
+
machineId: explanationMachineId(),
|
|
877
|
+
developerName: developerId(),
|
|
878
|
+
note,
|
|
879
|
+
});
|
|
880
|
+
}
|
|
881
|
+
function explainGuardStop(kind, event, call, reason, ruleId) {
|
|
882
|
+
return (0, blockExplanation_1.buildBlockExplanation)({
|
|
883
|
+
outcome: 'block',
|
|
884
|
+
event,
|
|
885
|
+
toolName: call.toolName,
|
|
886
|
+
toolArgs: call.toolArgs,
|
|
887
|
+
source: { kind, ruleId, reason },
|
|
888
|
+
consoleUrl: (0, notify_1.consoleUrl)(),
|
|
889
|
+
machineId: explanationMachineId(),
|
|
890
|
+
developerName: developerId(),
|
|
891
|
+
});
|
|
807
892
|
}
|
|
808
893
|
function spoolLocalFinding(input) {
|
|
809
894
|
(0, telemetry_1.spoolEvent)({
|
|
@@ -1195,6 +1280,7 @@ async function hookCommandInner(args, config, io) {
|
|
|
1195
1280
|
remember: 'session',
|
|
1196
1281
|
reason: finding.reason.replace(/[.\s]+$/, ''),
|
|
1197
1282
|
where: 'local-safety',
|
|
1283
|
+
localFinding: finding,
|
|
1198
1284
|
});
|
|
1199
1285
|
if (prepared.outcome === 'ask') {
|
|
1200
1286
|
ask = true;
|
|
@@ -1323,7 +1409,8 @@ async function hookCommandInner(args, config, io) {
|
|
|
1323
1409
|
return;
|
|
1324
1410
|
}
|
|
1325
1411
|
spoolLocalFinding({ finding: localBlock, toolName: 'prompt', operation: 'prompt' });
|
|
1326
|
-
|
|
1412
|
+
const explained = explainLocalSafetyStop('block', 'prompt', { toolName: 'prompt', toolArgs: {} }, localBlock);
|
|
1413
|
+
respond(true, explained.userMessage, `FullCourtDefense blocked this prompt locally (${localBlock.ruleId}). Do not retry. ${explained.agentMessage}`);
|
|
1327
1414
|
return;
|
|
1328
1415
|
}
|
|
1329
1416
|
if (localOnly) {
|
|
@@ -1357,7 +1444,8 @@ async function runTaintApproveOnce(ctx, event, call, sessId, taintFinding) {
|
|
|
1357
1444
|
const hardBlock = () => {
|
|
1358
1445
|
spoolCallEvent(call, { decision: 'block', reason: taintFinding.reason, ruleId: taintFinding.ruleId, offlineEnforced: true });
|
|
1359
1446
|
(0, telemetry_1.triggerFlush)(true);
|
|
1360
|
-
|
|
1447
|
+
const explained = explainGuardStop('taint', event, call, taintFinding.reason, taintFinding.ruleId);
|
|
1448
|
+
respond(true, explained.userMessage, `FullCourtDefense blocked this ${event} locally (${taintFinding.ruleId}): ${taintFinding.reason} Do not retry. ${explained.agentMessage}`);
|
|
1361
1449
|
return false;
|
|
1362
1450
|
};
|
|
1363
1451
|
const approveOnceEnabled = process.env.FCD_TAINT_APPROVE_ONCE_DISABLED !== 'true'
|
|
@@ -1410,10 +1498,11 @@ async function runTaintApproveOnce(ctx, event, call, sessId, taintFinding) {
|
|
|
1410
1498
|
offlineEnforced: true,
|
|
1411
1499
|
});
|
|
1412
1500
|
(0, telemetry_1.triggerFlush)(true);
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1501
|
+
const outcomeNote = outcome === 'denied'
|
|
1502
|
+
? 'You denied the approve-once request.'
|
|
1503
|
+
: 'The approve-once request expired with no decision. If you want this action, re-run it and approve from your own terminal (fullcourtdefense approve).';
|
|
1504
|
+
const explained = explainGuardStop('taint', event, call, `${taintFinding.reason} ${outcomeNote}`, taintFinding.ruleId);
|
|
1505
|
+
respond(true, explained.userMessage, `FullCourtDefense blocked this ${event} locally (${taintFinding.ruleId}): ${taintFinding.reason} The developer was asked and did not approve. Do not retry. ${explained.agentMessage}`);
|
|
1417
1506
|
return false;
|
|
1418
1507
|
}
|
|
1419
1508
|
async function enforceActionPolicy(ctx) {
|
|
@@ -1448,7 +1537,8 @@ async function enforceActionPolicy(ctx) {
|
|
|
1448
1537
|
return;
|
|
1449
1538
|
}
|
|
1450
1539
|
spoolLocalFinding({ finding: localBlock, toolName: call.toolName, operation: event });
|
|
1451
|
-
|
|
1540
|
+
const explained = explainLocalSafetyStop('block', event, call, localBlock);
|
|
1541
|
+
respond(true, explained.userMessage, `FullCourtDefense blocked this ${event} locally (${localBlock.ruleId}). Do not retry. ${explained.agentMessage}`);
|
|
1452
1542
|
return;
|
|
1453
1543
|
}
|
|
1454
1544
|
// Local Safety rule set to "Ask the developer" (no block rule matched).
|
|
@@ -1464,7 +1554,8 @@ async function enforceActionPolicy(ctx) {
|
|
|
1464
1554
|
}
|
|
1465
1555
|
else if (!(0, devConfirm_1.isHumanPresent)()) {
|
|
1466
1556
|
spoolLocalFinding({ finding: localAsk, toolName: call.toolName, operation: event });
|
|
1467
|
-
|
|
1557
|
+
const explained = explainLocalSafetyStop('block', event, call, localAsk, 'This rule asks the developer to confirm, but no developer is present on this machine (CI / ephemeral), so it is blocked.');
|
|
1558
|
+
respond(true, explained.userMessage, `FullCourtDefense blocked this ${event} locally (${localAsk.ruleId}): the rule requires the developer's confirmation and no human is present here. Do not retry. ${explained.agentMessage}`);
|
|
1468
1559
|
return;
|
|
1469
1560
|
}
|
|
1470
1561
|
else {
|
|
@@ -1483,7 +1574,8 @@ async function enforceActionPolicy(ctx) {
|
|
|
1483
1574
|
}
|
|
1484
1575
|
spoolCallEvent(call, { decision: 'block', reason: selfApproval.reason, ruleId: 'local-taint-self-approval', offlineEnforced: true });
|
|
1485
1576
|
(0, telemetry_1.triggerFlush)(true);
|
|
1486
|
-
|
|
1577
|
+
const explained = explainGuardStop('self_protection', event, call, selfApproval.reason, 'local-taint-self-approval');
|
|
1578
|
+
respond(true, explained.userMessage, `FullCourtDefense blocked this ${event} locally (local-taint-self-approval): ${selfApproval.reason} Do not retry. ${explained.agentMessage}`);
|
|
1487
1579
|
return;
|
|
1488
1580
|
}
|
|
1489
1581
|
// --- Deterministic taint tracking (local, no backend) ---
|
|
@@ -1605,7 +1697,8 @@ async function enforceActionPolicy(ctx) {
|
|
|
1605
1697
|
const reason = localVerdict.reason || `${call.toolName}: blocked by org Action Policy`;
|
|
1606
1698
|
spoolCallEvent(call, { decision: 'block', reason: `local policy (block mode, local-first): ${reason}`, offlineEnforced: true });
|
|
1607
1699
|
(0, telemetry_1.triggerFlush)(true);
|
|
1608
|
-
|
|
1700
|
+
const explained = explainPolicyStop('block', event, call, localVerdict, ctx.localPolicies);
|
|
1701
|
+
respond(true, explained.userMessage, explained.agentMessage);
|
|
1609
1702
|
return;
|
|
1610
1703
|
}
|
|
1611
1704
|
if (localVerdict.verdict === 'log') {
|
|
@@ -1616,7 +1709,7 @@ async function enforceActionPolicy(ctx) {
|
|
|
1616
1709
|
return;
|
|
1617
1710
|
}
|
|
1618
1711
|
// require_approval, developer scope + human present → IDE-native ask (no org queue).
|
|
1619
|
-
if (tryDeveloperConfirmation({ event, respond, shadow }, call, localVerdict, payload, 'local-first'))
|
|
1712
|
+
if (tryDeveloperConfirmation({ event, respond, shadow, localPolicies: ctx.localPolicies }, call, localVerdict, payload, 'local-first'))
|
|
1620
1713
|
return;
|
|
1621
1714
|
// require_approval (org scope) → synchronous gate below creates the approval action.
|
|
1622
1715
|
}
|
|
@@ -1719,7 +1812,8 @@ async function enforceActionPolicy(ctx) {
|
|
|
1719
1812
|
message: `${call.toolName} needs human approval${policyName ? ` (${policyName})` : ''}.`,
|
|
1720
1813
|
url: queueUrl,
|
|
1721
1814
|
});
|
|
1722
|
-
|
|
1815
|
+
const explained = explainPolicyStop('approval', event, call, { policyName, policyId: result.actionPolicy?.policyId, matchedRule: result.actionPolicy?.matchedRule, reason }, ctx.localPolicies, `Approve at ${queueUrl}`);
|
|
1816
|
+
respond(true, explained.userMessage, `This ${event} requires human approval per Action Policy. Stop and ask the developer to approve it in the FullCourtDefense console: ${queueUrl}. ${explained.agentMessage}`);
|
|
1723
1817
|
return;
|
|
1724
1818
|
}
|
|
1725
1819
|
// Make the wait visible on the machine: the hook process has no UI and its
|
|
@@ -1752,7 +1846,8 @@ async function enforceActionPolicy(ctx) {
|
|
|
1752
1846
|
respond(false, undefined, `[FullCourtDefense shadow] would block ${call.toolName}${policyName ? ` (${policyName})` : ''}: ${reason}`);
|
|
1753
1847
|
return;
|
|
1754
1848
|
}
|
|
1755
|
-
|
|
1849
|
+
const explained = explainPolicyStop(result.decision === 'mask' ? 'mask' : 'block', event, call, { policyName, policyId: result.actionPolicy?.policyId, matchedRule: result.actionPolicy?.matchedRule, reason }, ctx.localPolicies);
|
|
1850
|
+
respond(true, explained.userMessage, explained.agentMessage);
|
|
1756
1851
|
}
|
|
1757
1852
|
async function waitForApproval(input) {
|
|
1758
1853
|
const headers = { 'Content-Type': 'application/json' };
|
|
@@ -62,6 +62,7 @@ const runtimeConfig_1 = require("../runtimeConfig");
|
|
|
62
62
|
const sessionLimits_1 = require("../sessionLimits");
|
|
63
63
|
const telemetry_1 = require("../telemetry");
|
|
64
64
|
const notify_1 = require("../notify");
|
|
65
|
+
const blockExplanation_1 = require("../blockExplanation");
|
|
65
66
|
const distress_1 = require("../distress");
|
|
66
67
|
const policyGateHealth_1 = require("../policyGateHealth");
|
|
67
68
|
const actionPolicyEngine_1 = require("../actionPolicyEngine");
|
|
@@ -869,6 +870,52 @@ class AgentGuardApi {
|
|
|
869
870
|
* unexpected errors FAIL OPEN — the guard's own bug must never break the
|
|
870
871
|
* developer's tool call.
|
|
871
872
|
*/
|
|
873
|
+
/**
|
|
874
|
+
* Same explanation the IDE hook gives: what / which rule by name / why / how
|
|
875
|
+
* to remove it. The MCP client shows the error text to the developer, so the
|
|
876
|
+
* multi-line user message IS the block text.
|
|
877
|
+
*/
|
|
878
|
+
function explainGatewayStop(input) {
|
|
879
|
+
let machineId;
|
|
880
|
+
try {
|
|
881
|
+
machineId = (0, machineIdentity_1.getMachineIdentity)().machineId;
|
|
882
|
+
}
|
|
883
|
+
catch { /* ignore */ }
|
|
884
|
+
return (0, blockExplanation_1.buildBlockExplanation)({
|
|
885
|
+
outcome: input.outcome,
|
|
886
|
+
event: 'mcp',
|
|
887
|
+
toolName: input.toolName,
|
|
888
|
+
toolArgs: (input.toolArgs && typeof input.toolArgs === 'object' ? input.toolArgs : {}),
|
|
889
|
+
source: input.source,
|
|
890
|
+
consoleUrl: (0, notify_1.consoleUrl)(),
|
|
891
|
+
machineId,
|
|
892
|
+
developerName: (0, localSafetySnapshot_1.hookSnapshotIdentity)().developerName,
|
|
893
|
+
note: input.note,
|
|
894
|
+
});
|
|
895
|
+
}
|
|
896
|
+
function explainGatewayLocalSafety(outcome, toolName, toolArgs, finding, note) {
|
|
897
|
+
const explained = explainGatewayStop({
|
|
898
|
+
outcome, toolName, toolArgs, note,
|
|
899
|
+
source: { kind: 'local_safety', itemId: finding.itemId, category: finding.category, source: finding.source, explanation: finding.explanation, reason: finding.reason },
|
|
900
|
+
});
|
|
901
|
+
return `${explained.userMessage}\nThis is a security policy decision, not a transient error — do not retry.`;
|
|
902
|
+
}
|
|
903
|
+
function explainGatewayPolicy(outcome, toolName, toolArgs, verdict, policies, note) {
|
|
904
|
+
const policy = verdict.policyId ? policies?.find(p => p.id === verdict.policyId) : undefined;
|
|
905
|
+
let facts = {};
|
|
906
|
+
let operation;
|
|
907
|
+
try {
|
|
908
|
+
const inferred = (0, actionPolicyEngine_1.inferToolContext)(toolName, (toolArgs || {}));
|
|
909
|
+
facts = inferred.context;
|
|
910
|
+
operation = inferred.operation;
|
|
911
|
+
}
|
|
912
|
+
catch { /* explanation never fails the gateway */ }
|
|
913
|
+
const explained = explainGatewayStop({
|
|
914
|
+
outcome, toolName, toolArgs, note,
|
|
915
|
+
source: { kind: 'policy', policyName: verdict.policyName, policyId: verdict.policyId, matchedRule: verdict.matchedRule, scope: policy?.scope, facts, operation, reason: verdict.reason },
|
|
916
|
+
});
|
|
917
|
+
return `${explained.userMessage}\nThis is a policy decision, not an error — do not retry.`;
|
|
918
|
+
}
|
|
872
919
|
function blockError(message) {
|
|
873
920
|
const err = new Error(message);
|
|
874
921
|
err.fcdDeliberateBlock = true;
|
|
@@ -1195,7 +1242,7 @@ class McpGatewayServer {
|
|
|
1195
1242
|
approvalScope: 'developer', confirmLatencyMs: answer.latencyMs,
|
|
1196
1243
|
});
|
|
1197
1244
|
(0, telemetry_1.triggerFlush)(true);
|
|
1198
|
-
throw blockError(
|
|
1245
|
+
throw blockError(explainGatewayLocalSafety('block', toolName, toolArgs, ask, `${answer.answer === 'timeout' ? 'You did not answer the confirmation prompt in time' : 'You declined the confirmation prompt'}, so this tool call was not executed.`));
|
|
1199
1246
|
}
|
|
1200
1247
|
}
|
|
1201
1248
|
else {
|
|
@@ -1219,7 +1266,7 @@ class McpGatewayServer {
|
|
|
1219
1266
|
}
|
|
1220
1267
|
else {
|
|
1221
1268
|
this.spoolLocalFinding(localBlock, toolName, operation);
|
|
1222
|
-
throw blockError(
|
|
1269
|
+
throw blockError(explainGatewayLocalSafety('block', toolName, toolArgs, localBlock, askNote.trim() || undefined));
|
|
1223
1270
|
}
|
|
1224
1271
|
}
|
|
1225
1272
|
// --- Machine-role session amount limits (pre-call: operation counts) ---
|
|
@@ -1316,7 +1363,7 @@ class McpGatewayServer {
|
|
|
1316
1363
|
(0, telemetry_1.spoolEvent)({ decision: 'block', toolName, operation: localOperation, reason: `local policy (block mode, local-first): ${reason}`, ruleId: local.policyName ? `action-policy:${local.policyName}` : undefined, offlineEnforced: true });
|
|
1317
1364
|
(0, telemetry_1.triggerFlush)(true);
|
|
1318
1365
|
(0, notify_1.notifyOs)({ title: 'FullCourtDefense — blocked by policy', message: `${toolName} blocked${local.policyName ? ` (${local.policyName})` : ''}.` });
|
|
1319
|
-
throw blockError(
|
|
1366
|
+
throw blockError(explainGatewayPolicy('block', toolName, toolArgs, local, cachedPolicies));
|
|
1320
1367
|
}
|
|
1321
1368
|
if (local.verdict === 'log') {
|
|
1322
1369
|
// "Warn only": the call runs, the fleet records Warned (not a silent
|
|
@@ -1394,7 +1441,7 @@ class McpGatewayServer {
|
|
|
1394
1441
|
: '';
|
|
1395
1442
|
(0, telemetry_1.spoolEvent)({ decision: 'block', toolName, operation: localOperation, reason: `local policy (offline): ${local.reason || local.verdict}`, offlineEnforced: true });
|
|
1396
1443
|
(0, telemetry_1.triggerFlush)(true);
|
|
1397
|
-
throw blockError(
|
|
1444
|
+
throw blockError(explainGatewayPolicy('block', toolName, toolArgs, local, cachedPolicies, `Enforced from the locally cached policy while the policy service is unreachable (${detail}).${approvalNote}`));
|
|
1398
1445
|
}
|
|
1399
1446
|
const localReason = blocking
|
|
1400
1447
|
? `[monitor] local policy would ${local.verdict} (offline): ${local.reason || ''}`
|
|
@@ -1476,16 +1523,8 @@ class McpGatewayServer {
|
|
|
1476
1523
|
// and where to review it — so the developer (and the AI agent relaying the error)
|
|
1477
1524
|
// can tell exactly why the call was refused instead of guessing.
|
|
1478
1525
|
const policy = preflight.actionPolicy;
|
|
1479
|
-
const
|
|
1480
|
-
|
|
1481
|
-
if (policy?.policyName)
|
|
1482
|
-
parts.push(`Policy: "${policy.policyName}"`);
|
|
1483
|
-
if (policy?.matchedRule)
|
|
1484
|
-
parts.push(`Matched rule: ${policy.matchedRule}`);
|
|
1485
|
-
parts.push(`Tool: ${toolName}${operation && operation !== toolName ? ` (operation: ${operation})` : ''}`);
|
|
1486
|
-
parts.push(`Review or change this policy in the FullCourtDefense console → Agent Security → Policies.`);
|
|
1487
|
-
parts.push('This is a policy decision, not an error — do not retry.');
|
|
1488
|
-
throw blockError(parts.join(' | '));
|
|
1526
|
+
const outcome = preflight.decision === 'approval' ? 'approval' : preflight.decision === 'mask' ? 'mask' : 'block';
|
|
1527
|
+
throw blockError(explainGatewayPolicy(outcome, toolName, toolArgs, { policyName: policy?.policyName, policyId: policy?.policyId, matchedRule: policy?.matchedRule, reason: policy?.reason || preflight.intentEvaluation?.reasons?.[0] }, cachedPolicies, outcome === 'approval' ? `Approve at ${(0, notify_1.approvalsConsoleUrl)()}` : undefined));
|
|
1489
1528
|
}
|
|
1490
1529
|
}
|
|
1491
1530
|
downstreamAttempted = true;
|
|
@@ -11,6 +11,18 @@ export declare function detectGitBash(): string | undefined;
|
|
|
11
11
|
* Git Bash installed (bash only — no zsh there).
|
|
12
12
|
*/
|
|
13
13
|
export declare function posixGuardSupported(): boolean;
|
|
14
|
+
/** The shared Node checker: exits 97 on a confirmed block, 0 otherwise (fail-open). */
|
|
15
|
+
export declare function buildGuardJs(nodePath: string, cliEntry: string): string;
|
|
16
|
+
/**
|
|
17
|
+
* Shared POSIX-shell prompt for an ask hit (sourced by both the bash and zsh
|
|
18
|
+
* guards — plain POSIX/bash syntax, no ZLE). Args: <meta line> <typed line>.
|
|
19
|
+
* Returns 0 = run the command, 1 = do not. Reads ONE key from /dev/tty with a
|
|
20
|
+
* timeout; y = run once, s = run + export FCD_SHELL_ASK_OK for this shell,
|
|
21
|
+
* anything else / timeout / no tty = not executed. Every answer is reported
|
|
22
|
+
* through the checker's --record mode. Fail-strict: an ask that cannot be
|
|
23
|
+
* answered is a block, never a silent allow.
|
|
24
|
+
*/
|
|
25
|
+
export declare function buildAskShellFunction(shell: 'bash' | 'zsh'): string;
|
|
14
26
|
export interface PosixShellGuardStatus {
|
|
15
27
|
supported: boolean;
|
|
16
28
|
installed: boolean;
|
|
@@ -36,6 +36,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
36
36
|
exports.ASK_CODE = void 0;
|
|
37
37
|
exports.detectGitBash = detectGitBash;
|
|
38
38
|
exports.posixGuardSupported = posixGuardSupported;
|
|
39
|
+
exports.buildGuardJs = buildGuardJs;
|
|
40
|
+
exports.buildAskShellFunction = buildAskShellFunction;
|
|
39
41
|
exports.getPosixShellGuardStatus = getPosixShellGuardStatus;
|
|
40
42
|
exports.isPosixShellGuardInstalled = isPosixShellGuardInstalled;
|
|
41
43
|
exports.installPosixShellGuardCommand = installPosixShellGuardCommand;
|
|
@@ -45,6 +47,7 @@ const fs = __importStar(require("fs"));
|
|
|
45
47
|
const os = __importStar(require("os"));
|
|
46
48
|
const path = __importStar(require("path"));
|
|
47
49
|
const shellGuard_1 = require("./shellGuard");
|
|
50
|
+
const notify_1 = require("../notify");
|
|
48
51
|
/**
|
|
49
52
|
* Interactive macOS/Linux shell guard — real-time blocking of dangerous commands
|
|
50
53
|
* TYPED by a human into bash or zsh (inside or outside the IDE).
|
|
@@ -160,6 +163,9 @@ function buildGuardJs(nodePath, cliEntry) {
|
|
|
160
163
|
`const CLI_ENTRY=${JSON.stringify(cliEntry)};`,
|
|
161
164
|
`const BLOCK_CODE=${BLOCK_CODE};`,
|
|
162
165
|
`const ASK_CODE=${exports.ASK_CODE};`,
|
|
166
|
+
`const RULES_URL=${JSON.stringify((0, notify_1.shellRulesConsoleUrl)())};`,
|
|
167
|
+
`// Which rule (by id + origin) and where an admin changes it — printed on every block so the developer is never left guessing.`,
|
|
168
|
+
`function unblockHint(rule){return 'Rule: "'+rule.reason+'" ('+(rule.source==='custom'?'org custom rule':'built-in Local Safety rule')+', id '+rule.id+'). To remove this block an admin can set it to Ask / Warn only / Off here: '+RULES_URL+'&rule='+encodeURIComponent(rule.id);}`,
|
|
163
169
|
`function loadRules(){try{const raw=JSON.parse(fs.readFileSync(RULES_PATH,'utf8'));const rules=(raw.rules||[]).map(r=>{try{return{id:r.id,category:r.category,severity:r.severity,reason:r.reason,source:r.source,action:r.action==='warn'?'warn':r.action==='ask'?'ask':'block',re:new RegExp(r.pattern,'i')};}catch{return null;}}).filter(Boolean);return{mode:raw.mode==='block'?'block':'monitor',rules};}catch{return{mode:'monitor',rules:[]};}}`,
|
|
164
170
|
`// Execution view (aligned with the CLI's stripInertDataSegments): quoted spans containing whitespace are`,
|
|
165
171
|
`// prose (inert) unless in a code position — interpreter -c/-Command flags, remote executors (ssh/wsl/docker`,
|
|
@@ -190,6 +196,7 @@ function buildGuardJs(nodePath, cliEntry) {
|
|
|
190
196
|
`}`,
|
|
191
197
|
`console.error('[FullCourtDefense] BLOCKED ['+(hit.severity||'')+']: '+hit.reason+' (rule '+hit.id+')');`,
|
|
192
198
|
`console.error('This command was not executed. Reported to your security dashboard.');`,
|
|
199
|
+
`console.error(unblockHint(hit));`,
|
|
193
200
|
`spoolEvent(hit,line,'block');process.exit(BLOCK_CODE);`,
|
|
194
201
|
``,
|
|
195
202
|
].join('\n');
|
|
@@ -213,6 +220,7 @@ function buildAskShellFunction(shell) {
|
|
|
213
220
|
` local __fcd_meta="$1" __fcd_line="$2" __fcd_tag __fcd_id __fcd_sev __fcd_reason __fcd_ans=""`,
|
|
214
221
|
` local __fcd_oldifs="$IFS"; IFS="$(printf '\\t')"; read -r __fcd_tag __fcd_id __fcd_sev __fcd_reason <<< "$__fcd_meta"; IFS="$__fcd_oldifs"`,
|
|
215
222
|
` printf '[FullCourtDefense] ASK [%s]: %s (rule %s)\\n' "$__fcd_sev" "$__fcd_reason" "$__fcd_id" >&2`,
|
|
223
|
+
` printf 'Rule id %s — an admin can set it to Ask / Warn only / Off here: %s&rule=%s\\n' "$__fcd_id" ${shSingleQuote((0, notify_1.shellRulesConsoleUrl)())} "$__fcd_id" >&2`,
|
|
216
224
|
` # Only a shell with a REAL keyboard may be asked: stdin must be this shell's`,
|
|
217
225
|
` # terminal. A spawned / piped / embedded shell (stdin not a tty) never opens`,
|
|
218
226
|
` # /dev/tty — on MSYS that can be some OTHER window's console — it blocks now.`,
|
|
@@ -51,6 +51,16 @@ export declare const SHELL_ASK_TIMEOUT_SECONDS = 30;
|
|
|
51
51
|
export declare const SHELL_ASK_REMEMBER_HOURS = 12;
|
|
52
52
|
/** Normalize a console action into what a typed-command guard can enforce. */
|
|
53
53
|
export declare function toShellGuardAction(raw: unknown): ShellGuardAction;
|
|
54
|
+
/**
|
|
55
|
+
* Leading command names that anchor a built-in rule — what a first-token hook
|
|
56
|
+
* (cmd.exe doskey) can key on. Derived from the shared tables so a new rule
|
|
57
|
+
* cannot be added without its command name reaching the cmd guard's index.
|
|
58
|
+
*/
|
|
59
|
+
/** Every built-in terminal rule id, ignoring console toggles (parity tests, docs). */
|
|
60
|
+
export declare function builtinRuleIds(): string[];
|
|
61
|
+
/** Every built-in terminal rule, all switched ON with default action (parity tests). */
|
|
62
|
+
export declare function builtinRulesAllOn(): ShellGuardRule[];
|
|
63
|
+
export declare function builtinRuleCommandNames(): string[];
|
|
54
64
|
interface ShellGuardRulesFile {
|
|
55
65
|
updatedAt: string;
|
|
56
66
|
mode: 'block' | 'monitor';
|
|
@@ -81,6 +91,8 @@ export declare function evaluateShellCommand(line: string, rules?: ShellGuardRul
|
|
|
81
91
|
* No-op unless the guard is installed. Never throws.
|
|
82
92
|
*/
|
|
83
93
|
export declare function refreshShellGuardRules(): void;
|
|
94
|
+
/** The profile guard script. Placeholders are substituted at install time. */
|
|
95
|
+
export declare function buildGuardPs1(nodePath: string, cliEntry: string): string;
|
|
84
96
|
/**
|
|
85
97
|
* The user's Documents folder from the shell-folder registry entry — the one
|
|
86
98
|
* place that knows about OneDrive/folder redirection. reg.exe is a plain
|