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.
- package/dist/codexMcpIdentity.d.ts +14 -0
- package/dist/codexMcpIdentity.js +32 -2
- package/dist/commands/agentCi.js +2 -14
- package/dist/commands/discover.js +1 -14
- package/dist/commands/discoverProxy.d.ts +23 -0
- package/dist/commands/discoverProxy.js +94 -0
- package/dist/commands/hook.d.ts +35 -0
- package/dist/commands/hook.js +87 -0
- package/dist/commands/mcpGateway.js +120 -23
- package/dist/devConfirm.d.ts +60 -0
- package/dist/devConfirm.js +161 -3
- package/dist/nativeHook.d.ts +7 -3
- package/dist/nativeHook.js +13 -42
- package/dist/version.json +1 -1
- package/package.json +3 -2
|
@@ -1,3 +1,16 @@
|
|
|
1
|
+
type TomlParse = (text: string) => Record<string, any>;
|
|
2
|
+
/**
|
|
3
|
+
* Load the TOML parser lazily. The native hook (`nativeHook.js`) is a
|
|
4
|
+
* fail-open surface in Codex: a hook process that crashes before printing a
|
|
5
|
+
* verdict is treated by Codex as "no opinion" and the tool call proceeds. A
|
|
6
|
+
* top-level `import` of a package that is missing from the install (the MSI
|
|
7
|
+
* ships an explicit node_modules allowlist) therefore disabled EVERY native
|
|
8
|
+
* hook event. Resolve the parser at first use instead so a missing module
|
|
9
|
+
* degrades to "no configured server names" while policies still evaluate.
|
|
10
|
+
*/
|
|
11
|
+
export declare function loadTomlParser(): TomlParse | null;
|
|
12
|
+
/** Test seam: force the "parser unavailable" path without uninstalling the package. */
|
|
13
|
+
export declare function __setTomlParserForTests(parser: TomlParse | null | undefined): void;
|
|
1
14
|
/**
|
|
2
15
|
* Name dictionary, not an effective configuration or an authorization source.
|
|
3
16
|
* Include user-owned profiles conservatively: hook payloads do not identify the
|
|
@@ -10,3 +23,4 @@ export declare function resolveCodexMcpTool(tool: string, serverNames: string[])
|
|
|
10
23
|
server: string;
|
|
11
24
|
tool: string;
|
|
12
25
|
} | undefined;
|
|
26
|
+
export {};
|
package/dist/codexMcpIdentity.js
CHANGED
|
@@ -33,16 +33,46 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.loadTomlParser = loadTomlParser;
|
|
37
|
+
exports.__setTomlParserForTests = __setTomlParserForTests;
|
|
36
38
|
exports.codexMcpServerNames = codexMcpServerNames;
|
|
37
39
|
exports.resolveCodexMcpTool = resolveCodexMcpTool;
|
|
38
40
|
const fs = __importStar(require("fs"));
|
|
39
41
|
const os = __importStar(require("os"));
|
|
40
42
|
const path = __importStar(require("path"));
|
|
41
|
-
|
|
43
|
+
let tomlParser;
|
|
44
|
+
/**
|
|
45
|
+
* Load the TOML parser lazily. The native hook (`nativeHook.js`) is a
|
|
46
|
+
* fail-open surface in Codex: a hook process that crashes before printing a
|
|
47
|
+
* verdict is treated by Codex as "no opinion" and the tool call proceeds. A
|
|
48
|
+
* top-level `import` of a package that is missing from the install (the MSI
|
|
49
|
+
* ships an explicit node_modules allowlist) therefore disabled EVERY native
|
|
50
|
+
* hook event. Resolve the parser at first use instead so a missing module
|
|
51
|
+
* degrades to "no configured server names" while policies still evaluate.
|
|
52
|
+
*/
|
|
53
|
+
function loadTomlParser() {
|
|
54
|
+
if (tomlParser !== undefined)
|
|
55
|
+
return tomlParser;
|
|
56
|
+
try {
|
|
57
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
58
|
+
tomlParser = require('@iarna/toml').parse;
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
tomlParser = null;
|
|
62
|
+
}
|
|
63
|
+
return tomlParser;
|
|
64
|
+
}
|
|
65
|
+
/** Test seam: force the "parser unavailable" path without uninstalling the package. */
|
|
66
|
+
function __setTomlParserForTests(parser) {
|
|
67
|
+
tomlParser = parser;
|
|
68
|
+
}
|
|
42
69
|
/** Parse config without executing commands or exposing its contents in errors. */
|
|
43
70
|
function readConfig(file) {
|
|
71
|
+
const parse = loadTomlParser();
|
|
72
|
+
if (!parse)
|
|
73
|
+
return {};
|
|
44
74
|
try {
|
|
45
|
-
return
|
|
75
|
+
return parse(fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, ''));
|
|
46
76
|
}
|
|
47
77
|
catch (err) {
|
|
48
78
|
if (err.code === 'ENOENT')
|
package/dist/commands/agentCi.js
CHANGED
|
@@ -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');
|
package/dist/commands/hook.d.ts
CHANGED
|
@@ -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.
|
package/dist/commands/hook.js
CHANGED
|
@@ -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.
|
|
961
|
-
*
|
|
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
|
|
1226
|
-
//
|
|
1227
|
-
//
|
|
1228
|
-
//
|
|
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 (
|
|
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.
|
|
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
|
|
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
|
|
1308
|
+
const no = describeDeveloperNo(answer);
|
|
1276
1309
|
(0, telemetry_1.spoolEvent)({
|
|
1277
1310
|
decision: 'block', toolName, operation: rememberScope.operation,
|
|
1278
|
-
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, `${
|
|
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.
|
|
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 (!
|
|
1840
|
-
|
|
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.
|
|
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
|
|
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
|
|
1928
|
+
const no = describeDeveloperNo(answer);
|
|
1895
1929
|
(0, telemetry_1.spoolEvent)({
|
|
1896
1930
|
decision: 'block', toolName, operation,
|
|
1897
|
-
reason: `${
|
|
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, `${
|
|
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) {
|
package/dist/devConfirm.d.ts
CHANGED
|
@@ -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;
|
package/dist/devConfirm.js
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/dist/nativeHook.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
/** Native hook adapters. Never emit an unsupported `ask` to an IDE. */
|
|
1
2
|
import { HookArgs } from './commands/hook';
|
|
2
3
|
import { BotGuardConfig } from './config';
|
|
4
|
+
import { confirmNativeAction } from './devConfirm';
|
|
3
5
|
export type NativeClient = 'codex' | 'gemini_cli' | 'windsurf' | 'kiro';
|
|
4
6
|
type Payload = Record<string, any>;
|
|
5
7
|
export declare const NATIVE_CLIENTS: NativeClient[];
|
|
@@ -15,11 +17,13 @@ export declare function nativeErrorVerdict(client: NativeClient, reason: string)
|
|
|
15
17
|
stderr: string;
|
|
16
18
|
exitCode: number;
|
|
17
19
|
};
|
|
18
|
-
/**
|
|
19
|
-
|
|
20
|
+
/**
|
|
21
|
+
* OS-owned dialog (shared with the MCP gateway; lives in devConfirm.ts): an agent
|
|
22
|
+
* cannot approve by writing `yes` to hook stdin. Re-exported for existing callers.
|
|
23
|
+
*/
|
|
24
|
+
export { confirmNativeAction };
|
|
20
25
|
export declare function evaluateNativeHook(client: NativeClient, raw: Payload, args: HookArgs, config: BotGuardConfig, confirm?: (reason: string) => Promise<boolean>): Promise<{
|
|
21
26
|
stdout: string;
|
|
22
27
|
stderr: string;
|
|
23
28
|
exitCode: number;
|
|
24
29
|
}>;
|
|
25
|
-
export {};
|
package/dist/nativeHook.js
CHANGED
|
@@ -1,19 +1,18 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.NATIVE_CLIENTS = void 0;
|
|
3
|
+
exports.confirmNativeAction = exports.NATIVE_CLIENTS = void 0;
|
|
4
4
|
exports.patchToolCalls = patchToolCalls;
|
|
5
5
|
exports.normalizeNativeInput = normalizeNativeInput;
|
|
6
6
|
exports.nativeVerdict = nativeVerdict;
|
|
7
7
|
exports.nativeErrorVerdict = nativeErrorVerdict;
|
|
8
|
-
exports.confirmNativeAction = confirmNativeAction;
|
|
9
8
|
exports.evaluateNativeHook = evaluateNativeHook;
|
|
10
9
|
/** Native hook adapters. Never emit an unsupported `ask` to an IDE. */
|
|
11
|
-
const child_process_1 = require("child_process");
|
|
12
10
|
const hook_1 = require("./commands/hook");
|
|
13
11
|
const config_1 = require("./config");
|
|
14
12
|
const runtimeConfig_1 = require("./runtimeConfig");
|
|
15
13
|
const hookIo_1 = require("./hookIo");
|
|
16
14
|
const devConfirm_1 = require("./devConfirm");
|
|
15
|
+
Object.defineProperty(exports, "confirmNativeAction", { enumerable: true, get: function () { return devConfirm_1.confirmNativeAction; } });
|
|
17
16
|
const telemetry_1 = require("./telemetry");
|
|
18
17
|
const codexMcpIdentity_1 = require("./codexMcpIdentity");
|
|
19
18
|
exports.NATIVE_CLIENTS = ['codex', 'gemini_cli', 'windsurf', 'kiro'];
|
|
@@ -111,44 +110,7 @@ function nativeErrorVerdict(client, reason) {
|
|
|
111
110
|
return { stdout: JSON.stringify({ decision: 'block', reason }), stderr: '', exitCode: 0 };
|
|
112
111
|
return nativeVerdict(client, true, reason);
|
|
113
112
|
}
|
|
114
|
-
|
|
115
|
-
async function confirmNativeAction(reason, timeoutMs = 60_000) {
|
|
116
|
-
if (!(0, devConfirm_1.isHumanPresent)())
|
|
117
|
-
return false;
|
|
118
|
-
let executable;
|
|
119
|
-
let args;
|
|
120
|
-
const message = `FullCourtDefense — developer approval\n\n${reason.slice(0, 1800)}\n\nAllow this action once?`;
|
|
121
|
-
if (process.platform === 'win32') {
|
|
122
|
-
executable = 'powershell.exe';
|
|
123
|
-
const encoded = Buffer.from(message, 'utf8').toString('base64');
|
|
124
|
-
const script = `$msg=[Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${encoded}')); $w=New-Object -ComObject WScript.Shell; $answer=$w.Popup($msg,${Math.ceil(timeoutMs / 1000)},'FullCourtDefense',292); if($answer -eq 6){exit 0}else{exit 1}`;
|
|
125
|
-
args = ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')];
|
|
126
|
-
}
|
|
127
|
-
else if (process.platform === 'darwin') {
|
|
128
|
-
executable = '/usr/bin/osascript';
|
|
129
|
-
args = ['-e', 'on run argv', '-e', 'display dialog (item 1 of argv) buttons {"Deny", "Allow once"} default button "Deny" giving up after 60', '-e', 'if gave up of result or button returned of result is not "Allow once" then error number -128', '-e', 'end run', message];
|
|
130
|
-
}
|
|
131
|
-
else {
|
|
132
|
-
executable = 'zenity';
|
|
133
|
-
args = ['--question', '--default-cancel', '--title=FullCourtDefense', `--text=${message}`, '--ok-label=Allow once', '--cancel-label=Deny', `--timeout=${Math.ceil(timeoutMs / 1000)}`];
|
|
134
|
-
}
|
|
135
|
-
return new Promise(resolve => {
|
|
136
|
-
let finished = false;
|
|
137
|
-
const child = (0, child_process_1.spawn)(executable, args, { stdio: 'ignore', windowsHide: true });
|
|
138
|
-
const cancelChild = () => child.kill();
|
|
139
|
-
process.once('exit', cancelChild);
|
|
140
|
-
const done = (allowed) => { if (!finished) {
|
|
141
|
-
finished = true;
|
|
142
|
-
clearTimeout(timer);
|
|
143
|
-
process.removeListener('exit', cancelChild);
|
|
144
|
-
resolve(allowed);
|
|
145
|
-
} };
|
|
146
|
-
const timer = setTimeout(() => { child.kill(); done(false); }, timeoutMs + 1000);
|
|
147
|
-
child.on('error', () => done(false));
|
|
148
|
-
child.on('exit', code => done(code === 0));
|
|
149
|
-
});
|
|
150
|
-
}
|
|
151
|
-
async function evaluateNativeHook(client, raw, args, config, confirm = confirmNativeAction) {
|
|
113
|
+
async function evaluateNativeHook(client, raw, args, config, confirm = devConfirm_1.confirmNativeAction) {
|
|
152
114
|
if (client === 'codex' && raw.tool_name === 'apply_patch') {
|
|
153
115
|
try {
|
|
154
116
|
// Evaluate every file operation before Codex executes the atomic patch.
|
|
@@ -165,8 +127,13 @@ async function evaluateNativeHook(client, raw, args, config, confirm = confirmNa
|
|
|
165
127
|
}
|
|
166
128
|
}
|
|
167
129
|
let payload;
|
|
130
|
+
const resolveCodexIdentity = client === 'codex' && String(raw.tool_name || '').startsWith('mcp__');
|
|
131
|
+
if (resolveCodexIdentity && !(0, codexMcpIdentity_1.loadTomlParser)()) {
|
|
132
|
+
// Degraded, never dead: policies still evaluate on the sanitized tool name.
|
|
133
|
+
process.stderr.write('FullCourtDefense: TOML parser unavailable in this install; Codex MCP server identity falls back to the sanitized tool name.\n');
|
|
134
|
+
}
|
|
168
135
|
try {
|
|
169
|
-
payload = normalizeNativeInput(client, raw,
|
|
136
|
+
payload = normalizeNativeInput(client, raw, resolveCodexIdentity ? (0, codexMcpIdentity_1.codexMcpServerNames)() : []);
|
|
170
137
|
}
|
|
171
138
|
catch (error) {
|
|
172
139
|
return nativeErrorVerdict(client, `FullCourtDefense could not parse the hook: ${String(error)}`);
|
|
@@ -197,6 +164,10 @@ async function evaluateNativeHook(client, raw, args, config, confirm = confirmNa
|
|
|
197
164
|
}
|
|
198
165
|
catch { /* deny */ }
|
|
199
166
|
denied = !allowed;
|
|
167
|
+
// Hand the click to the MCP gateway that will see this exact call next, so the
|
|
168
|
+
// developer is not asked twice for one action (single-use, 2-minute, exact digest).
|
|
169
|
+
if (allowed && call && mapped?.event === 'mcp')
|
|
170
|
+
(0, devConfirm_1.recordConfirmedAction)(call.toolName, (0, devConfirm_1.actionDigest)(call.toolArgs));
|
|
200
171
|
(0, telemetry_1.spoolEvent)({ decision: allowed ? 'allow' : 'block', toolName: call?.toolName || payload.tool_name,
|
|
201
172
|
reason: allowed ? 'Developer approved once in the FullCourtDefense dialog.' : 'Developer declined, confirmation unavailable, or timed out.',
|
|
202
173
|
approvalScope: 'developer', ...(allowed ? { approvedBy: 'developer' } : {}) });
|
package/dist/version.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fullcourtdefense-cli",
|
|
3
|
-
"version": "1.34.
|
|
3
|
+
"version": "1.34.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",
|