fullcourtdefense-cli 1.6.5 → 1.6.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.
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export type DeterministicDirection = 'request' | 'response';
|
|
2
|
+
export interface DeterministicFinding {
|
|
3
|
+
blocked: true;
|
|
4
|
+
ruleId: string;
|
|
5
|
+
category: 'sensitive_file' | 'metadata_ssrf' | 'destructive_command' | 'destructive_sql' | 'infra_destroy' | 'reverse_shell' | 'secret_exfiltration';
|
|
6
|
+
reason: string;
|
|
7
|
+
evidence: string;
|
|
8
|
+
}
|
|
9
|
+
export declare function scanDeterministicToolCall(toolName: string, toolArgs: Record<string, unknown>): DeterministicFinding | undefined;
|
|
10
|
+
export declare function scanDeterministicTextResponse(text: string): DeterministicFinding | undefined;
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.scanDeterministicToolCall = scanDeterministicToolCall;
|
|
4
|
+
exports.scanDeterministicTextResponse = scanDeterministicTextResponse;
|
|
5
|
+
const SECRET_PATTERNS = [
|
|
6
|
+
{ label: 'private key material', re: /-----BEGIN (?:RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----/i },
|
|
7
|
+
{ label: 'AWS access key', re: /\bAKIA[0-9A-Z]{16}\b/ },
|
|
8
|
+
{ label: 'GitHub token', re: /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9_]{20,}\b/ },
|
|
9
|
+
{ label: 'GitHub fine-grained token', re: /\bgithub_pat_[A-Za-z0-9_]{20,}\b/ },
|
|
10
|
+
{ label: 'Anthropic API key', re: /\bsk-ant-[A-Za-z0-9_-]{20,}\b/ },
|
|
11
|
+
{ label: 'OpenAI project key', re: /\bsk-proj-[A-Za-z0-9_-]{20,}\b/ },
|
|
12
|
+
{ label: 'OpenAI API key', re: /\bsk-[A-Za-z0-9_-]{32,}\b/ },
|
|
13
|
+
{ label: 'Slack token', re: /\bxox[baprs]-[A-Za-z0-9-]{20,}\b/ },
|
|
14
|
+
{ label: 'Stripe live key', re: /\b[rs]k_live_[0-9a-zA-Z]{24,}\b/ },
|
|
15
|
+
{ label: 'Google API key', re: /\bAIza[0-9A-Za-z_-]{35}\b/ },
|
|
16
|
+
{ label: 'HuggingFace token', re: /\bhf_[A-Za-z0-9]{30,}\b/ },
|
|
17
|
+
{ label: 'database URL with password', re: /\b(?:postgres(?:ql)?|mongodb(?:\+srv)?|mysql):\/\/[^/\s'":]+:[^@\s'"]+@/i },
|
|
18
|
+
];
|
|
19
|
+
const METADATA_ENDPOINTS = [
|
|
20
|
+
'169.254.169.254',
|
|
21
|
+
'metadata.google.internal',
|
|
22
|
+
'metadata.azure.com',
|
|
23
|
+
'100.100.100.200',
|
|
24
|
+
];
|
|
25
|
+
const OUTBOUND_TOOL_HINT = /(?:http|fetch|request|curl|wget|webhook|upload|send|email|mail|slack|discord|teams|post|put|publish|notify)/i;
|
|
26
|
+
const COMMAND_TOOL_HINT = /(?:shell|bash|command|cmd|powershell|terminal|exec|subprocess|run|script)/i;
|
|
27
|
+
const SQL_TOOL_HINT = /(?:sql|query|database|postgres|mysql|sqlite|snowflake|bigquery|db)/i;
|
|
28
|
+
const COMMAND_KEYS = new Set([
|
|
29
|
+
'command',
|
|
30
|
+
'cmd',
|
|
31
|
+
'script',
|
|
32
|
+
'shell',
|
|
33
|
+
'bash',
|
|
34
|
+
'powershell',
|
|
35
|
+
'args.command',
|
|
36
|
+
'toolArgs.command',
|
|
37
|
+
]);
|
|
38
|
+
const SQL_KEYS = new Set([
|
|
39
|
+
'query',
|
|
40
|
+
'sql',
|
|
41
|
+
'statement',
|
|
42
|
+
'migration',
|
|
43
|
+
'args.query',
|
|
44
|
+
'args.sql',
|
|
45
|
+
'toolArgs.query',
|
|
46
|
+
'toolArgs.sql',
|
|
47
|
+
]);
|
|
48
|
+
function evidence(value) {
|
|
49
|
+
const compact = value.replace(/\s+/g, ' ').trim();
|
|
50
|
+
return compact.length > 180 ? `${compact.slice(0, 180)}...` : compact;
|
|
51
|
+
}
|
|
52
|
+
function decodeLoosely(value) {
|
|
53
|
+
let out = value;
|
|
54
|
+
for (let i = 0; i < 2; i++) {
|
|
55
|
+
try {
|
|
56
|
+
const decoded = decodeURIComponent(out);
|
|
57
|
+
if (decoded === out)
|
|
58
|
+
break;
|
|
59
|
+
out = decoded;
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
function normalizeForPath(value) {
|
|
68
|
+
return decodeLoosely(value)
|
|
69
|
+
.replace(/\\/g, '/')
|
|
70
|
+
.replace(/\/+/g, '/')
|
|
71
|
+
.toLowerCase();
|
|
72
|
+
}
|
|
73
|
+
function normalizeForCommand(value) {
|
|
74
|
+
return decodeLoosely(value).replace(/\s+/g, ' ').trim();
|
|
75
|
+
}
|
|
76
|
+
function collectStrings(value, keyPath = 'args', out = [], depth = 0) {
|
|
77
|
+
if (depth > 6 || value === null || value === undefined)
|
|
78
|
+
return out;
|
|
79
|
+
if (typeof value === 'string') {
|
|
80
|
+
out.push({ keyPath, value });
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
if (typeof value === 'number' || typeof value === 'boolean') {
|
|
84
|
+
out.push({ keyPath, value: String(value) });
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
if (Array.isArray(value)) {
|
|
88
|
+
value.slice(0, 50).forEach((item, index) => collectStrings(item, `${keyPath}.${index}`, out, depth + 1));
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
if (typeof value === 'object') {
|
|
92
|
+
for (const [key, nested] of Object.entries(value).slice(0, 100)) {
|
|
93
|
+
collectStrings(nested, `${keyPath}.${key}`, out, depth + 1);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
function findSecret(value) {
|
|
99
|
+
for (const pattern of SECRET_PATTERNS) {
|
|
100
|
+
const match = value.match(pattern.re);
|
|
101
|
+
if (match?.[0])
|
|
102
|
+
return { label: pattern.label, value: match[0] };
|
|
103
|
+
}
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
function containsMetadataEndpoint(value) {
|
|
107
|
+
const normalized = decodeLoosely(value).toLowerCase();
|
|
108
|
+
return METADATA_ENDPOINTS.some(endpoint => normalized.includes(endpoint));
|
|
109
|
+
}
|
|
110
|
+
function containsSensitiveCredentialPath(value) {
|
|
111
|
+
const text = normalizeForPath(value);
|
|
112
|
+
const traversal = /(?:^|\/)\.\.(?:\/|$)/.test(text) || /%2e/i.test(value);
|
|
113
|
+
const checks = [
|
|
114
|
+
{ label: 'SSH private key', re: /(?:^|\/)\.ssh\/id_(?:rsa|ed25519|ecdsa|dsa)(?:$|[/?#\s'"])/ },
|
|
115
|
+
{ label: 'SSH config', re: /(?:^|\/)\.ssh\/config(?:$|[/?#\s'"])/ },
|
|
116
|
+
{ label: 'AWS credentials', re: /(?:^|\/)\.aws\/(?:credentials|config)(?:$|[/?#\s'"])/ },
|
|
117
|
+
{ label: 'Kubernetes config', re: /(?:^|\/)\.kube\/config(?:$|[/?#\s'"])/ },
|
|
118
|
+
{ label: 'Docker auth config', re: /(?:^|\/)\.docker\/config\.json(?:$|[/?#\s'"])/ },
|
|
119
|
+
{ label: 'npm credentials', re: /(?:^|\/)\.npmrc(?:$|[/?#\s'"])/ },
|
|
120
|
+
{ label: 'PyPI credentials', re: /(?:^|\/)\.pypirc(?:$|[/?#\s'"])/ },
|
|
121
|
+
{ label: 'netrc credentials', re: /(?:^|\/)\.netrc(?:$|[/?#\s'"])/ },
|
|
122
|
+
{ label: 'GCP ADC credentials', re: /application_default_credentials\.json(?:$|[/?#\s'"])/ },
|
|
123
|
+
{ label: 'Linux shadow file', re: /(?:^|\/)etc\/(?:shadow|sudoers)(?:$|[/?#\s'"])/ },
|
|
124
|
+
{ label: 'PowerShell history', re: /powershell\/psreadline\/consolehost_history\.txt(?:$|[/?#\s'"])/ },
|
|
125
|
+
{ label: 'browser credential store', re: /(?:login data|cookies|key4\.db|logins\.json)(?:$|[/?#\s'"])/ },
|
|
126
|
+
];
|
|
127
|
+
for (const check of checks) {
|
|
128
|
+
if (check.re.test(text))
|
|
129
|
+
return check.label;
|
|
130
|
+
}
|
|
131
|
+
if (traversal && /(?:^|\/)\.env(?:\.[a-z0-9_-]+)?(?:$|[/?#\s'"])/.test(text)) {
|
|
132
|
+
return 'environment file via path traversal';
|
|
133
|
+
}
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
136
|
+
function destructiveCommandReason(value) {
|
|
137
|
+
const text = normalizeForCommand(value);
|
|
138
|
+
const lower = text.toLowerCase();
|
|
139
|
+
if (/\brm\s+-[a-z]*r[a-z]*f[a-z]*\s+(?:["']?\/["']?|\*)(?:\s|$|[;&|])/.test(lower))
|
|
140
|
+
return 'recursive force delete of filesystem root';
|
|
141
|
+
if (/\bsudo\s+rm\s+-[a-z]*r[a-z]*f[a-z]*\s+(?:["']?\/["']?|\*)(?:\s|$|[;&|])/.test(lower))
|
|
142
|
+
return 'recursive force delete of filesystem root';
|
|
143
|
+
if (/\b(?:del|erase)\s+\/[a-z]*s[a-z]*\s+\/[a-z]*q[a-z]*\s+[a-z]:\\(?:\s|$)/i.test(text))
|
|
144
|
+
return 'recursive Windows drive delete';
|
|
145
|
+
if (lower.includes('remove-item') && lower.includes('-recurse') && lower.includes('-force') && /[a-z]:\\?(?:\s|$)/i.test(text))
|
|
146
|
+
return 'recursive Windows drive delete';
|
|
147
|
+
if (/: *\(\) *\{ *: *\| *: *& *\} *; *:/.test(text))
|
|
148
|
+
return 'fork bomb';
|
|
149
|
+
if (/\bmkfs(?:\.[a-z0-9]+)?\s+\/dev\//i.test(text))
|
|
150
|
+
return 'filesystem format command';
|
|
151
|
+
if (/\bdd\b(?=.*\bof=\/dev\/(?:sd|xvd|hd|nvme|disk))/i.test(text))
|
|
152
|
+
return 'raw disk overwrite command';
|
|
153
|
+
if (/\bchmod\s+-r\s+777\s+\/(?:\s|$)/i.test(text))
|
|
154
|
+
return 'recursive permission change on root';
|
|
155
|
+
return undefined;
|
|
156
|
+
}
|
|
157
|
+
function reverseShellReason(value) {
|
|
158
|
+
const text = normalizeForCommand(value);
|
|
159
|
+
if (/\bbash\s+-i\b.*\/dev\/tcp\//i.test(text))
|
|
160
|
+
return 'bash reverse shell';
|
|
161
|
+
if (/\b(?:nc|ncat|netcat)\b.*\s-e\s+(?:\/bin\/)?(?:sh|bash)\b/i.test(text))
|
|
162
|
+
return 'netcat reverse shell';
|
|
163
|
+
if (/\bsocat\b.*\bexec:(?:\/bin\/)?(?:sh|bash)\b/i.test(text))
|
|
164
|
+
return 'socat reverse shell';
|
|
165
|
+
if (/\b(?:curl|wget)\b[^|;&]{0,300}\|\s*(?:sudo\s+)?(?:sh|bash)\b/i.test(text))
|
|
166
|
+
return 'remote script piped to shell';
|
|
167
|
+
if (/\bpython(?:3)?\s+-c\b(?=.*socket)(?=.*subprocess)/i.test(text))
|
|
168
|
+
return 'python reverse shell';
|
|
169
|
+
return undefined;
|
|
170
|
+
}
|
|
171
|
+
function destructiveSqlReason(value) {
|
|
172
|
+
const text = normalizeForCommand(value);
|
|
173
|
+
if (/\bdrop\s+database\b/i.test(text))
|
|
174
|
+
return 'DROP DATABASE';
|
|
175
|
+
if (/\bdrop\s+schema\b/i.test(text))
|
|
176
|
+
return 'DROP SCHEMA';
|
|
177
|
+
return undefined;
|
|
178
|
+
}
|
|
179
|
+
function infraDestroyReason(value) {
|
|
180
|
+
const text = normalizeForCommand(value);
|
|
181
|
+
if (/\bterraform\s+destroy\b(?=.*--auto-approve)/i.test(text))
|
|
182
|
+
return 'terraform destroy with auto-approve';
|
|
183
|
+
if (/\bpulumi\s+destroy\b(?=.*(?:--yes|-y))/i.test(text))
|
|
184
|
+
return 'pulumi destroy with auto-approve';
|
|
185
|
+
if (/\bkubectl\s+delete\s+(?:namespace|clusterrole|secret)\b/i.test(text))
|
|
186
|
+
return 'destructive kubectl delete';
|
|
187
|
+
if (/\bkubectl\s+delete\s+all\b(?=.*--all)/i.test(text))
|
|
188
|
+
return 'broad kubectl delete all';
|
|
189
|
+
if (/\bgcloud\s+projects\s+delete\b/i.test(text))
|
|
190
|
+
return 'GCP project deletion';
|
|
191
|
+
if (/\bgcloud\s+sql\s+instances\s+delete\b/i.test(text))
|
|
192
|
+
return 'GCP SQL instance deletion';
|
|
193
|
+
if (/\baws\s+s3\s+rm\s+s3:\/\/\S+\s+--recursive\b/i.test(text))
|
|
194
|
+
return 'recursive S3 deletion';
|
|
195
|
+
if (/\baws\s+cloudformation\s+delete-stack\b/i.test(text))
|
|
196
|
+
return 'CloudFormation stack deletion';
|
|
197
|
+
if (/\baws\s+iam\s+attach-user-policy\b(?=.*AdministratorAccess)/i.test(text))
|
|
198
|
+
return 'IAM administrator privilege grant';
|
|
199
|
+
if (/\baws\s+iam\s+create-access-key\b/i.test(text))
|
|
200
|
+
return 'IAM access key creation';
|
|
201
|
+
if (/\baz\s+group\s+delete\b(?=.*(?:--yes|-y))/i.test(text))
|
|
202
|
+
return 'Azure resource group deletion';
|
|
203
|
+
return undefined;
|
|
204
|
+
}
|
|
205
|
+
function isOutboundContext(toolName, candidates) {
|
|
206
|
+
if (OUTBOUND_TOOL_HINT.test(toolName))
|
|
207
|
+
return true;
|
|
208
|
+
return candidates.some(candidate => /(?:url|uri|webhook|endpoint|recipient|channel|email|to|body|payload|message|content)/i.test(candidate.keyPath));
|
|
209
|
+
}
|
|
210
|
+
function isCommandContext(toolName, candidate) {
|
|
211
|
+
const lastKey = candidate.keyPath.split('.').pop() || '';
|
|
212
|
+
return COMMAND_TOOL_HINT.test(toolName) || COMMAND_KEYS.has(lastKey) || COMMAND_KEYS.has(candidate.keyPath);
|
|
213
|
+
}
|
|
214
|
+
function isSqlContext(toolName, candidate) {
|
|
215
|
+
const lastKey = candidate.keyPath.split('.').pop() || '';
|
|
216
|
+
return SQL_TOOL_HINT.test(toolName) || SQL_KEYS.has(lastKey) || SQL_KEYS.has(candidate.keyPath);
|
|
217
|
+
}
|
|
218
|
+
function scanDeterministicToolCall(toolName, toolArgs) {
|
|
219
|
+
const candidates = collectStrings(toolArgs);
|
|
220
|
+
const outbound = isOutboundContext(toolName, candidates);
|
|
221
|
+
for (const candidate of candidates) {
|
|
222
|
+
const sensitivePath = containsSensitiveCredentialPath(candidate.value);
|
|
223
|
+
if (sensitivePath) {
|
|
224
|
+
return {
|
|
225
|
+
blocked: true,
|
|
226
|
+
ruleId: 'local-sensitive-credential-path',
|
|
227
|
+
category: 'sensitive_file',
|
|
228
|
+
reason: `Blocked local agent access to ${sensitivePath}.`,
|
|
229
|
+
evidence: evidence(candidate.value),
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
for (const candidate of candidates) {
|
|
234
|
+
if (containsMetadataEndpoint(candidate.value)) {
|
|
235
|
+
return {
|
|
236
|
+
blocked: true,
|
|
237
|
+
ruleId: 'local-cloud-metadata-ssrf',
|
|
238
|
+
category: 'metadata_ssrf',
|
|
239
|
+
reason: 'Blocked request to cloud metadata endpoint.',
|
|
240
|
+
evidence: evidence(candidate.value),
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
for (const candidate of candidates) {
|
|
245
|
+
if (!isCommandContext(toolName, candidate))
|
|
246
|
+
continue;
|
|
247
|
+
const reverseShell = reverseShellReason(candidate.value);
|
|
248
|
+
if (reverseShell) {
|
|
249
|
+
return {
|
|
250
|
+
blocked: true,
|
|
251
|
+
ruleId: 'local-reverse-shell',
|
|
252
|
+
category: 'reverse_shell',
|
|
253
|
+
reason: `Blocked ${reverseShell}.`,
|
|
254
|
+
evidence: evidence(candidate.value),
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
const destructive = destructiveCommandReason(candidate.value);
|
|
258
|
+
if (destructive) {
|
|
259
|
+
return {
|
|
260
|
+
blocked: true,
|
|
261
|
+
ruleId: 'local-destructive-command',
|
|
262
|
+
category: 'destructive_command',
|
|
263
|
+
reason: `Blocked ${destructive}.`,
|
|
264
|
+
evidence: evidence(candidate.value),
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
const infraDestroy = infraDestroyReason(candidate.value);
|
|
268
|
+
if (infraDestroy) {
|
|
269
|
+
return {
|
|
270
|
+
blocked: true,
|
|
271
|
+
ruleId: 'local-infra-destroy',
|
|
272
|
+
category: 'infra_destroy',
|
|
273
|
+
reason: `Blocked ${infraDestroy}.`,
|
|
274
|
+
evidence: evidence(candidate.value),
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
for (const candidate of candidates) {
|
|
279
|
+
if (!isSqlContext(toolName, candidate))
|
|
280
|
+
continue;
|
|
281
|
+
const destructiveSql = destructiveSqlReason(candidate.value);
|
|
282
|
+
if (destructiveSql) {
|
|
283
|
+
return {
|
|
284
|
+
blocked: true,
|
|
285
|
+
ruleId: 'local-destructive-sql',
|
|
286
|
+
category: 'destructive_sql',
|
|
287
|
+
reason: `Blocked destructive SQL command: ${destructiveSql}.`,
|
|
288
|
+
evidence: evidence(candidate.value),
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
if (outbound) {
|
|
293
|
+
for (const candidate of candidates) {
|
|
294
|
+
const secret = findSecret(candidate.value);
|
|
295
|
+
if (secret) {
|
|
296
|
+
return {
|
|
297
|
+
blocked: true,
|
|
298
|
+
ruleId: 'local-secret-exfiltration',
|
|
299
|
+
category: 'secret_exfiltration',
|
|
300
|
+
reason: `Blocked outbound data containing ${secret.label}.`,
|
|
301
|
+
evidence: evidence(secret.value),
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return undefined;
|
|
307
|
+
}
|
|
308
|
+
function scanDeterministicTextResponse(text) {
|
|
309
|
+
const secret = findSecret(text);
|
|
310
|
+
if (!secret)
|
|
311
|
+
return undefined;
|
|
312
|
+
return {
|
|
313
|
+
blocked: true,
|
|
314
|
+
ruleId: 'local-secret-in-tool-response',
|
|
315
|
+
category: 'secret_exfiltration',
|
|
316
|
+
reason: `Blocked tool response containing ${secret.label}.`,
|
|
317
|
+
evidence: evidence(secret.value),
|
|
318
|
+
};
|
|
319
|
+
}
|
package/dist/commands/hook.js
CHANGED
|
@@ -38,6 +38,7 @@ const fs = __importStar(require("fs"));
|
|
|
38
38
|
const os = __importStar(require("os"));
|
|
39
39
|
const path = __importStar(require("path"));
|
|
40
40
|
const config_1 = require("../config");
|
|
41
|
+
const deterministicGuard_1 = require("./deterministicGuard");
|
|
41
42
|
const DEBUG_LOG = path.join(os.homedir(), '.fullcourtdefense-hook.log');
|
|
42
43
|
/** Append a diagnostic line so we can see exactly what Cursor invoked + the verdict. */
|
|
43
44
|
function dbg(obj) {
|
|
@@ -240,16 +241,19 @@ function mcpServerName(p) {
|
|
|
240
241
|
return str(p.mcp_server_name) || str(p.server_name) || str(p.serverName) || str(p.mcpServer) || undefined;
|
|
241
242
|
}
|
|
242
243
|
/**
|
|
243
|
-
* Runtime agent identity for a tool call. For MCP calls we
|
|
244
|
-
* per-server identity (`<developer
|
|
245
|
-
*
|
|
246
|
-
* the
|
|
244
|
+
* Runtime agent identity for a tool call. For MCP calls we use a RUNTIME-AGNOSTIC
|
|
245
|
+
* per-server identity (`<developer>-<server>`) — the client (cursor/codex/claude/...) is
|
|
246
|
+
* intentionally NOT part of the identity, because the same logical agent (the MCP server)
|
|
247
|
+
* is the same agent no matter which IDE/client invokes it. This mirrors the gateway's
|
|
248
|
+
* `perServerAgentName`, so ONE per-agent policy matches the same server across every runtime.
|
|
249
|
+
* The client is still reported separately as `agentClient` metadata for optional per-runtime
|
|
250
|
+
* constraints. Non-MCP events keep the collapsed per-developer IDE-runtime identity.
|
|
247
251
|
*/
|
|
248
252
|
function agentNameForCall(event, p) {
|
|
249
253
|
if (event === 'mcp') {
|
|
250
254
|
const server = mcpServerName(p);
|
|
251
255
|
if (server)
|
|
252
|
-
return `${safeIdentityPart(developerId())}
|
|
256
|
+
return `${safeIdentityPart(developerId())}-${safeIdentityPart(server)}`;
|
|
253
257
|
}
|
|
254
258
|
return `cursor-${developerId()}`;
|
|
255
259
|
}
|
|
@@ -370,6 +374,16 @@ async function enforceActionPolicy(ctx) {
|
|
|
370
374
|
respond(false);
|
|
371
375
|
return;
|
|
372
376
|
}
|
|
377
|
+
const localBlock = (0, deterministicGuard_1.scanDeterministicToolCall)(call.toolName, call.toolArgs);
|
|
378
|
+
if (localBlock) {
|
|
379
|
+
dbg({ phase: 'local_deterministic_block', event, tool: call.toolName, ruleId: localBlock.ruleId, category: localBlock.category });
|
|
380
|
+
if (shadow) {
|
|
381
|
+
respond(false, undefined, `[FullCourtDefense shadow] would block ${call.toolName}: ${localBlock.reason}`);
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
respond(true, `Blocked by FullCourtDefense local guard — ${localBlock.reason}`, `FullCourtDefense blocked this ${event} locally (${localBlock.ruleId}). Do not retry.`);
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
373
387
|
const headers = { 'Content-Type': 'application/json' };
|
|
374
388
|
if (shieldKey)
|
|
375
389
|
headers['x-shield-key'] = shieldKey;
|
|
@@ -382,9 +396,9 @@ async function enforceActionPolicy(ctx) {
|
|
|
382
396
|
headers,
|
|
383
397
|
body: JSON.stringify({
|
|
384
398
|
shieldId,
|
|
385
|
-
// For MCP calls this mirrors the gateway's per-server identity
|
|
386
|
-
// (`<developer
|
|
387
|
-
//
|
|
399
|
+
// For MCP calls this mirrors the gateway's runtime-agnostic per-server identity
|
|
400
|
+
// (`<developer>-<server>`), so ONE per-agent policy matches the same server on the
|
|
401
|
+
// gateway path AND this in-IDE hook path, across every client/runtime.
|
|
388
402
|
agentName: agentNameForCall(event, payload),
|
|
389
403
|
toolName: call.toolName,
|
|
390
404
|
toolArgs: call.toolArgs,
|
|
@@ -52,6 +52,7 @@ const child_process_1 = require("child_process");
|
|
|
52
52
|
const config_1 = require("../config");
|
|
53
53
|
const discoverPaths_1 = require("./discoverPaths");
|
|
54
54
|
const discover_1 = require("./discover");
|
|
55
|
+
const deterministicGuard_1 = require("./deterministicGuard");
|
|
55
56
|
const DEFAULT_API_URL = 'https://api.fullcourtdefense.ai';
|
|
56
57
|
const MANAGED_SERVER_NAME = 'agentguard-gateway';
|
|
57
58
|
const INSTALL_GATEWAY_ARGS = { includeShieldKey: false };
|
|
@@ -588,6 +589,9 @@ class McpGatewayServer {
|
|
|
588
589
|
let approvalActionId;
|
|
589
590
|
let operation;
|
|
590
591
|
try {
|
|
592
|
+
const localBlock = (0, deterministicGuard_1.scanDeterministicToolCall)(toolName, toolArgs);
|
|
593
|
+
if (localBlock)
|
|
594
|
+
throw new Error(`${localBlock.reason} (${localBlock.ruleId}: ${localBlock.evidence})`);
|
|
591
595
|
const preflight = await this.api.checkToolCall({ toolName, toolArgs });
|
|
592
596
|
operation = preflight.operation;
|
|
593
597
|
if (!preflight.allowed) {
|
|
@@ -604,6 +608,9 @@ class McpGatewayServer {
|
|
|
604
608
|
}
|
|
605
609
|
}
|
|
606
610
|
const rawResult = await this.downstream.callTool(toolName, toolArgs);
|
|
611
|
+
const localResponseBlock = (0, deterministicGuard_1.scanDeterministicTextResponse)(contentToText(rawResult));
|
|
612
|
+
if (localResponseBlock)
|
|
613
|
+
throw new Error(`${localResponseBlock.reason} (${localResponseBlock.ruleId}: ${localResponseBlock.evidence})`);
|
|
607
614
|
const finalResult = this.gatewayConfig.scanResponse
|
|
608
615
|
? await this.api.scanToolResponse({ toolName, operation, toolArgs, result: rawResult })
|
|
609
616
|
: rawResult;
|
|
@@ -1022,8 +1029,13 @@ function backupFile(file) {
|
|
|
1022
1029
|
}
|
|
1023
1030
|
catch { /* best effort */ }
|
|
1024
1031
|
}
|
|
1025
|
-
|
|
1026
|
-
|
|
1032
|
+
// RUNTIME-AGNOSTIC per-server agent identity: `<developer>-<server>`. The client
|
|
1033
|
+
// (cursor/codex/claude/...) is deliberately excluded — the same MCP server is the same
|
|
1034
|
+
// logical agent regardless of which IDE/runtime invokes it, so ONE per-agent policy applies
|
|
1035
|
+
// everywhere. The client is still carried separately as `agentClient` metadata (see GatewayConfig)
|
|
1036
|
+
// for optional per-runtime constraints. `agentClient` is kept as a param for signature stability.
|
|
1037
|
+
function perServerAgentName(developerName, _agentClient, serverName) {
|
|
1038
|
+
return `${safeIdentityPart(developerName)}-${safeIdentityPart(serverName)}`;
|
|
1027
1039
|
}
|
|
1028
1040
|
function wrapJsonConfigFile(file, gatewayConfig, agentClient, dryRun) {
|
|
1029
1041
|
const stats = newWrapStats();
|
package/dist/version.json
CHANGED
package/package.json
CHANGED
|
@@ -1,42 +1,43 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "fullcourtdefense-cli",
|
|
3
|
-
"version": "1.6.
|
|
4
|
-
"description": "Full Court Defense CLI — security scanning for AI agents from your terminal",
|
|
5
|
-
"main": "dist/index.js",
|
|
6
|
-
"bin": {
|
|
7
|
-
"fullcourtdefense": "dist/index.js",
|
|
8
|
-
"fcd": "dist/index.js",
|
|
9
|
-
"botguard": "dist/index.js"
|
|
10
|
-
},
|
|
11
|
-
"files": [
|
|
12
|
-
"dist",
|
|
13
|
-
"README.md"
|
|
14
|
-
],
|
|
15
|
-
"scripts": {
|
|
16
|
-
"build": "tsc && node scripts/copy-attack-corpus.js",
|
|
17
|
-
"
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
"
|
|
22
|
-
"
|
|
23
|
-
"
|
|
24
|
-
"
|
|
25
|
-
"
|
|
26
|
-
"
|
|
27
|
-
"
|
|
28
|
-
"
|
|
29
|
-
"
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
"
|
|
33
|
-
|
|
34
|
-
"
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "fullcourtdefense-cli",
|
|
3
|
+
"version": "1.6.7",
|
|
4
|
+
"description": "Full Court Defense CLI — security scanning for AI agents from your terminal",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"fullcourtdefense": "dist/index.js",
|
|
8
|
+
"fcd": "dist/index.js",
|
|
9
|
+
"botguard": "dist/index.js"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"dist",
|
|
13
|
+
"README.md"
|
|
14
|
+
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"build": "tsc && node scripts/copy-attack-corpus.js",
|
|
17
|
+
"test:deterministic-guard": "npm run build && node scripts/test-deterministic-guard.js",
|
|
18
|
+
"prepublishOnly": "npm run build"
|
|
19
|
+
},
|
|
20
|
+
"keywords": [
|
|
21
|
+
"llm",
|
|
22
|
+
"security",
|
|
23
|
+
"guardrails",
|
|
24
|
+
"ai-safety",
|
|
25
|
+
"prompt-injection",
|
|
26
|
+
"cli",
|
|
27
|
+
"red-teaming",
|
|
28
|
+
"owasp",
|
|
29
|
+
"mcp",
|
|
30
|
+
"rag"
|
|
31
|
+
],
|
|
32
|
+
"license": "MIT",
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@types/node": "^25.3.0",
|
|
35
|
+
"typescript": "^5.3.0"
|
|
36
|
+
},
|
|
37
|
+
"engines": {
|
|
38
|
+
"node": ">=18.0.0"
|
|
39
|
+
},
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"yaml": "^2.8.4"
|
|
42
|
+
}
|
|
43
|
+
}
|