fullcourtdefense-cli 1.34.5 → 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.
@@ -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 {};
@@ -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
- const toml_1 = require("@iarna/toml");
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 (0, toml_1.parse)(fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, ''));
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 the client can show a
1226
- // dialog and a human is present. Session memory reuses the IDE hook's
1227
- // ledger (same tool + rule => remembered). Decline, cancel, timeout, a
1228
- // client without elicitation, or no human => block, never a silent allow.
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 (this.clientSupportsElicitation && (0, devConfirm_1.isHumanPresent)()) {
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.elicitDeveloperConfirmation({ toolName, reason: ask.reason, ruleLabel: `${origin} "${ask.itemId}" (${ask.ruleId})`, evidence: ask.evidence });
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 MCP client prompt${answer.remember ? ' (remembered for this session)' : ''} — ${ask.reason}`,
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 how = answer.answer === 'timeout' ? 'developer confirmation not given (prompt timed out)' : 'developer declined in MCP client prompt';
1302
+ const no = describeDeveloperNo(answer);
1276
1303
  (0, telemetry_1.spoolEvent)({
1277
1304
  decision: 'block', toolName, operation: rememberScope.operation,
1278
- reason: `${how} — ${ask.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, `${answer.answer === 'timeout' ? 'You did not answer the confirmation prompt in time' : 'You declined the confirmation prompt'}, so this tool call was not executed.`));
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.clientSupportsElicitation
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 (!this.clientSupportsElicitation) {
1840
- const reason = 'This rule requires developer confirmation inside the IDE, but this MCP client does not support confirmation prompts. The action was not executed. Update or reconnect the IDE integration.';
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.elicitDeveloperConfirmation({
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 MCP client prompt${answer.remember ? ' (remembered for this session)' : ''}${policyLabel}`,
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 how = answer.answer === 'timeout' ? 'developer confirmation not given (prompt timed out)' : 'developer declined in MCP client prompt';
1922
+ const no = describeDeveloperNo(answer);
1895
1923
  (0, telemetry_1.spoolEvent)({
1896
1924
  decision: 'block', toolName, operation,
1897
- reason: `${how}${policyLabel}`,
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, `${answer.answer === 'timeout' ? 'You did not answer the confirmation prompt in time' : 'You declined the confirmation prompt'}, so this tool call was not executed.`));
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) {
@@ -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;
@@ -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
- if (expired.length > 0 || remembered.length !== ledger.remembered.length) {
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;
@@ -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
- /** OS-owned dialog: an agent cannot approve by writing `yes` to hook stdin. */
19
- export declare function confirmNativeAction(reason: string, timeoutMs?: number): Promise<boolean>;
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 {};
@@ -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
- /** OS-owned dialog: an agent cannot approve by writing `yes` to hook stdin. */
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, client === 'codex' && String(raw.tool_name || '').startsWith('mcp__') ? (0, codexMcpIdentity_1.codexMcpServerNames)() : []);
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
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "1.34.5"
2
+ "version": "1.34.6"
3
3
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullcourtdefense-cli",
3
- "version": "1.34.5",
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",