fullcourtdefense-cli 1.34.5 → 1.34.7

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')
@@ -41,6 +41,7 @@ const fs = __importStar(require("fs"));
41
41
  const path = __importStar(require("path"));
42
42
  const config_1 = require("../config");
43
43
  const discoverPaths_1 = require("./discoverPaths");
44
+ const discoverProxy_1 = require("./discoverProxy");
44
45
  const discoverAgentFiles_1 = require("./discoverAgentFiles");
45
46
  const discoverSecrets_1 = require("./discoverSecrets");
46
47
  const discover_1 = require("./discover");
@@ -88,19 +89,6 @@ function parseTomlMcpServers(content) {
88
89
  }
89
90
  return servers;
90
91
  }
91
- function extractServerMap(parsed) {
92
- if (!parsed || typeof parsed !== 'object')
93
- return {};
94
- if (parsed.mcpServers && typeof parsed.mcpServers === 'object')
95
- return parsed.mcpServers;
96
- if (parsed.servers && typeof parsed.servers === 'object')
97
- return parsed.servers;
98
- if (parsed.mcp?.servers && typeof parsed.mcp.servers === 'object')
99
- return parsed.mcp.servers;
100
- if (parsed['mcp.servers'] && typeof parsed['mcp.servers'] === 'object')
101
- return parsed['mcp.servers'];
102
- return {};
103
- }
104
92
  function riskSignalsForServer(name, server) {
105
93
  const text = [
106
94
  name,
@@ -129,7 +117,7 @@ function discoverMcpServers(cwd, extraPath) {
129
117
  const parsed = isToml
130
118
  ? parseTomlMcpServers(fs.readFileSync(candidate.path, 'utf8'))
131
119
  : parseJsonFile(candidate.path);
132
- const map = extractServerMap(parsed);
120
+ const map = (0, discoverProxy_1.extractServerMap)(parsed);
133
121
  for (const [name, server] of Object.entries(map)) {
134
122
  const cfg = server;
135
123
  const command = typeof cfg.command === 'string' ? cfg.command : undefined;
@@ -153,19 +153,6 @@ function deriveProjectRoots(candidates) {
153
153
  return [...roots];
154
154
  }
155
155
  /** MCP servers can be declared under several keys across clients. */
156
- function extractServerMap(parsed) {
157
- if (!parsed || typeof parsed !== 'object')
158
- return {};
159
- if (parsed.mcpServers && typeof parsed.mcpServers === 'object')
160
- return parsed.mcpServers;
161
- if (parsed.servers && typeof parsed.servers === 'object')
162
- return parsed.servers;
163
- if (parsed.mcp?.servers && typeof parsed.mcp.servers === 'object')
164
- return parsed.mcp.servers;
165
- if (parsed['mcp.servers'] && typeof parsed['mcp.servers'] === 'object')
166
- return parsed['mcp.servers'];
167
- return {};
168
- }
169
156
  function parseTomlString(value) {
170
157
  const trimmed = value.trim().replace(/,$/, '');
171
158
  if (!trimmed)
@@ -347,7 +334,7 @@ function parseConfigFile(filePath, source) {
347
334
  return [];
348
335
  }
349
336
  const out = [];
350
- for (const [name, cfgRaw] of Object.entries(extractServerMap(parsed))) {
337
+ for (const [name, cfgRaw] of Object.entries((0, discoverProxy_1.extractServerMap)(parsed))) {
351
338
  pushDiscoveredServer(out, name, cfgRaw, source, filePath);
352
339
  }
353
340
  // Claude Code stores MCP per-project inside ~/.claude.json
@@ -25,6 +25,29 @@ export interface ProxyClassifiableServer {
25
25
  configPath: string;
26
26
  }
27
27
  export declare function isFcdGatewayServer(server: ProxyClassifiableServer): boolean;
28
+ /**
29
+ * The MCP server map of a parsed client config, whatever the client calls it:
30
+ * `mcpServers` (Cursor, Claude, Windsurf, Gemini), `servers` / `mcp.servers`
31
+ * (VS Code), `mcp_servers` (Codex TOML). Shared by discovery, agent-ci and the
32
+ * hook so every surface reads the same servers.
33
+ */
34
+ export declare function extractServerMap(parsed: any): Record<string, any>;
35
+ /**
36
+ * Every entry named `serverName` across this machine's AI-client MCP configs
37
+ * (Cursor, Claude Code incl. per-project maps in ~/.claude.json, Codex TOML,
38
+ * VS Code, Windsurf, Gemini, ...). Matches the exact name or the sanitized
39
+ * alias clients such as Codex expose (`patria-bank` -> `patria_bank`).
40
+ * Unreadable / unparsable files are skipped, never thrown.
41
+ */
42
+ export declare function findClientMcpServers(serverName: string, cwd?: string, clientKey?: string): ProxyClassifiableServer[];
43
+ /**
44
+ * True when every config entry for `serverName` — in the given client's configs
45
+ * when `clientKey` is set (`claude_code`, `vscode`, ...), else on the whole
46
+ * machine — runs through the FullCourtDefense MCP gateway. Unknown name, or the
47
+ * same name wrapped in one config and direct in another, is `false` — the caller
48
+ * keeps its own guard, so an action is at worst confirmed twice, never zero times.
49
+ */
50
+ export declare function isServerBehindFcdGateway(serverName: string, cwd?: string, clientKey?: string): boolean;
28
51
  export declare function extractGatewayDownstream(server: ProxyClassifiableServer): {
29
52
  command?: string;
30
53
  args?: string[];
@@ -36,6 +36,9 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.FCD_HOOK_TAG = exports.FCD_HOOK_MARKER = exports.FCD_GATEWAY_SERVER_NAMES = void 0;
37
37
  exports.isReferenceConfigSource = isReferenceConfigSource;
38
38
  exports.isFcdGatewayServer = isFcdGatewayServer;
39
+ exports.extractServerMap = extractServerMap;
40
+ exports.findClientMcpServers = findClientMcpServers;
41
+ exports.isServerBehindFcdGateway = isServerBehindFcdGateway;
39
42
  exports.extractGatewayDownstream = extractGatewayDownstream;
40
43
  exports.buildGatewayWrapIndex = buildGatewayWrapIndex;
41
44
  exports.classifyProxyStatus = classifyProxyStatus;
@@ -46,6 +49,7 @@ const fs = __importStar(require("fs"));
46
49
  const os = __importStar(require("os"));
47
50
  const path = __importStar(require("path"));
48
51
  const discoverPaths_1 = require("./discoverPaths");
52
+ const codexMcpIdentity_1 = require("../codexMcpIdentity");
49
53
  /**
50
54
  * Config sources that no AI client actually loads. A plain `mcp.json` at a
51
55
  * repo root is documentation/sample material — Cursor, Claude Code, VS Code,
@@ -76,6 +80,96 @@ function isFcdGatewayServer(server) {
76
80
  return blob.includes('mcp-gateway')
77
81
  && (blob.includes('fullcourtdefense') || blob.includes('agentguard') || blob.includes('botguard') || blob.includes('fcd-managed'));
78
82
  }
83
+ /**
84
+ * The MCP server map of a parsed client config, whatever the client calls it:
85
+ * `mcpServers` (Cursor, Claude, Windsurf, Gemini), `servers` / `mcp.servers`
86
+ * (VS Code), `mcp_servers` (Codex TOML). Shared by discovery, agent-ci and the
87
+ * hook so every surface reads the same servers.
88
+ */
89
+ function extractServerMap(parsed) {
90
+ if (!parsed || typeof parsed !== 'object')
91
+ return {};
92
+ if (parsed.mcpServers && typeof parsed.mcpServers === 'object')
93
+ return parsed.mcpServers;
94
+ if (parsed.mcp_servers && typeof parsed.mcp_servers === 'object')
95
+ return parsed.mcp_servers;
96
+ if (parsed.servers && typeof parsed.servers === 'object')
97
+ return parsed.servers;
98
+ if (parsed.mcp?.servers && typeof parsed.mcp.servers === 'object')
99
+ return parsed.mcp.servers;
100
+ if (parsed['mcp.servers'] && typeof parsed['mcp.servers'] === 'object')
101
+ return parsed['mcp.servers'];
102
+ return {};
103
+ }
104
+ function sanitizedServerAlias(name) {
105
+ return name.toLowerCase().replace(/[^a-z0-9_]/g, '_');
106
+ }
107
+ /**
108
+ * Every entry named `serverName` across this machine's AI-client MCP configs
109
+ * (Cursor, Claude Code incl. per-project maps in ~/.claude.json, Codex TOML,
110
+ * VS Code, Windsurf, Gemini, ...). Matches the exact name or the sanitized
111
+ * alias clients such as Codex expose (`patria-bank` -> `patria_bank`).
112
+ * Unreadable / unparsable files are skipped, never thrown.
113
+ */
114
+ function findClientMcpServers(serverName, cwd = process.cwd(), clientKey) {
115
+ const wanted = serverName.toLowerCase();
116
+ const wantedAlias = sanitizedServerAlias(serverName);
117
+ const out = [];
118
+ for (const candidate of (0, discoverPaths_1.discoverScanTargets)(cwd)) {
119
+ if (clientKey && candidate.clientKey !== clientKey)
120
+ continue;
121
+ let parsed;
122
+ try {
123
+ const raw = fs.readFileSync(candidate.path, 'utf8').replace(/^\uFEFF/, '');
124
+ if (candidate.path.toLowerCase().endsWith('.toml')) {
125
+ const toml = (0, codexMcpIdentity_1.loadTomlParser)();
126
+ parsed = toml ? toml(raw) : null;
127
+ }
128
+ else {
129
+ parsed = JSON.parse(raw);
130
+ }
131
+ }
132
+ catch {
133
+ continue;
134
+ }
135
+ if (!parsed)
136
+ continue;
137
+ const maps = [extractServerMap(parsed)];
138
+ if (parsed.projects && typeof parsed.projects === 'object') {
139
+ for (const project of Object.values(parsed.projects))
140
+ maps.push(extractServerMap(project));
141
+ }
142
+ for (const map of maps) {
143
+ for (const [name, cfg] of Object.entries(map)) {
144
+ if (name.toLowerCase() !== wanted && sanitizedServerAlias(name) !== wantedAlias)
145
+ continue;
146
+ if (!cfg || typeof cfg !== 'object')
147
+ continue;
148
+ const entry = cfg;
149
+ out.push({
150
+ serverName: name,
151
+ command: typeof entry.command === 'string' ? entry.command : undefined,
152
+ args: Array.isArray(entry.args) ? entry.args.map(String) : undefined,
153
+ url: typeof entry.url === 'string' ? entry.url : typeof entry.endpoint === 'string' ? entry.endpoint : undefined,
154
+ source: candidate.source,
155
+ configPath: candidate.path,
156
+ });
157
+ }
158
+ }
159
+ }
160
+ return out;
161
+ }
162
+ /**
163
+ * True when every config entry for `serverName` — in the given client's configs
164
+ * when `clientKey` is set (`claude_code`, `vscode`, ...), else on the whole
165
+ * machine — runs through the FullCourtDefense MCP gateway. Unknown name, or the
166
+ * same name wrapped in one config and direct in another, is `false` — the caller
167
+ * keeps its own guard, so an action is at worst confirmed twice, never zero times.
168
+ */
169
+ function isServerBehindFcdGateway(serverName, cwd = process.cwd(), clientKey) {
170
+ const matches = findClientMcpServers(serverName, cwd, clientKey);
171
+ return matches.length > 0 && matches.every(isFcdGatewayServer);
172
+ }
79
173
  function extractGatewayDownstream(server) {
80
174
  const args = server.args || [];
81
175
  const urlIdx = args.indexOf('--mcp-url');
@@ -89,6 +89,41 @@ export declare function canonicalizeCursorPayload(argEvent: string | undefined,
89
89
  payload: Record<string, unknown>;
90
90
  ignore: boolean;
91
91
  };
92
+ /**
93
+ * Cursor 3.19+ also fires GENERIC tool events — `preToolUse` / `postToolUse`
94
+ * (lower-camel `hook_event_name`, MCP tools named `MCP:<tool>`, built-ins by
95
+ * their tool name) — for the same action its dedicated hooks already report
96
+ * (`beforeMCPExecution` / `afterMCPExecution` / `beforeShellExecution` / ...).
97
+ * Evaluated as-is, one MCP call was judged three times: the generic pre-event
98
+ * (whose `MCP:` name matches no per-tool rule, so it fell through to broad
99
+ * company rules and raised a phantom manager approval), the dedicated
100
+ * pre-event, and the generic POST-event — judged as if the call were about to
101
+ * run, after it had already run.
102
+ *
103
+ * preToolUse -> ignore for MCP (beforeMCPExecution is the authoritative surface,
104
+ * same rule as Shell in normalizeCursorBuiltinTool); built-ins go
105
+ * through the shared taxonomy.
106
+ * postToolUse -> a post-execution correlation signal, never enforced.
107
+ * Returns null for every other payload.
108
+ */
109
+ export declare function canonicalizeCursorGenericToolEvent(payload: Record<string, unknown>): {
110
+ event: HookEvent;
111
+ payload: Record<string, unknown>;
112
+ ignore: boolean;
113
+ } | null;
114
+ /**
115
+ * The FullCourtDefense MCP gateway already sits in front of this server.
116
+ * - Cursor reports the server's command line on `beforeMCPExecution`; ours is
117
+ * `<node> <cli> mcp-gateway --mcp-command|--mcp-url ... --agent-name ...`
118
+ * (Docker's MCP toolkit is `docker mcp gateway run` — no hyphen, no such flags).
119
+ * When a command line is present it is authoritative.
120
+ * - Claude Code / VS Code name the server (`mcp__<server>__<tool>`) but not its
121
+ * command: the server is looked up in THAT client's MCP configs (`client` is
122
+ * the hook's `--agent-client`, e.g. `claude-code` -> Claude Code's configs).
123
+ * Anything unknown is `false`: the hook then keeps its own guard, so an action
124
+ * is at worst confirmed twice, never zero times.
125
+ */
126
+ export declare function isGatewayWrappedMcpServer(payload: Record<string, unknown>, client?: string, cwd?: string): boolean;
92
127
  /**
93
128
  * Map a Cursor execution-hook payload onto an Action Policy tool call.
94
129
  * Returns null when there is nothing actionable to evaluate.
@@ -38,6 +38,8 @@ exports.canonicalBuiltinToolName = canonicalBuiltinToolName;
38
38
  exports.mapBuiltinToolPayload = mapBuiltinToolPayload;
39
39
  exports.normalizeCursorBuiltinTool = normalizeCursorBuiltinTool;
40
40
  exports.canonicalizeCursorPayload = canonicalizeCursorPayload;
41
+ exports.canonicalizeCursorGenericToolEvent = canonicalizeCursorGenericToolEvent;
42
+ exports.isGatewayWrappedMcpServer = isGatewayWrappedMcpServer;
41
43
  exports.buildToolCall = buildToolCall;
42
44
  exports.classifyGateHttpFailure = classifyGateHttpFailure;
43
45
  exports.gateHttpFailureDetail = gateHttpFailureDetail;
@@ -64,6 +66,7 @@ const devConfirm_1 = require("../devConfirm");
64
66
  // same rotated hook.log and read stdin identically.
65
67
  const hookIo_1 = require("../hookIo");
66
68
  const fileWriteCanon_1 = require("../fileWriteCanon");
69
+ const discoverProxy_1 = require("./discoverProxy");
67
70
  function inferEvent(explicit, payload) {
68
71
  const e = (explicit || '').toLowerCase();
69
72
  if (e) {
@@ -293,6 +296,9 @@ function normalizeCursorBuiltinTool(payload) {
293
296
  * IDE false-positive corpus (Layer A) evaluates exactly what the hook evaluates.
294
297
  */
295
298
  function canonicalizeCursorPayload(argEvent, payload) {
299
+ const generic = canonicalizeCursorGenericToolEvent(payload);
300
+ if (generic)
301
+ return generic;
296
302
  const event = inferEvent(argEvent, payload);
297
303
  if (event !== 'mcp')
298
304
  return { event, payload, ignore: false };
@@ -303,6 +309,76 @@ function canonicalizeCursorPayload(argEvent, payload) {
303
309
  return { event: 'unknown', payload, ignore: true };
304
310
  return { event: builtin.event, payload: builtin.payload, ignore: false };
305
311
  }
312
+ /** Cursor names MCP tools `MCP:<tool>` in its generic tool events. */
313
+ const CURSOR_GENERIC_MCP_PREFIX = 'MCP:';
314
+ /**
315
+ * Cursor 3.19+ also fires GENERIC tool events — `preToolUse` / `postToolUse`
316
+ * (lower-camel `hook_event_name`, MCP tools named `MCP:<tool>`, built-ins by
317
+ * their tool name) — for the same action its dedicated hooks already report
318
+ * (`beforeMCPExecution` / `afterMCPExecution` / `beforeShellExecution` / ...).
319
+ * Evaluated as-is, one MCP call was judged three times: the generic pre-event
320
+ * (whose `MCP:` name matches no per-tool rule, so it fell through to broad
321
+ * company rules and raised a phantom manager approval), the dedicated
322
+ * pre-event, and the generic POST-event — judged as if the call were about to
323
+ * run, after it had already run.
324
+ *
325
+ * preToolUse -> ignore for MCP (beforeMCPExecution is the authoritative surface,
326
+ * same rule as Shell in normalizeCursorBuiltinTool); built-ins go
327
+ * through the shared taxonomy.
328
+ * postToolUse -> a post-execution correlation signal, never enforced.
329
+ * Returns null for every other payload.
330
+ */
331
+ function canonicalizeCursorGenericToolEvent(payload) {
332
+ const hookEventName = str(payload.hook_event_name);
333
+ if (hookEventName !== 'preToolUse' && hookEventName !== 'postToolUse')
334
+ return null;
335
+ const toolName = str(payload.tool_name);
336
+ const isMcp = toolName.startsWith(CURSOR_GENERIC_MCP_PREFIX);
337
+ if (hookEventName === 'postToolUse') {
338
+ if (isMcp) {
339
+ return { event: 'post', payload: { ...payload, tool_name: toolName.slice(CURSOR_GENERIC_MCP_PREFIX.length), fcd_post_of: 'mcp' }, ignore: false };
340
+ }
341
+ const builtin = normalizeCursorBuiltinTool(payload);
342
+ if (!builtin || builtin.event === 'ignore')
343
+ return { event: 'unknown', payload, ignore: true };
344
+ return { event: 'post', payload: { ...builtin.payload, fcd_post_of: builtin.event }, ignore: false };
345
+ }
346
+ if (isMcp)
347
+ return { event: 'unknown', payload, ignore: true };
348
+ const builtin = normalizeCursorBuiltinTool(payload);
349
+ if (!builtin)
350
+ return null;
351
+ if (builtin.event === 'ignore')
352
+ return { event: 'unknown', payload, ignore: true };
353
+ return { event: builtin.event, payload: builtin.payload, ignore: false };
354
+ }
355
+ /**
356
+ * The FullCourtDefense MCP gateway already sits in front of this server.
357
+ * - Cursor reports the server's command line on `beforeMCPExecution`; ours is
358
+ * `<node> <cli> mcp-gateway --mcp-command|--mcp-url ... --agent-name ...`
359
+ * (Docker's MCP toolkit is `docker mcp gateway run` — no hyphen, no such flags).
360
+ * When a command line is present it is authoritative.
361
+ * - Claude Code / VS Code name the server (`mcp__<server>__<tool>`) but not its
362
+ * command: the server is looked up in THAT client's MCP configs (`client` is
363
+ * the hook's `--agent-client`, e.g. `claude-code` -> Claude Code's configs).
364
+ * Anything unknown is `false`: the hook then keeps its own guard, so an action
365
+ * is at worst confirmed twice, never zero times.
366
+ */
367
+ function isGatewayWrappedMcpServer(payload, client, cwd = process.cwd()) {
368
+ const command = str(payload.command);
369
+ if (command)
370
+ return /\bmcp-gateway\b/.test(command) && /--(?:mcp-command|mcp-url|mcp-args|agent-name)\b/.test(command);
371
+ const server = mcpServerName(payload);
372
+ if (!server)
373
+ return false;
374
+ const clientKey = client ? client.toLowerCase().replace(/-/g, '_') : undefined;
375
+ try {
376
+ return (0, discoverProxy_1.isServerBehindFcdGateway)(server, cwd, clientKey);
377
+ }
378
+ catch {
379
+ return false;
380
+ }
381
+ }
306
382
  /**
307
383
  * Which AI client invoked a Claude-format hook. Claude Code sets CLAUDECODE=1;
308
384
  * VS Code Copilot runs hooks inside the VS Code process environment.
@@ -1746,6 +1822,17 @@ async function enforceActionPolicy(ctx) {
1746
1822
  }
1747
1823
  // Record untrusted ingress for this event (never blocks).
1748
1824
  (0, taintLedger_1.noteIngress)(sessId, event, call.toolName, call.toolArgs);
1825
+ // ONE enforcement point per MCP call. A server the FullCourtDefense MCP gateway
1826
+ // wraps is checked by the gateway in-line — same vendored engine, same cached
1827
+ // policies, one developer dialog, one org approval, one audit record. Judging
1828
+ // it here as well gave one action two prompts (IDE ask + gateway dialog) and
1829
+ // two approval requests. Local Safety, taint and the self-protection guards
1830
+ // above still ran; only the Action Policy step is the gateway's.
1831
+ if (event === 'mcp' && isGatewayWrappedMcpServer(payload, ctx.client)) {
1832
+ (0, hookIo_1.dbg)({ phase: 'policy_deferred_to_gateway', event, tool: call.toolName, server: mcpServerName(payload) });
1833
+ respond(false);
1834
+ return;
1835
+ }
1749
1836
  const headers = { 'Content-Type': 'application/json' };
1750
1837
  if (shieldKey)
1751
1838
  headers['x-shield-key'] = shieldKey;
@@ -948,6 +948,38 @@ 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
+ if (answer.answer === 'unavailable') {
973
+ return {
974
+ reason: 'developer confirmation not possible (no dialog can be shown on this machine and the MCP client offers no prompt)',
975
+ explanation: 'This rule needs your confirmation, but no confirmation prompt can be shown here (no display and the client cannot ask)',
976
+ };
977
+ }
978
+ return {
979
+ reason: `developer declined in ${via}${autoNote}`,
980
+ explanation: 'You declined the confirmation prompt',
981
+ };
982
+ }
951
983
  class McpGatewayServer {
952
984
  gatewayConfig;
953
985
  downstreamSpec;
@@ -957,8 +989,8 @@ class McpGatewayServer {
957
989
  api;
958
990
  /**
959
991
  * The MCP client declared `capabilities.elicitation` on initialize — it can
960
- * show the user a server-initiated question. That is the only channel a stdio
961
- * proxy has to "Ask the developer"; without it an ask rule fails strict.
992
+ * show the user a server-initiated question. Used only when no OS dialog can be
993
+ * shown (headless) or when `FCD_CONFIRM_CHANNEL=mcp-client` — see confirmWithDeveloper.
962
994
  */
963
995
  clientSupportsElicitation = false;
964
996
  /** Server -> client requests (elicitation) awaiting the client's response, by id. */
@@ -1222,10 +1254,11 @@ class McpGatewayServer {
1222
1254
  process.stderr.write(`FullCourtDefense warning (${warning.itemId}): ${warning.reason}\n`);
1223
1255
  }
1224
1256
  // "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.
1257
+ // human — through the MCP client (elicitation) when it can show a dialog,
1258
+ // otherwise (or when the client answers for the user) through the OS
1259
+ // dialog — whenever a human is present. Session memory reuses the IDE
1260
+ // hook's ledger (same tool + rule => remembered). Decline, cancel, timeout
1261
+ // or no human => block, never a silent allow.
1229
1262
  let localBlock = requestOutcome.blockingFinding || requestOutcome.askFinding;
1230
1263
  let askNote = '';
1231
1264
  if (!requestOutcome.blockingFinding && requestOutcome.askFinding && !reportOnlyMode) {
@@ -1245,7 +1278,7 @@ class McpGatewayServer {
1245
1278
  (0, telemetry_1.triggerFlush)(false);
1246
1279
  localBlock = undefined;
1247
1280
  }
1248
- else if (this.clientSupportsElicitation && (0, devConfirm_1.isHumanPresent)()) {
1281
+ else if ((0, devConfirm_1.isHumanPresent)()) {
1249
1282
  const digest = (0, devConfirm_1.actionDigest)(toolArgs);
1250
1283
  const marker = (0, devConfirm_1.raiseDeveloperConfirmation)({
1251
1284
  event: 'mcp_gateway', toolName, operation: rememberScope.operation, argsDigest: digest,
@@ -1253,7 +1286,7 @@ class McpGatewayServer {
1253
1286
  sessionId, remember: 'none',
1254
1287
  });
1255
1288
  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 });
1289
+ const answer = await this.confirmWithDeveloper({ toolName, reason: ask.reason, ruleLabel: `${origin} "${ask.itemId}" (${ask.ruleId})`, evidence: ask.evidence, argsDigest: digest });
1257
1290
  if (answer.answer === 'accept') {
1258
1291
  // Settle through the shared ledger so "remember for this session" is
1259
1292
  // recorded exactly like an IDE approval would be.
@@ -1262,7 +1295,7 @@ class McpGatewayServer {
1262
1295
  (0, devConfirm_1.settleDeveloperConfirmation)('mcp_gateway', toolName, digest);
1263
1296
  (0, telemetry_1.spoolEvent)({
1264
1297
  decision: 'allow', toolName, operation: rememberScope.operation,
1265
- reason: `developer confirmed in MCP client prompt${answer.remember ? ' (remembered for this session)' : ''} — ${ask.reason}`,
1298
+ 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
1299
  ruleId: ask.ruleId, category: ask.category, categoryId: ask.categoryId, itemId: ask.itemId, source: ask.source,
1267
1300
  evidence: ask.evidence, policyHash: ask.policyHash, offlineEnforced: true,
1268
1301
  approvalScope: 'developer', approvedBy: 'developer', confirmLatencyMs: answer.latencyMs,
@@ -1272,22 +1305,20 @@ class McpGatewayServer {
1272
1305
  }
1273
1306
  else {
1274
1307
  (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';
1308
+ const no = describeDeveloperNo(answer);
1276
1309
  (0, telemetry_1.spoolEvent)({
1277
1310
  decision: 'block', toolName, operation: rememberScope.operation,
1278
- reason: `${how} — ${ask.reason}`,
1311
+ reason: `${no.reason} — ${ask.reason}`,
1279
1312
  ruleId: ask.ruleId, category: ask.category, categoryId: ask.categoryId, itemId: ask.itemId, source: ask.source,
1280
1313
  evidence: ask.evidence, policyHash: ask.policyHash, offlineEnforced: true,
1281
1314
  approvalScope: 'developer', confirmLatencyMs: answer.latencyMs,
1282
1315
  });
1283
1316
  (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.`));
1317
+ throw blockError(explainGatewayLocalSafety('block', toolName, toolArgs, ask, `${no.explanation}, so this tool call was not executed.`));
1285
1318
  }
1286
1319
  }
1287
1320
  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).';
1321
+ askNote = ' This rule asks the developer to confirm, but no human is present on this machine (CI / ephemeral workload), so it blocks here.';
1291
1322
  }
1292
1323
  }
1293
1324
  if (localBlock) {
@@ -1836,8 +1867,10 @@ class McpGatewayServer {
1836
1867
  * throws the block error when the developer declines or does not answer.
1837
1868
  */
1838
1869
  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.';
1870
+ if (!(0, devConfirm_1.isHumanPresent)()) {
1871
+ // The engine normally degrades developer scope to org where no human exists;
1872
+ // this is the belt for a rule that still reached us with developer scope.
1873
+ const reason = 'This rule requires developer confirmation, but no human is present on this machine (CI / ephemeral workload). The action was not executed.';
1841
1874
  (0, telemetry_1.spoolEvent)({ decision: 'block', toolName, operation, reason, approvalScope: 'developer', ruleId: verdict.policyId });
1842
1875
  (0, telemetry_1.triggerFlush)(true);
1843
1876
  throw blockError(reason);
@@ -1865,7 +1898,7 @@ class McpGatewayServer {
1865
1898
  });
1866
1899
  (0, telemetry_1.spoolEvent)({
1867
1900
  decision: 'approval', toolName, operation,
1868
- reason: `developer confirmation requested via MCP client prompt${policyLabel}${verdict.reason ? `: ${verdict.reason}` : ''}`,
1901
+ reason: `developer confirmation requested via ${(0, devConfirm_1.nativeDialogAvailable)() ? 'FullCourtDefense dialog' : this.clientSupportsElicitation ? 'MCP client prompt' : 'FullCourtDefense dialog'}${policyLabel}${verdict.reason ? `: ${verdict.reason}` : ''}`,
1869
1902
  ruleId, offlineEnforced: true, approvalScope: 'developer',
1870
1903
  });
1871
1904
  (0, telemetry_1.triggerFlush)(false);
@@ -1873,32 +1906,89 @@ class McpGatewayServer {
1873
1906
  ? ' 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
1907
  : '';
1875
1908
  process.stderr.write(`FullCourtDefense asking the developer to confirm ${toolName}${policyLabel}: ${verdict.reason || verdict.matchedRule || 'action policy'}\n`);
1876
- const answer = await this.elicitDeveloperConfirmation({
1909
+ const answer = await this.confirmWithDeveloper({
1877
1910
  toolName,
1878
1911
  reason: `${verdict.reason || `${toolName} needs your confirmation`}.${ceiling}`,
1879
1912
  allowRemember: false,
1880
1913
  ruleLabel: verdict.policyName ? `Action Policy "${verdict.policyName}"${verdict.matchedRule ? ` — ${verdict.matchedRule}` : ''}` : 'Action Policy',
1914
+ argsDigest: digest,
1881
1915
  });
1882
1916
  if (answer.answer === 'accept') {
1883
1917
  // Policy memory limits are authoritative; a client cannot upgrade 'none'.
1884
1918
  (0, devConfirm_1.settleDeveloperConfirmation)('mcp_gateway', toolName, digest);
1885
1919
  (0, telemetry_1.spoolEvent)({
1886
1920
  decision: 'allow', toolName, operation,
1887
- reason: `developer confirmed in MCP client prompt${answer.remember ? ' (remembered for this session)' : ''}${policyLabel}`,
1921
+ 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
1922
  ruleId, offlineEnforced: true, approvalScope: 'developer', approvedBy: 'developer', confirmLatencyMs: answer.latencyMs,
1889
1923
  });
1890
1924
  (0, telemetry_1.triggerFlush)(false);
1891
1925
  return true;
1892
1926
  }
1893
1927
  (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';
1928
+ const no = describeDeveloperNo(answer);
1895
1929
  (0, telemetry_1.spoolEvent)({
1896
1930
  decision: 'block', toolName, operation,
1897
- reason: `${how}${policyLabel}`,
1931
+ reason: `${no.reason}${policyLabel}`,
1898
1932
  ruleId, offlineEnforced: true, approvalScope: 'developer', confirmLatencyMs: answer.latencyMs,
1899
1933
  });
1900
1934
  (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.`));
1935
+ throw blockError(explainGatewayPolicy('block', toolName, toolArgs, verdict, policies, `${no.explanation}, so this tool call was not executed.`));
1936
+ }
1937
+ /**
1938
+ * "Ask the developer" — ONE dialog, the same in every MCP client.
1939
+ *
1940
+ * The FullCourtDefense OS dialog (PowerShell popup / osascript / zenity) is the
1941
+ * channel. It is owned by this process, not by the IDE, so the agent cannot
1942
+ * answer it and the client cannot answer it for the user. In-client prompts
1943
+ * (MCP elicitation) turned out to be unreliable exactly where it matters:
1944
+ * Codex Desktop "Full access" declines every elicitation in milliseconds,
1945
+ * Cursor 3.19 declines it after ~20s without rendering it — in both cases the
1946
+ * developer saw nothing and the audit said "declined". Order:
1947
+ * 1. no human on this machine => unavailable (caller blocks)
1948
+ * 2. the IDE hook's dialog already confirmed => accept (same call, not asked twice)
1949
+ * THIS exact call moments ago
1950
+ * 3. OS dialog => the developer's answer
1951
+ * 4. no dialog possible (headless, no display, => in-client prompt if the client
1952
+ * binary missing) supports it; a client that answers
1953
+ * for the user is still a "no"
1954
+ * `FCD_CONFIRM_CHANNEL=mcp-client` swaps 3 and 4 for clients whose in-chat prompt
1955
+ * an org has verified. Every path that is not an explicit "yes" from a human
1956
+ * ends in a block.
1957
+ */
1958
+ async confirmWithDeveloper(input) {
1959
+ if (!(0, devConfirm_1.isHumanPresent)())
1960
+ return { answer: 'unavailable', remember: false, latencyMs: 0, clientAutoAnswered: false };
1961
+ const handoff = input.argsDigest ? (0, devConfirm_1.consumeConfirmedAction)(input.toolName, input.argsDigest) : undefined;
1962
+ if (handoff) {
1963
+ return { answer: 'accept', remember: false, latencyMs: Math.max(0, Date.now() - Date.parse(handoff.confirmedAt)), channel: 'hook_dialog', clientAutoAnswered: false };
1964
+ }
1965
+ const preferClient = (process.env.FCD_CONFIRM_CHANNEL || '').toLowerCase() === 'mcp-client' && this.clientSupportsElicitation;
1966
+ let clientAutoAnswered = false;
1967
+ if (preferClient) {
1968
+ const viaClient = await this.elicitDeveloperConfirmation(input);
1969
+ const automated = viaClient.clientError || (0, devConfirm_1.looksLikeClientAutoAnswer)(viaClient.answer, viaClient.latencyMs);
1970
+ if (!automated)
1971
+ return { ...viaClient, channel: 'mcp_client', clientAutoAnswered: false };
1972
+ clientAutoAnswered = true;
1973
+ process.stderr.write(`FullCourtDefense: the MCP client answered the confirmation prompt for ${input.toolName} by itself in ${viaClient.latencyMs}ms (no prompt reached the developer); asking in the FullCourtDefense dialog instead.\n`);
1974
+ }
1975
+ const dialog = await this.askInOsDialog(input, clientAutoAnswered);
1976
+ if (dialog.answer !== 'unavailable')
1977
+ return dialog;
1978
+ if (!preferClient && this.clientSupportsElicitation) {
1979
+ process.stderr.write(`FullCourtDefense: no dialog can be shown on this machine for ${input.toolName} (headless / no display); asking through the MCP client prompt instead.\n`);
1980
+ const viaClient = await this.elicitDeveloperConfirmation(input);
1981
+ const automated = viaClient.clientError || (0, devConfirm_1.looksLikeClientAutoAnswer)(viaClient.answer, viaClient.latencyMs);
1982
+ return { ...viaClient, answer: automated ? 'decline' : viaClient.answer, channel: 'mcp_client', clientAutoAnswered: automated };
1983
+ }
1984
+ return dialog;
1985
+ }
1986
+ async askInOsDialog(input, clientAutoAnswered) {
1987
+ const evidence = input.evidence ? `\nEvidence: ${input.evidence.slice(0, 240)}` : '';
1988
+ const dialog = await (0, devConfirm_1.confirmNativeActionDetailed)(`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);
1989
+ process.stderr.write(`FullCourtDefense: dialog for ${input.toolName} ended with "${dialog.outcome}" after ${dialog.latencyMs}ms.\n`);
1990
+ const answer = dialog.outcome === 'allow' ? 'accept' : dialog.outcome === 'deny' ? 'decline' : dialog.outcome;
1991
+ return { answer, remember: false, latencyMs: dialog.latencyMs, channel: 'os_dialog', clientAutoAnswered };
1902
1992
  }
1903
1993
  async elicitDeveloperConfirmation(input) {
1904
1994
  const started = Date.now();
@@ -1925,8 +2015,15 @@ class McpGatewayServer {
1925
2015
  },
1926
2016
  }, ELICITATION_TIMEOUT_MS);
1927
2017
  const latencyMs = Date.now() - started;
2018
+ // One diagnostic line per answer — this is how "the developer saw no prompt but
2019
+ // the call was blocked" gets diagnosed from the IDE's MCP log alone.
2020
+ process.stderr.write(`FullCourtDefense: MCP client answered the confirmation prompt for ${input.toolName} in ${latencyMs}ms: ${response ? JSON.stringify(response.error ? { error: response.error } : response.result).slice(0, 300) : 'no answer (timeout)'}\n`);
1928
2021
  if (!response)
1929
2022
  return { answer: 'timeout', remember: false, latencyMs };
2023
+ // A JSON-RPC error ("method not found", "not supported in this mode") means no
2024
+ // dialog was shown: the client could not ask, it did not get a "no".
2025
+ if (response.error)
2026
+ return { answer: 'decline', remember: false, latencyMs, clientError: true };
1930
2027
  const result = (response.result && typeof response.result === 'object') ? response.result : undefined;
1931
2028
  const content = (result?.content && typeof result.content === 'object') ? result.content : undefined;
1932
2029
  if (result?.action === 'accept' && content?.confirm === true) {
@@ -1,4 +1,36 @@
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>;
22
+ /**
23
+ * How the OS dialog ended. `unavailable` = no dialog could be shown at all (no human,
24
+ * no display, dialog binary missing / failed to start) — the caller may try another
25
+ * channel. `deny` covers Deny, closing the window and the dialog's own give-up.
26
+ */
27
+ export type NativeDialogOutcome = 'allow' | 'deny' | 'timeout' | 'unavailable';
28
+ /** True when this process can put a dialog in front of a person on this machine. */
29
+ export declare function nativeDialogAvailable(): boolean;
30
+ export declare function confirmNativeActionDetailed(reason: string, timeoutMs?: number): Promise<{
31
+ outcome: NativeDialogOutcome;
32
+ latencyMs: number;
33
+ }>;
2
34
  export interface PendingConfirmation {
3
35
  /** Stable key: sha256(event|toolName|argsDigest). */
4
36
  key: string;
@@ -32,8 +64,23 @@ export interface RememberedConfirmation {
32
64
  confirmedAt: string;
33
65
  expiresAt: string;
34
66
  }
67
+ /**
68
+ * An explicit "Allow once" a human clicked in the OS dialog raised by the IDE hook for one
69
+ * exact action (tool + args digest). The MCP gateway that receives the same call a moment
70
+ * later consumes it instead of asking a second time. Single-use, short-lived, exact-match.
71
+ */
72
+ export interface ConfirmedAction {
73
+ /** sha256(bareToolName|argsDigest). */
74
+ key: string;
75
+ toolName: string;
76
+ argsDigest: string;
77
+ confirmedAt: string;
78
+ expiresAt: string;
79
+ }
35
80
  /** A raised confirmation that received no post-execution signal in time counts as not approved. */
36
81
  export declare const CONFIRM_PENDING_TTL_MS: number;
82
+ /** The hook's dialog answer only carries over to the gateway call it was given for. */
83
+ export declare const CONFIRMED_ACTION_TTL_MS: number;
37
84
  /** CI / pipeline environments — no human at a keyboard. Shared by hooks and discovery. */
38
85
  export declare function isCiEnvironment(env?: NodeJS.ProcessEnv): boolean;
39
86
  /**
@@ -49,6 +96,19 @@ export declare function isHumanPresent(env?: NodeJS.ProcessEnv): boolean;
49
96
  */
50
97
  export declare function actionDigest(toolArgs: Record<string, unknown>): string;
51
98
  export declare function pendingKey(event: string, toolName: string, argsDigest: string): string;
99
+ /**
100
+ * The tool name as the MCP server sees it. The IDE hook names an MCP call
101
+ * `mcp__<server>__<tool>` (Codex/Claude) or `<server>/<tool>`; the gateway sees `<tool>`.
102
+ */
103
+ export declare function bareToolName(toolName: string): string;
104
+ /** The IDE hook's OS dialog got an explicit "Allow once" for this exact action. */
105
+ export declare function recordConfirmedAction(toolName: string, argsDigest: string, now?: Date): ConfirmedAction;
106
+ /**
107
+ * Take (and remove) a still-valid hook-dialog approval for this exact action, so the
108
+ * gateway does not ask the human twice for one tool call. Never matches a different
109
+ * tool, different arguments, an expired entry, or the same entry twice.
110
+ */
111
+ export declare function consumeConfirmedAction(toolName: string, argsDigest: string, now?: Date): ConfirmedAction | undefined;
52
112
  export interface RememberScope {
53
113
  toolName: string;
54
114
  operation: string;
@@ -33,11 +33,18 @@ 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;
39
+ exports.nativeDialogAvailable = nativeDialogAvailable;
40
+ exports.confirmNativeActionDetailed = confirmNativeActionDetailed;
37
41
  exports.isCiEnvironment = isCiEnvironment;
38
42
  exports.isHumanPresent = isHumanPresent;
39
43
  exports.actionDigest = actionDigest;
40
44
  exports.pendingKey = pendingKey;
45
+ exports.bareToolName = bareToolName;
46
+ exports.recordConfirmedAction = recordConfirmedAction;
47
+ exports.consumeConfirmedAction = consumeConfirmedAction;
41
48
  exports.raiseDeveloperConfirmation = raiseDeveloperConfirmation;
42
49
  exports.settleDeveloperConfirmation = settleDeveloperConfirmation;
43
50
  exports.rejectDeveloperConfirmation = rejectDeveloperConfirmation;
@@ -66,10 +73,101 @@ exports.isConfirmationRemembered = isConfirmationRemembered;
66
73
  * containers and cloud coding agents set CI-style env vars or FCD_EPHEMERAL; there the engine's
67
74
  * `effectiveApprovalScope` degrades to `org` and none of this runs.
68
75
  */
76
+ const child_process_1 = require("child_process");
69
77
  const crypto = __importStar(require("crypto"));
70
78
  const fs = __importStar(require("fs"));
71
79
  const os = __importStar(require("os"));
72
80
  const path = __importStar(require("path"));
81
+ /**
82
+ * Fastest plausible HUMAN answer to a confirmation prompt. A person has to notice
83
+ * the dialog, read at least the tool name and press a button; MCP clients that
84
+ * answer an `elicitation/create` for the user (Codex Desktop in "Full access" —
85
+ * `approval_policy = never` — declines every elicitation without rendering it)
86
+ * respond in tens of milliseconds. A decline below this bound is treated as the
87
+ * CLIENT's answer, not the developer's, and the question is re-asked through the
88
+ * OS dialog. Accepts are never re-asked; a fast accept only ever costs a prompt.
89
+ */
90
+ exports.MIN_HUMAN_ANSWER_MS = 1000;
91
+ /** True when a negative elicitation answer arrived faster than a human can read the prompt. */
92
+ function looksLikeClientAutoAnswer(answer, latencyMs) {
93
+ return answer === 'decline' && latencyMs < exports.MIN_HUMAN_ANSWER_MS;
94
+ }
95
+ /**
96
+ * OS-owned confirmation dialog — the IDE-independent fallback for "Ask the developer".
97
+ * PowerShell `WScript.Shell.Popup` on Windows, `osascript` on macOS, `zenity` elsewhere.
98
+ * The agent cannot answer it: it is not connected to hook stdin, the MCP stream or the
99
+ * agent's terminal. Resolves `true` only for an explicit "Allow once"; decline, close,
100
+ * timeout, a missing dialog binary or no human present all resolve `false`.
101
+ */
102
+ async function confirmNativeAction(reason, timeoutMs = 60_000) {
103
+ return (await confirmNativeActionDetailed(reason, timeoutMs)).outcome === 'allow';
104
+ }
105
+ /** True when this process can put a dialog in front of a person on this machine. */
106
+ function nativeDialogAvailable() {
107
+ if (!isHumanPresent())
108
+ return false;
109
+ if (process.platform === 'win32' || process.platform === 'darwin')
110
+ return true;
111
+ return Boolean(process.env.DISPLAY || process.env.WAYLAND_DISPLAY);
112
+ }
113
+ async function confirmNativeActionDetailed(reason, timeoutMs = 60_000) {
114
+ if (!nativeDialogAvailable())
115
+ return { outcome: 'unavailable', latencyMs: 0 };
116
+ const started = Date.now();
117
+ let executable;
118
+ let args;
119
+ const message = `FullCourtDefense — developer approval\n\n${reason.slice(0, 1800)}\n\nAllow this action once?`;
120
+ const timeoutSeconds = Math.max(1, Math.ceil(timeoutMs / 1000));
121
+ if (process.platform === 'win32') {
122
+ executable = 'powershell.exe';
123
+ const encoded = Buffer.from(message, 'utf8').toString('base64');
124
+ // 292 = Yes/No + question icon + "No" default; +4096 (MB_SYSTEMMODAL) keeps the
125
+ // popup above the IDE window that triggered it, so a fallback ask is never
126
+ // hidden behind Codex/Cursor and silently timed out.
127
+ 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}`;
128
+ args = ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')];
129
+ }
130
+ else if (process.platform === 'darwin') {
131
+ executable = '/usr/bin/osascript';
132
+ 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];
133
+ }
134
+ else {
135
+ executable = 'zenity';
136
+ args = ['--question', '--default-cancel', '--title=FullCourtDefense', `--text=${message}`, '--ok-label=Allow once', '--cancel-label=Deny', `--timeout=${timeoutSeconds}`];
137
+ }
138
+ return new Promise(resolve => {
139
+ let finished = false;
140
+ let child;
141
+ const done = (outcome) => {
142
+ if (finished)
143
+ return;
144
+ finished = true;
145
+ clearTimeout(timer);
146
+ process.removeListener('exit', cancelChild);
147
+ resolve({ outcome, latencyMs: Date.now() - started });
148
+ };
149
+ const cancelChild = () => child?.kill();
150
+ const timer = setTimeout(() => { child?.kill(); done('timeout'); }, timeoutMs + 1000);
151
+ try {
152
+ child = (0, child_process_1.spawn)(executable, args, { stdio: 'ignore', windowsHide: true });
153
+ }
154
+ catch {
155
+ done('unavailable');
156
+ return;
157
+ }
158
+ process.once('exit', cancelChild);
159
+ // Spawn failure (binary missing, no permission) = nothing was shown. A child
160
+ // that ran and exited non-zero = the person (or the dialog's give-up) said no;
161
+ // the dialog cannot tell Deny from close from give-up, so a "no" that arrives
162
+ // at the deadline is reported as a timeout.
163
+ child.on('error', () => done('unavailable'));
164
+ child.on('exit', code => {
165
+ if (code === 0)
166
+ return done('allow');
167
+ done(Date.now() - started >= timeoutMs - 500 ? 'timeout' : 'deny');
168
+ });
169
+ });
170
+ }
73
171
  /** A raised confirmation that received no post-execution signal in time counts as not approved. */
74
172
  exports.CONFIRM_PENDING_TTL_MS = 10 * 60_000;
75
173
  const REMEMBER_1H_MS = 60 * 60_000;
@@ -77,6 +175,9 @@ const REMEMBER_1H_MS = 60 * 60_000;
77
175
  const REMEMBER_SESSION_MAX_MS = 12 * 60 * 60_000;
78
176
  const MAX_PENDING = 50;
79
177
  const MAX_REMEMBERED = 200;
178
+ /** The hook's dialog answer only carries over to the gateway call it was given for. */
179
+ exports.CONFIRMED_ACTION_TTL_MS = 2 * 60_000;
180
+ const MAX_CONFIRMED_ACTIONS = 50;
80
181
  /** CI / pipeline environments — no human at a keyboard. Shared by hooks and discovery. */
81
182
  function isCiEnvironment(env = process.env) {
82
183
  return env.GITHUB_ACTIONS === 'true'
@@ -109,10 +210,11 @@ function readLedger() {
109
210
  pending: Array.isArray(parsed?.pending) ? parsed.pending.filter((p) => p && typeof p === 'object') : [],
110
211
  // Older ledgers could remember declines. Require fresh affirmative consent.
111
212
  remembered: parsed?.formatVersion === 2 && Array.isArray(parsed?.remembered) ? parsed.remembered.filter((r) => r && typeof r === 'object') : [],
213
+ confirmedActions: Array.isArray(parsed?.confirmedActions) ? parsed.confirmedActions.filter((c) => c && typeof c === 'object') : [],
112
214
  };
113
215
  }
114
216
  catch {
115
- return { pending: [], remembered: [] };
217
+ return { pending: [], remembered: [], confirmedActions: [] };
116
218
  }
117
219
  }
118
220
  function writeLedger(ledger) {
@@ -157,6 +259,60 @@ function actionDigest(toolArgs) {
157
259
  function pendingKey(event, toolName, argsDigest) {
158
260
  return sha256(`${event}|${toolName.toLowerCase()}|${argsDigest}`);
159
261
  }
262
+ /**
263
+ * The tool name as the MCP server sees it. The IDE hook names an MCP call
264
+ * `mcp__<server>__<tool>` (Codex/Claude) or `<server>/<tool>`; the gateway sees `<tool>`.
265
+ */
266
+ function bareToolName(toolName) {
267
+ let name = toolName.trim();
268
+ if (name.startsWith('mcp__')) {
269
+ const rest = name.slice('mcp__'.length);
270
+ const idx = rest.indexOf('__');
271
+ name = idx >= 0 ? rest.slice(idx + 2) : rest;
272
+ }
273
+ else if (name.includes('/')) {
274
+ name = name.slice(name.lastIndexOf('/') + 1);
275
+ }
276
+ return name.toLowerCase();
277
+ }
278
+ function confirmedActionKey(toolName, argsDigest) {
279
+ return sha256(`confirmed|${bareToolName(toolName)}|${argsDigest}`);
280
+ }
281
+ /** The IDE hook's OS dialog got an explicit "Allow once" for this exact action. */
282
+ function recordConfirmedAction(toolName, argsDigest, now = new Date()) {
283
+ const entry = {
284
+ key: confirmedActionKey(toolName, argsDigest),
285
+ toolName: bareToolName(toolName),
286
+ argsDigest,
287
+ confirmedAt: now.toISOString(),
288
+ expiresAt: new Date(now.getTime() + exports.CONFIRMED_ACTION_TTL_MS).toISOString(),
289
+ };
290
+ const ledger = readLedger();
291
+ ledger.confirmedActions = ledger.confirmedActions.filter(c => c.key !== entry.key && Date.parse(c.expiresAt) > now.getTime());
292
+ ledger.confirmedActions.push(entry);
293
+ if (ledger.confirmedActions.length > MAX_CONFIRMED_ACTIONS)
294
+ ledger.confirmedActions = ledger.confirmedActions.slice(-MAX_CONFIRMED_ACTIONS);
295
+ writeLedger(ledger);
296
+ return entry;
297
+ }
298
+ /**
299
+ * Take (and remove) a still-valid hook-dialog approval for this exact action, so the
300
+ * gateway does not ask the human twice for one tool call. Never matches a different
301
+ * tool, different arguments, an expired entry, or the same entry twice.
302
+ */
303
+ function consumeConfirmedAction(toolName, argsDigest, now = new Date()) {
304
+ const ledger = readLedger();
305
+ const key = confirmedActionKey(toolName, argsDigest);
306
+ const hit = ledger.confirmedActions.find(c => c.key === key);
307
+ const live = ledger.confirmedActions.filter(c => Number.isFinite(Date.parse(c.expiresAt)) && Date.parse(c.expiresAt) > now.getTime());
308
+ const valid = hit && live.includes(hit) ? hit : undefined;
309
+ const next = live.filter(c => c !== valid);
310
+ if (next.length !== ledger.confirmedActions.length) {
311
+ ledger.confirmedActions = next;
312
+ writeLedger(ledger);
313
+ }
314
+ return valid;
315
+ }
160
316
  function rememberKey(scope) {
161
317
  const rule = scope.policyId ? `${scope.policyId}|${scope.matchedRule || ''}` : `op:${scope.operation.toLowerCase()}`;
162
318
  return sha256(`${scope.toolName.toLowerCase()}|${rule}`);
@@ -227,9 +383,11 @@ function sweepExpiredConfirmations(now = new Date(), ttlMs = exports.CONFIRM_PEN
227
383
  const cutoff = now.getTime() - ttlMs;
228
384
  const expired = ledger.pending.filter(p => Date.parse(p.raisedAt) < cutoff);
229
385
  const remembered = ledger.remembered.filter(r => Date.parse(r.expiresAt) > now.getTime());
230
- if (expired.length > 0 || remembered.length !== ledger.remembered.length) {
386
+ const confirmedActions = ledger.confirmedActions.filter(c => Date.parse(c.expiresAt) > now.getTime());
387
+ if (expired.length > 0 || remembered.length !== ledger.remembered.length || confirmedActions.length !== ledger.confirmedActions.length) {
231
388
  ledger.pending = ledger.pending.filter(p => Date.parse(p.raisedAt) >= cutoff);
232
389
  ledger.remembered = remembered;
390
+ ledger.confirmedActions = confirmedActions;
233
391
  writeLedger(ledger);
234
392
  }
235
393
  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.7"
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.7",
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",