fullcourtdefense-cli 1.6.6 → 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
+ }
@@ -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) {
@@ -373,6 +374,16 @@ async function enforceActionPolicy(ctx) {
373
374
  respond(false);
374
375
  return;
375
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
+ }
376
387
  const headers = { 'Content-Type': 'application/json' };
377
388
  if (shieldKey)
378
389
  headers['x-shield-key'] = shieldKey;
@@ -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;
package/dist/version.json CHANGED
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "1.6.6"
2
+ "version": "1.6.7"
3
3
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullcourtdefense-cli",
3
- "version": "1.6.6",
3
+ "version": "1.6.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": {
@@ -14,6 +14,7 @@
14
14
  ],
15
15
  "scripts": {
16
16
  "build": "tsc && node scripts/copy-attack-corpus.js",
17
+ "test:deterministic-guard": "npm run build && node scripts/test-deterministic-guard.js",
17
18
  "prepublishOnly": "npm run build"
18
19
  },
19
20
  "keywords": [