fullcourtdefense-cli 1.34.4 → 1.34.6
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.js +9 -3
- package/dist/codexMcpIdentity.d.ts +14 -0
- package/dist/codexMcpIdentity.js +32 -2
- package/dist/commands/mcpGateway.js +90 -21
- package/dist/devConfirm.d.ts +48 -0
- package/dist/devConfirm.js +130 -3
- package/dist/nativeHook.d.ts +7 -3
- package/dist/nativeHook.js +13 -42
- package/dist/version.json +1 -1
- package/package.json +3 -2
|
@@ -686,7 +686,7 @@ const PLACEHOLDER_WORD_RE = /example|sample|placeholder|redacted|dummy|changeme|
|
|
|
686
686
|
* ~ 1 in 62^6 per position. Structural — no vocabulary.
|
|
687
687
|
*/
|
|
688
688
|
const REPEATED_CHUNK_RE = /(.{3,})\1{2,}/;
|
|
689
|
-
function isHighEntropySecretToken(token) {
|
|
689
|
+
function isHighEntropySecretToken(token, allowWordSlug = false) {
|
|
690
690
|
if (token.length < 24 || token.length > 512)
|
|
691
691
|
return false;
|
|
692
692
|
// `NAME=value` / `--flag=value` is an assignment expression, not a value: the
|
|
@@ -695,7 +695,7 @@ function isHighEntropySecretToken(token) {
|
|
|
695
695
|
// is a reference; `GITHUB_TOKEN=<40 random chars>` is still a secret).
|
|
696
696
|
const assignment = /^-{0,2}[A-Za-z_][\w.-]*=(.+)$/.exec(token);
|
|
697
697
|
if (assignment)
|
|
698
|
-
return isHighEntropySecretToken(assignment[1]);
|
|
698
|
+
return isHighEntropySecretToken(assignment[1], allowWordSlug);
|
|
699
699
|
// Any URI (postgres://, redis://, amqp://, https://…) is configuration; only
|
|
700
700
|
// the password inside its userinfo can be a secret — judged as a value.
|
|
701
701
|
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(token))
|
|
@@ -723,6 +723,12 @@ function isHighEntropySecretToken(token) {
|
|
|
723
723
|
return false; // url / path
|
|
724
724
|
if (/^[a-z]+(?:[A-Z][a-z0-9]+)+$/.test(token) || /^[a-z]+(?:_[a-z0-9]+)+$/.test(token))
|
|
725
725
|
return false; // camelCase / snake_case identifiers
|
|
726
|
+
// Lowercase word slugs with a numeric suffix are identifiers (for example
|
|
727
|
+
// a dated customer/run ID). Separators and the suffix alone do not turn
|
|
728
|
+
// those words into random credential material. Known credential formats
|
|
729
|
+
// and explicitly named secret assignments are checked before this fallback.
|
|
730
|
+
if (allowWordSlug && /^[a-z]+(?:-[a-z]+)+(?:-\d+)?$/.test(token))
|
|
731
|
+
return false;
|
|
726
732
|
let classes = 0;
|
|
727
733
|
if (/[a-z]/.test(token))
|
|
728
734
|
classes++;
|
|
@@ -847,7 +853,7 @@ function detectSecretKind(raw) {
|
|
|
847
853
|
re.lastIndex = 0;
|
|
848
854
|
let m;
|
|
849
855
|
while ((m = re.exec(value)) !== null) {
|
|
850
|
-
if (isHighEntropySecretToken(m[1].replace(/[\\"'`]+$/, '')))
|
|
856
|
+
if (isHighEntropySecretToken(m[1].replace(/[\\"'`]+$/, ''), re === GENERIC_VALUE_RE))
|
|
851
857
|
return 'high_entropy';
|
|
852
858
|
}
|
|
853
859
|
}
|
|
@@ -1,3 +1,16 @@
|
|
|
1
|
+
type TomlParse = (text: string) => Record<string, any>;
|
|
2
|
+
/**
|
|
3
|
+
* Load the TOML parser lazily. The native hook (`nativeHook.js`) is a
|
|
4
|
+
* fail-open surface in Codex: a hook process that crashes before printing a
|
|
5
|
+
* verdict is treated by Codex as "no opinion" and the tool call proceeds. A
|
|
6
|
+
* top-level `import` of a package that is missing from the install (the MSI
|
|
7
|
+
* ships an explicit node_modules allowlist) therefore disabled EVERY native
|
|
8
|
+
* hook event. Resolve the parser at first use instead so a missing module
|
|
9
|
+
* degrades to "no configured server names" while policies still evaluate.
|
|
10
|
+
*/
|
|
11
|
+
export declare function loadTomlParser(): TomlParse | null;
|
|
12
|
+
/** Test seam: force the "parser unavailable" path without uninstalling the package. */
|
|
13
|
+
export declare function __setTomlParserForTests(parser: TomlParse | null | undefined): void;
|
|
1
14
|
/**
|
|
2
15
|
* Name dictionary, not an effective configuration or an authorization source.
|
|
3
16
|
* Include user-owned profiles conservatively: hook payloads do not identify the
|
|
@@ -10,3 +23,4 @@ export declare function resolveCodexMcpTool(tool: string, serverNames: string[])
|
|
|
10
23
|
server: string;
|
|
11
24
|
tool: string;
|
|
12
25
|
} | undefined;
|
|
26
|
+
export {};
|
package/dist/codexMcpIdentity.js
CHANGED
|
@@ -33,16 +33,46 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.loadTomlParser = loadTomlParser;
|
|
37
|
+
exports.__setTomlParserForTests = __setTomlParserForTests;
|
|
36
38
|
exports.codexMcpServerNames = codexMcpServerNames;
|
|
37
39
|
exports.resolveCodexMcpTool = resolveCodexMcpTool;
|
|
38
40
|
const fs = __importStar(require("fs"));
|
|
39
41
|
const os = __importStar(require("os"));
|
|
40
42
|
const path = __importStar(require("path"));
|
|
41
|
-
|
|
43
|
+
let tomlParser;
|
|
44
|
+
/**
|
|
45
|
+
* Load the TOML parser lazily. The native hook (`nativeHook.js`) is a
|
|
46
|
+
* fail-open surface in Codex: a hook process that crashes before printing a
|
|
47
|
+
* verdict is treated by Codex as "no opinion" and the tool call proceeds. A
|
|
48
|
+
* top-level `import` of a package that is missing from the install (the MSI
|
|
49
|
+
* ships an explicit node_modules allowlist) therefore disabled EVERY native
|
|
50
|
+
* hook event. Resolve the parser at first use instead so a missing module
|
|
51
|
+
* degrades to "no configured server names" while policies still evaluate.
|
|
52
|
+
*/
|
|
53
|
+
function loadTomlParser() {
|
|
54
|
+
if (tomlParser !== undefined)
|
|
55
|
+
return tomlParser;
|
|
56
|
+
try {
|
|
57
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
58
|
+
tomlParser = require('@iarna/toml').parse;
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
tomlParser = null;
|
|
62
|
+
}
|
|
63
|
+
return tomlParser;
|
|
64
|
+
}
|
|
65
|
+
/** Test seam: force the "parser unavailable" path without uninstalling the package. */
|
|
66
|
+
function __setTomlParserForTests(parser) {
|
|
67
|
+
tomlParser = parser;
|
|
68
|
+
}
|
|
42
69
|
/** Parse config without executing commands or exposing its contents in errors. */
|
|
43
70
|
function readConfig(file) {
|
|
71
|
+
const parse = loadTomlParser();
|
|
72
|
+
if (!parse)
|
|
73
|
+
return {};
|
|
44
74
|
try {
|
|
45
|
-
return
|
|
75
|
+
return parse(fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, ''));
|
|
46
76
|
}
|
|
47
77
|
catch (err) {
|
|
48
78
|
if (err.code === 'ENOENT')
|
|
@@ -948,6 +948,32 @@ function isBlockError(err) {
|
|
|
948
948
|
}
|
|
949
949
|
/** How long an MCP elicitation ("Ask the developer" dialog in the client) waits before it counts as declined. */
|
|
950
950
|
const ELICITATION_TIMEOUT_MS = 5 * 60_000;
|
|
951
|
+
/**
|
|
952
|
+
* The OS dialog is the fallback channel. Same bound as the IDE hook's dialog and
|
|
953
|
+
* inside every known client's MCP tool-call timeout (Codex `tool_timeout_sec`
|
|
954
|
+
* defaults to 60s) — a click after the client gave up would be wasted.
|
|
955
|
+
*/
|
|
956
|
+
const OS_DIALOG_TIMEOUT_MS = 60_000;
|
|
957
|
+
/** Human-readable channel label for audit reasons and block explanations. */
|
|
958
|
+
function confirmChannelLabel(answer) {
|
|
959
|
+
if (answer.channel === 'hook_dialog')
|
|
960
|
+
return 'FullCourtDefense dialog (IDE hook, same call — not asked twice)';
|
|
961
|
+
return answer.channel === 'os_dialog' ? 'FullCourtDefense dialog' : 'MCP client prompt';
|
|
962
|
+
}
|
|
963
|
+
function describeDeveloperNo(answer) {
|
|
964
|
+
const via = confirmChannelLabel(answer);
|
|
965
|
+
const autoNote = answer.clientAutoAnswered ? ' (the MCP client auto-declined the in-client prompt; the developer was asked in the FullCourtDefense dialog)' : '';
|
|
966
|
+
if (answer.answer === 'timeout') {
|
|
967
|
+
return {
|
|
968
|
+
reason: `developer confirmation not given (${via} timed out)${autoNote}`,
|
|
969
|
+
explanation: 'You did not answer the confirmation prompt in time',
|
|
970
|
+
};
|
|
971
|
+
}
|
|
972
|
+
return {
|
|
973
|
+
reason: `developer declined in ${via}${autoNote}`,
|
|
974
|
+
explanation: 'You declined the confirmation prompt',
|
|
975
|
+
};
|
|
976
|
+
}
|
|
951
977
|
class McpGatewayServer {
|
|
952
978
|
gatewayConfig;
|
|
953
979
|
downstreamSpec;
|
|
@@ -1222,10 +1248,11 @@ class McpGatewayServer {
|
|
|
1222
1248
|
process.stderr.write(`FullCourtDefense warning (${warning.itemId}): ${warning.reason}\n`);
|
|
1223
1249
|
}
|
|
1224
1250
|
// "Ask the developer" rule with no hard block on the same call: ask the
|
|
1225
|
-
// human through the MCP client (elicitation) when
|
|
1226
|
-
//
|
|
1227
|
-
//
|
|
1228
|
-
//
|
|
1251
|
+
// human — through the MCP client (elicitation) when it can show a dialog,
|
|
1252
|
+
// otherwise (or when the client answers for the user) through the OS
|
|
1253
|
+
// dialog — whenever a human is present. Session memory reuses the IDE
|
|
1254
|
+
// hook's ledger (same tool + rule => remembered). Decline, cancel, timeout
|
|
1255
|
+
// or no human => block, never a silent allow.
|
|
1229
1256
|
let localBlock = requestOutcome.blockingFinding || requestOutcome.askFinding;
|
|
1230
1257
|
let askNote = '';
|
|
1231
1258
|
if (!requestOutcome.blockingFinding && requestOutcome.askFinding && !reportOnlyMode) {
|
|
@@ -1245,7 +1272,7 @@ class McpGatewayServer {
|
|
|
1245
1272
|
(0, telemetry_1.triggerFlush)(false);
|
|
1246
1273
|
localBlock = undefined;
|
|
1247
1274
|
}
|
|
1248
|
-
else if (
|
|
1275
|
+
else if ((0, devConfirm_1.isHumanPresent)()) {
|
|
1249
1276
|
const digest = (0, devConfirm_1.actionDigest)(toolArgs);
|
|
1250
1277
|
const marker = (0, devConfirm_1.raiseDeveloperConfirmation)({
|
|
1251
1278
|
event: 'mcp_gateway', toolName, operation: rememberScope.operation, argsDigest: digest,
|
|
@@ -1253,7 +1280,7 @@ class McpGatewayServer {
|
|
|
1253
1280
|
sessionId, remember: 'none',
|
|
1254
1281
|
});
|
|
1255
1282
|
process.stderr.write(`FullCourtDefense asking the developer to confirm ${toolName}: ${ask.reason} (${origin} "${ask.itemId}", ${ask.ruleId})\n`);
|
|
1256
|
-
const answer = await this.
|
|
1283
|
+
const answer = await this.confirmWithDeveloper({ toolName, reason: ask.reason, ruleLabel: `${origin} "${ask.itemId}" (${ask.ruleId})`, evidence: ask.evidence, argsDigest: digest });
|
|
1257
1284
|
if (answer.answer === 'accept') {
|
|
1258
1285
|
// Settle through the shared ledger so "remember for this session" is
|
|
1259
1286
|
// recorded exactly like an IDE approval would be.
|
|
@@ -1262,7 +1289,7 @@ class McpGatewayServer {
|
|
|
1262
1289
|
(0, devConfirm_1.settleDeveloperConfirmation)('mcp_gateway', toolName, digest);
|
|
1263
1290
|
(0, telemetry_1.spoolEvent)({
|
|
1264
1291
|
decision: 'allow', toolName, operation: rememberScope.operation,
|
|
1265
|
-
reason: `developer confirmed in
|
|
1292
|
+
reason: `developer confirmed in ${confirmChannelLabel(answer)}${answer.remember ? ' (remembered for this session)' : ''}${answer.clientAutoAnswered ? ' (the MCP client auto-declined the in-client prompt)' : ''} — ${ask.reason}`,
|
|
1266
1293
|
ruleId: ask.ruleId, category: ask.category, categoryId: ask.categoryId, itemId: ask.itemId, source: ask.source,
|
|
1267
1294
|
evidence: ask.evidence, policyHash: ask.policyHash, offlineEnforced: true,
|
|
1268
1295
|
approvalScope: 'developer', approvedBy: 'developer', confirmLatencyMs: answer.latencyMs,
|
|
@@ -1272,22 +1299,20 @@ class McpGatewayServer {
|
|
|
1272
1299
|
}
|
|
1273
1300
|
else {
|
|
1274
1301
|
(0, devConfirm_1.rejectDeveloperConfirmation)('mcp_gateway', toolName, digest);
|
|
1275
|
-
const
|
|
1302
|
+
const no = describeDeveloperNo(answer);
|
|
1276
1303
|
(0, telemetry_1.spoolEvent)({
|
|
1277
1304
|
decision: 'block', toolName, operation: rememberScope.operation,
|
|
1278
|
-
reason: `${
|
|
1305
|
+
reason: `${no.reason} — ${ask.reason}`,
|
|
1279
1306
|
ruleId: ask.ruleId, category: ask.category, categoryId: ask.categoryId, itemId: ask.itemId, source: ask.source,
|
|
1280
1307
|
evidence: ask.evidence, policyHash: ask.policyHash, offlineEnforced: true,
|
|
1281
1308
|
approvalScope: 'developer', confirmLatencyMs: answer.latencyMs,
|
|
1282
1309
|
});
|
|
1283
1310
|
(0, telemetry_1.triggerFlush)(true);
|
|
1284
|
-
throw blockError(explainGatewayLocalSafety('block', toolName, toolArgs, ask, `${
|
|
1311
|
+
throw blockError(explainGatewayLocalSafety('block', toolName, toolArgs, ask, `${no.explanation}, so this tool call was not executed.`));
|
|
1285
1312
|
}
|
|
1286
1313
|
}
|
|
1287
1314
|
else {
|
|
1288
|
-
askNote = this.
|
|
1289
|
-
? ' This rule asks the developer to confirm, but no human is present on this machine (CI / ephemeral workload), so it blocks here.'
|
|
1290
|
-
: ' This rule asks the developer to confirm; this MCP client cannot show a confirmation dialog (no elicitation support), so it blocks here (the IDE hook can ask).';
|
|
1315
|
+
askNote = ' This rule asks the developer to confirm, but no human is present on this machine (CI / ephemeral workload), so it blocks here.';
|
|
1291
1316
|
}
|
|
1292
1317
|
}
|
|
1293
1318
|
if (localBlock) {
|
|
@@ -1836,8 +1861,10 @@ class McpGatewayServer {
|
|
|
1836
1861
|
* throws the block error when the developer declines or does not answer.
|
|
1837
1862
|
*/
|
|
1838
1863
|
async askDeveloperForPolicy(toolName, toolArgs, operation, verdict, policies) {
|
|
1839
|
-
if (!
|
|
1840
|
-
|
|
1864
|
+
if (!(0, devConfirm_1.isHumanPresent)()) {
|
|
1865
|
+
// The engine normally degrades developer scope to org where no human exists;
|
|
1866
|
+
// this is the belt for a rule that still reached us with developer scope.
|
|
1867
|
+
const reason = 'This rule requires developer confirmation, but no human is present on this machine (CI / ephemeral workload). The action was not executed.';
|
|
1841
1868
|
(0, telemetry_1.spoolEvent)({ decision: 'block', toolName, operation, reason, approvalScope: 'developer', ruleId: verdict.policyId });
|
|
1842
1869
|
(0, telemetry_1.triggerFlush)(true);
|
|
1843
1870
|
throw blockError(reason);
|
|
@@ -1865,7 +1892,7 @@ class McpGatewayServer {
|
|
|
1865
1892
|
});
|
|
1866
1893
|
(0, telemetry_1.spoolEvent)({
|
|
1867
1894
|
decision: 'approval', toolName, operation,
|
|
1868
|
-
reason: `developer confirmation requested via MCP client prompt${policyLabel}${verdict.reason ? `: ${verdict.reason}` : ''}`,
|
|
1895
|
+
reason: `developer confirmation requested via ${this.clientSupportsElicitation ? 'MCP client prompt' : 'FullCourtDefense dialog'}${policyLabel}${verdict.reason ? `: ${verdict.reason}` : ''}`,
|
|
1869
1896
|
ruleId, offlineEnforced: true, approvalScope: 'developer',
|
|
1870
1897
|
});
|
|
1871
1898
|
(0, telemetry_1.triggerFlush)(false);
|
|
@@ -1873,32 +1900,70 @@ class McpGatewayServer {
|
|
|
1873
1900
|
? ' This policy is set to Block; on a developer workstation it asks you instead. An admin can mark it "Hard block" in the console to stop this without asking.'
|
|
1874
1901
|
: '';
|
|
1875
1902
|
process.stderr.write(`FullCourtDefense asking the developer to confirm ${toolName}${policyLabel}: ${verdict.reason || verdict.matchedRule || 'action policy'}\n`);
|
|
1876
|
-
const answer = await this.
|
|
1903
|
+
const answer = await this.confirmWithDeveloper({
|
|
1877
1904
|
toolName,
|
|
1878
1905
|
reason: `${verdict.reason || `${toolName} needs your confirmation`}.${ceiling}`,
|
|
1879
1906
|
allowRemember: false,
|
|
1880
1907
|
ruleLabel: verdict.policyName ? `Action Policy "${verdict.policyName}"${verdict.matchedRule ? ` — ${verdict.matchedRule}` : ''}` : 'Action Policy',
|
|
1908
|
+
argsDigest: digest,
|
|
1881
1909
|
});
|
|
1882
1910
|
if (answer.answer === 'accept') {
|
|
1883
1911
|
// Policy memory limits are authoritative; a client cannot upgrade 'none'.
|
|
1884
1912
|
(0, devConfirm_1.settleDeveloperConfirmation)('mcp_gateway', toolName, digest);
|
|
1885
1913
|
(0, telemetry_1.spoolEvent)({
|
|
1886
1914
|
decision: 'allow', toolName, operation,
|
|
1887
|
-
reason: `developer confirmed in
|
|
1915
|
+
reason: `developer confirmed in ${confirmChannelLabel(answer)}${answer.remember ? ' (remembered for this session)' : ''}${answer.clientAutoAnswered ? ' (the MCP client auto-declined the in-client prompt)' : ''}${policyLabel}`,
|
|
1888
1916
|
ruleId, offlineEnforced: true, approvalScope: 'developer', approvedBy: 'developer', confirmLatencyMs: answer.latencyMs,
|
|
1889
1917
|
});
|
|
1890
1918
|
(0, telemetry_1.triggerFlush)(false);
|
|
1891
1919
|
return true;
|
|
1892
1920
|
}
|
|
1893
1921
|
(0, devConfirm_1.rejectDeveloperConfirmation)('mcp_gateway', toolName, digest);
|
|
1894
|
-
const
|
|
1922
|
+
const no = describeDeveloperNo(answer);
|
|
1895
1923
|
(0, telemetry_1.spoolEvent)({
|
|
1896
1924
|
decision: 'block', toolName, operation,
|
|
1897
|
-
reason: `${
|
|
1925
|
+
reason: `${no.reason}${policyLabel}`,
|
|
1898
1926
|
ruleId, offlineEnforced: true, approvalScope: 'developer', confirmLatencyMs: answer.latencyMs,
|
|
1899
1927
|
});
|
|
1900
1928
|
(0, telemetry_1.triggerFlush)(true);
|
|
1901
|
-
throw blockError(explainGatewayPolicy('block', toolName, toolArgs, verdict, policies, `${
|
|
1929
|
+
throw blockError(explainGatewayPolicy('block', toolName, toolArgs, verdict, policies, `${no.explanation}, so this tool call was not executed.`));
|
|
1930
|
+
}
|
|
1931
|
+
/**
|
|
1932
|
+
* "Ask the developer" that works in every MCP client. Order:
|
|
1933
|
+
* 1. no human on this machine => unavailable (caller blocks)
|
|
1934
|
+
* 2. client advertises elicitation => in-client prompt
|
|
1935
|
+
* accept / human-speed decline / timeout => that IS the developer's answer
|
|
1936
|
+
* decline faster than a human can read (the client answered for the
|
|
1937
|
+
* user — Codex Desktop "Full access" declines every elicitation without
|
|
1938
|
+
* showing it) or an error response => fall through to 3
|
|
1939
|
+
* 3. OS-owned dialog (PowerShell popup / osascript / zenity)
|
|
1940
|
+
* Every path that is not an explicit "yes" from a human ends in a block.
|
|
1941
|
+
*/
|
|
1942
|
+
async confirmWithDeveloper(input) {
|
|
1943
|
+
if (!(0, devConfirm_1.isHumanPresent)())
|
|
1944
|
+
return { answer: 'unavailable', remember: false, latencyMs: 0, clientAutoAnswered: false };
|
|
1945
|
+
// 0. The IDE hook already showed the OS dialog for THIS call (same tool, same
|
|
1946
|
+
// args) and the developer clicked "Allow once" moments ago.
|
|
1947
|
+
const handoff = input.argsDigest ? (0, devConfirm_1.consumeConfirmedAction)(input.toolName, input.argsDigest) : undefined;
|
|
1948
|
+
if (handoff) {
|
|
1949
|
+
return { answer: 'accept', remember: false, latencyMs: Math.max(0, Date.now() - Date.parse(handoff.confirmedAt)), channel: 'hook_dialog', clientAutoAnswered: false };
|
|
1950
|
+
}
|
|
1951
|
+
let clientAutoAnswered = false;
|
|
1952
|
+
if (this.clientSupportsElicitation) {
|
|
1953
|
+
const viaClient = await this.elicitDeveloperConfirmation(input);
|
|
1954
|
+
const automated = viaClient.clientError || (0, devConfirm_1.looksLikeClientAutoAnswer)(viaClient.answer, viaClient.latencyMs);
|
|
1955
|
+
if (!automated)
|
|
1956
|
+
return { ...viaClient, channel: 'mcp_client', clientAutoAnswered: false };
|
|
1957
|
+
clientAutoAnswered = true;
|
|
1958
|
+
process.stderr.write(`FullCourtDefense: the MCP client answered the confirmation prompt for ${input.toolName} by itself in ${viaClient.latencyMs}ms (no dialog reached the developer — e.g. Codex "Full access"); asking in the FullCourtDefense dialog instead.\n`);
|
|
1959
|
+
}
|
|
1960
|
+
const started = Date.now();
|
|
1961
|
+
const evidence = input.evidence ? `\nEvidence: ${input.evidence.slice(0, 240)}` : '';
|
|
1962
|
+
const allowed = await (0, devConfirm_1.confirmNativeAction)(`Agent: ${this.gatewayConfig.agentName}\nIDE: ${this.gatewayConfig.agentClient}\nDeveloper: ${this.gatewayConfig.developerName || 'Unknown'}\nTool: ${input.toolName}\nRule: ${input.ruleLabel}\nWhy: ${input.reason}${evidence}\n\nYour answer is reported to your security dashboard as your decision.`, OS_DIALOG_TIMEOUT_MS);
|
|
1963
|
+
const latencyMs = Date.now() - started;
|
|
1964
|
+
// The dialog cannot tell "Deny" from "closed / timed out" — both are a no.
|
|
1965
|
+
const timedOut = !allowed && latencyMs >= OS_DIALOG_TIMEOUT_MS - 500;
|
|
1966
|
+
return { answer: allowed ? 'accept' : timedOut ? 'timeout' : 'decline', remember: false, latencyMs, channel: 'os_dialog', clientAutoAnswered };
|
|
1902
1967
|
}
|
|
1903
1968
|
async elicitDeveloperConfirmation(input) {
|
|
1904
1969
|
const started = Date.now();
|
|
@@ -1927,6 +1992,10 @@ class McpGatewayServer {
|
|
|
1927
1992
|
const latencyMs = Date.now() - started;
|
|
1928
1993
|
if (!response)
|
|
1929
1994
|
return { answer: 'timeout', remember: false, latencyMs };
|
|
1995
|
+
// A JSON-RPC error ("method not found", "not supported in this mode") means no
|
|
1996
|
+
// dialog was shown: the client could not ask, it did not get a "no".
|
|
1997
|
+
if (response.error)
|
|
1998
|
+
return { answer: 'decline', remember: false, latencyMs, clientError: true };
|
|
1930
1999
|
const result = (response.result && typeof response.result === 'object') ? response.result : undefined;
|
|
1931
2000
|
const content = (result?.content && typeof result.content === 'object') ? result.content : undefined;
|
|
1932
2001
|
if (result?.action === 'accept' && content?.confirm === true) {
|
package/dist/devConfirm.d.ts
CHANGED
|
@@ -1,4 +1,24 @@
|
|
|
1
1
|
export type ConfirmRemember = 'none' | 'session' | '1h';
|
|
2
|
+
/**
|
|
3
|
+
* Fastest plausible HUMAN answer to a confirmation prompt. A person has to notice
|
|
4
|
+
* the dialog, read at least the tool name and press a button; MCP clients that
|
|
5
|
+
* answer an `elicitation/create` for the user (Codex Desktop in "Full access" —
|
|
6
|
+
* `approval_policy = never` — declines every elicitation without rendering it)
|
|
7
|
+
* respond in tens of milliseconds. A decline below this bound is treated as the
|
|
8
|
+
* CLIENT's answer, not the developer's, and the question is re-asked through the
|
|
9
|
+
* OS dialog. Accepts are never re-asked; a fast accept only ever costs a prompt.
|
|
10
|
+
*/
|
|
11
|
+
export declare const MIN_HUMAN_ANSWER_MS = 1000;
|
|
12
|
+
/** True when a negative elicitation answer arrived faster than a human can read the prompt. */
|
|
13
|
+
export declare function looksLikeClientAutoAnswer(answer: 'accept' | 'decline' | 'timeout', latencyMs: number): boolean;
|
|
14
|
+
/**
|
|
15
|
+
* OS-owned confirmation dialog — the IDE-independent fallback for "Ask the developer".
|
|
16
|
+
* PowerShell `WScript.Shell.Popup` on Windows, `osascript` on macOS, `zenity` elsewhere.
|
|
17
|
+
* The agent cannot answer it: it is not connected to hook stdin, the MCP stream or the
|
|
18
|
+
* agent's terminal. Resolves `true` only for an explicit "Allow once"; decline, close,
|
|
19
|
+
* timeout, a missing dialog binary or no human present all resolve `false`.
|
|
20
|
+
*/
|
|
21
|
+
export declare function confirmNativeAction(reason: string, timeoutMs?: number): Promise<boolean>;
|
|
2
22
|
export interface PendingConfirmation {
|
|
3
23
|
/** Stable key: sha256(event|toolName|argsDigest). */
|
|
4
24
|
key: string;
|
|
@@ -32,8 +52,23 @@ export interface RememberedConfirmation {
|
|
|
32
52
|
confirmedAt: string;
|
|
33
53
|
expiresAt: string;
|
|
34
54
|
}
|
|
55
|
+
/**
|
|
56
|
+
* An explicit "Allow once" a human clicked in the OS dialog raised by the IDE hook for one
|
|
57
|
+
* exact action (tool + args digest). The MCP gateway that receives the same call a moment
|
|
58
|
+
* later consumes it instead of asking a second time. Single-use, short-lived, exact-match.
|
|
59
|
+
*/
|
|
60
|
+
export interface ConfirmedAction {
|
|
61
|
+
/** sha256(bareToolName|argsDigest). */
|
|
62
|
+
key: string;
|
|
63
|
+
toolName: string;
|
|
64
|
+
argsDigest: string;
|
|
65
|
+
confirmedAt: string;
|
|
66
|
+
expiresAt: string;
|
|
67
|
+
}
|
|
35
68
|
/** A raised confirmation that received no post-execution signal in time counts as not approved. */
|
|
36
69
|
export declare const CONFIRM_PENDING_TTL_MS: number;
|
|
70
|
+
/** The hook's dialog answer only carries over to the gateway call it was given for. */
|
|
71
|
+
export declare const CONFIRMED_ACTION_TTL_MS: number;
|
|
37
72
|
/** CI / pipeline environments — no human at a keyboard. Shared by hooks and discovery. */
|
|
38
73
|
export declare function isCiEnvironment(env?: NodeJS.ProcessEnv): boolean;
|
|
39
74
|
/**
|
|
@@ -49,6 +84,19 @@ export declare function isHumanPresent(env?: NodeJS.ProcessEnv): boolean;
|
|
|
49
84
|
*/
|
|
50
85
|
export declare function actionDigest(toolArgs: Record<string, unknown>): string;
|
|
51
86
|
export declare function pendingKey(event: string, toolName: string, argsDigest: string): string;
|
|
87
|
+
/**
|
|
88
|
+
* The tool name as the MCP server sees it. The IDE hook names an MCP call
|
|
89
|
+
* `mcp__<server>__<tool>` (Codex/Claude) or `<server>/<tool>`; the gateway sees `<tool>`.
|
|
90
|
+
*/
|
|
91
|
+
export declare function bareToolName(toolName: string): string;
|
|
92
|
+
/** The IDE hook's OS dialog got an explicit "Allow once" for this exact action. */
|
|
93
|
+
export declare function recordConfirmedAction(toolName: string, argsDigest: string, now?: Date): ConfirmedAction;
|
|
94
|
+
/**
|
|
95
|
+
* Take (and remove) a still-valid hook-dialog approval for this exact action, so the
|
|
96
|
+
* gateway does not ask the human twice for one tool call. Never matches a different
|
|
97
|
+
* tool, different arguments, an expired entry, or the same entry twice.
|
|
98
|
+
*/
|
|
99
|
+
export declare function consumeConfirmedAction(toolName: string, argsDigest: string, now?: Date): ConfirmedAction | undefined;
|
|
52
100
|
export interface RememberScope {
|
|
53
101
|
toolName: string;
|
|
54
102
|
operation: string;
|
package/dist/devConfirm.js
CHANGED
|
@@ -33,11 +33,16 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
-
exports.CONFIRM_PENDING_TTL_MS = void 0;
|
|
36
|
+
exports.CONFIRMED_ACTION_TTL_MS = exports.CONFIRM_PENDING_TTL_MS = exports.MIN_HUMAN_ANSWER_MS = void 0;
|
|
37
|
+
exports.looksLikeClientAutoAnswer = looksLikeClientAutoAnswer;
|
|
38
|
+
exports.confirmNativeAction = confirmNativeAction;
|
|
37
39
|
exports.isCiEnvironment = isCiEnvironment;
|
|
38
40
|
exports.isHumanPresent = isHumanPresent;
|
|
39
41
|
exports.actionDigest = actionDigest;
|
|
40
42
|
exports.pendingKey = pendingKey;
|
|
43
|
+
exports.bareToolName = bareToolName;
|
|
44
|
+
exports.recordConfirmedAction = recordConfirmedAction;
|
|
45
|
+
exports.consumeConfirmedAction = consumeConfirmedAction;
|
|
41
46
|
exports.raiseDeveloperConfirmation = raiseDeveloperConfirmation;
|
|
42
47
|
exports.settleDeveloperConfirmation = settleDeveloperConfirmation;
|
|
43
48
|
exports.rejectDeveloperConfirmation = rejectDeveloperConfirmation;
|
|
@@ -66,10 +71,72 @@ exports.isConfirmationRemembered = isConfirmationRemembered;
|
|
|
66
71
|
* containers and cloud coding agents set CI-style env vars or FCD_EPHEMERAL; there the engine's
|
|
67
72
|
* `effectiveApprovalScope` degrades to `org` and none of this runs.
|
|
68
73
|
*/
|
|
74
|
+
const child_process_1 = require("child_process");
|
|
69
75
|
const crypto = __importStar(require("crypto"));
|
|
70
76
|
const fs = __importStar(require("fs"));
|
|
71
77
|
const os = __importStar(require("os"));
|
|
72
78
|
const path = __importStar(require("path"));
|
|
79
|
+
/**
|
|
80
|
+
* Fastest plausible HUMAN answer to a confirmation prompt. A person has to notice
|
|
81
|
+
* the dialog, read at least the tool name and press a button; MCP clients that
|
|
82
|
+
* answer an `elicitation/create` for the user (Codex Desktop in "Full access" —
|
|
83
|
+
* `approval_policy = never` — declines every elicitation without rendering it)
|
|
84
|
+
* respond in tens of milliseconds. A decline below this bound is treated as the
|
|
85
|
+
* CLIENT's answer, not the developer's, and the question is re-asked through the
|
|
86
|
+
* OS dialog. Accepts are never re-asked; a fast accept only ever costs a prompt.
|
|
87
|
+
*/
|
|
88
|
+
exports.MIN_HUMAN_ANSWER_MS = 1000;
|
|
89
|
+
/** True when a negative elicitation answer arrived faster than a human can read the prompt. */
|
|
90
|
+
function looksLikeClientAutoAnswer(answer, latencyMs) {
|
|
91
|
+
return answer === 'decline' && latencyMs < exports.MIN_HUMAN_ANSWER_MS;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* OS-owned confirmation dialog — the IDE-independent fallback for "Ask the developer".
|
|
95
|
+
* PowerShell `WScript.Shell.Popup` on Windows, `osascript` on macOS, `zenity` elsewhere.
|
|
96
|
+
* The agent cannot answer it: it is not connected to hook stdin, the MCP stream or the
|
|
97
|
+
* agent's terminal. Resolves `true` only for an explicit "Allow once"; decline, close,
|
|
98
|
+
* timeout, a missing dialog binary or no human present all resolve `false`.
|
|
99
|
+
*/
|
|
100
|
+
async function confirmNativeAction(reason, timeoutMs = 60_000) {
|
|
101
|
+
if (!isHumanPresent())
|
|
102
|
+
return false;
|
|
103
|
+
let executable;
|
|
104
|
+
let args;
|
|
105
|
+
const message = `FullCourtDefense — developer approval\n\n${reason.slice(0, 1800)}\n\nAllow this action once?`;
|
|
106
|
+
const timeoutSeconds = Math.max(1, Math.ceil(timeoutMs / 1000));
|
|
107
|
+
if (process.platform === 'win32') {
|
|
108
|
+
executable = 'powershell.exe';
|
|
109
|
+
const encoded = Buffer.from(message, 'utf8').toString('base64');
|
|
110
|
+
// 292 = Yes/No + question icon + "No" default; +4096 (MB_SYSTEMMODAL) keeps the
|
|
111
|
+
// popup above the IDE window that triggered it, so a fallback ask is never
|
|
112
|
+
// hidden behind Codex/Cursor and silently timed out.
|
|
113
|
+
const script = `$msg=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${encoded}')); $w=New-Object -ComObject WScript.Shell; $answer=$w.Popup($msg,${timeoutSeconds},'FullCourtDefense',4388); if($answer -eq 6){exit 0}else{exit 1}`;
|
|
114
|
+
args = ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')];
|
|
115
|
+
}
|
|
116
|
+
else if (process.platform === 'darwin') {
|
|
117
|
+
executable = '/usr/bin/osascript';
|
|
118
|
+
args = ['-e', 'on run argv', '-e', `display dialog (item 1 of argv) buttons {"Deny", "Allow once"} default button "Deny" giving up after ${timeoutSeconds}`, '-e', 'if gave up of result or button returned of result is not "Allow once" then error number -128', '-e', 'end run', message];
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
executable = 'zenity';
|
|
122
|
+
args = ['--question', '--default-cancel', '--title=FullCourtDefense', `--text=${message}`, '--ok-label=Allow once', '--cancel-label=Deny', `--timeout=${timeoutSeconds}`];
|
|
123
|
+
}
|
|
124
|
+
return new Promise(resolve => {
|
|
125
|
+
let finished = false;
|
|
126
|
+
const child = (0, child_process_1.spawn)(executable, args, { stdio: 'ignore', windowsHide: true });
|
|
127
|
+
const cancelChild = () => child.kill();
|
|
128
|
+
process.once('exit', cancelChild);
|
|
129
|
+
const done = (allowed) => { if (!finished) {
|
|
130
|
+
finished = true;
|
|
131
|
+
clearTimeout(timer);
|
|
132
|
+
process.removeListener('exit', cancelChild);
|
|
133
|
+
resolve(allowed);
|
|
134
|
+
} };
|
|
135
|
+
const timer = setTimeout(() => { child.kill(); done(false); }, timeoutMs + 1000);
|
|
136
|
+
child.on('error', () => done(false));
|
|
137
|
+
child.on('exit', code => done(code === 0));
|
|
138
|
+
});
|
|
139
|
+
}
|
|
73
140
|
/** A raised confirmation that received no post-execution signal in time counts as not approved. */
|
|
74
141
|
exports.CONFIRM_PENDING_TTL_MS = 10 * 60_000;
|
|
75
142
|
const REMEMBER_1H_MS = 60 * 60_000;
|
|
@@ -77,6 +144,9 @@ const REMEMBER_1H_MS = 60 * 60_000;
|
|
|
77
144
|
const REMEMBER_SESSION_MAX_MS = 12 * 60 * 60_000;
|
|
78
145
|
const MAX_PENDING = 50;
|
|
79
146
|
const MAX_REMEMBERED = 200;
|
|
147
|
+
/** The hook's dialog answer only carries over to the gateway call it was given for. */
|
|
148
|
+
exports.CONFIRMED_ACTION_TTL_MS = 2 * 60_000;
|
|
149
|
+
const MAX_CONFIRMED_ACTIONS = 50;
|
|
80
150
|
/** CI / pipeline environments — no human at a keyboard. Shared by hooks and discovery. */
|
|
81
151
|
function isCiEnvironment(env = process.env) {
|
|
82
152
|
return env.GITHUB_ACTIONS === 'true'
|
|
@@ -109,10 +179,11 @@ function readLedger() {
|
|
|
109
179
|
pending: Array.isArray(parsed?.pending) ? parsed.pending.filter((p) => p && typeof p === 'object') : [],
|
|
110
180
|
// Older ledgers could remember declines. Require fresh affirmative consent.
|
|
111
181
|
remembered: parsed?.formatVersion === 2 && Array.isArray(parsed?.remembered) ? parsed.remembered.filter((r) => r && typeof r === 'object') : [],
|
|
182
|
+
confirmedActions: Array.isArray(parsed?.confirmedActions) ? parsed.confirmedActions.filter((c) => c && typeof c === 'object') : [],
|
|
112
183
|
};
|
|
113
184
|
}
|
|
114
185
|
catch {
|
|
115
|
-
return { pending: [], remembered: [] };
|
|
186
|
+
return { pending: [], remembered: [], confirmedActions: [] };
|
|
116
187
|
}
|
|
117
188
|
}
|
|
118
189
|
function writeLedger(ledger) {
|
|
@@ -157,6 +228,60 @@ function actionDigest(toolArgs) {
|
|
|
157
228
|
function pendingKey(event, toolName, argsDigest) {
|
|
158
229
|
return sha256(`${event}|${toolName.toLowerCase()}|${argsDigest}`);
|
|
159
230
|
}
|
|
231
|
+
/**
|
|
232
|
+
* The tool name as the MCP server sees it. The IDE hook names an MCP call
|
|
233
|
+
* `mcp__<server>__<tool>` (Codex/Claude) or `<server>/<tool>`; the gateway sees `<tool>`.
|
|
234
|
+
*/
|
|
235
|
+
function bareToolName(toolName) {
|
|
236
|
+
let name = toolName.trim();
|
|
237
|
+
if (name.startsWith('mcp__')) {
|
|
238
|
+
const rest = name.slice('mcp__'.length);
|
|
239
|
+
const idx = rest.indexOf('__');
|
|
240
|
+
name = idx >= 0 ? rest.slice(idx + 2) : rest;
|
|
241
|
+
}
|
|
242
|
+
else if (name.includes('/')) {
|
|
243
|
+
name = name.slice(name.lastIndexOf('/') + 1);
|
|
244
|
+
}
|
|
245
|
+
return name.toLowerCase();
|
|
246
|
+
}
|
|
247
|
+
function confirmedActionKey(toolName, argsDigest) {
|
|
248
|
+
return sha256(`confirmed|${bareToolName(toolName)}|${argsDigest}`);
|
|
249
|
+
}
|
|
250
|
+
/** The IDE hook's OS dialog got an explicit "Allow once" for this exact action. */
|
|
251
|
+
function recordConfirmedAction(toolName, argsDigest, now = new Date()) {
|
|
252
|
+
const entry = {
|
|
253
|
+
key: confirmedActionKey(toolName, argsDigest),
|
|
254
|
+
toolName: bareToolName(toolName),
|
|
255
|
+
argsDigest,
|
|
256
|
+
confirmedAt: now.toISOString(),
|
|
257
|
+
expiresAt: new Date(now.getTime() + exports.CONFIRMED_ACTION_TTL_MS).toISOString(),
|
|
258
|
+
};
|
|
259
|
+
const ledger = readLedger();
|
|
260
|
+
ledger.confirmedActions = ledger.confirmedActions.filter(c => c.key !== entry.key && Date.parse(c.expiresAt) > now.getTime());
|
|
261
|
+
ledger.confirmedActions.push(entry);
|
|
262
|
+
if (ledger.confirmedActions.length > MAX_CONFIRMED_ACTIONS)
|
|
263
|
+
ledger.confirmedActions = ledger.confirmedActions.slice(-MAX_CONFIRMED_ACTIONS);
|
|
264
|
+
writeLedger(ledger);
|
|
265
|
+
return entry;
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Take (and remove) a still-valid hook-dialog approval for this exact action, so the
|
|
269
|
+
* gateway does not ask the human twice for one tool call. Never matches a different
|
|
270
|
+
* tool, different arguments, an expired entry, or the same entry twice.
|
|
271
|
+
*/
|
|
272
|
+
function consumeConfirmedAction(toolName, argsDigest, now = new Date()) {
|
|
273
|
+
const ledger = readLedger();
|
|
274
|
+
const key = confirmedActionKey(toolName, argsDigest);
|
|
275
|
+
const hit = ledger.confirmedActions.find(c => c.key === key);
|
|
276
|
+
const live = ledger.confirmedActions.filter(c => Number.isFinite(Date.parse(c.expiresAt)) && Date.parse(c.expiresAt) > now.getTime());
|
|
277
|
+
const valid = hit && live.includes(hit) ? hit : undefined;
|
|
278
|
+
const next = live.filter(c => c !== valid);
|
|
279
|
+
if (next.length !== ledger.confirmedActions.length) {
|
|
280
|
+
ledger.confirmedActions = next;
|
|
281
|
+
writeLedger(ledger);
|
|
282
|
+
}
|
|
283
|
+
return valid;
|
|
284
|
+
}
|
|
160
285
|
function rememberKey(scope) {
|
|
161
286
|
const rule = scope.policyId ? `${scope.policyId}|${scope.matchedRule || ''}` : `op:${scope.operation.toLowerCase()}`;
|
|
162
287
|
return sha256(`${scope.toolName.toLowerCase()}|${rule}`);
|
|
@@ -227,9 +352,11 @@ function sweepExpiredConfirmations(now = new Date(), ttlMs = exports.CONFIRM_PEN
|
|
|
227
352
|
const cutoff = now.getTime() - ttlMs;
|
|
228
353
|
const expired = ledger.pending.filter(p => Date.parse(p.raisedAt) < cutoff);
|
|
229
354
|
const remembered = ledger.remembered.filter(r => Date.parse(r.expiresAt) > now.getTime());
|
|
230
|
-
|
|
355
|
+
const confirmedActions = ledger.confirmedActions.filter(c => Date.parse(c.expiresAt) > now.getTime());
|
|
356
|
+
if (expired.length > 0 || remembered.length !== ledger.remembered.length || confirmedActions.length !== ledger.confirmedActions.length) {
|
|
231
357
|
ledger.pending = ledger.pending.filter(p => Date.parse(p.raisedAt) >= cutoff);
|
|
232
358
|
ledger.remembered = remembered;
|
|
359
|
+
ledger.confirmedActions = confirmedActions;
|
|
233
360
|
writeLedger(ledger);
|
|
234
361
|
}
|
|
235
362
|
return expired;
|
package/dist/nativeHook.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
/** Native hook adapters. Never emit an unsupported `ask` to an IDE. */
|
|
1
2
|
import { HookArgs } from './commands/hook';
|
|
2
3
|
import { BotGuardConfig } from './config';
|
|
4
|
+
import { confirmNativeAction } from './devConfirm';
|
|
3
5
|
export type NativeClient = 'codex' | 'gemini_cli' | 'windsurf' | 'kiro';
|
|
4
6
|
type Payload = Record<string, any>;
|
|
5
7
|
export declare const NATIVE_CLIENTS: NativeClient[];
|
|
@@ -15,11 +17,13 @@ export declare function nativeErrorVerdict(client: NativeClient, reason: string)
|
|
|
15
17
|
stderr: string;
|
|
16
18
|
exitCode: number;
|
|
17
19
|
};
|
|
18
|
-
/**
|
|
19
|
-
|
|
20
|
+
/**
|
|
21
|
+
* OS-owned dialog (shared with the MCP gateway; lives in devConfirm.ts): an agent
|
|
22
|
+
* cannot approve by writing `yes` to hook stdin. Re-exported for existing callers.
|
|
23
|
+
*/
|
|
24
|
+
export { confirmNativeAction };
|
|
20
25
|
export declare function evaluateNativeHook(client: NativeClient, raw: Payload, args: HookArgs, config: BotGuardConfig, confirm?: (reason: string) => Promise<boolean>): Promise<{
|
|
21
26
|
stdout: string;
|
|
22
27
|
stderr: string;
|
|
23
28
|
exitCode: number;
|
|
24
29
|
}>;
|
|
25
|
-
export {};
|
package/dist/nativeHook.js
CHANGED
|
@@ -1,19 +1,18 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.NATIVE_CLIENTS = void 0;
|
|
3
|
+
exports.confirmNativeAction = exports.NATIVE_CLIENTS = void 0;
|
|
4
4
|
exports.patchToolCalls = patchToolCalls;
|
|
5
5
|
exports.normalizeNativeInput = normalizeNativeInput;
|
|
6
6
|
exports.nativeVerdict = nativeVerdict;
|
|
7
7
|
exports.nativeErrorVerdict = nativeErrorVerdict;
|
|
8
|
-
exports.confirmNativeAction = confirmNativeAction;
|
|
9
8
|
exports.evaluateNativeHook = evaluateNativeHook;
|
|
10
9
|
/** Native hook adapters. Never emit an unsupported `ask` to an IDE. */
|
|
11
|
-
const child_process_1 = require("child_process");
|
|
12
10
|
const hook_1 = require("./commands/hook");
|
|
13
11
|
const config_1 = require("./config");
|
|
14
12
|
const runtimeConfig_1 = require("./runtimeConfig");
|
|
15
13
|
const hookIo_1 = require("./hookIo");
|
|
16
14
|
const devConfirm_1 = require("./devConfirm");
|
|
15
|
+
Object.defineProperty(exports, "confirmNativeAction", { enumerable: true, get: function () { return devConfirm_1.confirmNativeAction; } });
|
|
17
16
|
const telemetry_1 = require("./telemetry");
|
|
18
17
|
const codexMcpIdentity_1 = require("./codexMcpIdentity");
|
|
19
18
|
exports.NATIVE_CLIENTS = ['codex', 'gemini_cli', 'windsurf', 'kiro'];
|
|
@@ -111,44 +110,7 @@ function nativeErrorVerdict(client, reason) {
|
|
|
111
110
|
return { stdout: JSON.stringify({ decision: 'block', reason }), stderr: '', exitCode: 0 };
|
|
112
111
|
return nativeVerdict(client, true, reason);
|
|
113
112
|
}
|
|
114
|
-
|
|
115
|
-
async function confirmNativeAction(reason, timeoutMs = 60_000) {
|
|
116
|
-
if (!(0, devConfirm_1.isHumanPresent)())
|
|
117
|
-
return false;
|
|
118
|
-
let executable;
|
|
119
|
-
let args;
|
|
120
|
-
const message = `FullCourtDefense — developer approval\n\n${reason.slice(0, 1800)}\n\nAllow this action once?`;
|
|
121
|
-
if (process.platform === 'win32') {
|
|
122
|
-
executable = 'powershell.exe';
|
|
123
|
-
const encoded = Buffer.from(message, 'utf8').toString('base64');
|
|
124
|
-
const script = `$msg=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${encoded}')); $w=New-Object -ComObject WScript.Shell; $answer=$w.Popup($msg,${Math.ceil(timeoutMs / 1000)},'FullCourtDefense',292); if($answer -eq 6){exit 0}else{exit 1}`;
|
|
125
|
-
args = ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')];
|
|
126
|
-
}
|
|
127
|
-
else if (process.platform === 'darwin') {
|
|
128
|
-
executable = '/usr/bin/osascript';
|
|
129
|
-
args = ['-e', 'on run argv', '-e', 'display dialog (item 1 of argv) buttons {"Deny", "Allow once"} default button "Deny" giving up after 60', '-e', 'if gave up of result or button returned of result is not "Allow once" then error number -128', '-e', 'end run', message];
|
|
130
|
-
}
|
|
131
|
-
else {
|
|
132
|
-
executable = 'zenity';
|
|
133
|
-
args = ['--question', '--default-cancel', '--title=FullCourtDefense', `--text=${message}`, '--ok-label=Allow once', '--cancel-label=Deny', `--timeout=${Math.ceil(timeoutMs / 1000)}`];
|
|
134
|
-
}
|
|
135
|
-
return new Promise(resolve => {
|
|
136
|
-
let finished = false;
|
|
137
|
-
const child = (0, child_process_1.spawn)(executable, args, { stdio: 'ignore', windowsHide: true });
|
|
138
|
-
const cancelChild = () => child.kill();
|
|
139
|
-
process.once('exit', cancelChild);
|
|
140
|
-
const done = (allowed) => { if (!finished) {
|
|
141
|
-
finished = true;
|
|
142
|
-
clearTimeout(timer);
|
|
143
|
-
process.removeListener('exit', cancelChild);
|
|
144
|
-
resolve(allowed);
|
|
145
|
-
} };
|
|
146
|
-
const timer = setTimeout(() => { child.kill(); done(false); }, timeoutMs + 1000);
|
|
147
|
-
child.on('error', () => done(false));
|
|
148
|
-
child.on('exit', code => done(code === 0));
|
|
149
|
-
});
|
|
150
|
-
}
|
|
151
|
-
async function evaluateNativeHook(client, raw, args, config, confirm = confirmNativeAction) {
|
|
113
|
+
async function evaluateNativeHook(client, raw, args, config, confirm = devConfirm_1.confirmNativeAction) {
|
|
152
114
|
if (client === 'codex' && raw.tool_name === 'apply_patch') {
|
|
153
115
|
try {
|
|
154
116
|
// Evaluate every file operation before Codex executes the atomic patch.
|
|
@@ -165,8 +127,13 @@ async function evaluateNativeHook(client, raw, args, config, confirm = confirmNa
|
|
|
165
127
|
}
|
|
166
128
|
}
|
|
167
129
|
let payload;
|
|
130
|
+
const resolveCodexIdentity = client === 'codex' && String(raw.tool_name || '').startsWith('mcp__');
|
|
131
|
+
if (resolveCodexIdentity && !(0, codexMcpIdentity_1.loadTomlParser)()) {
|
|
132
|
+
// Degraded, never dead: policies still evaluate on the sanitized tool name.
|
|
133
|
+
process.stderr.write('FullCourtDefense: TOML parser unavailable in this install; Codex MCP server identity falls back to the sanitized tool name.\n');
|
|
134
|
+
}
|
|
168
135
|
try {
|
|
169
|
-
payload = normalizeNativeInput(client, raw,
|
|
136
|
+
payload = normalizeNativeInput(client, raw, resolveCodexIdentity ? (0, codexMcpIdentity_1.codexMcpServerNames)() : []);
|
|
170
137
|
}
|
|
171
138
|
catch (error) {
|
|
172
139
|
return nativeErrorVerdict(client, `FullCourtDefense could not parse the hook: ${String(error)}`);
|
|
@@ -197,6 +164,10 @@ async function evaluateNativeHook(client, raw, args, config, confirm = confirmNa
|
|
|
197
164
|
}
|
|
198
165
|
catch { /* deny */ }
|
|
199
166
|
denied = !allowed;
|
|
167
|
+
// Hand the click to the MCP gateway that will see this exact call next, so the
|
|
168
|
+
// developer is not asked twice for one action (single-use, 2-minute, exact digest).
|
|
169
|
+
if (allowed && call && mapped?.event === 'mcp')
|
|
170
|
+
(0, devConfirm_1.recordConfirmedAction)(call.toolName, (0, devConfirm_1.actionDigest)(call.toolArgs));
|
|
200
171
|
(0, telemetry_1.spoolEvent)({ decision: allowed ? 'allow' : 'block', toolName: call?.toolName || payload.tool_name,
|
|
201
172
|
reason: allowed ? 'Developer approved once in the FullCourtDefense dialog.' : 'Developer declined, confirmation unavailable, or timed out.',
|
|
202
173
|
approvalScope: 'developer', ...(allowed ? { approvedBy: 'developer' } : {}) });
|
package/dist/version.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fullcourtdefense-cli",
|
|
3
|
-
"version": "1.34.
|
|
3
|
+
"version": "1.34.6",
|
|
4
4
|
"description": "Full Court Defense CLI — security scanning for AI agents from your terminal",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -94,8 +94,9 @@
|
|
|
94
94
|
"test:agent-ci-gate": "npm run build && node scripts/test-agent-ci-gate.js",
|
|
95
95
|
"test:ide-fp-corpus": "npm run build && node scripts/test-ide-fp-corpus.js",
|
|
96
96
|
"test:ide-fp-corpus:hook": "npm run build && node scripts/test-ide-fp-corpus.js --hook",
|
|
97
|
+
"test:msi-payload-deps": "npm run build && node scripts/test-msi-payload-deps.js",
|
|
97
98
|
"build:msi": "powershell -NoProfile -ExecutionPolicy Bypass -File installer/windows/Build-Msi.ps1",
|
|
98
|
-
"prepublishOnly": "npm run build && node scripts/test-shell-parity.js && node scripts/test-terminal-mcp-ask.js"
|
|
99
|
+
"prepublishOnly": "npm run build && node scripts/test-msi-payload-deps.js && node scripts/test-shell-parity.js && node scripts/test-terminal-mcp-ask.js"
|
|
99
100
|
},
|
|
100
101
|
"keywords": [
|
|
101
102
|
"llm",
|