fullcourtdefense-cli 1.3.7 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/hook.d.ts +17 -5
- package/dist/commands/hook.js +225 -75
- package/dist/commands/installCursorHook.js +18 -7
- package/dist/commands/mcpGateway.d.ts +6 -0
- package/dist/commands/mcpGateway.js +356 -0
- package/dist/index.js +33 -2
- package/dist/version.json +1 -1
- package/package.json +1 -1
package/dist/commands/hook.d.ts
CHANGED
|
@@ -3,12 +3,21 @@ import { BotGuardConfig } from '../config';
|
|
|
3
3
|
* `fullcourtdefense hook` — Cursor hook bridge.
|
|
4
4
|
*
|
|
5
5
|
* Cursor invokes this command for agent lifecycle events (beforeSubmitPrompt,
|
|
6
|
-
* beforeShellExecution, beforeMCPExecution, afterFileEdit,
|
|
7
|
-
* JSON payload on stdin and reads a JSON verdict on stdout.
|
|
8
|
-
* blocks the action, which we use as the cross-event, cross-version-safe block
|
|
9
|
-
* signal (not every event documents a `permission` output field).
|
|
6
|
+
* beforeShellExecution, beforeMCPExecution, afterFileEdit, beforeReadFile).
|
|
7
|
+
* Cursor pipes a JSON payload on stdin and reads a JSON verdict on stdout.
|
|
10
8
|
*
|
|
11
|
-
*
|
|
9
|
+
* Two enforcement engines, by event:
|
|
10
|
+
* - prompt -> Shield text analysis (POST /api/shield/proxy/{shieldId}).
|
|
11
|
+
* Action Policies don't apply to free-form chat, so prompts are
|
|
12
|
+
* judged by the developer-chat Shield (monitor or block per mode).
|
|
13
|
+
* - shell / mcp / file / read -> Action Policies, the SAME engine the MCP
|
|
14
|
+
* gateway uses (POST /api/agent-security/runtime/check-tool-call).
|
|
15
|
+
* The org's policies attached to the runtime identity decide:
|
|
16
|
+
* allow / block / require_approval — enforced in EVERY repo on the
|
|
17
|
+
* machine, regardless of which folder Cursor is open in.
|
|
18
|
+
*
|
|
19
|
+
* require_approval pauses the action and polls the approval endpoint until a
|
|
20
|
+
* human approves/rejects in the FullCourtDefense console (same as the gateway).
|
|
12
21
|
*/
|
|
13
22
|
export interface HookArgs {
|
|
14
23
|
event?: string;
|
|
@@ -18,5 +27,8 @@ export interface HookArgs {
|
|
|
18
27
|
shadow?: string;
|
|
19
28
|
failClosed?: string;
|
|
20
29
|
timeout?: string;
|
|
30
|
+
approvalMode?: string;
|
|
31
|
+
approvalTimeoutMs?: string;
|
|
32
|
+
approvalPollMs?: string;
|
|
21
33
|
}
|
|
22
34
|
export declare function hookCommand(args: HookArgs, config: BotGuardConfig): Promise<void>;
|
package/dist/commands/hook.js
CHANGED
|
@@ -102,48 +102,72 @@ function inferEvent(explicit, payload) {
|
|
|
102
102
|
return 'file';
|
|
103
103
|
return 'unknown';
|
|
104
104
|
}
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
105
|
+
const str = (v) => (typeof v === 'string' ? v : '');
|
|
106
|
+
/** Pull the meaningful text to scan out of a Cursor PROMPT hook payload. */
|
|
107
|
+
function extractPromptText(p) {
|
|
108
|
+
const direct = str(p.prompt) || str(p.text) || str(p.message) || str(p.content);
|
|
109
|
+
if (direct)
|
|
110
|
+
return direct;
|
|
111
|
+
const promptObj = p.prompt;
|
|
112
|
+
if (promptObj && typeof promptObj === 'object') {
|
|
113
|
+
const t = str(promptObj.text) || str(promptObj.content);
|
|
114
|
+
if (t)
|
|
115
|
+
return t;
|
|
116
|
+
}
|
|
117
|
+
const messages = p.messages;
|
|
118
|
+
if (Array.isArray(messages) && messages.length) {
|
|
119
|
+
const last = messages[messages.length - 1];
|
|
120
|
+
return str(last?.content) || str(last?.text);
|
|
121
|
+
}
|
|
122
|
+
return '';
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Map a Cursor execution-hook payload onto an Action Policy tool call.
|
|
126
|
+
* Returns null when there is nothing actionable to evaluate.
|
|
127
|
+
*/
|
|
128
|
+
function buildToolCall(event, p) {
|
|
108
129
|
switch (event) {
|
|
109
|
-
case '
|
|
110
|
-
const
|
|
111
|
-
if (
|
|
112
|
-
return
|
|
113
|
-
|
|
114
|
-
const promptObj = p.prompt;
|
|
115
|
-
if (promptObj && typeof promptObj === 'object') {
|
|
116
|
-
const t = str(promptObj.text) || str(promptObj.content);
|
|
117
|
-
if (t)
|
|
118
|
-
return t;
|
|
119
|
-
}
|
|
120
|
-
const messages = p.messages;
|
|
121
|
-
if (Array.isArray(messages) && messages.length) {
|
|
122
|
-
const last = messages[messages.length - 1];
|
|
123
|
-
return str(last?.content) || str(last?.text);
|
|
124
|
-
}
|
|
125
|
-
return '';
|
|
130
|
+
case 'shell': {
|
|
131
|
+
const command = str(p.command) || str(p.cmd);
|
|
132
|
+
if (!command)
|
|
133
|
+
return null;
|
|
134
|
+
return { toolName: 'shell', toolArgs: { command, cwd: str(p.cwd) || undefined } };
|
|
126
135
|
}
|
|
127
|
-
case 'shell':
|
|
128
|
-
return str(p.command) || str(p.cmd);
|
|
129
136
|
case 'mcp': {
|
|
130
|
-
const
|
|
131
|
-
const
|
|
132
|
-
const
|
|
133
|
-
return
|
|
137
|
+
const toolName = str(p.tool_name) || str(p.toolName) || str(p.name) || 'mcp';
|
|
138
|
+
const rawInput = p.tool_input ?? p.arguments ?? p.input ?? p.params;
|
|
139
|
+
const toolArgs = rawInput && typeof rawInput === 'object' ? rawInput : { value: rawInput };
|
|
140
|
+
return { toolName, toolArgs };
|
|
134
141
|
}
|
|
135
142
|
case 'file': {
|
|
143
|
+
const filePath = str(p.file_path) || str(p.path);
|
|
136
144
|
const edits = p.edits;
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
145
|
+
const content = Array.isArray(edits)
|
|
146
|
+
? edits.map((e) => str(e.new_text) || str(e.text) || str(e.content)).filter(Boolean).join('\n')
|
|
147
|
+
: (str(p.content) || str(p.new_text));
|
|
148
|
+
if (!filePath && !content)
|
|
149
|
+
return null;
|
|
150
|
+
return { toolName: 'edit_file', toolArgs: { path: filePath || undefined, content: content || undefined } };
|
|
151
|
+
}
|
|
152
|
+
case 'read': {
|
|
153
|
+
const filePath = str(p.file_path) || str(p.path);
|
|
154
|
+
if (!filePath)
|
|
155
|
+
return null;
|
|
156
|
+
return { toolName: 'read_file', toolArgs: { path: filePath } };
|
|
141
157
|
}
|
|
142
|
-
case 'read':
|
|
143
|
-
return str(p.file_path) || str(p.path) || '';
|
|
144
158
|
default:
|
|
145
|
-
return
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
function summarizeToolCall(toolName, toolArgs) {
|
|
163
|
+
const parts = [toolName];
|
|
164
|
+
for (const [k, v] of Object.entries(toolArgs)) {
|
|
165
|
+
if (v === undefined || v === null)
|
|
166
|
+
continue;
|
|
167
|
+
const sv = typeof v === 'string' ? v : safeJson(v);
|
|
168
|
+
parts.push(`${k}=${sv.slice(0, 200)}`);
|
|
146
169
|
}
|
|
170
|
+
return parts.join(' ').slice(0, 500);
|
|
147
171
|
}
|
|
148
172
|
function safeJson(v) {
|
|
149
173
|
try {
|
|
@@ -153,7 +177,7 @@ function safeJson(v) {
|
|
|
153
177
|
return String(v);
|
|
154
178
|
}
|
|
155
179
|
}
|
|
156
|
-
/** Per-event attribution that maps onto the existing Shield runtime headers. */
|
|
180
|
+
/** Per-event attribution that maps onto the existing Shield runtime headers (prompt path). */
|
|
157
181
|
function attributionFor(event, p) {
|
|
158
182
|
const h = {};
|
|
159
183
|
const set = (k, v) => { if (v)
|
|
@@ -164,33 +188,12 @@ function attributionFor(event, p) {
|
|
|
164
188
|
set('x-runtime-event-type', 'user_message');
|
|
165
189
|
set('x-runtime-trust', 'trusted_user');
|
|
166
190
|
break;
|
|
167
|
-
case 'shell':
|
|
168
|
-
set('x-input-source', 'tool');
|
|
169
|
-
set('x-runtime-event-type', 'action_request');
|
|
170
|
-
set('x-action-type', 'shell');
|
|
171
|
-
set('x-runtime-sink', 'execute');
|
|
172
|
-
set('x-runtime-trust', 'untrusted');
|
|
173
|
-
break;
|
|
174
|
-
case 'mcp':
|
|
175
|
-
set('x-input-source', 'tool');
|
|
176
|
-
set('x-runtime-event-type', 'tool_call');
|
|
177
|
-
set('x-tool-name', String(p.tool_name || p.toolName || p.name || 'mcp'));
|
|
178
|
-
set('x-runtime-trust', 'untrusted');
|
|
179
|
-
break;
|
|
180
|
-
case 'file':
|
|
181
|
-
set('x-input-source', 'generated');
|
|
182
|
-
set('x-runtime-event-type', 'memory_write');
|
|
183
|
-
break;
|
|
184
|
-
case 'read':
|
|
185
|
-
set('x-input-source', 'tool');
|
|
186
|
-
set('x-runtime-event-type', 'memory_read');
|
|
187
|
-
break;
|
|
188
191
|
default:
|
|
189
192
|
set('x-input-source', 'user');
|
|
190
193
|
}
|
|
191
194
|
return h;
|
|
192
195
|
}
|
|
193
|
-
/** Stable developer identity, used for per-developer attribution
|
|
196
|
+
/** Stable developer identity, used for per-developer attribution. */
|
|
194
197
|
function developerId() {
|
|
195
198
|
if (process.env.FULLCOURTDEFENSE_DEVELOPER_ID)
|
|
196
199
|
return process.env.FULLCOURTDEFENSE_DEVELOPER_ID;
|
|
@@ -209,10 +212,29 @@ function sessionId(p) {
|
|
|
209
212
|
return fromPayload;
|
|
210
213
|
return `cursor-${os.hostname()}`;
|
|
211
214
|
}
|
|
215
|
+
function machineMetadata() {
|
|
216
|
+
let username = 'unknown';
|
|
217
|
+
try {
|
|
218
|
+
username = os.userInfo().username;
|
|
219
|
+
}
|
|
220
|
+
catch { /* ignore */ }
|
|
221
|
+
return {
|
|
222
|
+
developerName: developerId(),
|
|
223
|
+
machineName: os.hostname(),
|
|
224
|
+
os: `${os.platform()} ${os.release()}`,
|
|
225
|
+
username,
|
|
226
|
+
cwd: process.cwd(),
|
|
227
|
+
workspacePath: process.env.WORKSPACE_PATH || process.env.CLAUDE_PROJECT_DIR || process.cwd(),
|
|
228
|
+
agentClient: 'cursor',
|
|
229
|
+
};
|
|
230
|
+
}
|
|
212
231
|
async function hookCommand(args, config) {
|
|
213
232
|
const shadow = args.shadow === 'true';
|
|
214
233
|
const failClosed = args.failClosed === 'true';
|
|
215
234
|
const timeoutMs = Number(args.timeout) > 0 ? Number(args.timeout) : 8000;
|
|
235
|
+
const approvalMode = args.approvalMode === 'block' ? 'block' : 'wait';
|
|
236
|
+
const approvalTimeoutMs = Number(args.approvalTimeoutMs) > 0 ? Number(args.approvalTimeoutMs) : 300000;
|
|
237
|
+
const approvalPollMs = Math.max(1000, Number(args.approvalPollMs) > 0 ? Number(args.approvalPollMs) : 3000);
|
|
216
238
|
const creds = (0, config_1.resolveCliCredentials)(config, {
|
|
217
239
|
shieldId: args.shieldId,
|
|
218
240
|
shieldKey: args.shieldKey,
|
|
@@ -245,13 +267,12 @@ async function hookCommand(args, config) {
|
|
|
245
267
|
}
|
|
246
268
|
}
|
|
247
269
|
const event = inferEvent(args.event, payload);
|
|
248
|
-
const text = extractText(event, payload).trim();
|
|
249
270
|
dbg({ phase: 'invoke', event, argEvent: args.event, pid: process.pid,
|
|
250
271
|
isTTY, rawLen: raw.length, rawPreview: raw.slice(0, 300), stdinErr, parseErr,
|
|
251
|
-
argv: process.argv.slice(2), payloadKeys: Object.keys(payload)
|
|
272
|
+
argv: process.argv.slice(2), payloadKeys: Object.keys(payload) });
|
|
252
273
|
// Emit the verdict in the shape Cursor expects for THIS event, then exit.
|
|
253
274
|
// beforeSubmitPrompt blocks via { continue: false }; the execution hooks
|
|
254
|
-
// (shell/mcp/read) block via { permission: "deny" }.
|
|
275
|
+
// (shell/mcp/read) block via { permission: "deny" }.
|
|
255
276
|
const respond = (blocked, userMsg, agentMsg) => {
|
|
256
277
|
const decision = event === 'prompt'
|
|
257
278
|
? (blocked ? { continue: false } : { continue: true })
|
|
@@ -267,18 +288,154 @@ async function hookCommand(args, config) {
|
|
|
267
288
|
// so exiting 2 would silently let blocked prompts through.
|
|
268
289
|
process.exit(0);
|
|
269
290
|
};
|
|
270
|
-
//
|
|
271
|
-
//
|
|
272
|
-
if (!text)
|
|
273
|
-
respond(false);
|
|
291
|
+
// No Shield configured → allow (fail-open by design: a misconfigured machine
|
|
292
|
+
// must not brick the developer's IDE).
|
|
274
293
|
if (!shieldId) {
|
|
275
|
-
respond(false, undefined, 'FullCourtDefense hook installed but no Shield ID configured (run `fullcourtdefense
|
|
294
|
+
respond(false, undefined, 'FullCourtDefense hook installed but no Shield ID configured (run `fullcourtdefense setup`).');
|
|
295
|
+
}
|
|
296
|
+
// --- Action Policy enforcement for actions (shell / mcp / file / read) ---
|
|
297
|
+
if (event === 'shell' || event === 'mcp' || event === 'file' || event === 'read') {
|
|
298
|
+
await enforceActionPolicy({
|
|
299
|
+
event, payload, apiUrl: apiUrl, shieldId: shieldId, shieldKey,
|
|
300
|
+
shadow, failClosed, timeoutMs, approvalMode, approvalTimeoutMs, approvalPollMs, respond,
|
|
301
|
+
});
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
// --- Shield text analysis for prompts (developer chat) ---
|
|
305
|
+
if (event === 'prompt') {
|
|
306
|
+
const text = extractPromptText(payload).trim();
|
|
307
|
+
if (!text)
|
|
308
|
+
respond(false);
|
|
309
|
+
await enforceShieldText({
|
|
310
|
+
event, text, payload, apiUrl: apiUrl, shieldId: shieldId, shieldKey,
|
|
311
|
+
shadow, failClosed, timeoutMs, respond,
|
|
312
|
+
});
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
// Unknown event → nothing to enforce.
|
|
316
|
+
respond(false);
|
|
317
|
+
}
|
|
318
|
+
async function enforceActionPolicy(ctx) {
|
|
319
|
+
const { event, payload, apiUrl, shieldId, shieldKey, shadow, failClosed, timeoutMs, respond } = ctx;
|
|
320
|
+
const call = buildToolCall(event, payload);
|
|
321
|
+
if (!call) {
|
|
322
|
+
respond(false);
|
|
323
|
+
return;
|
|
276
324
|
}
|
|
325
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
326
|
+
if (shieldKey)
|
|
327
|
+
headers['x-shield-key'] = shieldKey;
|
|
328
|
+
let result;
|
|
329
|
+
try {
|
|
330
|
+
const controller = new AbortController();
|
|
331
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
332
|
+
const resp = await fetch(`${apiUrl}/api/agent-security/runtime/check-tool-call`, {
|
|
333
|
+
method: 'POST',
|
|
334
|
+
headers,
|
|
335
|
+
body: JSON.stringify({
|
|
336
|
+
shieldId,
|
|
337
|
+
agentName: `cursor-${developerId()}`,
|
|
338
|
+
toolName: call.toolName,
|
|
339
|
+
toolArgs: call.toolArgs,
|
|
340
|
+
argsSummary: summarizeToolCall(call.toolName, call.toolArgs),
|
|
341
|
+
sessionId: sessionId(payload),
|
|
342
|
+
source: 'cursor_hook',
|
|
343
|
+
...machineMetadata(),
|
|
344
|
+
}),
|
|
345
|
+
signal: controller.signal,
|
|
346
|
+
});
|
|
347
|
+
clearTimeout(timer);
|
|
348
|
+
if (!resp.ok) {
|
|
349
|
+
dbg({ phase: 'policy_http_error', event, status: resp.status });
|
|
350
|
+
respond(failClosed, failClosed ? `FullCourtDefense policy gate unavailable (HTTP ${resp.status}) — blocked by fail-closed policy.` : undefined, `FullCourtDefense policy gate returned HTTP ${resp.status}.`);
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
const body = await resp.json().catch(() => ({}));
|
|
354
|
+
if (!body.success || !body.data) {
|
|
355
|
+
dbg({ phase: 'policy_no_data', event, error: body.error });
|
|
356
|
+
respond(failClosed, failClosed ? 'FullCourtDefense policy gate error — blocked by fail-closed policy.' : undefined, body.error);
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
result = body.data;
|
|
360
|
+
}
|
|
361
|
+
catch (err) {
|
|
362
|
+
dbg({ phase: 'policy_exception', event, error: err instanceof Error ? err.message : String(err) });
|
|
363
|
+
respond(failClosed, failClosed ? 'FullCourtDefense policy gate unreachable — blocked by fail-closed policy.' : undefined, `FullCourtDefense hook error: ${err instanceof Error ? err.message : String(err)}.`);
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
const reason = result.actionPolicy?.reason
|
|
367
|
+
|| result.intentEvaluation?.reasons?.[0]
|
|
368
|
+
|| `${call.toolName} ${result.decision}ed by Action Policy.`;
|
|
369
|
+
const policyName = result.actionPolicy?.policyName;
|
|
370
|
+
dbg({ phase: 'policy_verdict', event, tool: call.toolName, decision: result.decision, allowed: result.allowed, policyName });
|
|
371
|
+
if (result.allowed && result.decision === 'allow') {
|
|
372
|
+
respond(false);
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
// require_approval — pause and wait for a human to approve in the console.
|
|
376
|
+
if (result.decision === 'approval') {
|
|
377
|
+
if (shadow) {
|
|
378
|
+
respond(false, undefined, `[FullCourtDefense shadow] would require approval for ${call.toolName}${policyName ? ` (${policyName})` : ''}: ${reason}`);
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
if (ctx.approvalMode === 'block' || !result.approvalActionId) {
|
|
382
|
+
respond(true, `Approval required by FullCourtDefense${policyName ? ` (${policyName})` : ''} — ${reason}`, `This ${event} requires human approval per Action Policy. Stop and ask the developer to approve it in the FullCourtDefense console.`);
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
const outcome = await waitForApproval({
|
|
386
|
+
apiUrl, shieldId, shieldKey, actionId: result.approvalActionId,
|
|
387
|
+
timeoutMs: ctx.approvalTimeoutMs, pollMs: ctx.approvalPollMs,
|
|
388
|
+
});
|
|
389
|
+
if (outcome.status === 'approved') {
|
|
390
|
+
respond(false, undefined, `FullCourtDefense: human approved this ${event}.`);
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
respond(true, `FullCourtDefense: ${outcome.message}`, `This ${event} was not approved (${outcome.status}). Do not retry until a human approves it in the FullCourtDefense console.`);
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
// block / mask / any other not-allowed decision.
|
|
397
|
+
if (shadow) {
|
|
398
|
+
respond(false, undefined, `[FullCourtDefense shadow] would block ${call.toolName}${policyName ? ` (${policyName})` : ''}: ${reason}`);
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
respond(true, `Blocked by FullCourtDefense${policyName ? ` (${policyName})` : ''} — ${reason}`, `FullCourtDefense blocked this ${event} per Action Policy: ${reason}. Do not retry.`);
|
|
402
|
+
}
|
|
403
|
+
async function waitForApproval(input) {
|
|
404
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
405
|
+
if (input.shieldKey)
|
|
406
|
+
headers['x-shield-key'] = input.shieldKey;
|
|
407
|
+
const url = `${input.apiUrl}/api/agent-security/runtime/approvals/${encodeURIComponent(input.actionId)}?shieldId=${encodeURIComponent(input.shieldId)}`;
|
|
408
|
+
const deadline = Date.now() + input.timeoutMs;
|
|
409
|
+
dbg({ phase: 'approval_wait_start', actionId: input.actionId, timeoutMs: input.timeoutMs });
|
|
410
|
+
while (Date.now() <= deadline) {
|
|
411
|
+
try {
|
|
412
|
+
const resp = await fetch(url, { method: 'GET', headers, signal: AbortSignal.timeout(15000) });
|
|
413
|
+
if (resp.ok) {
|
|
414
|
+
const body = await resp.json().catch(() => ({}));
|
|
415
|
+
const approval = body.data?.approval;
|
|
416
|
+
if (approval?.approvalStatus === 'approved') {
|
|
417
|
+
return { status: 'approved', message: 'approved by a human reviewer.' };
|
|
418
|
+
}
|
|
419
|
+
if (approval?.approvalStatus === 'rejected') {
|
|
420
|
+
return { status: 'rejected', message: `approval rejected for ${approval.toolName}.` };
|
|
421
|
+
}
|
|
422
|
+
if (approval?.approvalStatus === 'expired') {
|
|
423
|
+
return { status: 'expired', message: `approval expired for ${approval.toolName}.` };
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
catch (err) {
|
|
428
|
+
dbg({ phase: 'approval_poll_error', actionId: input.actionId, error: err instanceof Error ? err.message : String(err) });
|
|
429
|
+
}
|
|
430
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(input.pollMs, Math.max(0, deadline - Date.now()))));
|
|
431
|
+
}
|
|
432
|
+
return { status: 'timeout', message: 'approval timed out — still waiting for human review.' };
|
|
433
|
+
}
|
|
434
|
+
async function enforceShieldText(ctx) {
|
|
435
|
+
const { event, text, payload, apiUrl, shieldId, shieldKey, shadow, failClosed, timeoutMs, respond } = ctx;
|
|
277
436
|
try {
|
|
278
437
|
const controller = new AbortController();
|
|
279
438
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
280
|
-
// Behaves exactly like a normal Shield agent calling the public proxy:
|
|
281
|
-
// Shield key + runtime/developer attribution headers, body { message }.
|
|
282
439
|
const headers = {
|
|
283
440
|
'Content-Type': 'application/json',
|
|
284
441
|
'x-runtime-source-id': developerId(),
|
|
@@ -296,28 +453,21 @@ async function hookCommand(args, config) {
|
|
|
296
453
|
});
|
|
297
454
|
clearTimeout(timer);
|
|
298
455
|
if (!resp.ok) {
|
|
299
|
-
// Backend rejected (quota, bad shield, etc.) — fail open unless told otherwise.
|
|
300
456
|
respond(failClosed, failClosed ? `FullCourtDefense gate unavailable (HTTP ${resp.status}) — blocked by fail-closed policy.` : undefined, `FullCourtDefense gate returned HTTP ${resp.status}.`);
|
|
301
457
|
}
|
|
302
458
|
const data = await resp.json().catch(() => ({}));
|
|
303
459
|
const sh = (data._shield || {});
|
|
304
460
|
const action = String(sh.action || '');
|
|
305
|
-
// Proxy returns 'blocked_input' / 'blocked_output' when it blocks; in monitor
|
|
306
|
-
// mode it returns 'allowed' (never block, even if wouldBlock is set).
|
|
307
461
|
const isBlocked = action.startsWith('blocked');
|
|
308
462
|
const reason = String(sh.reason || 'policy_violation');
|
|
309
463
|
const explanation = String(sh.reason || 'Flagged by FullCourtDefense.');
|
|
310
|
-
if (!isBlocked)
|
|
464
|
+
if (!isBlocked)
|
|
311
465
|
respond(false);
|
|
312
|
-
|
|
313
|
-
// Blocked verdict. In shadow mode we annotate but let it through.
|
|
314
|
-
if (shadow) {
|
|
466
|
+
if (shadow)
|
|
315
467
|
respond(false, undefined, `[FullCourtDefense shadow] would block ${event} (${reason}): ${explanation}`);
|
|
316
|
-
}
|
|
317
468
|
respond(true, `Blocked by FullCourtDefense — ${reason}.`, `FullCourtDefense blocked this ${event} as "${reason}". Do not retry; revise to remove the flagged content.`);
|
|
318
469
|
}
|
|
319
470
|
catch (err) {
|
|
320
|
-
// Network error / timeout.
|
|
321
471
|
respond(failClosed, failClosed ? 'FullCourtDefense gate unreachable — blocked by fail-closed policy.' : undefined, `FullCourtDefense hook error: ${err instanceof Error ? err.message : String(err)}.`);
|
|
322
472
|
}
|
|
323
473
|
}
|
|
@@ -47,12 +47,16 @@ const COLOR = {
|
|
|
47
47
|
// find our entries regardless of the install path (npm global vs local dev).
|
|
48
48
|
const MANAGED_MARKER = '--fcd-managed true';
|
|
49
49
|
const MANAGED_TAG = 'fullcourtdefense';
|
|
50
|
+
// `timeoutSec` is the Cursor-side hook timeout. Action events can pause for a
|
|
51
|
+
// human approval decision, so they need a long timeout (Cursor kills the hook
|
|
52
|
+
// at this limit). Allow/block verdicts still return in <1s — the long timeout
|
|
53
|
+
// only matters while waiting for a human to approve in the console.
|
|
50
54
|
const EVENT_MAP = {
|
|
51
|
-
prompt: { hookKey: 'beforeSubmitPrompt', flag: 'prompt', failClosedDefault: false },
|
|
52
|
-
shell: { hookKey: 'beforeShellExecution', flag: 'shell', failClosedDefault: true },
|
|
53
|
-
mcp: { hookKey: 'beforeMCPExecution', flag: 'mcp', failClosedDefault: true },
|
|
54
|
-
file: { hookKey: 'afterFileEdit', flag: 'file', failClosedDefault: false },
|
|
55
|
-
read: { hookKey: 'beforeReadFile', flag: 'read', failClosedDefault: false },
|
|
55
|
+
prompt: { hookKey: 'beforeSubmitPrompt', flag: 'prompt', failClosedDefault: false, timeoutSec: 10 },
|
|
56
|
+
shell: { hookKey: 'beforeShellExecution', flag: 'shell', failClosedDefault: true, timeoutSec: 300 },
|
|
57
|
+
mcp: { hookKey: 'beforeMCPExecution', flag: 'mcp', failClosedDefault: true, timeoutSec: 300 },
|
|
58
|
+
file: { hookKey: 'afterFileEdit', flag: 'file', failClosedDefault: false, timeoutSec: 300 },
|
|
59
|
+
read: { hookKey: 'beforeReadFile', flag: 'read', failClosedDefault: false, timeoutSec: 300 },
|
|
56
60
|
};
|
|
57
61
|
/** Build the absolute, shell-agnostic command that invokes this CLI's `hook`. */
|
|
58
62
|
function buildHookCommand(flag, opts) {
|
|
@@ -120,11 +124,11 @@ async function installCursorHookCommand(args, config) {
|
|
|
120
124
|
const credFile = ensureHomeShield(creds);
|
|
121
125
|
const json = readHooksJson(file);
|
|
122
126
|
for (const e of events) {
|
|
123
|
-
const { hookKey, flag, failClosedDefault } = EVENT_MAP[e];
|
|
127
|
+
const { hookKey, flag, failClosedDefault, timeoutSec } = EVENT_MAP[e];
|
|
124
128
|
const failClosed = args.failClosed !== undefined ? args.failClosed === 'true' : failClosedDefault;
|
|
125
129
|
const entry = {
|
|
126
130
|
command: buildHookCommand(flag, { shadow, failClosed }),
|
|
127
|
-
timeout:
|
|
131
|
+
timeout: timeoutSec,
|
|
128
132
|
};
|
|
129
133
|
if (failClosed)
|
|
130
134
|
entry.failClosed = true;
|
|
@@ -140,6 +144,13 @@ async function installCursorHookCommand(args, config) {
|
|
|
140
144
|
console.log(`${COLOR.gray}Scope:${COLOR.reset} ${projectScope ? 'project (.cursor/hooks.json)' : 'machine-wide (~/.cursor/hooks.json)'}`);
|
|
141
145
|
console.log(`${COLOR.gray}File:${COLOR.reset} ${file}`);
|
|
142
146
|
console.log(`${COLOR.gray}Events:${COLOR.reset} ${events.map((e) => EVENT_MAP[e].hookKey).join(', ')}`);
|
|
147
|
+
const actionEvents = events.filter((e) => e !== 'prompt');
|
|
148
|
+
if (actionEvents.length) {
|
|
149
|
+
console.log(`${COLOR.gray}Enforcement:${COLOR.reset} ${actionEvents.join(', ')} checked against your org Action Policies (allow / block / wait-for-human-approval) in every repo.`);
|
|
150
|
+
}
|
|
151
|
+
if (events.includes('prompt')) {
|
|
152
|
+
console.log(`${COLOR.gray}Prompts:${COLOR.reset} scanned by your Shield (developer-chat).`);
|
|
153
|
+
}
|
|
143
154
|
if (shadow)
|
|
144
155
|
console.log(`${COLOR.yellow}Mode: SHADOW (monitor only — nothing is blocked).${COLOR.reset}`);
|
|
145
156
|
if (credFile)
|
|
@@ -58,6 +58,12 @@ export interface InstallMcpGatewayArgs extends McpGatewayArgs {
|
|
|
58
58
|
export declare function mcpGatewayCommand(args: McpGatewayArgs, config: BotGuardConfig): Promise<void>;
|
|
59
59
|
export declare function installCursorMcpGatewayCommand(args: InstallCursorMcpGatewayArgs, config: BotGuardConfig): Promise<void>;
|
|
60
60
|
export declare function installMcpGatewayCommand(args: InstallMcpGatewayArgs, config: BotGuardConfig): Promise<void>;
|
|
61
|
+
export interface ProtectAllArgs extends McpGatewayArgs {
|
|
62
|
+
dryRun?: string;
|
|
63
|
+
config?: string;
|
|
64
|
+
}
|
|
65
|
+
export declare function protectAllCommand(args: ProtectAllArgs, config: BotGuardConfig): Promise<void>;
|
|
66
|
+
export declare function unprotectAllCommand(args: ProtectAllArgs, config: BotGuardConfig): Promise<void>;
|
|
61
67
|
export declare function installClaudeCodeMcpGatewayCommand(args: InstallClaudeCodeMcpGatewayArgs, config: BotGuardConfig): Promise<void>;
|
|
62
68
|
export declare function installClaudeDesktopMcpGatewayCommand(args: InstallClaudeDesktopMcpGatewayArgs, config: BotGuardConfig): Promise<void>;
|
|
63
69
|
export declare function installCodexMcpGatewayCommand(args: InstallCodexMcpGatewayArgs, config: BotGuardConfig): Promise<void>;
|
|
@@ -37,6 +37,8 @@ exports.ALL_GATEWAY_CLIENTS = void 0;
|
|
|
37
37
|
exports.mcpGatewayCommand = mcpGatewayCommand;
|
|
38
38
|
exports.installCursorMcpGatewayCommand = installCursorMcpGatewayCommand;
|
|
39
39
|
exports.installMcpGatewayCommand = installMcpGatewayCommand;
|
|
40
|
+
exports.protectAllCommand = protectAllCommand;
|
|
41
|
+
exports.unprotectAllCommand = unprotectAllCommand;
|
|
40
42
|
exports.installClaudeCodeMcpGatewayCommand = installClaudeCodeMcpGatewayCommand;
|
|
41
43
|
exports.installClaudeDesktopMcpGatewayCommand = installClaudeDesktopMcpGatewayCommand;
|
|
42
44
|
exports.installCodexMcpGatewayCommand = installCodexMcpGatewayCommand;
|
|
@@ -940,6 +942,360 @@ async function installMcpGatewayCommand(args, config) {
|
|
|
940
942
|
console.log('Add --upload to refresh AI Fleet after install.');
|
|
941
943
|
}
|
|
942
944
|
}
|
|
945
|
+
const CLIENT_KEY_TO_AGENT = {
|
|
946
|
+
cursor: 'cursor',
|
|
947
|
+
claude_code: 'claude-code',
|
|
948
|
+
claude_desktop: 'claude-desktop',
|
|
949
|
+
codex: 'codex',
|
|
950
|
+
gemini_cli: 'gemini-cli',
|
|
951
|
+
windsurf: 'windsurf',
|
|
952
|
+
vscode: 'vscode',
|
|
953
|
+
};
|
|
954
|
+
function newWrapStats() { return { wrapped: [], skippedManaged: [], skippedRemote: [] }; }
|
|
955
|
+
/** True when an MCP server entry already runs through the AgentGuard gateway. */
|
|
956
|
+
function isGatewayWrappedEntry(entry) {
|
|
957
|
+
if (!entry || typeof entry !== 'object')
|
|
958
|
+
return false;
|
|
959
|
+
const args = Array.isArray(entry.args) ? entry.args.map(String) : [];
|
|
960
|
+
return args.includes('mcp-gateway');
|
|
961
|
+
}
|
|
962
|
+
/** Recover the original downstream command+args from a wrapped entry's args. */
|
|
963
|
+
function extractWrappedDownstream(entry) {
|
|
964
|
+
const args = Array.isArray(entry?.args) ? entry.args.map(String) : [];
|
|
965
|
+
const ci = args.indexOf('--mcp-command');
|
|
966
|
+
if (ci === -1 || ci + 1 >= args.length)
|
|
967
|
+
return null;
|
|
968
|
+
const command = args[ci + 1];
|
|
969
|
+
let downstreamArgs = [];
|
|
970
|
+
const ai = args.indexOf('--mcp-args');
|
|
971
|
+
if (ai !== -1 && args[ai + 1]) {
|
|
972
|
+
try {
|
|
973
|
+
const parsed = JSON.parse(args[ai + 1]);
|
|
974
|
+
if (Array.isArray(parsed))
|
|
975
|
+
downstreamArgs = parsed.map(String);
|
|
976
|
+
}
|
|
977
|
+
catch { /* leave empty */ }
|
|
978
|
+
}
|
|
979
|
+
return { command, args: downstreamArgs };
|
|
980
|
+
}
|
|
981
|
+
/** All server maps inside a parsed JSON config, across every known client shape. */
|
|
982
|
+
function collectJsonServerMaps(json) {
|
|
983
|
+
const maps = [];
|
|
984
|
+
const pushIfMap = (m) => { if (m && typeof m === 'object' && !Array.isArray(m))
|
|
985
|
+
maps.push(m); };
|
|
986
|
+
if (json && typeof json === 'object') {
|
|
987
|
+
pushIfMap(json.mcpServers); // Cursor, Claude, Gemini, Windsurf, Kiro, Codex mcp.json
|
|
988
|
+
pushIfMap(json.servers); // VS Code .vscode/mcp.json
|
|
989
|
+
if (json.mcp && typeof json.mcp === 'object')
|
|
990
|
+
pushIfMap(json.mcp.servers); // VS Code settings.json
|
|
991
|
+
if (json.projects && typeof json.projects === 'object') {
|
|
992
|
+
for (const proj of Object.values(json.projects))
|
|
993
|
+
pushIfMap(proj?.mcpServers); // Claude Code .claude.json
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
return maps;
|
|
997
|
+
}
|
|
998
|
+
function backupFile(file) {
|
|
999
|
+
try {
|
|
1000
|
+
const bak = `${file}.fcd-backup-${Date.now()}`;
|
|
1001
|
+
fs.copyFileSync(file, bak);
|
|
1002
|
+
}
|
|
1003
|
+
catch { /* best effort */ }
|
|
1004
|
+
}
|
|
1005
|
+
function perServerAgentName(developerName, agentClient, serverName) {
|
|
1006
|
+
return `${safeIdentityPart(developerName)}-${agentClient}-${safeIdentityPart(serverName)}`;
|
|
1007
|
+
}
|
|
1008
|
+
function wrapJsonConfigFile(file, gatewayConfig, agentClient, dryRun) {
|
|
1009
|
+
const stats = newWrapStats();
|
|
1010
|
+
let json;
|
|
1011
|
+
try {
|
|
1012
|
+
json = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
1013
|
+
}
|
|
1014
|
+
catch {
|
|
1015
|
+
return null; // not JSON / unreadable — skip silently
|
|
1016
|
+
}
|
|
1017
|
+
if (!json || typeof json !== 'object')
|
|
1018
|
+
return null;
|
|
1019
|
+
const maps = collectJsonServerMaps(json);
|
|
1020
|
+
if (maps.length === 0)
|
|
1021
|
+
return stats;
|
|
1022
|
+
const nodeExe = process.execPath;
|
|
1023
|
+
let changed = false;
|
|
1024
|
+
for (const map of maps) {
|
|
1025
|
+
for (const [name, entry] of Object.entries(map)) {
|
|
1026
|
+
if (!entry || typeof entry !== 'object')
|
|
1027
|
+
continue;
|
|
1028
|
+
if (name === MANAGED_SERVER_NAME || isGatewayWrappedEntry(entry)) {
|
|
1029
|
+
stats.skippedManaged.push(name);
|
|
1030
|
+
continue;
|
|
1031
|
+
}
|
|
1032
|
+
if (typeof entry.command !== 'string' || !entry.command) {
|
|
1033
|
+
stats.skippedRemote.push(name);
|
|
1034
|
+
continue;
|
|
1035
|
+
}
|
|
1036
|
+
const downstreamCommand = entry.command;
|
|
1037
|
+
const downstreamArgs = Array.isArray(entry.args) ? entry.args.map(String) : [];
|
|
1038
|
+
const perServer = { ...gatewayConfig, agentName: perServerAgentName(gatewayConfig.developerName, agentClient, name) };
|
|
1039
|
+
const wrappedArgs = buildGatewayCommandArgs(perServer, downstreamCommand, downstreamArgs, INSTALL_GATEWAY_ARGS);
|
|
1040
|
+
entry.command = nodeExe;
|
|
1041
|
+
entry.args = wrappedArgs;
|
|
1042
|
+
// entry.env and any other fields are preserved as-is.
|
|
1043
|
+
stats.wrapped.push(name);
|
|
1044
|
+
changed = true;
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
if (changed && !dryRun) {
|
|
1048
|
+
backupFile(file);
|
|
1049
|
+
fs.writeFileSync(file, `${JSON.stringify(json, null, 2)}\n`, 'utf8');
|
|
1050
|
+
}
|
|
1051
|
+
return stats;
|
|
1052
|
+
}
|
|
1053
|
+
function unwrapJsonConfigFile(file, dryRun) {
|
|
1054
|
+
let json;
|
|
1055
|
+
try {
|
|
1056
|
+
json = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
1057
|
+
}
|
|
1058
|
+
catch {
|
|
1059
|
+
return null;
|
|
1060
|
+
}
|
|
1061
|
+
if (!json || typeof json !== 'object')
|
|
1062
|
+
return null;
|
|
1063
|
+
const maps = collectJsonServerMaps(json);
|
|
1064
|
+
const restored = [];
|
|
1065
|
+
let changed = false;
|
|
1066
|
+
for (const map of maps) {
|
|
1067
|
+
for (const [name, entry] of Object.entries(map)) {
|
|
1068
|
+
if (name === MANAGED_SERVER_NAME) {
|
|
1069
|
+
delete map[name];
|
|
1070
|
+
changed = true;
|
|
1071
|
+
continue;
|
|
1072
|
+
}
|
|
1073
|
+
if (!isGatewayWrappedEntry(entry))
|
|
1074
|
+
continue;
|
|
1075
|
+
const downstream = extractWrappedDownstream(entry);
|
|
1076
|
+
if (!downstream)
|
|
1077
|
+
continue;
|
|
1078
|
+
entry.command = downstream.command;
|
|
1079
|
+
entry.args = downstream.args;
|
|
1080
|
+
restored.push(name);
|
|
1081
|
+
changed = true;
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
if (changed && !dryRun) {
|
|
1085
|
+
backupFile(file);
|
|
1086
|
+
fs.writeFileSync(file, `${JSON.stringify(json, null, 2)}\n`, 'utf8');
|
|
1087
|
+
}
|
|
1088
|
+
return { restored };
|
|
1089
|
+
}
|
|
1090
|
+
/** Parse a single-line TOML scalar string value: command = "x". */
|
|
1091
|
+
function parseTomlInlineString(line) {
|
|
1092
|
+
const m = line.match(/=\s*"((?:[^"\\]|\\.)*)"/);
|
|
1093
|
+
if (!m)
|
|
1094
|
+
return undefined;
|
|
1095
|
+
try {
|
|
1096
|
+
return JSON.parse(`"${m[1]}"`);
|
|
1097
|
+
}
|
|
1098
|
+
catch {
|
|
1099
|
+
return m[1];
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
/** Parse a single-line TOML string array: args = ["a", "b"]. */
|
|
1103
|
+
function parseTomlInlineStringArray(line) {
|
|
1104
|
+
const m = line.match(/=\s*(\[.*\])\s*$/);
|
|
1105
|
+
if (!m)
|
|
1106
|
+
return undefined;
|
|
1107
|
+
try {
|
|
1108
|
+
const parsed = JSON.parse(m[1]);
|
|
1109
|
+
if (Array.isArray(parsed))
|
|
1110
|
+
return parsed.map(String);
|
|
1111
|
+
}
|
|
1112
|
+
catch { /* not single-line */ }
|
|
1113
|
+
return undefined;
|
|
1114
|
+
}
|
|
1115
|
+
function tomlStringArrayLiteral(values) {
|
|
1116
|
+
return `[${values.map(v => JSON.stringify(v)).join(', ')}]`;
|
|
1117
|
+
}
|
|
1118
|
+
/**
|
|
1119
|
+
* Wrap/unwrap Codex config.toml in place, line by line, so [mcp_servers.NAME.env]
|
|
1120
|
+
* subtables and unrelated config are preserved. Only single-line command/args
|
|
1121
|
+
* are handled; multi-line arrays are skipped (rare for MCP servers).
|
|
1122
|
+
*/
|
|
1123
|
+
function transformCodexToml(file, mode, gatewayConfig, dryRun) {
|
|
1124
|
+
let content;
|
|
1125
|
+
try {
|
|
1126
|
+
content = fs.readFileSync(file, 'utf8');
|
|
1127
|
+
}
|
|
1128
|
+
catch {
|
|
1129
|
+
return null;
|
|
1130
|
+
}
|
|
1131
|
+
const lines = content.split(/\r?\n/);
|
|
1132
|
+
const stats = newWrapStats();
|
|
1133
|
+
const nodeExe = process.execPath;
|
|
1134
|
+
const sections = [];
|
|
1135
|
+
let current = null;
|
|
1136
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1137
|
+
const trimmed = lines[i].trim();
|
|
1138
|
+
const header = trimmed.match(/^\[(.+?)\]\s*$/);
|
|
1139
|
+
if (header) {
|
|
1140
|
+
const tablePath = header[1];
|
|
1141
|
+
const isServer = /^mcp_servers\.[^.]+$/.test(tablePath);
|
|
1142
|
+
if (isServer) {
|
|
1143
|
+
current = { name: tablePath.slice('mcp_servers.'.length), cmdLine: -1, argsLine: -1 };
|
|
1144
|
+
sections.push(current);
|
|
1145
|
+
}
|
|
1146
|
+
else
|
|
1147
|
+
current = null;
|
|
1148
|
+
continue;
|
|
1149
|
+
}
|
|
1150
|
+
if (current) {
|
|
1151
|
+
if (/^command\s*=/.test(trimmed) && current.cmdLine === -1) {
|
|
1152
|
+
current.cmdLine = i;
|
|
1153
|
+
current.command = parseTomlInlineString(lines[i]);
|
|
1154
|
+
}
|
|
1155
|
+
else if (/^args\s*=/.test(trimmed) && current.argsLine === -1) {
|
|
1156
|
+
current.argsLine = i;
|
|
1157
|
+
current.args = parseTomlInlineStringArray(lines[i]);
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
let changed = false;
|
|
1162
|
+
for (const section of sections) {
|
|
1163
|
+
if (section.cmdLine === -1 || !section.command) {
|
|
1164
|
+
continue;
|
|
1165
|
+
}
|
|
1166
|
+
const wrappedAlready = section.command === nodeExe && (section.args || []).includes('mcp-gateway');
|
|
1167
|
+
if (mode === 'wrap') {
|
|
1168
|
+
if (section.name === MANAGED_SERVER_NAME || wrappedAlready) {
|
|
1169
|
+
stats.skippedManaged.push(section.name);
|
|
1170
|
+
continue;
|
|
1171
|
+
}
|
|
1172
|
+
const perServer = { ...gatewayConfig, agentName: perServerAgentName(gatewayConfig.developerName, 'codex', section.name) };
|
|
1173
|
+
const wrappedArgs = buildGatewayCommandArgs(perServer, section.command, section.args || [], INSTALL_GATEWAY_ARGS);
|
|
1174
|
+
const indent = lines[section.cmdLine].match(/^\s*/)?.[0] || '';
|
|
1175
|
+
lines[section.cmdLine] = `${indent}command = ${JSON.stringify(nodeExe)}`;
|
|
1176
|
+
const argsLineText = `${indent}args = ${tomlStringArrayLiteral(wrappedArgs)}`;
|
|
1177
|
+
if (section.argsLine !== -1)
|
|
1178
|
+
lines[section.argsLine] = argsLineText;
|
|
1179
|
+
else
|
|
1180
|
+
lines.splice(section.cmdLine + 1, 0, argsLineText);
|
|
1181
|
+
stats.wrapped.push(section.name);
|
|
1182
|
+
changed = true;
|
|
1183
|
+
}
|
|
1184
|
+
else {
|
|
1185
|
+
if (!wrappedAlready)
|
|
1186
|
+
continue;
|
|
1187
|
+
const downstream = extractWrappedDownstream({ args: section.args });
|
|
1188
|
+
if (!downstream)
|
|
1189
|
+
continue;
|
|
1190
|
+
const indent = lines[section.cmdLine].match(/^\s*/)?.[0] || '';
|
|
1191
|
+
lines[section.cmdLine] = `${indent}command = ${JSON.stringify(downstream.command)}`;
|
|
1192
|
+
if (section.argsLine !== -1)
|
|
1193
|
+
lines[section.argsLine] = `${indent}args = ${tomlStringArrayLiteral(downstream.args)}`;
|
|
1194
|
+
stats.wrapped.push(section.name);
|
|
1195
|
+
changed = true;
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
if (changed && !dryRun) {
|
|
1199
|
+
backupFile(file);
|
|
1200
|
+
fs.writeFileSync(file, lines.join('\n'), 'utf8');
|
|
1201
|
+
}
|
|
1202
|
+
return stats;
|
|
1203
|
+
}
|
|
1204
|
+
function protectAllTargetFiles(extra) {
|
|
1205
|
+
const cwd = process.cwd();
|
|
1206
|
+
const seen = new Set();
|
|
1207
|
+
const files = [];
|
|
1208
|
+
for (const candidate of (0, discoverPaths_1.candidateConfigPaths)(cwd, extra)) {
|
|
1209
|
+
const key = path.resolve(candidate.path).toLowerCase();
|
|
1210
|
+
if (seen.has(key))
|
|
1211
|
+
continue;
|
|
1212
|
+
if (!fs.existsSync(candidate.path))
|
|
1213
|
+
continue;
|
|
1214
|
+
seen.add(key);
|
|
1215
|
+
files.push(candidate);
|
|
1216
|
+
}
|
|
1217
|
+
for (const target of (0, discoverPaths_1.claudeDesktopInstallTargets)()) {
|
|
1218
|
+
const key = path.resolve(target.file).toLowerCase();
|
|
1219
|
+
if (target.exists && !seen.has(key)) {
|
|
1220
|
+
seen.add(key);
|
|
1221
|
+
files.push({ path: target.file, source: target.source, clientKey: 'claude_desktop' });
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
return files;
|
|
1225
|
+
}
|
|
1226
|
+
async function protectAllCommand(args, config) {
|
|
1227
|
+
(0, config_1.requireCliSetup)(config, { shieldId: args.shieldId, shieldKey: args.shieldKey, apiUrl: args.apiUrl }, { requireOrganizationId: false });
|
|
1228
|
+
const gatewayConfig = resolveGatewayConfig(args, config);
|
|
1229
|
+
const dryRun = args.dryRun === 'true';
|
|
1230
|
+
const files = protectAllTargetFiles(args.config);
|
|
1231
|
+
console.log('\n\x1b[1m\x1b[36mFullCourtDefense — protect every MCP server in every client\x1b[0m');
|
|
1232
|
+
console.log(`\x1b[2mRuntime identity: ${gatewayConfig.developerName} | Shield: ${gatewayConfig.shieldId}${dryRun ? ' | DRY RUN' : ''}\x1b[0m\n`);
|
|
1233
|
+
if (files.length === 0) {
|
|
1234
|
+
console.log('No MCP client config files found on this machine. Configure an MCP server in any client, then re-run.');
|
|
1235
|
+
return;
|
|
1236
|
+
}
|
|
1237
|
+
let totalWrapped = 0;
|
|
1238
|
+
let totalManaged = 0;
|
|
1239
|
+
let totalRemote = 0;
|
|
1240
|
+
for (const file of files) {
|
|
1241
|
+
const agentClient = CLIENT_KEY_TO_AGENT[file.clientKey] || 'cursor';
|
|
1242
|
+
const isToml = /\.toml$/i.test(file.path);
|
|
1243
|
+
const stats = isToml
|
|
1244
|
+
? transformCodexToml(file.path, 'wrap', gatewayConfig, dryRun)
|
|
1245
|
+
: wrapJsonConfigFile(file.path, gatewayConfig, agentClient, dryRun);
|
|
1246
|
+
if (!stats)
|
|
1247
|
+
continue;
|
|
1248
|
+
if (stats.wrapped.length === 0 && stats.skippedManaged.length === 0 && stats.skippedRemote.length === 0)
|
|
1249
|
+
continue;
|
|
1250
|
+
totalWrapped += stats.wrapped.length;
|
|
1251
|
+
totalManaged += stats.skippedManaged.length;
|
|
1252
|
+
totalRemote += stats.skippedRemote.length;
|
|
1253
|
+
console.log(`\x1b[1m${file.source}\x1b[0m \x1b[2m${file.path}\x1b[0m`);
|
|
1254
|
+
if (stats.wrapped.length)
|
|
1255
|
+
console.log(` \x1b[32m✓ wrapped:\x1b[0m ${stats.wrapped.join(', ')}`);
|
|
1256
|
+
if (stats.skippedManaged.length)
|
|
1257
|
+
console.log(` \x1b[2m• already protected:\x1b[0m ${stats.skippedManaged.join(', ')}`);
|
|
1258
|
+
if (stats.skippedRemote.length)
|
|
1259
|
+
console.log(` \x1b[33m• skipped (remote/no command):\x1b[0m ${stats.skippedRemote.join(', ')}`);
|
|
1260
|
+
console.log('');
|
|
1261
|
+
}
|
|
1262
|
+
console.log('\x1b[1mSummary\x1b[0m');
|
|
1263
|
+
console.log(` ${dryRun ? 'would wrap' : 'wrapped'}: ${totalWrapped} already protected: ${totalManaged} skipped remote: ${totalRemote}`);
|
|
1264
|
+
console.log(` Each protected server enforces your org Action Policies (allow / block / wait-for-human-approval) on its tool calls.`);
|
|
1265
|
+
if (!dryRun && totalWrapped > 0)
|
|
1266
|
+
console.log(' Backups saved next to each edited file (*.fcd-backup-*). Restart each client to load the protected servers.');
|
|
1267
|
+
console.log(' Reverse anytime with: \x1b[1mfullcourtdefense unprotect-all\x1b[0m');
|
|
1268
|
+
}
|
|
1269
|
+
async function unprotectAllCommand(args, config) {
|
|
1270
|
+
const dryRun = args.dryRun === 'true';
|
|
1271
|
+
const files = protectAllTargetFiles(args.config);
|
|
1272
|
+
let gatewayConfig;
|
|
1273
|
+
try {
|
|
1274
|
+
gatewayConfig = resolveGatewayConfig(args, config);
|
|
1275
|
+
}
|
|
1276
|
+
catch { /* unwrap doesn't need creds */ }
|
|
1277
|
+
const fallbackConfig = gatewayConfig || { developerName: 'local-user' };
|
|
1278
|
+
console.log('\n\x1b[1m\x1b[36mFullCourtDefense — remove gateway wrapping from all clients\x1b[0m\n');
|
|
1279
|
+
let total = 0;
|
|
1280
|
+
for (const file of files) {
|
|
1281
|
+
const isToml = /\.toml$/i.test(file.path);
|
|
1282
|
+
if (isToml) {
|
|
1283
|
+
const stats = transformCodexToml(file.path, 'unwrap', fallbackConfig, dryRun);
|
|
1284
|
+
if (stats && stats.wrapped.length) {
|
|
1285
|
+
total += stats.wrapped.length;
|
|
1286
|
+
console.log(`\x1b[1m${file.source}\x1b[0m restored: ${stats.wrapped.join(', ')}`);
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
else {
|
|
1290
|
+
const res = unwrapJsonConfigFile(file.path, dryRun);
|
|
1291
|
+
if (res && res.restored.length) {
|
|
1292
|
+
total += res.restored.length;
|
|
1293
|
+
console.log(`\x1b[1m${file.source}\x1b[0m restored: ${res.restored.join(', ')}`);
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
console.log(`\n${dryRun ? 'Would restore' : 'Restored'} ${total} server(s) to their original commands. Restart each client.`);
|
|
1298
|
+
}
|
|
943
1299
|
async function installClaudeCodeMcpGatewayCommand(args, config) {
|
|
944
1300
|
const gatewayConfig = resolveGatewayConfig(args, config, {
|
|
945
1301
|
agentClient: 'claude-code',
|
package/dist/index.js
CHANGED
|
@@ -118,10 +118,18 @@ function printHelp() {
|
|
|
118
118
|
discover Finds MCP servers, local secrets, agent rules/skills, and machine
|
|
119
119
|
blast radius on this laptop. Use --surface all --upload for AI Fleet.
|
|
120
120
|
install-cursor-hook
|
|
121
|
-
Installs a Cursor hook so
|
|
122
|
-
|
|
121
|
+
Installs a Cursor hook so EVERY agent action on this machine is
|
|
122
|
+
checked against your org's Action Policies — in any repo/folder.
|
|
123
|
+
Prompts are scanned by your Shield; shell/MCP/file/read actions
|
|
124
|
+
are allowed, blocked, or paused for human approval per policy.
|
|
123
125
|
uninstall-cursor-hook
|
|
124
126
|
Removes the FullCourtDefense Cursor hook entries.
|
|
127
|
+
protect-all Auto-detect EVERY MCP server already configured in EVERY client
|
|
128
|
+
(Cursor, Claude Code/Desktop, Codex, Gemini, Windsurf, VS Code)
|
|
129
|
+
and wrap each through the Shield proxy — no --mcp-command needed.
|
|
130
|
+
Enforces your org Action Policies on every tool. --dry-run to preview.
|
|
131
|
+
unprotect-all
|
|
132
|
+
Reverse protect-all: restore every server to its original command.
|
|
125
133
|
install-mcp-gateway
|
|
126
134
|
Install MCP gateway into selected clients (--clients all for every tool).
|
|
127
135
|
install-cursor-mcp-gateway
|
|
@@ -472,6 +480,9 @@ async function main() {
|
|
|
472
480
|
shadow: flags.shadow,
|
|
473
481
|
failClosed: flags['fail-closed'],
|
|
474
482
|
timeout: flags.timeout,
|
|
483
|
+
approvalMode: flags['approval-mode'],
|
|
484
|
+
approvalTimeoutMs: flags['approval-timeout-ms'],
|
|
485
|
+
approvalPollMs: flags['approval-poll-ms'],
|
|
475
486
|
};
|
|
476
487
|
await (0, hook_1.hookCommand)(args, config);
|
|
477
488
|
break;
|
|
@@ -494,6 +505,26 @@ async function main() {
|
|
|
494
505
|
await (0, mcpGateway_1.installMcpGatewayCommand)(buildInstallArgs(), config);
|
|
495
506
|
break;
|
|
496
507
|
}
|
|
508
|
+
case 'protect-all':
|
|
509
|
+
case 'wrap-all': {
|
|
510
|
+
const args = {
|
|
511
|
+
...buildGatewayArgs(),
|
|
512
|
+
dryRun: flags['dry-run'],
|
|
513
|
+
config: flags.config,
|
|
514
|
+
};
|
|
515
|
+
await (0, mcpGateway_1.protectAllCommand)(args, config);
|
|
516
|
+
break;
|
|
517
|
+
}
|
|
518
|
+
case 'unprotect-all':
|
|
519
|
+
case 'unwrap-all': {
|
|
520
|
+
const args = {
|
|
521
|
+
...buildGatewayArgs(),
|
|
522
|
+
dryRun: flags['dry-run'],
|
|
523
|
+
config: flags.config,
|
|
524
|
+
};
|
|
525
|
+
await (0, mcpGateway_1.unprotectAllCommand)(args, config);
|
|
526
|
+
break;
|
|
527
|
+
}
|
|
497
528
|
case 'install-cursor-mcp-gateway': {
|
|
498
529
|
const args = {
|
|
499
530
|
...buildGatewayArgs(),
|
package/dist/version.json
CHANGED